【基础学习】操作系统学习笔记 - 进程与线程:进程同步与信号量、信号量临界区保护、信号量的代码实现、死锁处理

在中国大学MOOC上学习操作系统
希望看视频可以直接点击 哈工大-操作系统课程MOOC

进程同步与信号量(Processes Synchronization and Semaphore)

除了切换和调度,在多进程图像中,进程间的合作也应当是合理有序的

进程合作:多进程共同完成一个任务

在这里插入图片描述
司机与售票员:借助信号
1. 司机启动车辆前向售票员确认已经关好车门
2. 售票员开门前要向司机确认已经停好了

生产者-消费者实例

在这里插入图片描述
在这里插入图片描述
实例解释:
BUFFER_SIZE标记Buffer的最大值
counter作为指示当前Buffer的已经使用的资源
当Buffer为空,消费者进程进入死循环,不可以消费;假设没有这个死循环会读出来空值/错误值。
当Buffer为满,生产者进程进入死循环,不可以生产;假设没有这个死循环,就会覆盖掉原来的正确数据。
当Buffer非空非满时,生产者和消费者根据自己需要进行生产和消费。
进程同步就是让进程合理地“走走停停”
在这里插入图片描述
只发信号解决不了所有问题,因为信号量含的信息太少了(true/false),下面说明了一个异常的情况:

  1. 缓冲区满后生产者P1向buffer放入一个item,sleep
  2. 又出现一个生产者P2(因为在生产者P1睡眠时无法通知P2)向buffer放入一个item,也会sleep
  3. 消费者C执行一次循环,counter–,唤醒P1
  4. 消费者C再执行一次循环,counter–,但是buffer现在没有满,这时不会再唤醒任何一个生产者了
  5. 自此P2一直处于sleep,不可能再被唤醒,P2没用了

可见上面的counter只能反映buffer的空闲状况,实际上还需要知道多少个生产者在睡眠,这就需要一个变量来记录,因此我们需要引入信号量来处理这个问题。
在这里插入图片描述

  1. 缓冲区满,P1执行,P1 sleep,sem = -1
  2. P2 执行,P2 sleep,sem = -2
  3. C执行,wakeup P1,sem = -1
  4. C执行,wakeup P2,sem = 0
  5. C执行,sem = 1
  6. P3执行,sem = 0

sem的语义:

  • 0 没有空闲缓冲区
  • -1 欠一个空闲缓冲区,需要申请(发信号)
  • 1 多一个空闲缓冲区

进一步的,可以理解为生产者(P)是资源,消费者(C)是进程,sem标记当前资源状态,0就是没有空闲,负数就是有进程在等待资源,整数代表资源空闲。当信号量小于等于0时,需要申请资源,当信号量大于0时代表有人释放了资源。
现在,我们可以通过信号量来判断当前资源的状态。

一种资源的数量为8,当前的信号量为2,说明有两个资源可以使用。
一种资源的数量为8,当前的信号量为0,说明当前资源恰好满负荷。
一种资源的数量为8,当前的信号量为-2,说明当前有2个进程在等待资源。

信号量

在这里插入图片描述

  1. 信号量(semaphore)具有如下结构:
    1. value,记录资源的个数
    2. queue,记录信号量的进程阻塞队列
  2. P(semaphore s),消费资源,test,查看一下是不是需要阻塞
  3. V(semaphore s),生产资源,increment,增加资源并唤醒进程
  4. 信号量的核心就在于如何对0进行处理。
V(semaphore s){
	s.value++;
	if(s.value <= 0){
		wakeup(s.queue);
	}
}
使用信号量解决生产者-消费者问题在这里插入图片描述

信号量视角:

  1. empty信号量
    1. 含义:空闲缓冲区
    2. 初值:BUFFER_SIZE
    3. 增加:消费者能增加
    4. 减少:生产者能减少
  2. full信号量
    1. 含义:已使用缓冲区
    2. 初值:0
    3. 增加:生产者能增加
    4. 减少:消费者能减少
  3. mutex信号量
    1. 含义:互斥信号量,用于防止多个进程同时访问
    2. 初值:1,代表可以申请
    3. 增加:任意想要对资源操作的进程,先申请
    4. 减少:申请成功的进程在对资源操作完毕时,再释放

生产者消费者视角:

  1. 生产者
    1. 阻塞时机:空闲缓冲区为0,即empty为0
    2. 逻辑:
      1. P(empty),先看一下是否有空闲缓冲区,没有就阻塞
      2. P(mutex),申请互斥信号量,防止其他进程并行操作,其他进程正在使用就阻塞
      3. 写入
      4. V(mutex),释放互斥信号量,其他进程可以来操作
      5. V(full),新增一个已使用缓冲区
  2. 消费者
    1. 阻塞时机:已使用缓冲区为0,即full为0
    2. 逻辑:
      1. P(full),先看下是否有已使用缓冲区,没有就阻塞
      2. P(mutex),申请互斥信号量,防止其他进程并行操作,其他进程正在使用就阻塞
      3. 读取
      4. V(mutex),释放互斥信号量,其他进程可以来操作
      5. P(empty),新增一个空闲缓冲区

