一文读懂操作系统之同步问题

多道程序设计(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 暂存所有在该信号量上等待的进程/线程。

总结

我们从底层硬件和原子指令开始,一层层地向上抽象出各种同步结构,适用于共享资源的同步和互斥访问

  • 1
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值