Operating System
孤立程序
1. C程序视角
-
状态机描述
-
状态 = 堆H + 栈S
-
栈帧列表(含PC) + 全局变量
-
开辟一段栈空间,存在栈指针寄存器(栈指针esp)、基址寄存器(帧指针ebp)
-
栈帧(从高地址往下生长)保存:函数的返回地址和参数,临时变量,函数调用上下文
-
-
初始状态 = main第一条语句
- 全局变量初始化
-
状态转换 = 指针移动到下一条语句,并执行该语句
- 函数调用:push 函数入口的PC到栈帧里
- 函数返回:pop 栈帧
-
应用:
- 递归程序使用了系统栈,所以手动模拟系统栈,就能将递归程序转为非递归
2. 汇编代码视角
- 状态机描述
- 状态 = 内存M + 寄存器R
- 栈帧中保存的其实就是寄存器R的状态
- 硬件先保存一部分,交付软件后,软件再保存所有
- 初始状态 = Label为_start的汇编中的第一条指令
- 状态转换 = 执行一条指令
操作系统中的程序
-
与程序的状态机几乎一致,稍微有点不同
-
孤立程序只能执行计算指令
-
而操作系统程序存在==系统调用syscall==。
- 系统调用syscall:准备好程序的当前状态(M,R),传递状态给软件,将本身完全交由操作系统
- 操作系统程序 = 计算 + syscall
- 使用strace查看程序运行时调用的syscall
- 操作系统提供API给上层,它们都需要相应权限
- 操作系统中程序状态机的初始状态略有不同
- 不在main,也不在_start中
- 是==程序的动态链接器==中的第一条指令,负责链接程序运行时所需的动态库
- 例如linux-x86:/lib64/ld-linux-x86-64.so.s
- 技巧:使用__attribute__((constructor))或__attribute__((destructor))
编译器
1. C程序视角 -> 汇编代码视角
- 编译的实质是状态机转换
- 如何保证编译正确性?
- 可观测行为一致:但实际上我们目前只是实现了单核编译器的正确性
- 最终内存一致性 Eventual memory consistency
- 写回
- 逻辑层面完全一致:解释执行/基于语义的直接翻译,低效
2. 宽松内存模型下的编译优化
- 宽松内存模型:只保证可观测行为一致,给这样一个稍宽松的前提实现代码rewriting
- 比如:Inline assembly,Call to external CU = write back visible memory
并发
1. 线程
- 实质是单个共享内存中的多个执行流
- 拥有独立的状态(M,R)或者叫(H,S)
- 共享全部的内存:指针可以互相引用,它也能用来实现线程同步
- 单个共享内存是由谁来管控呢?
- 单个进程管理一个内存空间,其中有多个执行流
2. 简化的线程操作
-
create:创建一个入口函数为fn(int tid)的新线程,并立即执行
-
tid为线程号
-
语义:新增一个stack frame列表,并初始化为fn(tid)
-
join:等待所有运行线程的fn返回
-
运行main的线程也会自动join,即等待所有线程结束
- 语义:在其他线程未执行完时,进入==死循环==;否则返回
3. 现代并发中出现的问题
-
并行与并发的关系
-
并行是同时间点发生,范围较严格;并发是同时间段发生,范围较宽松。并发包含了并行。
- 单核处理器只能并发:一个时间段内,线程执行时可能会被中断,并切换到另一个线程执行
-
而多核处理器除并发外,还可以并行:一个时间点,多个核心的线程同时执行
-
并发导致了==原子性的丧失==:一段代码/指令的执行不可能独占整个计算机
-
内存中读后写指令(load+store)默认不是一条原子指令,可能会被打断或干扰
-
解决:实现互斥(保证临界区==如共享内存==中指令的串行化),保证原子性
-
编译器优化导致了==同步性与顺序一致性的丧失==:编译器会以单线程的思路对多线程的程序进行优化
-
编译器会进行:顺序指令的重排序、保证最终内存一致性
- 而并发程序中共享内存可以保证线程同步、指令也不是顺序进行的
- 这样的优化会让线程同步失效、数据相关指令的顺序被重排
-
解决:
- 提供volatile,保证变量所在内存的读写操作,不被优化
- 利用volatile实现memory barrier,防止指令重排
- 利用mfence或者其他原子指令
-
现代处理器导致了==可见性的丧失==:CPU也是特殊意义上的编译器(汇编$OP$->$\mu OP$),它里面也有类似的优化
-
CPU会进行:指令乱序发射,按序提交,尽可能多执行$\mu OP$
- 也就是说,就算汇编指令确定了正确顺序,CPU中issue后的execute顺序也不一定是这个顺序
实现互斥
1. Peterson算法与Dekker算法
背景:
- 那时没有原子指令,load和store是两条指令,因此实现锁的lock和unlock时会线程冲突,所以提出了Peterson协议
我称Peterson算法为谦让算法,还有一个类似的算法是Dekker算法
这个算法实际上是一个protocol,目的是防止线程A因为速度太快,在线程B提出访问请求前,直接提出请求并访问了
- 不阻止就形成不了线程的竞争关系,自然就不能实现互斥
内容:
- A提出访问请求(举旗),B提出访问请求(举旗)
- A覆盖占用标识(申请让B先用)
-
B覆盖占用标识(申请让A先用)
-
此时的A先用标识覆盖了之前的B先用标识
-
情况:
-
如果除了自己没人举旗子,则进入;
- 如果自己举了旗子,且标识为自己先用,则进入;
- 否则等待
- 举旗的目的:
- 让进程知道现在是不是需要处理互斥
- 谦让的目的:
- 不让自己投票后立刻进去。当然,如果对方不举旗就没有这个担忧了。
2. 验证正确性
- 手画状态机
- 使用Model Checker,用BFS等+优化绘制一个图的所有状态空间
- 模拟线程:使用Python Generator,即yield关键字
3. lock前缀————硬件实现原子性
-
由于内存中读后写指令(load+store)默认不是一条原子指令,我们除了使用Peterson算法等软件机制,我们还能用硬件机制————实现一条load后立即store的原子指令
-
最简单的一个:lock xchg原子指令
-
更多的原子指令在stdstomic.h中间接调用
-
需要硬件保证:store一定写入内存、lock指令之间一定有确定的顺序而不是乱序
4. 实现互斥1:用原子指令实现Spin Lock
-
设置一个共享变量isLock = Key
-
当需要load+store时,且isLock == Key,则用xchg将isLock原子置换为NoKey。执行完load+store后,将isLock置换回Key
-
当需要load+store时,且isLock != Key,则==循环查看isLock==,直到符合置换条件
-
自旋锁:当变量被自旋锁锁上时,申请线程需要循环查看锁状态,直到解锁
-
由于OS具有中断机制,上述的自旋锁不够完整:
-
给资源上锁后,OS触发外部中断,但是中断程序内部也需要使用该资源,因此中断程序会由于==无法得到资源==而被挂起,发生死锁。
- 理想的fix:lock-unlock期间强制关闭中断,或者保持lock-unlock期间中断状态不变
- 实现中断状态不变:保存中断状态I并禁止中途中断,中断全局/每CPU/每线程设置一个中断计数器,初值为0,lock就+1,unlock就-1,当值为0时就恢复中断为I
-
但是关闭中断或者保持中断的代价太大了
-
优化:Read-Copy-Update(牺牲部分CPU的读写一致性)
-
给资源设置一个指针,指向当前版本
-
上锁时,不改写原版本,而是创建一个一致的副本并修改为新版本
- 新版本创建完后,将指针指向新版本
-
等所有CPU都切换过一次版本了,就将旧版本删除
-
自旋锁的劣势:
-
由于等待线程循环查看isLock,使得线程过多反而性能越低
- 自旋锁实现互斥需要保持中断状态,代价太大
- 因此自旋锁只适合短临界区
5. 实现互斥2:Mutex Lock
- 与自旋锁的实现一致,只是由OS来间接上锁/解锁
- 线程使用Syscall提交一个上锁请求,OS查看资源是否已经有锁
- 若无,则获得资源
- 若有,OS调度CPU切换线程,将请求线程放入等待队列,直到资源解锁再唤醒
- 线程使用Syscall提交一个解锁请求
- 与Spin Lock的区别:
- 自旋一次失败时:线程直接进入等待,而不是循环查看isLock,即它只会执行一次spin,效率比Spin Lock快。
- 自旋一次成功时:线程需要进入OS一次才能开始使用资源,效率比Spin Lock慢。