信号量临界区保护(Critical Section)

什么是信号量

是一个整型变量,通过对整性变量的访问和修改,让各个进程有序推进。

共同修改信号量时引出的问题

在这里插入图片描述
当两个进程同时P进行(empty)操作时,就有可能发生上面的情况。即,两个empty–,因为CPU调度,empty–还没执行完毕就切换了。

  1. P1.register = empty (P1.register = -1)
  2. P1.register = P1.register(P1.register = -2)
  3. P2.register = empty (P2.register = -1)
  4. P2.register = P2.register - 1 (P2.register = -2)
  5. empty = P1.register (empty = -2)
  6. empty = P2.register (empty = -2)

按理说empty在两次P操作后,应该由最初的-1变成-3,这里变成了-2,显然错了。

竞争条件(Race Condition)在这里插入图片描述

左侧是错误的结果,右侧是正确的结果。
这种错误由调度产生,无法通过编程来解决。并且难于定位和调试。

解决竞争条件的直观想法

在写共享变量empty时阻止其他进程也访问empty。
原子操作
在这里插入图片描述

临界区

在这里插入图片描述
读写信号量的代码一定是临界区。
进程代码结构:

  1. 剩余区
  2. 进入区
  3. 临界区
  4. 退出区
  5. 剩余区
原则

在这里插入图片描述

  1. 互斥进入:如果一个进程在临界区中执行,则其他进程不允许进入
    1. 这些进程间的约束关系被称为互斥(mutual exclusion)
    2. 这保证了这里是临界区
  2. 好的临界区保护原则
    1. 有空让进:若干进程要求进入空闲临界区时,应尽快使一个进程进入临界区
    2. 有限等待:从进城发出进入请求到允许进入,不能无限等待。
轮转法

又称“值日法”
在这里插入图片描述
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算法

结合了标记和轮转两种思想。
在这里插入图片描述
算法的正确性:

  1. 满足互斥进入。turn要么为1要么为零
  2. 满足有空让进。如果P0不在临界区,那么flag0 == false或者turn ==1,使得P1一定可以进入。
  3. 满足有限等待。当P1希望进去的时候,flag1==true,此时进不去一定是P0在临界区执行,等到P0执行完毕后,一定会置flag0为false,P1即可进入。
面包店算法:多进程

仍然是标记+轮转的结合
如何轮转:每个进程都获得一个序号,序号最小的那个进入。(类似排队取号)
如何标记:进程离开时序号为0,不为0的序号即为标记。
面包店:每个进入商店的客户都获得一个号码,号码最小的先得到服务;号码相同时,名字靠前的先服务。
在这里插入图片描述
i标识是哪个进程
初始化过程:

  1. choosingi=true,表示先不进去
  2. 每个新进程进来先取一个比当前所有号都要多1的号

想要进入:

  1. 先把choosingi设为false
  2. 开始和其他进程比较:
    1. 这个进程正在访问临界区吗?访问则等待
    2. 这个进程离开了吗(序号为0)?这个进程的号是否比我小?如果没有离开并且进程优先级比我高就等待。

离开:

  • 把自己的标号设置为0,表示已经访问过临界区

算法正确性:

  1. 互斥进入:Pi在临界区内,Pk试图进入,一定有num[i],i小于num[k],k,Pk循环等待。因为只要序号大于某个进程,就一定不会获取进入临界区的资格。
  2. 有空让进。如果没有进程在临界区中,最小序号的进程一定能进入。
  3. 有限等待。离开临界区的进程再次进入一定排在最后,所以最多等待前面的所有进程,而不会等待后来进入的进程。
回到开始

不要忘记了我们为什么要对进程调度进行这么复杂的操作,因为我们要保护empty–不被打断,无论谁进来,都必须保证empty–的操作是以原子的级别执行的。这样我们就达到了对信号量临界区保护的目的。

硬件方法1-阻止调度

尽管我们在上面提出了一系列的算法来解决临界区竞争的问题,但是总是太复杂了,实际上有更简单的方法,就是对硬件进行处理。
在这里插入图片描述
只有中断的时候会触发进程的调度,我们只需要阻止中断,或者让时间片保持非零,等到临界区代码执行结束后,再允许调度。
所以,访问临界区前通过cli()来关闭中断,然后执行临界区代码,退出时再通过sti()来开启中断,就可以达到阻止中断的目的。非常简单,不过很难在商业系统上运用
关于cli:当使用cli以后,CPU不再根据INTR管脚来判断中断。
什么时候不好用?
多CPU时会很难处理,CPU有一个管脚INTR,只能控制当前任务所在的CPU,多个CPU在一起就没办法全部控制了。

硬件方法2-硬件原子指令法

