本文引用图片来自 李治军: 操作系统32讲
信号量
在支持多进程的系统中,进程间的合作是一个常见现象,下图以数据采集系统中生产者和消费者的合作为例:
生产者是一个爬虫,不断爬取数据放到缓冲区,消费者是数据清洗程序,不断从缓冲区取出数据做进一步处理
生产者生产一个数据counter就加一,消费者消费一个数据counter就减一。当counter等于BUFFER_SIZE表示缓冲区满生产者要停下,counter等于0表示缓冲区空费者要停下
生产者和消费者通过表示资源数量的counter来完成合作,避免了越界访问缓冲区使系统崩溃。counter在这里就是一个信号,决定生产者或消费者是等待还是继续工作的信号,通过收发信号保证了生产者和消费者进程的有序推进
但是,在实际上只收发信号是不足以保证多进程的有序推进的
如下假设有两个生产者P1和P2,一个消费者C1:
1. 假设当前P1或P2生产一个数据后导致缓冲区满counter==BUFFER_SIZE
2. P1发现缓冲区满开始sleep
3. P2发现缓冲区满开始sleep
4. C1消费一个数据,counter减一 counter==BUFFER_SIZE - 1,唤醒P1或P2(假设唤醒的是P1)
5. C1再消费一个数据,counter减一 counter==BUFFER_SIZE - 2,不执行唤醒操作
6. P2在缓冲区不满的情况下仍在sleep...
7. 当下一次缓冲区满P1也会进入sleep,此时又有两个待唤醒的生产者,C1还是只能唤醒一个...
在有多个生产者的情况下单单依靠收发counter这个信号来表示缓冲区满或不满是不够的,会导致有的生产者永远无法被唤醒,所以我们需要信号能够携带更多的信息,此时信号也就升级成信号量
我们使用如下的信号量:
sem大于0表示资源缺口(如sem=2表示还差两个资源缓冲区满),sem小于0表示当前等待唤醒的生产者数量,sem初始化为BUFFER_SIZE表示缓冲区空
信号量就是携带了同步信息的变量,同步信息可能有多种(如上例的sem),通过使用信号量可以保证多个进程间的有序推进
值得一提的是Linux 0.11中没有信号量
临界区
进程间通过信号量来合作,每个进程都可以修改信号量会不会导致问题呢?会的
在生产者生产资源后对sem减一时其实一共有三个步骤
sem = sem - 1
1. register = sem // 将sem变量的值放入寄存器
2. register = register - 1 // 寄存器的值减一
3. sem = register // 将寄存器的值放回sem变量
假设此时有两个生产者P1,P2同时生产了资源需要对sem进行修改就可能产生以下执行顺序
sem = 4
1. P1 register = sem
2. P1 register = register - 1 // P1时间片结束调度P2运行
3. P2 register = sem
4. P2 register = register - 1 // P2时间片结束调度P1运行
5. sem = P1 register
6. sem = P2 register
sem 期望为2,实际为3
多个进程同时对信号量修改会导致信号量出现期望之外的值,解决办法也是很容易想到的,禁止多个进程同时修改信号量
sem = 4
1. P1检查sem是否已锁住,没有则上锁
2. P1 register = sem
3. P1 register = register - 1 // P1时间片结束调度P2运行
4. P2检查sem是否已锁住,发现已被锁,等待
5. sem = P1 register
6. sem == 3
7. P1给sem解锁
8. P2检查sem是否已锁住,没有则上锁
9. P2 register = sem
10. P2 register = register - 1
11. sem = P2 register
12. sem == 2
13. P2给sem解锁
sem 期望为2,实际为2
通过锁保证了多个进程间同时只有一个进程能对信号量进行修改进而保证了多进程的合作
上例中sem一次只允许一个进程修改称为临界资源,上锁和解锁之间的代码片段访问临界资源一次只允许一个进程进入称为临界区
进入临界区
一般我们的代码结构如下,我们需要做的工作主要是在进入区,也就是判断进程能不能进入临界区
临界区的进入有以下原则:
- 互斥进入:一个进程进入了临界区则禁止其他进程进入,基本原则
- 有空让进:临界区空闲时应该让需要进入的进程进入
- 有限等待:等待进入临界区的进程不能无限等待
以下简单介绍几个基本的临界区进入算法:
轮换法
使用单个变量表示轮到哪个进程进入临界区
轮换法满足互斥进入,但是当P0退出临界区后turn等于1,此时必须等P1进入一次临界区并退出后P0才能再次进入,即使P0第一次退出临界区后临界区空闲,不满足有空让进原则
标记法
进程拥有自己的标记表示是否想要进入临界区
标记法满足互斥进入以及有空让进原则,但可能造成无限等待
当调度造成两个进程的执行顺序为上图中的1234所示时,两个进程都会陷入自旋中无法自拔
Peterson法
结合标记和轮换两种思想
- 满足互斥性:
P0进入临界区时turn等于1,P1进入临界区时turn等于0,此时有turn==0==1,矛盾 - 满足有空让进:当前轮到
P1进入,但P1不在临界区也不想进入,此时有flag[1]等于false,P0可以顺利进入,轮到P0时同理 - 满足有限等待:任意一个进程进入时都会改变
turn的值自身不可能一直进入
面包店法
结合标记和轮换两种思想,适用于n个进程(n>2)
1. 每个进程进入临界区前先取号,取的号是当前n个进程中号码的最大值加1,保证后来的进程号最大
2. 遍历n个进程,如果有进程正在取号则等待,如果有比自己的号小且不为0的进程则等待
3. 进入临界区,操作完退出时将自己的号置为0
该方法类似我们在银行办业务,进去先取号,一次只服务一个人,办完业务后号码作废如果还想办理业务就得重新取号排队
- 满足互斥性:假设
Pi在临界区,后来的Pk想进入,此时必定有num[i] < num[k],Pk无法进入 - 满足有空让进:没有进程在临界区时号最小的进程一定能够进入
- 满足有限等待:进程离开临界区后重新取号最多等待n个进程就能进入
以上临界区进入算法都是代码层面的,下面介绍基于硬件的临界区保护方法
开关中断法
多个进程使用临界资源出问题是因为在使用过程中的进程调度导致执行中断,调度由内核完成,而要进入内核必须发生中断,所以可以关闭中断来避免调度
通过cli和sti指令就可以控制中断的关闭和开启进而避免使用临界资源时发生调度
控制中断的关闭和开启来保护临界区很简单,但是在多核CPU以及多个CPU中该方法无效。cli主要是告诉CPU屏蔽来自INTR引脚的中断,当前CPU在临界区的代码执行完成前不进行调度。但是多核CPU以及多个CPU的情况下每个核心或CPU都可以接收INTR引脚的中断信号,它们照常进行调度可能会调度到同样需要进入临界区的进程执行,或者说其他CPU当前正在执行的进程就需要进入临界区
原子指令法
需要操作的指令组合在一起形成一个原子操作,要么都执行要么都不执行
如果当前锁为false,那么TestAndSet中返回锁状态以及上锁的操作将一次执行完成,中途不会被打断,进程获取到锁进入临界区
在多核CPU或多CPU的情况下可以通过锁内存总线的方式保证同时只有一个核心或CPU执行TestAndSet