文章目录
- 前言
- 计算机系统概述
- OS的基本概念
- OS的发展历程
- OS的运行机制
- OS体系结构
- OS引导
- 虚拟机
- 进程和线程
- 进程和线程基础
- 进程
- 进程状态
- 进程控制
- 进程通信
- 线程
- 线程实现
- CPU调度
- 调度的层次
- 进程调度细节
- 调度算法
- 评价指标
- 批处理调度算法
- 交互式调度方法
- 同步与互斥
- 基本概念
- 互斥
- 互斥软件实现
- 互斥硬件实现
- 互斥锁(自旋锁)
- 信号量
- 信号量机制
- 信号量实现互斥同步
- 经典信号量问题
- 生产者消费者——基本的分析思路
- 多生产者多消费者——多种生产者
- 吸烟者问题——多功能生产者
- 哲学家进餐问题——连续申请多个资源
- 读者写者问题——优先级互斥问题
- 管程
- 死锁
- 死锁概述
- 预防死锁——破坏死锁条件
- 避免死锁——单步动态预测
- 检测和解除死锁
前言
学校OS课程的知识和408有一定的重叠,但是还不太够,因此我又一次打开了王道的OS课程。
这个笔记同理,只记最关键的内容和思考,直接针对408,基础性的概念性的知识以视频为主。
计算机系统概述
OS的基本概念
OS提供的服务:
- 用户级别:
- GUI,就是windows
- 命令接口
- 联机:有交互性,即cmd命令行
- 脱机:批处理,即.bat文件
- 程序员级别:
- 系统调用:OS的api
- 系统调用可以通过c语言的操作系统库函数调用,但是c语言本质上比系统调用还高一级
异步的前提是并发,程序之间交叉前进,即走走停停,无法预知。
OS的发展历程
- 手工阶段,缺点:
- 独占
- 人太慢,机器速度逐渐加快,人拖累机器
- 单通道批处理
- 优:提升预处理速度,不拖累机器
- 缺:独占
- 多通道批处理
- 优:解决独占,实现并发,提升效率
- 缺:无交互能力
- 分时系统
- 优:解决交互能力,用户“看起来”独占
- 缺:没有优先级
- 实时系统
- 优:解决了优先级响应问题,及时可靠
OS的运行机制
OS内核相当于OS的管理员,因此特权指令只能是内核执行。
平时用户的特权调用,操作都是向管理员申请,而不是亲力亲为。
两个状态的切换:
- 升级:特权指令触发中断(硬件),OS响应中断的时候进入核心态
- 降级:OS主动修改PSW让出控制权(软件)
- 修改PSW的指令,本身就是特权指令
中断和计组第5章衔接
涉及到进程之间的协调
,就一定要OS接入,进而需要系统调用。
需要注意,陷入指令是用户态指令(请求),接下来才会因为内中断进入核心态(执行)
OS体系结构
我们OS学的功能,可以放在内核,也可以放在用户,这就形成了大内核和微内核的区别。
微内核暴露的接口多,易于维护和扩展,但是沟通成本大,要反复调用。
- 分层结构
- 类似于计网的层次结构,结构清晰,通病是效率偏低
- 模块化
- 主模块分离,模块之间分离,平等
- 优点:可以同时开发,且效率不错
- 缺点:模块间的图关系很难把握
- 动态可加载模块。
- 可加载说白了就是插件,有没有都不影响运行,因此可以动态加载,比如驱动
- 主模块分离,模块之间分离,平等
- 宏内核和微内核
- 微内核相当于一个服务器,中转不同模块之间的`消息传递
- 外核
- 外核可以提供一些高级的资源(未经抽象的资源)分配方式
- 内核分配的资源都是抽象的,虚拟化的,比如虚拟地址,外核可以直接分配物理地址,在一些需要频繁跳跃的场景,外核直接分配一片连续空间效果会很好。当然,外核也负责保证安全。
- 跳过虚拟步骤,就相当于跳过了映射层,可以提高效率,缺点是复杂。
OS引导
简单来说,就是开机扫ROM就可以把操作系统拉起来,但是具体还是要分几步走:
- 扫ROM,启动BIOS,自检
- 读磁盘的MBR,获取分区
- 从活动分区(C盘)中读PBR,获取C盘根目录
- 通过目录找到操作系统的程序,拉到内存中,完成OS启动
这四步环环相扣,前一个获取了信息,后一步才能根据此信息行动。
而第4步用的程序,位置一般在C:/Windows/Boot/下面。
虚拟机
- 第一类VMM,相当于传统OS的加强版,直接运行在硬件上
- 虚拟OS看起来像一个OS,也有内核,但是实际上还是用户态,因此一个特权指令实际上要经过一次虚拟的系统调用+一次真正的系统调用。
- 迁移性差,因为直接和硬件耦合
- 第二类VMM,是寄居在宿主OS之上的,分为两部分
- 用户态的VMM和宿主的应用程序是共存的
- 核心态的VMM是以驱动的形式存在的,持续运行在内核态
进程和线程
进程和线程基础
进程
PCB,记录进程元数据:
- 描述信息。用于区分进程,PID,UID
- 进程控制和管理信息。和进程运行状态有关
- 资源分配清单。
- 资源是进程外部的,而数据段是程序产生的内部数据
- 处理器相关信息。寄存器上下文
进程特征:
- 并发性和异步是一起的
- 结构性指的是每个进程的结构都一样,都是PCB+数据段+程序段
进程状态
进程控制
创建原语分为4步:
- PCB创建和初始化
- 分配资源(此时进入就绪态)
- 插入就绪队列
撤销是逆过程:
- 剥夺所有资源,清理子进程
- 删除PCB
阻塞和唤醒,是可逆的过程
- 修改PCB:可逆,所以现场要保护起来
- 切换队列:把PCB放到对应队列中
无论是阻塞还是进程切换,都要剥夺CPU,而进程有一些内容还存在寄存器中,这些寄存器上下文就是保护现场要做的工作,是中断隐指令的内容。
进程通信
- 共享储存
- 把两个进程的虚拟储存,映射到同一个物理储存区域
- 因此要互斥访问
- 消息传递
- 每一个进程都有一个消息队列
- 直接通信:A进程把消息直接挂到B进程消息队列里
- 间接通信:以信箱为中介,B要主动去信箱取出A发来的消息
- 管道通信
- 联系生产者消费者,管道其实就是一个循环队列,是个内存缓冲区
- 管道互斥访问,因此是半双工(像水管)
线程
- TCB:Thread CB
- 一个进程内部,线程之间资源共享
- 因此切换成本很小,其就是为了频繁切换,提高并发性而生的。
- 一个程序的多线程可以放到不同进程中
- 多核CPU的超线程
- 但是这样就无法共享资源了,各用个的
线程可以理解为剥离掉公用资源后,剩下的相互独立的部分
因此线程的内容比较少,切换的时候只需要保证TCB里面的一些寄存器上下文就可以,比进程少很多。
线程实现
用户级线程,适用于早期OS没有线程管理功能的时候:
- 本质上是用户自己用代码(线程库)管理线程的调度
- 在OS看来,只有一个进程而已
- 线程的调度是纯用户态行为,这种调度非常简单(线程库的逻辑)
- 优缺点
- 优:不需要系统调用,高效
- 缺:假线程,代码在一个线程卡住,实际上都卡住了
内核级线程,这是经典的OS负责的线程:
- 优点:真并发
- 缺点:核心压力大
因此内核级线程又分出多种模式:
- 一对一
- 这其实和内核级线程一样
- 多对一:多个用户级线程对应一个内核线程,而CPU只能看到内核线程
- 这个模式比较鸡肋,和用户级线程一样
- 多对多:多个用户级线程,映射到多个内核线程
- 这才是真神
- 可以把用户线程切分成3块,每一块都用多对一模型映射到一个线程,整体上三个部分有序工作,互相之间不会拖累,兼顾了效率和并发性
CPU调度
调度的层次
作业调度,作业≈一个程序,储存的位置是外存,作业调度≈程序启动
低级调度,针对进程,切换CPU
中级调度,和作业调度一样都是在内外存之间的,区别如下:
- 作业调度是比较彻底,就是启动和终止
- 中级调度类似于休眠(挂起),暂时放到外存(手机的扩展内存技术就是这样实现的)
- 引入7状态模型,对就绪和阻塞分别增加对应的挂起状态
- 在挂起状态(外存里),也可以实现阻塞到就绪的转变
- 从运行态可以跳过就绪态,直接跳到挂起
进程调度细节
进程调度时机:
- 主动
- 被动。说白了就是被抢占
- 中断处理(中断隐指令)和原子操作,都会关中断,因此不会被打断
- OS内核处理
内核
临界区的时候,权限高,不可被打断
区分一下:
- 狭义进程调度。在CPU空闲的时候,抓一个就绪态进程激活
- 进程切换。剥夺一个运行的进程,换成另外一个进程
- 两个操作都要恢复新现场
- 相比于调度,切换额外要做的操作是保护旧现场
广义的进程调度,可能包含了进程切换这一过程
最后提一嘴调度程序,我们说,调度是由OS控制的,本质上就是软件,那么说白了,负责调度的管理者,还是一个程序。
这个程序什么时候会运行呢?
- 如果是非抢占的时候,就是异步的运行,在特殊情况才运行(创销,阻塞唤醒)
- 抢占式的,那么就要定期巡查,决定一个CPU是否应该抢占
调度算法
评价指标
- 周转时间=等待+处理时间
- 带权周转时间
- 本质上是个比例值,可以衡量等待时间在周转时间中的占比
- 越大,则等待越久
- 等待时间。
- 执行之前的时间,执行起来后等IO的时间不算
- 作业的等待时间是在成为进程之前的那段时间
- 进程的等待时间就是创建态+就绪态的那段时间
- 响应时间
- 和等待时间类似,但是针对的只是一种请求,比如键盘,鼠标
批处理调度算法
对于非纯计算程序,IO时间不算等待,因此还要抛去IO操作的部分。
FCFS(其实就是FIFO),之所以对短作业不利,就是因为短作业的带权周转时间会很大,这代表其体验很差。
短=Short,即SJF,SPF
具体计算,要分为三部分:
- 还没来的作业
- 作业池中的作业
- 正在运行的作业
正在运行的作业只能在作业池中挑选,而不能是还没有进入作业池的,因此第一个任务只能是P1,即使他时间很长。
SJF分两种:
- 非抢占式的SJF如上
- 比较简单,顺序操作
- 只需要在作业完成时,分析作业池即可
- 抢占式的SJF(
SRTN
)- 如果有一个新的作业来了,那么就有可能比当前正在运行的作业的剩余时间短,此时就把作业替换回作业池中(记得标注剩余时间)
- 具体做题的时候,比非抢占式要额外多分析新作业来的时间点
- 题目区分细节
- 默认非抢占,但是比较模糊
- 考虑到抢占,SRTN的平均周转时间肯定是最少的
- 如果默认作业没有到达顺序的先后之分,那么非抢占SJF=SRTN
- SJF对长作业不利,无论是否抢占,都可能造成饥饿现象
HRRN用到响应比指标,综合了等待时间和处理时间,在保证了优先级的前提下,修复了SJF饥饿的问题。
分析思路和SJF类似,都是分成三部分,HRRN为非抢占式的,所以只在CPU空闲的时候对作业池进行分析。
注意,其等待时间是从到达开始计数的。
交互式调度方法
RR(时间片轮转)
- 轮转本身很简单
- 建立一个就绪队列,每一个时间片出队,处理队头对应的进程
- 时间片完了以后就插到队尾
- 难在新任务来的时候,会改变队列结构
- 新任务来了,也是插在队尾
- 如果A任务的时间片完了,此时B任务刚来,这二者的顺序以B为先,毕竟新来的要照顾一下(注意,这个照顾顺序只针对想要同时入队的两个任务,前面早就在队里的任务仍然在前面)
- 如果任务处理完,时间片还没用完,会直接终止当前时间片
- 很正常很人性化的设计,干等着没意思
因此,整个分析过程需要考虑的也是处理完以及新任务刚来的两种时刻。
严格来说,RR不会剥夺正在执行的时间片,但是先来先插到队尾的这种逻辑,以及规定时间片用完就剥夺,这两个操作具有抢占性,所以我们规定RR是抢占式的(剥夺的来源是时间片本身,而不是其他进程)
时间片太大,就会退化为FCFS,太小则切换开销占时间片的比例就太大了(类似于流水线那个感觉),效率降低。
优先级调度算法。优先数越高,就越排在前面激活。
此方法分为抢占式和非抢占式。
分析节点参考SJF。
多级反馈队列调度,这个是666,真神
我们直接分析一下其性质,相当复杂,但是又相当合理:
- 队列内部是RR,队列之间是抢占
- 关于优先级
- 新手保护:刚来的,优先级最高
- 耗时降级:如果耗时长,每完整执行一个时间片,优先级就会降低一级,直到降无可降
- 被剥夺不降级:被剥夺严格来说不算执行完一个时间片,因此不降级,只是按RR原则放到了队尾。不过还会有补偿,新获得的时间片又是一个完整的时间片
- 关于轮转
- 优先高级:高一级的队列为空,才对下一级进行RR遍历(有抢占性,如果高一级来了新的,则立马切到高一级)
- 保障低级:越低级,时间片越长。虽然低优先级低人一等,但是总得让人家执行完,所以低优先级的时间片反而会更多,一旦轮到了,就可以持续执行很长时间(当然,如果你不争气,执行不完那就继续降级,又或者被强占,总之还是低人一等)
优缺点分析:
- 公平:FCFS
- 快响应:RR+优先级调度
- 短进程优先:SPF
- 自动优先级
- 如果想要保证任务的高优先级,可以尽量让其不降级,比如如果进程因为IO主动放弃时间片,此时我们不认为其执行完毕,因此不降级,这样就保证了IO的高优先级
- 缺点:仍然不是绝对公平,因此会造成饥饿
在具体实践中,为了防止饥饿出现,可能一个队列会分配固定的时间上限,如果超过这个界限,还是要切换队列的,不能一直卡在高优先级队列。
此外,不同队列内部的策略还可以不一样。
同步与互斥
基本概念
异步就是无序性
同步就是有序性,A一定在B前。
遵循的原则,总结起来就是,忙可以先等一下,等久了就撤(让权),但是你不能让我等太久,过一段时间我还得拿回权利并且进入临界区。
互斥
互斥软件实现
单标志法是最原始的轮询
本质上,turn代表谦让以及指定,刚开始就指定一个进程,而一个进程执行完以后又会指定另外一个进程。
但是其问题在于,临界区使用权和CPU占用权是分裂的,让给你用,并不代表你就能用,即空闲也进不去,违背“空闲让进”
比如此时标记0进程能用临界区,但是此时CPU时间片属于1,此时while循环就会持续1个时间片,一边是忙等,另一边是空闲没人用
双标志法,把谦让逻辑变成了,将指定变成了排他
逻辑,占用逻辑。
占用和释放,只修改自己的占用标记,不去指定他人。
双标志先检查,检查和上锁不连贯,此时,按照1526顺序,A还没上锁,B就通过检查了,就会同时进入临界区,即“忙”也能进去,违反了忙则等待。
为了修正,出现了双标志后检查法,这种方法先把临界区标记了,再检查,那么就可以确保别人拿不到临界区(缺德做法),但是这很明显不靠谱,会出现都用不上的情况,按照1526顺序,AB都标记,则会排他,那么AB就都会卡在检查阶段。
此时,即使临界区空闲,AB谁也不让着谁,谁也用不上,这违背了“空闲让进”,而且一直卡死,违背了“有限等待”
peterson算法,综合了单标志和双标志后检查法
标志代表意愿,turn代表谦让,最后被谦让的那一个,就可以获得使用权。
以1627举例,2先谦让一下,但是7后面有谦让一下,于是2就勉为其难的进入了,和现实的套路一模一样。
本质上,turn同一时间有且只能有一个值,因此同一时间只能有一个进程跳出循环,且必有一个进程跳出循环。
非常牛逼的思路,但是仍然优缺点,因为进程仍然是忙等状态,即违反了“让权等待”,但是已经是成本最低的了,这个操作可以通过时间片轮转来剥夺。
互斥硬件实现
关中断的缺点:
- 多处理机,关不了其他CPU,进程可以借助其他CPU访问临界
- 仅内核。因为这个操作给用户太危险
TestSet的操作,说白了就是用old检查,并对lock进行上锁,这是一个原子过程,检查和上锁是一气呵成的。
如果lock原来就是true,那么再上也无所谓
如果lock原来是false,那么就可以同时实现上锁+退出循环,不用担心被打断
上面这个代码只是模拟,实际上是硬件TSL指令实现的,是原子的,而软件编程是无法达到这个效果的。
缺点和peterson一样,都是忙等,不满足“让权等待”原则
还有一个Swap指令(Exchange,XCHG指令),和TSL基本一样的逻辑和特性
互斥锁(自旋锁)
互斥锁是一种思想,和mutex操作很像,就是申请和释放。
但是其申请过程是忙等的,所以TSL,swap指令都是自旋锁,单标指法其实也是自旋锁,申请过程都具有原子性
当然,正如单标志法哪里说的,忙等其实不完全忙等,时间片没了就退出(单处理器没有RR,所以就是彻底忙等)。甚至说有时候反而有意外效果,等待的时候不用切换上下文,有时候成本反而低。
信号量
信号量机制
整形信号量,说白了就是双标志先检查,但是P和V是原语,可以保证不会同时进入
但是因为底层还是一个循环,仍然会忙等,不满足让权等待。
记录型信号量引入阻塞队列,解决了忙等现象
- 原来是忙等,现在发现资源不够就丢到阻塞队列里。
- 极限情况为0,-1后为负,此时是第一个进程阻塞
- 如果资源够了,且阻塞队列里有进程,就唤醒
- 极限情况为-1,+1后为0,此时把阻塞队列里最后一个进程唤醒
信号量实现互斥同步
semaphore mutex = 1
:代表记录型信号量,有等待队列
互斥比较简单,mutex=1,PV夹住临界区。
同步是前V后P,用一个信号量关联两个进程,等V操作执行完后,P才能执行下去。
给定一个拓扑图,只需要把每一个前驱后继关系都用一个信号量定义一下即可。
之后每一个节点都是一个进程,把边定义的前驱后继关系写到进程里面即可:
- 入边写P
- 出边写V
经典信号量问题
生产者消费者——基本的分析思路
需要注意一个细节就是,P操作是不可互换的,因为mutex只夹临界区,夹得多了就会出问题(死锁)
V操作可以互换,因为V操作一定不会被卡住
多生产者多消费者——多种生产者
首先是最简单的两组关系:
- apple和orange各自有一对同步关系
- plate关系:关键在于盘子,盘子是双方的一个中介,并不能单看父或者母,要把父母统一为一方,把子女统一为另一方
具体实现如下,三个同步信号量,这里将plate设置为“还可以放的空间”,因此初值为1
因此,整体就实现了一个PV结构,每一个进程,都是有P有V。
mutex实际上可有可无,因为我们这里的资源上限为1,已经相当于mutex的作用了,但是如果盘子空间变成2,就得加mutex了,否则就可能发生覆盖现象。
如果反过来呢,plate=盘中可用水果数量,会出问题,比如dad,此时就是V(apple),V(plate),可以看到这个进程是没有P的,也就是说不会被阻塞,他可以一直释放。
所以我们这里还可以总结出一条生产者消费者问题中,隐性的要求,就是PV一定是要成环的,相互制约,一个进程只有V无P,必然是有问题的,在我们制定信号量意义的时候,也应该考虑构造一个PV环结构。
吸烟者问题——多功能生产者
一个多功能生产者,给多个单功能消费者提供原料,同步关系如下
再拓展一下思路,如果finish定义为1呢?
那么就要把生产者的P操作放在最开始,消费者是不变的,仍然可以正常运行(因为仍然是PV环,而且PV关系没有变)
分析一下是否需要mutex,因为只有一个生产者,所以缓冲区最多有1个元素,不会出问题。
但是呢,如果有n生产者,此时因为生产者是V在前的,第一次生产不受阻塞,所以就可能会让缓冲区里存在n个元素,所以这种写法其实不好,如果是按照我那个P在前的写法,即使是有多个生产者进程,只要规定finish=1,就只能有一个元素被生产
哲学家进餐问题——连续申请多个资源
这个问题本身不难,难在如何解决死锁。
哲学家进餐问题的死锁情况为,每个哲学家都只P了一半,都卡在了第二个P上。
本质上,哲学家进餐是连续申请多个资源,如果申请的途中被卡了,而且是集体卡顿,那就死锁了。
所以解决哲学家死锁,就要从这些领域入手:
- 最多让n-1个哲学家同时进餐,这样就一定可以保证有1个哲学家不会被卡死
- 可以用一个值为n-1的信号量限制
- 限定哲学家拿资源的顺序,强制哲学家进行两两竞争
- 比如指定奇数哲学家先左边的,偶数先拿右边的,那么这一对哲学家必然是两个必有一个阻塞,而另一个没被阻塞的哲学家,在另一边不存在竞争,一定可以吃到
- 保证哲学家多个P操作的原子性
- 在一连串P外面加个mutex即可
- 即使一个哲学家被卡,其他哲学家也不可能是P操作被卡,即其他哲学家在吃饭,这是暂时的,可以恢复的
读者写者问题——优先级互斥问题
代码分为4个版本:
- 单进程读/写(全部互斥)
- 读者优先
- 读写公平
- 写者优先
这四个版本,2实现了多读者同读,234逐步提高写者的优先级,具体的思想如下:
- 插队逻辑:通过if语句,可以制造插队,提高优先级
- 副作用是多进程可以同读同写
- 读者优先利用了这个副作用(其本意只是提高优先级,顺带实现同读)
- 抵消插队逻辑:在保留插队逻辑副作用(同读)的基础上,抵消插队带来的优先级效果
- 在插队逻辑外面加一个信号量即可(设为w)
- 注意,对w而言,高优先级的进程,w是覆盖所有代码的,低优先级的进程,w仅覆盖进入区以及插队逻辑,由此,w提高优先级和插队逻辑的优先级效果就互相抵消了
- 这种思路必须要基于插队逻辑才行,因为w并不是针对临界区的管控,至少还得有一个信号量(rw)管控临界区
- 控制同时读/写:在插队逻辑的前提下,选择性的控制是否可以同读同写
- 读者优先中,直接对临界区信号量(rw)加插队逻辑,利用了插队的副作用,无伤实现同读
- 写者优先不可以这么做,所以需要把插队逻辑外提到非临界区信号量(w),即使可以插队,新来的也得一起卡在临界区外面
这是最基础的读者优先结构,这个结构务必理解透彻了。
- rw:直接对临界区上锁,副作用是会造成读读互斥
- 插队逻辑(消除读读互斥并提升r的优先级)
- if判断:现在加了判断,使得读者里面,只有第一个和最后一个读者需要维护锁,其他情况下,只要有读者在读,新来的就可以直接读
- mutex:令count判断部分原子化,保护count变量
读写公平如上,是在读者优先的前提下修改的,仍然保证了读者不互斥的特性
但是使用信号量w额外增加了读者对写者的反制能力,说白了就是用w抵消了读者的插队能力。
这个w加的位置非常巧妙:
- 对于写者来说,w是覆盖了临界区的,也就是说,可以造成写者互斥的效果
- 对于读者来说,w只是卡在了最开始的进入区,这样就不会造成读者互斥
- w和rw使得读者和写者可以相互钳制
- 读者在进入区,则写者卡在w
- 读者在临界区(获取rw,释放w),则写者可以进一步卡在rw(已经获取了w)
- 写者在进入区(获取w,卡在rw),其余读者/写者已经进不了进入区了(卡在w)
- 写者在临界区,同写者在进入区
如果要实现写者优先呢?
还是插队逻辑,在读写公平的前提下,给写者增加一个插队逻辑
为了防止出现同写情况,需要将插队逻辑外提到w上。
- 不能像读者那样插。读者是可以共同读的,写者不行,所以不能给rw加插队逻辑
- 考虑给w加插队逻辑,这样,写者可以源源不断的到达“进入区”
- 实际上,这个操作就是修改了读写公平里的这句描述:
其余读者/写者已经进不了进入区了(卡在w)
,给写者开了个后门
- 实际上,这个操作就是修改了读写公平里的这句描述:
- 为什么不能像读优先那样,直接照搬写一个写优先?
- 因为读优先是可以同读的,但是写无法同写,所以要外提插队逻辑,所以一定是不能照搬的
- 写优先还可以爆改一下,反正都写优先了,把读的插队逻辑去掉也是可以的,当然这样就不能同读了
semaphore rw=1; //读写公用临界区信号量
int rcount=0; //读者插队逻辑
semaphore rmutex=1;
semaphore w=1; //用于提升write的优先级
int wcount=0; //写者插队逻辑
semaphore wmutex=1;
reader(){
while(1){
P(w);//抵消插队逻辑优先级
P(rmutex);//保护rcount
if(rcount==0)//插队逻辑
P(rw);
rcount++;
V(rmutex);
V(w);//注意,抵消插队逻辑优先级的时候,被抵消方的V(w)插在临界区前
写文件//临界区
P(rmutex);
rcount--;
if(rcount==0)
V(rw);
V(rmutex);
}
}
writer(){
while(1){
P(wmutex);
if(wcount==0)//w对写者加入插队逻辑
P(w);
wcount++;
V(wmutex);
P(rw);//公用临界区信号量
读文件//临界区
V(rw);
P(wmutex);
wcount--;
if(wcount==0)
V(w);
V(wmutex);
}
}
代码分为4个版本:
- 单进程读/写(全部互斥)
- 读者优先
- 读写公平
- 写者优先
这四个版本,2实现了多读者同读,234逐步提高写者的优先级,具体的思想如下:
- 插队逻辑:通过if语句,可以制造插队,提高优先级
- 副作用是多进程可以同读同写
- 读者优先利用了这个副作用(其本意只是提高优先级,顺带实现同读)
- 抵消插队逻辑:在保留插队逻辑副作用(同读)的基础上,抵消插队带来的优先级效果
- 在插队逻辑外面加一个信号量即可(设为w)
- 注意,对w而言,高优先级的进程,w是覆盖所有代码的,低优先级的进程,w仅覆盖进入区以及插队逻辑,由此,w提高优先级和插队逻辑的优先级效果就互相抵消了
- 这种思路必须要基于插队逻辑才行,因为w并不是针对临界区的管控,至少还得有一个信号量(rw)管控临界区
- 控制同时读/写:在插队逻辑的前提下,通过控制插队的信号量,选择性的控制是否可以同读同写
- 如果把插队逻辑加在临界区信号量上,就会造成同读/写,外提则不会
- 读者优先中,直接对临界区信号量(rw)加插队逻辑,利用了插队的副作用,无伤实现同读
- 写者优先不可以这么做,所以需要把插队逻辑外提到非临界区信号量(w),即使可以插队,新来的也得一起卡在临界区外面
管程
PV操作和生产消费过程混在一起,耦合度高,容易出错
因此直接把控制互斥同步的部分剥离出来,封装成类(管程):
- 在管程内部定义变量和初始化
- 在管程内部定义方法,实现同步机制
- 至于mutex互斥,管程通过
方法的互斥
来实现,同一时间只能有一个进程调用管程- 编译器会自动实现,也就是说你只需要定义同步就可以
死锁
死锁概述
死锁的条件:
- 前提是争抢(互斥)
- 其次是占着不放(不剥夺)
- 不仅不放,还要持续地请求别的资源(请求和保持条件)
- 换句话说就是,在只获取了部分资源的前提下,还要获取更多资源
- 满足这三个大前提,一旦成环(循环等待条件),则死锁
预防死锁——破坏死锁条件
- 破坏互斥。这个思路看着就不靠谱
- 破坏不剥夺。进程要反复切换,开销大,而且持续剥夺会导致饥饿
- 方案一:主动退位
- 方案二:OS协助剥夺
- 破坏保持和请求。
- 保持和请求本质上是因为只获取了一部分资源,不得不继续请求剩余资源,那么我们干脆一次性给到位再让他启动,否则就干脆不给,没有中间态。
- 很显然利用率低,饥饿
- 破坏循环等待。
- 给资源编号,规定一个进程申请资源的编号是递增的
- 众多进程中,必然有一个进程掌握已有的最大编号的资源(比如下图的7号资源)
- 这个进程需要的资源编号肯定比在场所有进程资源的编号都大(至少是8),也就是说这个大编号进程要的资源,只可能是空闲的,即使其他进程卡死,这个进程一定也可以执行下去
- 很显然,不方便,浪费
避免死锁——单步动态预测
看下来可以发现,死锁预防的方法缺陷都很大。
银行家算法是一个动态预测的方法,其实和前面那个破坏保持和请求条件的思路类似,保证资源够用,具体做法如下:
- 每次分配之前,我都要确保分配之后仍然是安全状态。
- 所谓的安全状态,就是分配了以后,我仍然有足够的资源让一个进程彻底执行完毕
- 这是做最坏的打算,只要我每一次分配都是在安全状态上,那就确保不会发生死锁。这是一种非常保守的思路。
当然,有的情况是最保守的策略都无法解决的,那就死锁是必然的,要从其他地方找问题(如下图)
检测和解除死锁
上面介绍的预防和避免,都比较简单,死锁检测通过软件的思路,设计算法去对图结构进行分析,可以得到好的全局分析结果。
给定一个资源分配图,图中有4类元素,注意点如下:
- 资源里面的点数代表资源最大出度
- 点数-资源已有出度=可用资源数量
- 进程的出度代表申请的资源数
具体做的时候,就是找出能够先执行完的进程
,执行完将资源释放,然后滚雪球化简资源分配图,直到所有进程执行完毕。
能够先执行完的进程怎么找呢?就是去计算一下,可用资源数量是否满足进程申请的资源数
以上图举例,P1申请1个资源,R2剩2-1=1个资源,所以P1可以执行下去,之后逐步化简就好
如果化简不完,最后有剩余的一个环,那么就代表这些进程构成了死锁循环,针对性的剥夺就好了。
这就是软件全局分析的好处,精准。
解除死锁的方法辨析:
- 资源剥夺法。
- 单纯剥夺资源,进程只是挂起(歇一会),这样可能导致饥饿
- 撤销进程法
- 直接remake死锁进程,啥都没了,可能造成浪费(白干了)
- 进程回退法
- 类似于git的回滚,回到一个可以完全消除所有边的状态
- 这个理想很好,实际上很难实现,因为要记录回退点
至于对哪个进程动手,归根结底就是对优先级低的动手,让出机会给高优先级任务。