在这里插入图片描述
实际上就是用了互斥信号量的思想,至于为什么不用互斥信号量,那是因为互斥信号量的修改本身也需要一个互斥信号量来保护,一直递归下去无穷无尽。所以硬件层次提供了一个类似互斥锁的东西,也就是我们的硬件原子指令。
原子指令允许一次性修改一个整型变量,这样就打断了我们上边设想的递归循环。
对应代码:

  1. 调用TestAndSet,并传入锁。
    1. 获取x的值并保存到rv
    2. 设置x的值为true
    3. 返回x的初值
  2. 如果锁上了,就空转,没锁上就访问临界区
  3. 把锁关上。
    也就是说如果lock为ftrue,那while就一直循环,如果lock为false,while循环才会结束,同时此时lock已经自动上锁。并且lock开关锁锁是一个原子操作,不可能被任何进程打断,这样就实现了临界区保护的硬件原子算法。

信号量的代码实现

开始

在这里插入图片描述
用户态代码producer:

  1. 打开一个empty信号量
    对于sem_open的实现(要在内核中实现):

    1. 定义一个结构体包含一个任务队列、整型信号量value和一个用于全局标识semtable的字符串name。然后创建一个大小为20个信号量的数组semtable。
    2. 定义sys_sem_open,入参是一个name字符串。首先,看看semtable中有没有这个name的信号量,若没有就创建。然后返回对应的下标。

    对于sem_wait的实现(内核级实现):

    1. 关中断
    2. 如果当前信号量小于零(资源须等待)同时执行信号量减1,就把自己阻塞,并且将自己加入到当前信号量的阻塞队列中,最后请求CPU调度到其他任务。
    3. 开中断

    对于V操作的实现:

    1. 关中断
    2. 当前value++大于0,那就出一个进程并设置为就绪,然后请求调度算法
    3. 开中断
  2. 连续执行5次以下操作:

    1. 判断是不是有空闲缓冲区。
    2. 在文件中写一个4字节的数据:当前的index
Linux0.11的实现(这里先跳过)

在这里插入图片描述
bread:从文件系统中读出一个磁盘块(block read?)

  1. 请求一块buffer
  2. 阻塞并读

lock_buffer:
看一下信号量:读源码可以直到bh->b_lock就是信号量

  1. 关中断
  2. 当b_lock为1就阻塞:sleep_on,否则上锁
  3. 开中断

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一直在阻塞,计算机就没法用了。

死锁成因

在这里插入图片描述

  1. 互斥
  2. 占有并等待
  3. 环路等待

四个必要条件:

  1. 互斥使用(Mutual exclusion)
    资源的固有特性:比如路口
  2. 不可抢占(No preemption)
    资源只能自愿放弃:比如车开走
  3. 请求和保持(Hold and wait)
    进程必须占有资源,再去申请
  4. 循环等待(Circular wait)
    在资源分配图中存在一个环路

前两个是资源的固有特性,很难消除

死锁处理方法
  1. 死锁预防:破环死锁出现的条件
    no smoking!没有火源就不会引起火灾
  2. 死锁避免:检测每个资源请求,如果会造成死锁就拒绝
    煤气报警器:煤气超标时,自动切断电源,打火前前检测
  3. 死锁检测和死锁恢复:检测死锁出现时,让一些进程回滚,让出资源
    出现火灾立刻灭火,让家里变回没有发生火灾之前的样子
  4. 死锁忽略:假装不知道死锁
    在太阳上可以就可以随便发生火灾
    这个在需要长时间运行的操作系统中不可取,比如服务器;如果是PC机则无所谓,大不了重启。
死锁预防
方法1:打破占有并等待

在进程执行前,一次性折你去哪个所有需要的资源,不会占有资源再去申请。
缺点:

  1. 需要预知资源的使用情况,编程困难
  2. 许多资源分配后很长时间才利用,资源利用率低
方法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)

  1. 先把当前可用资源赋给Work,所有进程置为没有完成
  2. 开始while循环:
    对于所有进程for循环:
    1. 如果这个进程没有完成且所需资源小于等于当前拥有的资源:
      1. Work直接加上这个进程所拥有的资源
      2. 标记当前进程结束,跳出for循环,跳到while继续for循环
    2. 否则跳出两层循环至最后(当前任务全部完成时或进程资源超过当前空闲资源时)
  3. 如果最后还是有进程没有执行完毕,就会出现死锁。

感觉这里写的有点问题?如果说按照上面的那个安全序列例子,当P0输入进来时(因为是第一个),会被if语句判断为false,立刻跳转到End,输出死锁。应该是要判断当前所有的进程都不满足时,才跳到end代码段?不过也无所谓,实现起来也很简单。

使用银行家算法

当进程申请时,先假装分配,然后调用银行家算法。
当银行家算法输出死锁时,就拒绝这次请求,否则放行。

死锁检测和死锁恢复

基本原因:每次申请时都要执行一遍这么高时间复杂度的算法,效率极低。不如当发现问题了我们再去检测,
即定时检测或者发现资源利用率低时再检测。
在这里插入图片描述

死锁忽略

引出死锁忽略:

  1. 使用死锁预防:引入太多不合理的因素,比如让资源利用率变低
  2. 使用死锁避免:每次申请都要用银行家算法,时间复杂度也太高了
  3. 死锁检测和死锁恢复:检测很容易,恢复很难,比如银行里存了钱,客户已经走了,怎么再退给他钱?
  4. 死锁忽略:死锁的出现不是确定的,又可以用重启来解决。
练习题

在这里插入图片描述
应该是B?

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值