多道程序设计(Multi-programming)是现代操作系统的重要特点,多个进程/线程的并发和并行执行成为了当今主流的操作系统架构。
在调度不同的进程/线程执行时涉及到调度算法,而多个进程/线程之间的并发和并行执行涉及到资源访问策略,如果不对资源访问加以一定程度的限制,那么资源将会出现各种意料之外的结果甚至是错误(饥饿,死锁······)。
这种约束资源访问的策略称为「同步」
餐前案例
假设操作系统记录着当前系统中进程 PID 的最大值全局变量 nextPid
。
操作系统中有多个进程共享这一个变量,现在有两个进程同时调用 fork()
来创建一个新的子进程。
整个流程如下所示
两个进程先后对全局变量进行自增操作,看起来没有任何的问题,但实际上隐患就在「自增」操作上。
我们把 pid 的赋值操作抽象成一行代码:pid = nextPid++
这行代码翻译成机器指令是四步:
这四步用人话就是这样:
- 把 nextPid 变量的值「加载」到寄存器 Register1
- 把寄存器 Register1 的值「赋值」给 pid 变量
- 将寄存器 Register1 的值「自增长」 1
- 将 Register1 的值「赋值」给 nextPid 变量
重点就在于这四条指令合并起来的操作是 pid = nextPid++
,但这行代码不是原子操作。
原子操作:一个区域内的所有指令要么全部执行完成,要么全部执行失败。
假设这两个进程要「几乎同时」执行这一个 fork()
操作,很有可能出现问题。
上图展示了两个进程同时进行 fork()
操作时,在执行关键点对应的机器代码的其中一种可能的顺序。
我们重点来看发生两次上下文切换时,寄存器 Register1 的值是如何发生改变的?
- 进程1 切换到 进程2:Register1 的值是
1000
,经过进程2的自增后 Register1 的值是1001
- 进程2 切换到 进程1(重点!):Register1 的值原本是
1001
,但在进程1的 PCB 中保存着进程切换时的寄存器 Register1 的值是1000
,此时调度回进程1时,将1000
加载到 Register1 中,覆盖了1001
,所以此次自增操作也是将 1000 自增为 1001
所以两次操作都是将 1000 自增为 1001,但实际上应该是 1002,这就是问题的关键所在。
实际上,所有语言里对全局变量的自增操作,出现「自增次数与实际数字不匹配」的情况,都可以用上面的理论解释。
这就是多进程/线程间对共享资源的不正确访问,所以我们才需要「同步策略」。
几个重要概念
竞态条件(Race Recognition)
竞态条件指的是:程序的结果依赖于并发执行的时间或者顺序。
原子操作(Atomic Operation)
原子操作指的是:一个区域内的所有指令要么全部执行完成,要么全部执行失败(原子性)。
称为原子操作的原因:在化学里原子是最小的一个粒子,不可再分。
像 i++
这样的指令并不是一种原子操作,它分为三步执行:
- 读取 i 变量值
- 将 i 自增 1
- 将自增后得到的新值赋值给变量 i
为了证明上面的论述,我用 Java 语言写了一个测试程序,程序结束的条件如下:
- i ≥ 10 代表 thread-A 胜出
- i ≤ -10 代表 thread-B 胜出
我们执行多次上面的程序后会发现线程A和线程B有可能胜出,这就是由于「非原子操作」产生的「竞态条件」。
临界区(Critical Section)
临界区是指:进程中需要访问「共享资源」的一段代码就会称为「临界区」。
处于临界区的代码在同一时刻只能被一个进程/线程执行,不能有多个进程/线程执行。
互斥(Mutual exclusion)
互斥指的是:当一个进程处于临界区并访问共享资源时,其它进程不能进入该临界区且不能访问该共享资源。
死锁(Dead Lock)
死锁指的是:两个进程/线程相互等待对方正在占有的资源,自己却不释放已占有的资源。
饥饿(Starvation)
饥饿指的是:一个处于就绪状态的进程持续被调度处理器忽略,虽然处于就绪状态但得不到执行。
实现互斥的三种方式
屏蔽硬件中断
只要屏蔽了中断,就不会产生上下文切换,因此没有并发。
产生并发的原因:
- 产生中断
- 系统调用
基于软件的解决方案
使用硬件中断的成本较高,且只能针对于单 CPU 的情况下使用。所以提出了基于软件的解决方案。有两种方式:
- 共享变量
- 信号量
共享变量:满足进程 Pi 和 Pj 之间互斥的经典的基于软件的解决方法
使用两个共享数据项
turn
:表示哪个进程可以进入临界区flags
:标志每个进程是否已经准备好进入临界区
原子操作指令
我们计算机的硬件提供了一些原语:中断禁用、原子操作指令。
所以我们可以充分利用这些硬件的原语实现互斥,具体的实现方式最常用的有两种:
- 锁:获得锁对应进入临界区的过程,释放锁对应离开临界区的过程
- 信号量:使用一个专用的变量标志着可进入临界区的进程/线程数量
现代体系结构中都提供了特殊的原子操作指令。特殊指的是:将多条指令合并为一条具备原子性的复合指令。
例如:
- Test-and-Set:测试并置位(类似于我们的 CAS)
- Swap:交换内存中的两个值
利用这两条复合指令就可以实现锁和信号量。
-
锁:自旋(忙等待)锁、阻塞式(非忙等待)锁
-
信号量:PV原语操作。
「信号量」是一个抽象的整型数据类型,拥有两个原子操作:
- P 操作:信号量 - 1,如果信号量 < 0 则等待,否则可以进入临界区
- V 操作:信号量 + 1,如果信号量 ≤ 0 则唤醒一个正在等待的进程/线程
我们来看一个小例子:火车使用铁轨。
初始值信号量为2
代表允许有两条列车可以通过该信号量进入到铁轨中,当第三条列车来临时,由于前两条列车都没有退出铁轨,所以需要在原地等待(P 操作发现 < 0)。
生产者-消费者模型
我们可以使用信号量实现「生产者-消费者」模型。
我们规定该模型中有多个生产者和一个消费者。
- 在任意时间只能有一个线程操作缓冲区
- 当缓冲区空时,消费者必须等待生产者往缓冲区中放入数据
- 当缓冲区满时,生产者必须等待消费者从缓冲区中取出数据
我们对于每个约束都需要一个信号量
- 01 信号量实现缓冲区互斥
- 缓冲区满信号量
fullBuffers
表示当前缓冲区有多少个元素 - 缓冲区空信号量
emptyBuffers
表示还可以往缓冲区中放入多少个元素
信号量的实现原理
使用硬件原语:
- 禁用中断
- 原子指令(Test-and-Set)
我用伪代码将 Semaphore 大致的实现原理描述出来。
我们采用 testAndSet()
对信号量的 cnt
值做更新,利用 waitQueue
暂存所有在该信号量上等待的进程/线程。
总结
我们从底层硬件和原子指令开始,一层层地向上抽象出各种同步结构,适用于共享资源的同步和互斥访问。