在中国大学MOOC上学习操作系统
希望看视频可以直接点击 哈工大-操作系统课程MOOC
进程同步与信号量(Processes Synchronization and Semaphore)
除了切换和调度,在多进程图像中,进程间的合作也应当是合理有序的。
进程合作:多进程共同完成一个任务
司机与售票员:借助信号
1. 司机启动车辆前向售票员确认已经关好车门
2. 售票员开门前要向司机确认已经停好了
生产者-消费者实例
实例解释:
BUFFER_SIZE标记Buffer的最大值
counter作为指示当前Buffer的已经使用的资源
当Buffer为空,消费者进程进入死循环,不可以消费;假设没有这个死循环会读出来空值/错误值。
当Buffer为满,生产者进程进入死循环,不可以生产;假设没有这个死循环,就会覆盖掉原来的正确数据。
当Buffer非空非满时,生产者和消费者根据自己需要进行生产和消费。
进程同步就是让进程合理地“走走停停”
只发信号解决不了所有问题,因为信号量含的信息太少了(true/false),下面说明了一个异常的情况:
- 缓冲区满后生产者P1向buffer放入一个item,sleep
- 又出现一个生产者P2(因为在生产者P1睡眠时无法通知P2)向buffer放入一个item,也会sleep
- 消费者C执行一次循环,counter–,唤醒P1
- 消费者C再执行一次循环,counter–,但是buffer现在没有满,这时不会再唤醒任何一个生产者了
- 自此P2一直处于sleep,不可能再被唤醒,P2没用了
可见上面的counter只能反映buffer的空闲状况,实际上还需要知道多少个生产者在睡眠,这就需要一个变量来记录,因此我们需要引入信号量来处理这个问题。
- 缓冲区满,P1执行,P1 sleep,sem = -1
- P2 执行,P2 sleep,sem = -2
- C执行,wakeup P1,sem = -1
- C执行,wakeup P2,sem = 0
- C执行,sem = 1
- P3执行,sem = 0
sem的语义:
- 0 没有空闲缓冲区
- -1 欠一个空闲缓冲区,需要申请(发信号)
- 1 多一个空闲缓冲区
进一步的,可以理解为生产者(P)是资源,消费者(C)是进程,sem标记当前资源状态,0就是没有空闲,负数就是有进程在等待资源,整数代表资源空闲。当信号量小于等于0时,需要申请资源,当信号量大于0时代表有人释放了资源。
现在,我们可以通过信号量来判断当前资源的状态。
一种资源的数量为8,当前的信号量为2,说明有两个资源可以使用。
一种资源的数量为8,当前的信号量为0,说明当前资源恰好满负荷。
一种资源的数量为8,当前的信号量为-2,说明当前有2个进程在等待资源。
信号量
- 信号量(semaphore)具有如下结构:
- value,记录资源的个数
- queue,记录信号量的进程阻塞队列
- P(semaphore s),消费资源,test,查看一下是不是需要阻塞
- V(semaphore s),生产资源,increment,增加资源并唤醒进程
- 信号量的核心就在于如何对0进行处理。
V(semaphore s){
s.value++;
if(s.value <= 0){
wakeup(s.queue);
}
}
使用信号量解决生产者-消费者问题
信号量视角:
- empty信号量
- 含义:空闲缓冲区
- 初值:BUFFER_SIZE
- 增加:消费者能增加
- 减少:生产者能减少
- full信号量
- 含义:已使用缓冲区
- 初值:0
- 增加:生产者能增加
- 减少:消费者能减少
- mutex信号量
- 含义:互斥信号量,用于防止多个进程同时访问
- 初值:1,代表可以申请
- 增加:任意想要对资源操作的进程,先申请
- 减少:申请成功的进程在对资源操作完毕时,再释放
生产者消费者视角:
- 生产者
- 阻塞时机:空闲缓冲区为0,即empty为0
- 逻辑:
- P(empty),先看一下是否有空闲缓冲区,没有就阻塞
- P(mutex),申请互斥信号量,防止其他进程并行操作,其他进程正在使用就阻塞
- 写入
- V(mutex),释放互斥信号量,其他进程可以来操作
- V(full),新增一个已使用缓冲区
- 消费者
- 阻塞时机:已使用缓冲区为0,即full为0
- 逻辑:
- P(full),先看下是否有已使用缓冲区,没有就阻塞
- P(mutex),申请互斥信号量,防止其他进程并行操作,其他进程正在使用就阻塞
- 读取
- V(mutex),释放互斥信号量,其他进程可以来操作
- P(empty),新增一个空闲缓冲区
信号量临界区保护(Critical Section)
什么是信号量
是一个整型变量,通过对整性变量的访问和修改,让各个进程有序推进。
共同修改信号量时引出的问题
当两个进程同时P进行(empty)操作时,就有可能发生上面的情况。即,两个empty–,因为CPU调度,empty–还没执行完毕就切换了。
- P1.register = empty (P1.register = -1)
- P1.register = P1.register(P1.register = -2)
- P2.register = empty (P2.register = -1)
- P2.register = P2.register - 1 (P2.register = -2)
- empty = P1.register (empty = -2)
- empty = P2.register (empty = -2)
按理说empty在两次P操作后,应该由最初的-1变成-3,这里变成了-2,显然错了。
竞争条件(Race Condition)
左侧是错误的结果,右侧是正确的结果。
这种错误由调度产生,无法通过编程来解决。并且难于定位和调试。
解决竞争条件的直观想法
在写共享变量empty时阻止其他进程也访问empty。
原子操作
临界区
读写信号量的代码一定是临界区。
进程代码结构:
- 剩余区
- 进入区
- 临界区
- 退出区
- 剩余区
原则
- 互斥进入:如果一个进程在临界区中执行,则其他进程不允许进入
- 这些进程间的约束关系被称为互斥(mutual exclusion)
- 这保证了这里是临界区
- 好的临界区保护原则
- 有空让进:若干进程要求进入空闲临界区时,应尽快使一个进程进入临界区
- 有限等待:从进城发出进入请求到允许进入,不能无限等待。
轮转法
又称“值日法”
turn用来标识是否轮到自己,轮流进入。
满足互斥进入。(关于这点可以用反证法,比如假设不是互斥进入的,也就是存在两个同时进入的可能,根据P0代码此时turn=1,根据P1此时turn=0,turn不可能既是1又是0,所以满足互斥进入)
不满足有空让进:比如turn==0时,进程P1被其它资源阻塞了,两个进程都在空转。
标记法
相当于P0在等P1释放资源,只要P1执行过代码,就相当于告诉所有进程:我已经执行过了。
在这种情况下就实现了有空让进的原则,并依然保持着互斥原则(反证法)。
但当两个进程同时想要进入的时候,比如P0执行过flag[0]=true后立刻调度到P1执行flag[1]=true,此时P1在空等,然后时间片用光,轮到P0执行,也在空等。这就不满足有限等待的原则了。
非对称标记
让A比B更”勤劳“。
A:先留字条,只要发现有B的纸条,就等待。等待一段时间无论有没有B的字条,只要发现没有牛奶就直接去买。扔掉字条。
B:先留字条,当没有发现A的纸条时,就查看是否有牛奶,没有牛奶再去买。扔掉字条。
Peterson算法
结合了标记和轮转两种思想。
算法的正确性:
- 满足互斥进入。turn要么为1要么为零
- 满足有空让进。如果P0不在临界区,那么flag0 == false或者turn ==1,使得P1一定可以进入。
- 满足有限等待。当P1希望进去的时候,flag1==true,此时进不去一定是P0在临界区执行,等到P0执行完毕后,一定会置flag0为false,P1即可进入。
面包店算法:多进程
仍然是标记+轮转的结合
如何轮转:每个进程都获得一个序号,序号最小的那个进入。(类似排队取号)
如何标记:进程离开时序号为0,不为0的序号即为标记。
面包店:每个进入商店的客户都获得一个号码,号码最小的先得到服务;号码相同时,名字靠前的先服务。
i标识是哪个进程
初始化过程:
- choosingi=true,表示先不进去
- 每个新进程进来先取一个比当前所有号都要多1的号
想要进入:
- 先把choosingi设为false
- 开始和其他进程比较:
- 这个进程正在访问临界区吗?访问则等待
- 这个进程离开了吗(序号为0)?这个进程的号是否比我小?如果没有离开并且进程优先级比我高就等待。
离开:
- 把自己的标号设置为0,表示已经访问过临界区
算法正确性:
- 互斥进入:Pi在临界区内,Pk试图进入,一定有num[i],i小于num[k],k,Pk循环等待。因为只要序号大于某个进程,就一定不会获取进入临界区的资格。
- 有空让进。如果没有进程在临界区中,最小序号的进程一定能进入。
- 有限等待。离开临界区的进程再次进入一定排在最后,所以最多等待前面的所有进程,而不会等待后来进入的进程。
回到开始
不要忘记了我们为什么要对进程调度进行这么复杂的操作,因为我们要保护empty–不被打断,无论谁进来,都必须保证empty–的操作是以原子的级别执行的。这样我们就达到了对信号量临界区保护的目的。
硬件方法1-阻止调度
尽管我们在上面提出了一系列的算法来解决临界区竞争的问题,但是总是太复杂了,实际上有更简单的方法,就是对硬件进行处理。
只有中断的时候会触发进程的调度,我们只需要阻止中断,或者让时间片保持非零,等到临界区代码执行结束后,再允许调度。
所以,访问临界区前通过cli()来关闭中断,然后执行临界区代码,退出时再通过sti()来开启中断,就可以达到阻止中断的目的。非常简单,不过很难在商业系统上运用
关于cli:当使用cli以后,CPU不再根据INTR管脚来判断中断。
什么时候不好用?
多CPU时会很难处理,CPU有一个管脚INTR,只能控制当前任务所在的CPU,多个CPU在一起就没办法全部控制了。
硬件方法2-硬件原子指令法
实际上就是用了互斥信号量的思想,至于为什么不用互斥信号量,那是因为互斥信号量的修改本身也需要一个互斥信号量来保护,一直递归下去无穷无尽。所以硬件层次提供了一个类似互斥锁的东西,也就是我们的硬件原子指令。
原子指令允许一次性修改一个整型变量,这样就打断了我们上边设想的递归循环。
对应代码:
- 调用TestAndSet,并传入锁。
- 获取x的值并保存到rv
- 设置x的值为true
- 返回x的初值
- 如果锁上了,就空转,没锁上就访问临界区
- 把锁关上。
也就是说如果lock为ftrue,那while就一直循环,如果lock为false,while循环才会结束,同时此时lock已经自动上锁。并且lock开关锁锁是一个原子操作,不可能被任何进程打断,这样就实现了临界区保护的硬件原子算法。
信号量的代码实现
开始
用户态代码producer:
-
打开一个empty信号量
对于sem_open的实现(要在内核中实现):- 定义一个结构体包含一个任务队列、整型信号量value和一个用于全局标识semtable的字符串name。然后创建一个大小为20个信号量的数组semtable。
- 定义sys_sem_open,入参是一个name字符串。首先,看看semtable中有没有这个name的信号量,若没有就创建。然后返回对应的下标。
对于sem_wait的实现(内核级实现):
- 关中断
- 如果当前信号量小于零(资源须等待)同时执行信号量减1,就把自己阻塞,并且将自己加入到当前信号量的阻塞队列中,最后请求CPU调度到其他任务。
- 开中断
对于V操作的实现:
- 关中断
- 当前value++大于0,那就出一个进程并设置为就绪,然后请求调度算法
- 开中断
-
连续执行5次以下操作:
- 判断是不是有空闲缓冲区。
- 在文件中写一个4字节的数据:当前的index
Linux0.11的实现(这里先跳过)
bread:从文件系统中读出一个磁盘块(block read?)
- 请求一块buffer
- 阻塞并读
lock_buffer:
看一下信号量:读源码可以直到bh->b_lock就是信号量
- 关中断
- 当b_lock为1就阻塞:sleep_on,否则上锁
- 开中断
sleep_on:几乎前面我们实现的一样,只是有一段tmp = p和p=current有些令人费解
最隐蔽的队列:sleep_on形成的队列
传入的**p是一个指向指针的指针,实际上它指向阻塞队列队首的指针。这段代码为了使当前进程阻塞。
struct task_struct *tmp // 申请了一个局部变量
tmp = *p // 使得tmp直接指向了队首,见上图
*p = current // 新阻塞队列的队首指向current
PCB中保存了内核栈,当前进程的内核栈中保存了tmp指针(因为局部变量被保存在栈中,通过内核栈就能找到这个栈),这个指针指向了下一个进程。而下一个进程依然也有一个tmp指针,指向下下个进程,这就形成了一个以current为队首、通过tmp指针连接的队列。
唤醒队列中的进程
磁盘中断(read_intr)->end_request->unlock_buffer->wake_up,wake_up结束后就返回至sleep_on的schedule那部分。
unlock_buffer:将b_lock置为0,开锁。
wake_up:把队首指针传入,把队首的state置为0,即就绪态。
wake_up结束后:如果有下一个进程,就把下一个进程的state置为就绪态,完成了唤醒。
死锁处理(Deadlock)
再看生产者-消费者实例
如果我们先申请mutex后申请empty会怎样?
mutex=1,P(mutex) -> mutex = 0
empty=0,P(empty) -> empty = -1,直接阻塞
此时如果希望解除阻塞,需要V操作来释放,必须走Consumer的代码,但是mutex被申请了,Consunmer的代码就无法走下去了,导致V(empty)不能被执行。
此时,形成了环路等待(我等我自己,生产者在等消费者释放空闲缓冲区,消费者在等生产者释放互斥锁),这就出现了死锁的情况。
死锁非常可怕,如果不断出现死锁的情况,最严重的后果就是所有进程都在等待,CPU一直在阻塞,计算机就没法用了。
死锁成因
- 互斥
- 占有并等待
- 环路等待
四个必要条件:
- 互斥使用(Mutual exclusion)
资源的固有特性:比如路口 - 不可抢占(No preemption)
资源只能自愿放弃:比如车开走 - 请求和保持(Hold and wait)
进程必须占有资源,再去申请 - 循环等待(Circular wait)
在资源分配图中存在一个环路
前两个是资源的固有特性,很难消除
死锁处理方法
- 死锁预防:破环死锁出现的条件
no smoking!没有火源就不会引起火灾 - 死锁避免:检测每个资源请求,如果会造成死锁就拒绝
煤气报警器:煤气超标时,自动切断电源,打火前前检测 - 死锁检测和死锁恢复:检测死锁出现时,让一些进程回滚,让出资源
出现火灾立刻灭火,让家里变回没有发生火灾之前的样子 - 死锁忽略:假装不知道死锁
在太阳上可以就可以随便发生火灾
这个在需要长时间运行的操作系统中不可取,比如服务器;如果是PC机则无所谓,大不了重启。
死锁预防
方法1:打破占有并等待
在进程执行前,一次性折你去哪个所有需要的资源,不会占有资源再去申请。
缺点:
- 需要预知资源的使用情况,编程困难
- 许多资源分配后很长时间才利用,资源利用率低
方法2:打破环路等待
对资源类型进行排序,资源申请必须按照顺序进行,不会出现环路等待。(或许就是预先根据资源申请顺序给资源编号,比如先I/O才能打印,那就要先申请I/O在去申请打印机)
缺点:仍然造成了资源的浪费。
死锁避免
如果系统中的所有进程存在一个可完成的执行序列P1,…,Pn,则称系统处于安全状态。
先排除B,根本不够用。
试一下A:
1. P1执行完毕,ABC资源532
2. P3执行,743
3. 现在已经哪个都可以执行了,正确
试一下C:
1. P3执行,441
2. P0不能执行,错误
试一下D:
1. P3执行,441
2. P4执行,443
3. P1执行,745
4. P2执行,(10)47
5. P0可以执行,正确
银行家算法
n代表进程数,m代表资源数,Need和Allocation都是n*m的二维数组。算法复杂度O(m*n^2)
- 先把当前可用资源赋给Work,所有进程置为没有完成
- 开始while循环:
对于所有进程for循环:- 如果这个进程没有完成且所需资源小于等于当前拥有的资源:
- Work直接加上这个进程所拥有的资源
- 标记当前进程结束,跳出for循环,跳到while继续for循环
- 否则跳出两层循环至最后(当前任务全部完成时或进程资源超过当前空闲资源时)
- 如果这个进程没有完成且所需资源小于等于当前拥有的资源:
- 如果最后还是有进程没有执行完毕,就会出现死锁。
感觉这里写的有点问题?如果说按照上面的那个安全序列例子,当P0输入进来时(因为是第一个),会被if语句判断为false,立刻跳转到End,输出死锁。应该是要判断当前所有的进程都不满足时,才跳到end代码段?不过也无所谓,实现起来也很简单。
使用银行家算法
当进程申请时,先假装分配,然后调用银行家算法。
当银行家算法输出死锁时,就拒绝这次请求,否则放行。
死锁检测和死锁恢复
基本原因:每次申请时都要执行一遍这么高时间复杂度的算法,效率极低。不如当发现问题了我们再去检测,
即定时检测或者发现资源利用率低时再检测。
死锁忽略
引出死锁忽略:
- 使用死锁预防:引入太多不合理的因素,比如让资源利用率变低
- 使用死锁避免:每次申请都要用银行家算法,时间复杂度也太高了
- 死锁检测和死锁恢复:检测很容易,恢复很难,比如银行里存了钱,客户已经走了,怎么再退给他钱?
- 死锁忽略:死锁的出现不是确定的,又可以用重启来解决。
练习题
应该是B?