第二章:进程管理

进程与线程

进程的概念,组成,特征

在学习进程管理之前,我们肯定需要了解一下进程的概念,进程非常容易与程序混淆,这里有必要对这两个概念进行辨析
首先来说程序,程序是一组计算机能识别和执行的指令,即程序是指令的集合,而进程则不一样,进程是程序的一次运行过程,说个更具体的例子,一个存放在硬盘里面的可执行文件就是一个程序,而打开任务管理器看到的那些就是进程,如下图所示
1695195564.jpg
这里要注意,一个进程是程序的一次执行过程,换言之,同一个进程如果执行了两次那么就会产生两个不同的进程,至此就区分了进程和程序的区别。
这里还有一个问题,操作系统应该如何区分进程,换句话说,对于同一个程序运行两次就会产生两个不同的进程,为什么操作系统会把这两个进程视为不同的进程,先看下面这个图
1695195824.jpg
这两个进程就是来源于同一个文件,进程名都一模一样,读者也可以在自己的电脑里找到这种进程名一样的进程,所以如果要用进程名来区分进程显然是不合理的,为了区分这些进程,操作系统就给每一个进程添加了一个唯一的识别码,我们称为PID(Process ID),如下图所示
1695195942.jpg
有了这个PID之后,系统就可以很清楚地将进程区分开,不会导致重名冲突
但对于一个进程而言只记录一个PID显然不够用的,操作系统想要管理这些进程就必须对每一个进程的详细信息进行记录,例如CPU使用情况,磁盘使用情况,分配内存情况等,这些信息就可以帮助操作系统实现对进程的控制和调度,操作系统将进程的详细信息存储在一个叫PCB的数据结构中,每个进程就会有一个PCB,中文叫做进程控制块,PCB记录了操作系统用于管理进程的所有信息。
拿到了PCB就拿到了进程的全部控制权,所以在操作系统的视角里,PCB就等同于进程,也是进程存在的唯一标志,一个进程被创建的时候操作系统为了管理它就会给它创建一个独一无二的PCB,进程结束时操作系统又会回收他的PCB,所以当我们同时运行三个程序的时候实际上操作系统是创建了3个PCB的。
PCB中会存放非常多的信息,以下列举一些

  • 进程描述信息
    • PID
    • UID
  • 进程控制和管理信息
    • CPU,磁盘等使用情况的统计
    • 进程当前状态
  • 资源分配清单
    • 正在使用的文件
    • 正在占用的内存区域
    • 正在使用的IO设备
  • 处理机相关信息
    • 保存该进程在CPU运行时的CPU状态,包括PC,PSW等寄存器的信息,主要用于进程切换

PCB中记录的信息非常之多,我们不可能去一一学习,我们只需要知道PCB中存放的是操作系统对进程进行管理所需要用到的所有信息。
需要注意的是,PCB是操作系统为了管理进程自己创建的,而不是由程序指令自行创建的,我们再来回顾一下程序的运行过程,首先得把程序以指令序列的方式读入内存,程序运行中的所有变量也会存放在内存,所以我们这就需要一个区域来存放程序的指令序列,还需要一个区域来存放程序运行时产生的区域,我们分别称这两个内存区域为程序段和数据段,程序段用于存放读入内存的指令序列,数据段用于存放程序运行时产生的数据。**一个进程实体(进程映像)就是由程序段,数据段,PCB三个部分组成。**其中PCB是操作系统用来管理进程的,所以是操作系统在使用它,而程序段和数据段则是进程自己在使用。
还是以同时运行两个程序为例子,当我们同时运行两个程序的时候,他们的程序段其实是相同的,但数据段和PCB则是不相同的,因为每个进程之间都是独立的, 程序段必然不可能相同,而这本质上也是两个不同的进程,所以PCB也是不同的两个,唯一相同的也只有程序段,因为他们作为同一个程序,运行时执行的指令是相同的。
1695198109.jpg
上面提到了进程实体的概念,进程实体和进程并不完全一样,进程是一个动态运行的过程,任务管理器的进程也是实时变化的,而进程实体则是静态的,进程实体可以反映一个进程在某一时刻的状态。

由于进程实体就是进程在某一时刻的状态,所以如果题目专门考察进程实体和进程的区别,否则我们可以把进程实体相当于进程

以下给出进程的完整定义:进程是进程实体的运行过程,是系统进行资源分配和调度的一个独立单位。
相比于静态的程序,进程拥有以下特征

  • 动态性(最基本特性):进程是程序的一次执行过程,是动态地产生、变化和消亡的
  • 并发性:内存中有多个进程实体,各进程可并发执行
  • 独立性:进程是能独立运行、独立获得资源、独立接受调度的基本单位
  • 异步性:各进程按各自独立的、不可预知的速度向前推进,操作系统要提供“进程同步机制”来解决异步问题
  • 结构性:每个进程都会配置一个PCB。结构上看,进程由程序段、数据段、PCB组成

后面会有线程的概念,在没有线程概念的操作系统重,进程是接受调度的基本单位,但如果引入了线程,那么线程就会成为接受调度的基本单位。

进程的状态与转换

当一个程序读入内存时,操作系统会为其分配进程,此时这个进程的状态就是创建态,进程处于创建态的时候操作系统会为其分配资源,初始化PCB,当进程创建完毕后,进程会处于就绪态,处于就绪态的进程就已经具备了运行条件,但是由于这个时候CPU正在运行其他进程,所以暂时就不能运行。
一个系统中可能会同时存在多个就绪态的进程,但一个CPU同一时刻只能运行一个进程,所以当CPU空闲的时候,操作系统就会根据一些算法从这些就绪状态的进程中选出一个进程来丢给CPU运行,如果一个进程当前正在CPU运行,那么这个进程就处于运行态,当进程处于运行态的时候就意味着CPU正在执行该进程程序段中的指令序列,但是指令序列的运行并不是一帆风顺的,例如指令需要调用打印机打印文件,但是打印机目前正在运行,只有等到打印机空闲的时候我才会继续执行,一个进程在运行中难免会等待某个事件发生,例如上例中的等待打印机空闲,或者也可能是等待其他进程的一个响应,操作系统中还有其他就绪态的进程要执行,不可能让这个进程在CPU上占着干等,所以操作系统会让这个进程下CPU,并让它进入阻塞态,换句话说,处于阻塞态的进程都是在等待某个事件的发生,在其等待的事件发生之前是不可能上处理机运行的,当其等待的事件发生之后,例如一个正在等待打印机空闲的阻塞态进程,打印机空闲之后操作系统会把打印机资源分配给这个阻塞态进程,并且将这个进程的状态改为就绪态,等待被CPU调用。
如果一个正在运行的进程运行结束了,他会发出一个exit系统调用,这个系统调用用于请求操作系统终止改进程,此时进程的状态就会变为终止态,操作系统会让这个进程下CPU,回收其所占用的资源,最后还需要回收进程PCB,当终止工作完成之后,该进程就会从进程彻底消失。
根据上文的描述,我们可以画出进程五状态图
进程五状态图
进程不可能从阻塞态直接转变为运行态,也不可能从就绪态直接转变为阻塞态,只会按照图中箭头的关系进行转换,在单核CPU中,同一时刻处于运行态的进程只会有一个,但多核CPU则允许多个进程同时处于运行态。这些状态信息都会被放在PCB中存储起来,操作系统也会把同一状态的PCB组织起来以方便统一管理。

阻塞态也可称为等待态,创建态可称为新建态,终止态可称为结束态

在进程五状态模型中,由于进程大部分时间处于运行,阻塞,就绪这三种状态,所以这三个状态为基本状态。
在系统中肯定不止一个进程,操作系统要同时管理这么多进程那肯定是需要把进程组织到一起来管理,为了方便管理,还会把同一个状态的PCB单独组织到一起,首先介绍第一种组织方式,链式方式,链式方式就是用链表组织,将处于不同状态的PCB放在不同的链表里,如下图所示
1695201345.jpg
除了链式方式外,另一种组织方式为索引方式,操作系统根据状态建立索引表,索引表中保存处于该状态的所有PCB的地址,如下图所示
1695201482.jpg

进程控制

进程控制就是为了实现进程状态的转换,我们前面只分析了进程状态转换的原因,本节则深入分析在系统状态转换时操作系统干了什么事。
PCB是一个非常重要的信息,为了让操作系统中PCB的信息时刻保持正确,进程控制必须使用原语来实现,原语的概念我们在前面接触过,就是指一气呵成的操作,中间不可被中断。

原语的一气呵成是通过开关中断来实现的,CPU每次执行完一条指令后,都回去检测是否有中断信号需要处理,当执行完一条指令后CPU发现有一个中断信号,那么CPU就会暂停执行当前程序,去执行中断处理子程序,但执行了关中断指令后,CPU不会再检查外部中断的信号,也就不会响应中断信号,一直到CPU执行完开中断指令后才会继续响应中断

进程创建原语

进程创建原语用于实现进程的创建,创建原语会干以下几件事

  • 申请空白PCB
  • 为新进程分配所需资源
  • 初始化PCB
  • 将PCB插入到就绪队列

创建原语即让进程从就绪态转变为就绪态,将其放入就绪队列
以下事件会引起操作系统使用创建原语

  • 用户登录:分时系统中,用户登录成功,系统会建立为其建立一个新的进程
  • 作业调度:多道批处理系统中,有新的作业放入内存时,会为其建立一个新的进程
  • 提供服务:用户向操作系统提出某些请求时,会新建一个进程处理该请求
  • 应用请求:由用户进程主动请求创建一个子进程
撤销原语

撤销原语用于终止进程使用的,使用了撤销原语之后就可以让进程从某一种状态转为终止态,最终从系统彻底消失,撤销原语做了以下事情

  • 从PCB集合中找到终止进程的PCB
  • 若进程正在运行,立即剥夺CPU,将CPU分配给其他进程
  • 终止其所有子进程
  • 将该进程拥有的所有资源归还给父进程或操作系统
  • 删除PCB

进程终止会将就绪/阻塞/运行这三种状态转变为终止态,最终从计算机消失,以下是常见的引起撤销原语的事件

  • 正常结束
  • 异常结束
  • 外界干预
阻塞原语和唤醒原语

当进程需要从运行态进入阻塞态的时候,操作系统就会执行阻塞原语,阻塞原语需要执行以下步骤

  • 找到要阻塞的进程对应的PCB
  • 保护进程运行现场,将PCB状态信息设置为“阻塞态”,暂时停止进程运行
  • 将PCB插入相应事件的等待队列

当一个阻塞进程所等待的事件发生了,就会被唤醒,也就会从阻塞态转变为就绪态,操作系统通过唤醒原语来实现,该原语需要执行以下步骤

  • 在事件等待队列中找到PCB
  • 将PCB从等待队列移除,设置进程为就绪态
  • 将PCB插入就绪队列,等待被调度

引起阻塞原语的事件:

  • 需要等待系统分配某种资源
  • 需要等待相互合作的其他进程完成工作

引起唤醒原语的事件:

  • 等待的事件发生

一个进程因为什么阻塞就应该因为什么唤醒,阻塞原语和唤醒原语必须成对使用

切换原语

切换原语会让一个正在运行的进程下处理机转为就绪态,并从就绪态中选一个进程上处理机,切换原语会让两个进程的状态发生改变
切换原语要做以下步骤

  • 将运行环境信息存入PCB
  • PCB移入相应队列
  • 选择另一个进程执行,并更新其PCB
  • 根据PCB恢复新进程所需的运行环境

这里切换原语提到了运行环境,包括阻塞原语中提到的的保护现场其实也一样,这里简单介绍一下,相当于科普性的内容,众所周知,相比于硬盘,内存的速度是很快的,但尽管如此也无法和CPU的速度相匹配,为了充分发挥CPU性能,CPU在内部设立了很多寄存器用来临时存储一些数据,可以把寄存器理解为办公室的书桌,把内存理解为办公室的书架,而硬盘则理解为外面的仓库,我们看书肯定是在桌子上看,不可能我趴在书架上看,同理我们执行指令的数据也需要放到寄存器里,而问题就出在寄存器他是公共的资源,我这个进程用了,下个进程一来就会把我原来的数据给我覆盖,我下次再来的时候数据就完全不一样了,为了避免这个情况,我在离开CPU之前肯定要把我现在的状态保存到我自己的PCB中,下次我再来的时候再从PCB复原即可。

总之,这些控制原语无非就是更新PCB,将PCB插入到相应队列,分配/回收资源这三步

进程通信

所谓的进程通信,顾名思义,就是两个及以上进程之间产生的数据交互,在一个系统之中肯定不止一个进程,这些进程难免需要相互配合来完成一件事,这就有了进程通信的需求。
出于安全性考虑,一个进程只能访问给他分配好的内存地址空间,这样看起来每个进程似乎都是被孤立起来的,为了实现进程之间的通信,就必须要操作系统提供支持,接下来介绍三种进程间的通信方式,共享存储、消息传递、管道

共享存储

在支持共享存储通信方式的计算机系统里,一个进程不仅可以拥有自己专属的内存地址空间,还可以申请一块共享的存储空间,这块共享的存储空间可以和其他进程共享,通信双方将这块共享存储区都映射到自己的虚拟地址空间之后,进程间通过共享存储区来实现数据的交换,如下图所示
1695206231.jpg
各个进程如果使用共享存储区的方式来通信,就必须要保证对共享存储区的访问必须是互斥的,也就是说通信的进程要保证同一时刻只允许一个进程进行写入操作,目前不用纠结于互斥的具体实现。
共享存储又可以分为基于存储区的共享和基于数据结构的共享,下分述:
基于存储区的共享:操作系统在内存中划出一块共享存储区,数据的形式、存放位置都由通信进程控制,而不是操作系统。这种共享方式速度很快,是一种高级通信方式。
基于数据结构的共享:比如共享空间里只能放个长度为10的数组。这种共享方式速度慢、限制多,是一种低级通信方式。

消息传递

使用消息传递机制时进程间的数据交换就以格式化的消息为单位,进程通信通过操作系统提供的 发送消息/接受消息 两个原语来进行数据交换
先来看一下什么叫做格式化的消息,消息传递所用到的格式化消息由两部分组成,分别为消息头和消息体,消息头保存信息,必须要在消息头中注明发送ID进程,接受ID进程,消息长度等信息,消息体则保存具体的数据。
消息传递的通信方式又可以分为直接和间接两种通信方式,直接通信方式则是发送进程要指明接受进程ID,间接通信方式则时通过“信箱”间接地来进行通信。
先来说直接通信方式,直接通信方式中,当P向Q发送消息时,首先要把消息弄成统一的规范形式,也就是格式化的消息,然后需要P使用send原语,操作系统会将消息挂到进程Q的消息队列上,这个消息队列保存在进程Q的PCB中,进程Q则可以使用receive原语来指定接受进程P的消息,从而实现数据通信。
1695207479.jpg

直接通信方式不管是发送还是接受,都需要指出具体的对象,发送方要指出自己要给哪个进程发消息,接收方则要指出自己要接受哪个进程的消息

接下来就是间接通信方式,间接通信方式和直接通信方式不太一样,是通过信箱来实现的,下面阐述一下具体细节
首先由接收方请求一个邮箱,当然也可以申请多个,并且同一个操作系统也同时存在很多邮箱,当进程P要向Q发消息时,P先申请一个邮箱,然后使用send原语将要发送的消息丢到指定的邮箱里,Q则需要使用receive原语从指定邮箱中接受数据,和直接通信方式不同,P和Q是通过邮箱来实现的通信,而直接通信方式则是直接挂载到对方的PCB上。操作系统允许多个进程往同一个信箱发送信息,也允许多个进程从同一个信箱接受消息。
1695208010.jpg

管道通信

管道通信是单向的通信,要实现双向通信得建立两个管道,所谓管道,就是一种特殊的共享文件,实际上就是在内存区中开辟了一个大小固定的缓冲区。
1695209207.jpg
这里需要区分一下共享存储和管道存储的区别,共享存储开辟的也是一个缓冲区,但是是不对进程的读写做任何限制的,也就是想怎么搞这片内存那就可以怎么搞,但管道不同,管道是单向的,一定是发送建立管道之后往里写,接收进程读,不能反过来,只能实现半双工通信,其次读写顺序也是严格要求的,都得从最开始的一个单元依次往后读/写,是一个数据流的形式,简而言之,管道通信对数据的读写一定是先进先出的,可以把管道这个内存缓冲区理解为循环队列。

  • 管道只能采用半双工通信,某一时间段内只能实现单向的传输。如果要实现双向同时通信,则需要设置两个管道。
  • 各个进程对管道的访问是互斥进行的(操作系统保证)
  • 当管道写满时,写进程将阻塞,直到读进程将管道中的数据取走,即可唤醒写进程。
  • 当管道读空时,读进程将阻塞,直到写进程往管道中写入数据,即可唤醒读进程。
  • 管道中的数据一旦被读出,就彻底消失。因此,当多个进程读同一个管道时,可能会错乱。对此,通常有两种解决方案:①一个管道允许多个写进程,一个读进程(2014年408真题高教社官方答案);②允许有多个写进程,多个读进程,但系统会让各个读进程轮流从管道中读数据(Liux的方案)

线程的概念

在介绍线程之前,我们认为进程是处理器的最小调度单位,所以进程也只能是串行执行的,但单个进程确实又有并发的需求,比如微信我希望在打电话的同时聊天一样,为了解决单个进程的并发问题,就引入了线程来提高并发性,
1695209887.jpg
可以把线程理解为“轻量级进程”。线程是一个基本的CPU执行单元,也是程序执行流的最小单位。引入线程之后,不仅是进程之间可以并发,进程内的各线程之间也可以并发,从而进一步提升了系统的并发度,使得一个进程内也可以并发处理各种任务(如QQ视频、文字聊天、传文件)引入线程后,进程只作为除CPU之外的系统资源的分配单元(如打印机、内存地址空间等都是分配给进程的)。线程则作为处理机的分配单元。
引入线程后带来的变化:

引入线程前引入线程后
资源分配和调度进程是资源分配、调度的基本单位进程是资源分配的基本单位,线程是调度的基本单位
并发性只能进程间并发各线程间也能并发,提升了并发度
系统开销传统的进程间并发,需要切换进程的运行环境,系统开销很大如果是同一进程内的线程切换,则不需要切换进程环境,系统开销小

以下是线程的属性

  • 线程是处理机调度的单位
  • 多CPU计算机中,各个线程可占用不同的CPU
  • 每个线程都有一个线程ID、线程控制块(TCB)
  • 线程也有就绪、阻塞、运行三种基本状态
  • 线程几乎不拥有系统资源
  • 同一进程的不同线程间共享进程的资源
  • 由于共享内存地址空间,同一进程中的线程间通信甚至无需系统干预
  • 同一进程中的线程切换,不会引起进程切换
  • 不同进程中的线程切换,会引起进程切换
  • 切换同进程内的线程,系统开销很小
  • 切换进程,系统开销较大

线程的实现方法

线程的实现方式分为用户级线程和内核级线程两种
用户级线程主要是早期不支持线程的操作系统使用的,当时的线程是由线程库来实现的,也就是程序员自己手写,和操作系统根本没关系,如下图所示
1695210986.jpg
也就是说这种方式实际上是程序员来模拟线程,实际的实现思路参考下面的代码

int main(void){
    int i = 0;
	while(true){
        if(i == 0){
            //处理事件1
        }
        if(i == 1){
            //处理事件2
        }
        if(i == 2){
            //处理事件3
        }
        i = (i+1)%3;
    }
    
}

由于while循环的执行是非常快的,所以我们就通过这种方式模拟出了线程
很多编程语言都会提供线程库,通过线程库可以实现线程相关的操作,但这都是基于线程库的,在操作系统的眼里只能看到进程,他也意识不到这些线程的存在。
下面对优缺点进行分析
优点:用户级线程的切换在用户空间即可完成,不需要切换到核心态,线程管理的系统开销小,效率高
缺点:当一个用户级线程被阻塞后,整个进程都会被阻塞,并发度不高。多个线程不可在多核处理机上并行运行。

随着操作系统的发展,也逐渐开始支持线程,操作系统支持的线程就是内核级的线程,如下图所示
1695211643.jpg
在引入了内核级线程后,线程的管理工作就由操作系统来完成,在进行线程切换的时候,由于需要操作系统介入,所以也需要从用户态转变为内核态,操作系统也可以意识到内核级线程的存在,接下来一样来分析一下优缺点
优点:当一个线程被阻塞后,别的线程还可以继续执行,并发能力强。多线程可在多核处理机上并行执行。
缺点:一个用户进程会占用多个内核级线程线程切换由操作系统内核完成,需要切换到核心态,因此线程管理的成本高,开销大

如果将这两种方式结合起来,就会有三种不同的多线程模型,分别是一对一,多对一,多对多,看下图
一对一模型
一对一模型就是一个用户级线程对应一个内核级线程
优点:当一个线程被阻塞后,别的线程还可以继续执行,并发能力强。多线程可在多核处理机上并行执行。
缺点:一个用户进程会占用多个内核级线程,线程切换由操作系统内核完成,需要切换到核心态,因此线程管理的成本高,开销大。

多对一
多个用户级线程映射到一个内核级线程,并且一个进程只分配了一个内核级线程
,这种映射关系就退化成了用户级线程的实现方式。
优点:用户级线程的切换在用户空间即可完成,不需要切换到核心态,线程管理的系统开销小,效率高
缺点:当一个用户级线程被阻塞后,整个进程都会被阻塞,并发度不高。多个线程不可在多核处理机上并行运行

多对多模型
多对多模型把n个用户级线程映射到m个内核级线程上,n>=m
这个模型中,如果其中一个内核级线程被阻塞,另一个内核级线程依然可以继续运行,并且由于内核级线程比用户级线程少,与一对一模型相比,操作系统的管理成本相应就会更小
这种模型克服了多对一模型并发度不高的缺点(一个阻塞全体阻塞),又克服了一对一模型中一个用户进程占用太多内核级线程,开销太大的缺点。
用户级线程可以看做是代码逻辑的载体,内核级线程则是运行机会的载体,换句话说,按上图的例子,三个用户级线程是共享两个用户级线程,至于如何选择内核级线程是线程库应该干的事,如果其中一个内核级线程被阻塞那还是有另一个内核级线程可用,只有两两个内核级线程都阻塞的情况下我们才会说这个进程处于阻塞状态。

线程的状态与转换

线程的状态与转换与进程的状态与转换是一模一样的,但线程的状态我们主要关注基本的三种状态,,即就绪,运行,阻塞,转换关系如下图所示
1695270278.jpg
线程的组织与控制与进程的组织与控制也是非常类似的,操作系统对进程的管理是通过PCB,而对线程的管理则是通过TCB,TCB包含了对该线程管理所需要用到的所有信息,如下图所示
1695270399.jpg
一个系统中肯定有多个线程,为了管理这些线程,就需要将这些线程组织起来,组织线程方式有很多种,可以将每个进程的线程都组织成一个线性表,也可以将系统中所有的线程组织为一个线性表,当然也可以参考进程表的逻辑,根据线程的状态来组织线程表。

处理机调度

调度的基本概念和层次

调度,意思和安排类似,举个例子,假设一个银行只有一个窗口,但是却又很多人在那儿等着,银行应该先服务谁呢,一般来说,银行会采用先来先服务原则,但是有的VIP用户可能会被优先服务,这其实就是调度。
同样,对于办理业务来说,有的人可能需要3分钟,有的人可能需要10分钟,有的人可能1分钟就办完了,这个时候可能这些人就会达成一个原则,就是时间使用短的优先服务,这也是一种调度。
从上面的例子可以看出,当有一堆任务要处理,但由于资源有限,这些事情没法同时处理。这就需要确定某种规则来决定处理这些任务的顺序,这就是“调度”研究的问题。
在操作系统中,有三个层次的调度,分别是高级调度,中级调度和低级调度
第一种调度叫做高级调度,高级调度也称为作业调度,这里出现了一个作业的概念,所谓作业,就是一个具体的任务,用户向操作系统提交了一个作业也就是说用户让操作系统启动一个程序来处理某个任务。
由于我们内存资源有限,通常不会讲所有作业都调入内存,所以会从硬盘中选择一个作业先调入内存,并且会为其建立PCB,每个作业只会被调入一次,也只会被调出一次,调入时创建PCB,调出时撤销PCB。从这里可以看出,作业调度是发生在硬盘和内存中的调度。
接下来来看低级调度,低级调度也称为进程调度,在内存中有很多处于就绪态的进程,但CPU核心数又是有限的,所以操作系统会按照某种策略,从就绪队列中挑选出一个进程,将处理机资源分配给他,并发性肯定离不开系统调度,所以我们也称系统调度为OS中最基本的一种调度,正应为系统调度要实现并发性,所以他的频率也是很高的,低级调度是CPU和内存中的调度。
最后一种是中级调度,中级调度也称为内存调度,当我们内存资源不够的时候,操作系统可能会将某些进程的数据从内存调出外存,等内存空闲的时候再调入内存,当进程数据被挂到外存之后,该进程就处于挂起状态,操作系统也会把处于挂起态的进程PCB组织成一个挂起队列,当有空闲的内存资源后,操作系统会根据某种策略从挂起队列中选择一个进程把它从硬盘中调回内存,这就是中级调度,中级调度是从硬盘调入内存的调度。

从发生频率上来讲,低级调度>中级调度>高级调度

接下来剖析一下挂起状态,挂起状态就是进程数据被调到硬盘后的进程所处的状态,而挂起状态又可以分为就绪挂起和阻塞挂起, 当进程处于阻塞态被挂起那就是阻塞挂起,当进程处于阻塞态时被挂起那就属于阻塞挂起,有了阻塞挂起和就绪挂起两种状态,我们前面的五状态模型就会扩充为七状态模型,如下图所示
1695565790.jpg
挂起和阻塞的进程都是无法直接获取到CPU服务的,其区别在于挂起态是将进程映像调到外存去了,而阻塞态下进程映像还在内存中。
有的操作系统会把就绪挂起、阻塞挂起分为两个挂起队列,甚至会根据阻塞原因不同再把阻塞挂起进程进一步细分为多个队列。

要做什么调度发生在什么之间调度频率对进程状态影响
高级调度 (作业调度)按照某种规则,从后备队列 中选择合适的作业将其调入 内存,并为其创建进程外存>内存 (面向作业)最低无>创建态>就绪态
中级调度 (内存调度)按照某种规则,从挂起队列 中选择合适的进程将其数据 调回内存外存>内存 (面向进程)中等挂起态→就绪态 (阻塞挂起>阻塞态)
低级调度 (进程调度)按照某种规则,从就绪队列 中选择一个进程为其分配处 理机内存>CPU最高就绪态>运行态

调度算法的评价指标

前面我们提到的,根据某种策略进行调度,实际上这种策略就是所谓的调度算法,我们评价一个调度算法也有一些指标。
CPU利用率也就是CPU处于忙碌的时间占总时间的比例,即 利用率 = 忙碌时间 总时间 利用率 = \frac{忙碌时间}{总时间} 利用率=总时间忙碌时间

一般来说利用率都是总时间占忙碌时间的比率,包括打印机这种外设

系统吞吐量就是指单位时间内完成的作业数量,即 系统吞吐量 = 总共完成了多少作业 总共花了多少时间 系统吞吐量 = \frac{总共完成了多少作业}{总共花了多少时间} 系统吞吐量=总共花了多少时间总共完成了多少作业
周转时间也就是某个作业从被提交到系统开始,到作业完成为止所花费的时间,周转时间包含以下四个部分

  • 作业在外存等待作业调度的时间
  • 进程在就绪队列上等待的时间
  • 进程处于运行态的时间
  • 进程处于阻塞态的时间

作业的周转时间 = 作业完成时间 - 作业提交给系统的时间
对于操作系统来说,他肯定不能局限在某一个进程,他关心的是平均周转时间,也就是所有作业周转时间的平均值,即 平均周转时间 = 所有作业周转时间之和 作业数量 平均周转时间 = \frac{所有作业周转时间之和}{作业数量} 平均周转时间=作业数量所有作业周转时间之和
接下来还有一个带权周转时间, 带权周转时间 = 作业周转时间 作业实际运行的时间 带权周转时间 = \frac{作业周转时间}{作业实际运行的时间} 带权周转时间=作业实际运行的时间作业周转时间,从这里可以看出,带权周转时间必然大于等于1,带权周转时间可以用来反映用户满意度,带权周转时间越小,说明作业被CPU服务得多,用户满意度高,所以带权周转时间与用户满意度呈负相关。
和平均周转时间一样,这里也会有一个平均带权周转时间: 平均带权周转时间 = 每个作业带权周转时间之和 作业数量 平均带权周转时间= \frac{每个作业带权周转时间之和}{作业数量} 平均带权周转时间=作业数量每个作业带权周转时间之和
用户肯定希望自己的进程等待越短的时间越好,对于进程来说,等待时间就是处于就绪态的时间(等待IO的时间不算入等待时间,因为其也可以看做正在被IO设备服务),而对于作业来说,不仅要考虑其处于就绪态的时间,还包括其在外存中等待被调度的时间。

一般来说,一个作业在CPU运行的总时长一般是确定不变的,所以调度算法实际上只会影响作业/进程的等待时间

同样,等待时间也有一个平均等待时间的指标,来评价整体性能
最后还有一个响应时间,响应时间就是从用户提交请求到首次产生响应所花费的时间
以上的性能指标都需要会计算

进程调度的时机,切换与过程调度方式

进程调度就是将就绪态的进程切换成运行态的调度算法,进程调度的时机就是指在什么时候会产生进程调度,一般有两种情况会产生进程调度,第一种是当前进程主动放弃,第二种是当前进程被动放弃,这里举几个例子,当进程正常结束,或者发出了IO请求,这些都是进程主动放弃处理机,而进程时间片用完,或者说有优先级更高的进程进入队列,当前进程都会被强行剥夺处理机使用权。
需要注意有几种情况不能进行进程调度

  • 在处理中断的过程中。中断处理过程复杂,与硬件密切相关,很难做到在中断处理过程中进行进程切换。
  • 进程在操作系统内核程序临界区中。
  • 在原子操作过程中(原语)。原子操作不可中断,要一气呵成(如之前讲过的修改PCB中进程状态标志,并把PCB放到相应队列)

1695568379.jpg
1695568435.jpg

有的操作系统允许进程被动放弃,有的操作系统则不允许进程被动放弃,根据这个不同,操作系统的调度方式又分为抢占式调度方式和非抢占式调度方式

  • 非剥夺调度方式,又称非抢占方式。即,只允许进程主动放弃处理机。在运行过程中即便有更紧迫的任务到达,当前进程依然会继续使用处理机,直到该进程终止或主动要求进入阻塞态。
  • 剥夺调度方式,又称抢占方式。当一个进程正在处理机上执行时,如果有一个更重要或更紧迫的进程需要使用处理机,则立即暂停正在执行的进程,将处理机分配给更重要紧迫的那个进程。

非抢占调度方式实现更简单,但无法及时处理紧急任务,只适用于早期批处理系统,而抢占式则可以优先处理紧急任务,也可以实现时间片轮转,这种方式则适用于分时操作系统和实时操作系统。
前面我们介绍了进程调度,下面在引入一个进程切换的概念,进程调度是指从就绪队列中选择一个进程上处理机运行,这里举个例子来说明何为进程切换,假如我现在处理机上的进程是A,就绪队列上有一个进程为B,当A时间片用完之后,我可以再选择进程A,这也是一种进程调度,但却不是进程切换,但如果我此时选择进程B,原来是A,现在是B,就会引发进程切换,通俗来讲,进程切换就是一个进程让出处理机,另一个进程占用处理机,并且这两个进程不能是同一进程。

广义的进程调度其实包括选择进程和进程切换两个步骤

进程切换主要有以下两个过程

  1. 对原来运行进程各种数据的保存
  2. 对新的进程各种数据的恢复
    (如:程序计数器、程序状态字、各种数据寄存器等处理机现场信息,这些信息一般保存在进程控制块)

进程切换是有代价的,进程切换越频繁并不意味着系统效率高,如果进程切换过于频繁,同样会导致系统效率整体降低

调度器、闲逛程序

调度器也称为调度程序,顾名思义就是用来实现调度的程序,所有的调度都是通过调度程序来完成的,调度程序一般要实现两个功能,一是选择谁运行,二是运行的时间,选择谁运行是取决于调度算法的,而让谁运行多长时间则是由时间片大小决定的,在需要调度的时候都会触发调度程序,主要有以下几个时机

  • 创建新进程
  • 进程退出
  • 运行进程阻塞
  • I/O中断发生(可能唤醒某些阻塞进程)

根据系统的不同,调度程序被触发的时机也会有差异,对于非抢占策略,只有运行结束或进程阻塞才会运行调度算法,而对于抢占式的调度策略,则不仅要在以上四个时机发生调度,在每个时钟中断也会发生调度。
下面来介绍一个比较特殊的进程,我们称之为闲逛进程,首先要知道的是,只要系统处于开机状态,那么CPU就是永远不会停止工作的,永远都会有进程在CPU上工作,哪怕现在没有就绪态的进程,系统也会让一个叫闲逛进程的进程上CPU运行,换句话说,闲逛进程就是没有其他就绪进程的时候操作系统会选择的进程。
闲逛进程有以下特点:

  • 优先级最低
  • 可能是0地址,占用一个完整的指令周期(每一个指令周期后都会检查中断)
  • 能耗低

批处理系统中常见的调度算法

在早期批处理系统中,主要采取的调度算法主要有以下几个

  • 先来先服务
  • 短作业有限
  • 高响应比优先
先来先服务(FCFS)

先来先服务算法的主要思想就是公平,就类似于数据结构中的队列,带入到生活中就是排队买东西,该算法就是先到达的进程先服务,后到达的进程后服务,该算法属于非抢占式算法。
先来先服务算法的优点就是非常公平且简单,但是缺点也非常明显,如果我这个任务只需要很短的时间就能完成,但是我前面的任务需要花很长的时间,那么就得让我等很长的时间,很显然,这种算法对短作业是不友好的,相反,对长作业就很有理。

由于该算法绝对的公平性,每一个任务都可以得到服务,只是时间早晚的问题,所以该算法并不会导致饥饿

短作业优先算法(SJF)

先来分析一下为什么先来先服务算法会导致这种问题,本质上来说,先来先服务算法只考虑了等待时间,也就是等待时间最长的进程先服务,并没有考虑进程的实际运行时间,所以才导致了其对短作业的不友好,短作业优先算法正是为了解决这个问题。
短作业优先算法有两个版本,分别是非抢占式的和抢占式的,其中抢占式的短作业优先算法又称为最短剩余时间优先算法(SRTN),我们这里所说的短作业优先算法默认都是非抢占式的,而对于抢占式的版本,我们会用最短剩余时间算法来替代,下分别介绍。
先介绍短作业优先算法,该算法为非抢占式算法,该算法会选择运行时间最短的算法,或者说要求服务时间最短的进程进行服务,由于该算法是非抢占式算法,所以该算法只会在程序运行结束或者发生阻塞主动下处理机才会被使用。
下面是最短剩余时间优先算法,顾名思义,该算法选择剩余运行时间最短的进程进行处理,该算法为抢占式算法,每当一个进程到达就绪队列的时候,或者说就绪队列发生改变的时候,就会调用该算法,当然,进程运行结束或者阻塞也会调用该算法,这毋庸置疑,该算法选择剩余时间最短的进程上处理机运行,何为剩余时间最短,举个例子,进程A要求运行时间为7个时间单位,当A运行了2个时间单位的时候,进程B到了就绪队列,此时就会调用该算法,假设B要求的运行时间为6,由于A进程还剩余7-2=5个时间单位,比B更短,所以还是会选择A进程,有运行了2个时间单位后,进程C到就绪队列,假设进程C的要求运行时间只要1个时间单位,由于A还剩5-2=3个时间单位,C的剩余时间更短,所以就会让C上处理机运行。
短作业有限算法和最短剩余时间优先算法的优点非常明显,其对短作业极其友好,并且最短剩余时间优先算法可以获得最少的平均周转时间,但缺点就是会导致长作业饥饿(长期得不到服务),如果有源源不断的短作业到来,长作业甚至会饿死(永远得不到服务)。

“短作业优先算法可以获得最短平均周转时间”这种说法并不严谨,因为其平均周转时间不如最短剩余时间优先算法,但在选项中也可能是正确选项

可能读者会有疑惑,就是对于短作业优先算法,我必须事先知道他的运行时间,这个信息从哪来呢?这个信息其实是开发者自己提供的,所以如你想的一样,开发者也有可能造假,这也是这种方式的缺陷之一。

高响应比优先算法(HRRN)

综合前面两个算法,先来先服务算法只考虑了等待时间不考虑运行时间,导致了其对短作业不友好,而短作业优先算法只考虑了运行时间而完全不考虑等待时间,导致了其对长作业不友好,这里很容易想到,高响应比算法就是将二者结合起来,既考虑等待时间有考虑运行时间。
首先来看响应比的计算公式 响应比 = 要求运行时间 + 等待时间 要求运行时间 响应比 = \frac{要求运行时间 + 等待时间}{要求运行时间} 响应比=要求运行时间要求运行时间+等待时间
高响应比优先算法只有非抢占的版本,也就是当进程停止运行或者主动放弃运行的时候才会重新计算响应比,在每次计算完响应比后,操作系统会选择一个响应比最高的程序上处理机运行。
如果等待时间为0,那么不管要求运行时间是多久,响应比都为1,响应比的增加是随等待时间的增加而增加的,而响应比随时间的增长速度则是由要求运行时间决定的,所以可以说这种算法既考虑了等待时间,又考虑了要求运行时间,是对上两种算法的综合考虑,同时,任何一个进程的响应比都会随着等待时间的增加而增加,所以该算法也解决了饥饿问题。

由于新进入就绪队列的响应比都为1,所以把高响应比优先算法设计为抢占式是毫无意义的行为

交互式操作系统的调度算法

前面提到的调度算法大多都是非抢占式算法,所以根本无法实现并发,所以只能适用于早期的批处理操作系统,而交互式调度算法则需要实现并发,所以交互式调度算法毫无疑问都是抢占式调度算法,同时,对于以下调度算法而言均只适用于进程调度,无法用于作业调度。
我们主要介绍四种调度算法

  • 时间片轮转算法
  • 优先级调度算法
  • 多级反馈队列调度算法
  • 多级队列调度算法
时间片轮转算法

时间片轮转的思想就是公平,时间片轮转算法会设置时间片,通常都很短,例如100ms,系统每隔100ms都会强行剥夺当前进程的CPU使用权,将该进程放到就绪队列的队尾,并且让就绪队列的第一个进程上处理机运行,这个每隔100ms的计时任务是通过时钟信号发生器实现的,这是一个硬件,此课程不涉及。
那么这里就有一个问题,就是时间片设置多少合适,首先时间片肯定不是越大越好,如果时间片设置的很大,以至于所有进程都能在一个时间片内结束工作,那么这个算法就会退化成前面的先来先服务算法,但时间片也不是越小越好,因为每次进程切换都是有开销的,如果时间片设置的过小,就会导致大量的开销成本,反而得不偿失,所以时间片需要在中间权衡一个利弊,一般来说,设计时间片的时候要让切换进程的开销占比不超过1%
该算法最大的优点就是公平,每个进程都会得到服务,不会导致饥饿,可用于分时操作系统,但问题也很突出,高频的进程切换其实是比较耗费性能的,并且该算法无法区分任务的紧急程度。

优先级调度算法

由于时间片轮转算法并不能区分任务的紧急程度,为了解决这个问题,就提出了优先级调度算法,优先级调度算法同时有抢占式和非抢占式两种,区别前面已经介绍过,非抢占式就是只有程序结束或者主动阻塞时才会调度,而抢占式在就绪队列更新的时候也会发生调度,除此之外没有区别。
在这种算法中,每一个进程都被赋予了一个优先级,调度时按照优先级来,优先级高的进程会被优先调度,恨显然,如果优先级是静止的,那么如果一直有高优先级的任务到来,那么低优先级的任务就会饥饿,所以为了解决这个问题,就引入了动态优先级,动态优先级允许程序的优先级在程序运行的过程中进行改变,如果程序等待时间长,可以适当提高其优先级,以避免饥饿。
优先级通常符合以下规则

  1. 系统进程优先级 高于 用户进程
  2. 前台进程优先级 高于 后台进程
  3. 操作系统更偏好 I/O型进程(或称 I/O繁忙型进程)
多级反馈队列调度算法

以上学过的所有算法都存在一些问题,也存在一些优点,多级反馈队列就是对这些算法的集大成者,结合了所有算法的优点,nginx系统正是采用了这种调度算法,所以我们一般不认为多级反馈队列的调度算法优缺点,如果硬要说缺点,就是他可能会导致饥饿。
多级反馈队列调度算法的规则比较复杂,下慢慢介绍
首先,多级反馈队列的调度算法会按照优先级设置多个就绪队列,每个队列有各自的时间片,优先级越高的队列时间片越小,如下图所示
1695674921.jpg

  • 设置多级就绪队列,各级队列优先级从高到低,时间片从小到大
  • 新进程到达时先进入第1级队列,按FCFS原则排队等待被分配时间片,若用完时间片进程还未结束,则进程进入下一级队列队尾。如果此时已经是在最下级的队列,则重新放回该队列队尾
  • 只有第 k 级队列为空时,才会为 k+1 级队头的进程分配时间片

该算法为抢占式算法,如果此时有进程正在CPU运行,如果高优先级的队列先来了一个进程,那么会立刻终止当前进程,转而给高优先级进程服务,被终止的进程会进入其当前队列的队尾,而不放入下一级队列。

多级队列调度算法

该算法是通过按照优先级划分很多队列,每一个队列可能会采用不同的调度算法,如下图所示
1695675430.jpg
队列之间存在优先级,这种优先级可以是固定的,也就是说高级优先级永远优于低级优先级,也可以是根据时间片划分的,例如这三个队列的时间片比例可以是50%,30%,20%

同步与互斥

基本概念

首先来了解什么是进程同步,同步和前面的异步其实是一组相对的概念,先来回顾一下异步性,异步就是指各个进程并发执行,各自以不可预知的速度向前推进,但是有时候我们必须得解决这个不可预知的问题,我们需要两个及以上的进程配合工作,就是需要A进程的某些代码必须在B进程的某些代码完成之后运行,就比如A想要把数据传递给B进程,我B进程的读取操作必须要在A进程的写入操作之后吧,我不可能说A进程还没有在缓冲区,B进程就直接上去读吧,这显然读不到数据,这就是同步问题。
而什么又是互斥呢,前面我们提资源共享方式的时候提到了互斥共享和同时共享,先来回顾一下,互斥共享就是指某些资源同一时间段内只允许一个进程访问,例如摄像头这些,而同时共享则是一个资源同一时间段内允许多个进程进程访问(宏观上并行,微观上并发),例如内存资源。我们把摄像头这种同一时间段内只允许同一个进程访问的资源称为临界资源,像摄像头这些就属于临界资源,对于临界资源的访问必须符合互斥原则,即同一时间段内只允许一个进程访问临界资源,如果其他进程还要访问这个临界资源,则需要让他等待,只有临界资源被释放之后,才允许等待的进程继续访问临界资源。
我们规定要访问临界资源只有通过临界区,并且临界区只有一个,所以我们不允许多个进程进入临界区,我们可以从逻辑上将访问临界资源的代码分为以下四个部分

  • 进入区:检查临界资源是否空闲,如果临界资源空闲,则允许进入临界区,并且对临界区上锁
  • 临界区(临界段):临界区就是用于访问临界资源的代码
  • 退出区:此时访问临界区结束,对临界区进行解锁
  • 剩余区:做其他后续处理

这里对临界区上锁和解锁的操作相当于设置了一个bool类型的变量,上锁置为1,解锁置为0,检查该变量是否为1,就可以判断是否被上锁

从上面的逻辑能看出,对临界资源的互斥操作其核心其实是通过进入区和退出区实现的,我们后面主要也是研究进入去和退出区应该怎么具体实现。
在正式说互斥问题之前,我们必须明确一些互斥功能原则:

  • 空闲让进。临界区空闲时,可以允许一个请求进入临界区的进程立即进入临界区;
  • 忙则等待。当已有进程进入临界区时,其他试图进入临界区的进程必须等待;
  • 有限等待。对请求访问的进程,应保证能在有限时间内进入临界区(保证不会饥饿);
  • 让权等待。当进程不能进入临界区时,应立即释放处理机,防止进程忙等待。

这些原则是在同步互斥问题中必须要遵守的,如果违反了第一条,那么临界资源则永远不可能被访问,如果违反了第二条,那么临界资源就相当于运行多个进程进行访问了,也就完全违背了临界资源的定义,如果违背了第三条,那么就会导致一些进程的饥饿,如果违反了第四条,那么CPU就会忙等,也就是他啥都不干,但是就是占着处理机不放。

进程互斥的实现方法

软件实现方法
单标志法

该算法的算法思想是为临界区设立一个变量,改变量保存一个进程号,用于表示目前允许哪个进程进入临界区,其代码的等价形式如下所示

int turn = 0;//用于表示目前运行进入临界区的进程号

// P0进程
while(turn != 0);//当前是否允许我进行访问
//访问临界资源
turn = 1;//让别人访问
//进行其他代码


// P1进程
while(turn != 1);//当前是否允许我进行访问
//访问临界资源
turn = 0;//让其他人进行访问
//进行其他代码

对于P0而言,会循环判断turn是否为0,如果turn为0则允许自己访问,否则就一直等待,当自己访问完毕后,会将临界区的访问权交给P1,这种方式存在明显的缺陷,因为turn变量的改变只能在一个进程对临界资源访问结束之后,所以如果P0访问临界区后,将turn变成了1,如果P1一直不使用临界资源,哪怕临界资源空闲,P0也不能访问临界资源,只有等P1进程访问一次临界资源之后才会将turn变为0,P0才能访问临界资源。
综上,这种方式确实可以实现互斥访问,但问题在于他只能实现轮流访问,也就是P0 -> P1 -> P0这样访问,哪怕临界资源空闲,P0也不能连续对临界资源访问两次,显然违背了空闲让进原则。

双标志先检查法

该算法的设计思想是设立一个bool类型的数组flag,用于记录每一个进程是否有访问临界区的意愿,其代码的等价形式如下

bool flag[2];
flag[0] = false;
flag[1] = false;

//p0进程
while(flag[1]);//其他进程是否有访问意愿,如果其他进程想访问,我就等待
flag[0] = true;//其他进程不想访问,那么我要访问,修改flag数组,表示我想访问
//访问临界资源
flag[0] = false;//访问结束,我不想访问了,修改我的访问意愿
//其他代码

//P1进程
while(flag[0]);//其他进程是否有访问意愿,如果其他进程想访问,我就等待
flag[0] = true;//其他进程不想访问,那么我要访问,修改flag数组,表示我想访问
//访问临界资源
flag[1] = false;//访问结束,我不想访问了,修改我的访问意愿
//其他代码

这种方式是设立一个数组,用于表示各个进程对该临界资源的访问意愿,只有当数组中的元素全为false的时候,表示其他所有进程都不想访问这个临界资源,自己才会进入访问临界资源,从这里也能看出,如果按照我们理想的情况,flag数组同一个时刻最多只能有一个值为true,这样便实现了互斥访问,这种方式看似没有问题,但需要考虑并发执行,当两个进程代码是并发的时候,代码的运行顺序可能如下所示(数字表示上代码行数)
6 -> 13 -> 14 -> 7 -> 14 -> 8 -> 15
如果是这种顺序运行,当运行到6之后立刻运行13,此时两个进程对flag数组的检测均为全false,所以两个进程都可以进入下一行代码,即14和7,此时两个进程分别都可以对flag数组进行修改,并同时进入临界区,这显然违反了忙则等待的原则,也就是说其在一定条件下会导致多个进程同时进入临界区。
该问题导致的根本原因在于并发性,对flag的检查和对flag的修改不是一气呵成的。

单标志后检查法

这种方式是单标志先检查法的修改,代码逻辑如下所示

bool flag[2];
flag[0] = false;
flag[1] = false;

//p0进程
flag[0] = true;//我想访问
while(flag[1]);//其他进程是否有访问意愿,如果其他进程想访问,我就等待
//访问临界资源
flag[0] = false;//访问结束,我不想访问了,修改我的访问意愿
//其他代码

//P1进程
flag[0] = true;//我想访问
while(flag[0]);//其他进程是否有访问意愿,如果其他进程想访问,我就等待
//访问临界资源
flag[1] = false;//访问结束,我不想访问了,修改我的访问意愿
//其他代码

我们再来按照前面的顺序分析一下
6 -> 13 -> 14 -> 7 -> 14 -> 8 -> 15
此时进入6,在进入13,flag被修改后,再到14和7那儿去判断,此时确实避免了上面的问题,也就是解决了忙则等待的问题,但新的问题又出现了,这样的话14和7两个while循环就成死循环了,任何一个都无法进入临界区的了,这显然违反了空闲让进和优先等待原则。

Peterson算法

该算法是对单标志法和双标志法的结合,代码的等价逻辑如下

bool flag[2];
int turn = 0;
flag[0] = flag[1] = false;

//P0进程
flag[0] = true;//我想访问
turn = 1;//假设你正在访问,或者说将先访问的权利交给对方,我先不访问
while(flag[1] && turn == 1);//如果你想访问,并且目前访问的是你,那么我等待
//临界区
flag[0] = false;
//剩余区

//P1进程
flag[1] = true;//我想访问
turn = 0;//假设你正在访问,或者说将先访问的权利交给对方,我先不访问
while(flag[0] && turn == 0);//如果你想访问,并且目前访问的是你,那么我等待
//临界区
flag[1] = false;
//剩余区

这种算法结合了单标志法和双标志法,在进入临界区之前,先表示自己要访问的意愿,并且假设对方正在访问,要想进入临界区,要么其他进程将资源给他,即修改turn,要么其他进程都没有访问。
这种方式解决了空闲让进,忙则等待,优先等待三个原则,但是由于又死循环,所以不会遵循让权等待的原则,哪怕是他进不了临界区,他也会一直死循环忙等,这其实也是前面所有算法都存在的问题。

区分一下忙等和死等的概念
忙等:违反了让权等待就是忙等,说的通俗一点,一个进程如果等不到临界资源还在处理机上不断运行占用处理机资源,这就是忙等
死等:违反了优先等待就是死等,也就是说死等的进程永远进入不了临界区
还需要注意,忙等只是占用处理机资源,并不意味着不会发生进程切换

硬件实现方法
中断屏蔽方法

中断屏蔽方式就是在进入临界区之前关中断,进入临界区之后开中断,其原理就是在进程进入临界区访问的时候不会发生系统调度,也就解决了互斥,这种方式实现起来非常简单且高效,但是也存在诸多问题,首先是其不适用于多核操作系统,这是由于关中断只适用于单个内核的关中断,也就是说内核1执行了关中断指令,那么只有内核1不会发生调度,内核2依旧可以正常执行,所以对于多内核的CPU,这种方式会导致多个进程进入临界区。除此之外,这种方式由于使用的是关中断和开中断指令,这两条指令权限高,属于特权指令,不能给用户进程使用所以这种方式其实并不适用于用户进程,只适用于内核进程。

TestAndSet指令(TS指令/TSL指令)

TS指令是TestAndSet指令的简称,该指令有时候也被称为(TestAndSetLock/TSL)
这条指令是由硬件实现的,属于原语操作,不能被打断,这里给出其等价的代码逻辑

bool TestAndSet(bool *lock){
    bool old;
    old = *lock;
    *old = true;
    return old;
}

该算法是通过硬件实现的原语操作,这里只是其等价逻辑的软件代码,这一点务必切记。
再改算法中,lock为上锁标志位,如果lock为true,说明该临界资源被上锁,如果lock为false,表示没有被上锁。
该算法的核心逻辑为,不管lock有没有被上锁,我都将他上锁,并且我拿到他原来的上锁状态,结合前面软件的双标志法,软件的双标志法中一共有两步,第一步是检查其他进程是否正在访问,对应这里的return old,如果返回的是true,则表示其他进程正在访问,否则则没有访问,而双标志法中的上锁操作对应的就是对lock的上锁,本质上相当于双标志法的硬件实现,由于该算法是硬件实现的一气呵成的操作,所以克服了双标志法的问题,以下我们用这个算法实现互斥逻辑

该算法是硬件实现后由指令的方式呈现给操作系统的

while(TestAndSet(&Lock));
//临界区
lock = false;
//剩余代码段

任何进程想访问临界区都需要使用这段代码,这段代码的本质就是对lock不停地上锁,这个不停上锁的过程相当于一直不停的在请求资源,在上锁的同时拿到原来的状态,如果原来的状态为true,说明有其他进程正在访问临界区,一旦临界区被释放,则该进程会立刻上锁,并且拿到的原来状态就是false,此时就可以进入临界区。
由于这种算法也会有一个while死循环,同样会有忙等问题,但这种方式实现简单,并且也支持多处理剂。

Swap指令(XCHG指令)

Swap指令也叫XCHG指令,这个指令的功能是交换两个变量的值,但其是硬件实现的,暴露给操作系统的是一个指令,所以其也是一气呵成不可中断的,接下来给出等价的软件逻辑。

Swap(bool *a,bool *b){
    bool temp = *a;
    *a = *b;
    *b = temp;
}

在TS指令中,我们其实是将lock不停地上锁,并且拿到lock原有的状态,这其实就相当于我们手里拿着一个true,将其和lock的状态不停交换,所以swap指令的实现逻辑和TS指令没有本质区别

bool old = true;
while(old = true){
    Swap(&old,&lock);
}
//临界区
lock = false;
//剩余代码

读者可以对比一下Swap指令实现的互斥逻辑和TS指令实现的互斥逻辑的区别,本质上都是将Lock指令不断设置为true,然后拿到lock原有的状态,根据lock原有的状态进行判断是否允许进入临界区。
由于本质逻辑其实是一样的,所以TS指令产生的问题Swap指令也无法避免,依旧会产生忙等的问题,优点也完全相同。

互斥锁

互斥锁在前面已经接触到,前面的Swap和TS指令都用是互斥锁
利用锁来解决进程互斥问题也是非常简单的解决方案,只需要提供两个操作,分别是获得锁和释放锁,当进程需要进入临界区的时候,会一直检查锁的状态,如果资源可用,那么就将资源状态设置为不可用,自己进入资源,如果资源不可用,那么就会一直进行循环判断。
很显然,这种方式需要连续循环判断,肯定会导致忙等,这种连续循环忙等的锁都可以称之为自旋锁,前面的TSL指令和Swap指令都属于自旋锁,但是虽然他忙等了,也是有一定优点的,比如他在等待期间不用切换上下文,并且其比较适用于多核处理器,而且如果其上锁的时间很短,等待的代价也会很低,而对于单核操作系统,由于这个循环操作是原语操作,所以这种忙等会直接吃掉一个核的计算能力,根本不会发生系统调度,所以就会直接造成死等。

信号量机制

信号量机制是1965年荷兰学者迪杰斯特拉提出的一种同步互斥解决方案
信号量其实就是一个变量,信号量主要分为整型信号量以及记录型信号量,当然还有一些AND型信号量,信号量集这种,在这里我们不涉及。
信号量其实就是用来表示系统中某种资源的剩余量,比如系统中有两个打印机,我就可以让信号量为2,当进程需要使用一个打印机的时候,我们让信号量-1,使用完后则让其+1,分别对应了wait操作和signal操作,换句话所,如果进程需要使用一个资源,那就调用wait操作,使用完毕后需要调用signal操作,通常wait操作我们也称为P操作,signal操作也称为V操作,这两个操作均有操作系统提供,并且均为原语操作。

整型信号量

整型信号量也就是说信号量是一个整型变量,直接用来表示资源的数量,下面给出整型信号量中的wait和signal操作的实现细节。

int s = 1;//信号量
void wait(int s){
    while(s<=0);
    s -= 1;
}
void signal(){
    s += 1;
}

这里s变量用来表示资源量,如果资源量为0甚至为负数,那么说明此时没有空闲资源,此时等待进程的资源就会循环等待,直到有其他资源释放资源,让信号量变为正数。

信号量虽然是一个整型变量,但可以看做是被封装的,对s的操作只有三种,分别是初始化,P操作和V操作

这个算法的核心逻辑和前面提到的算法其实也很类似,当s只有1的时候,-1的操作相当于就是上锁了,+1的操作相当于就是解锁了,并且也是原语操作,所以当信号量为1的时候,这种算法就相当于一个互斥锁。从这里可以看出整型信号量其实也存在一个问题,还是忙等的问题,因为他里面也有一个死循环,当拿不到资源的时候也会一直等待。

细心的读者可能会发现这里给出的整型信号量算法的一个严重问题,由于wait和signal都是原语操作,而原语本质是关中断实现的,它是一气呵成的,所以当一个进程进入wait后,发现资源不够循环等待的时候,他是在wait这个原语操作中等待的,这是否意味着他会像前面的自旋锁一样,导致这一整个内核无法发生调度呢?答案是否定的,当处于等待的时候依旧会发生调度,这里的代码并不严谨,之所以用这个代码只是因为很多教科书也用的这个代码。

记录型信号量

其实说了半天,我们前面的算法好像没有一个是可以解决忙等的,接下来介绍的记录型信号量就可以完美解决忙等现象。
记录型信号量,其实还是一个信号量,只不过这个信号量不再是简单的整型变量了,而是一个结构体,结构体定义如下:

typedef struct {
	int value;//剩余资源量
	struct process *L;//等待队列
} semaphore;

这种信号量不仅包含了剩余资源数,还维护了一个队列,这个队列是用来存放进程的,用来存放的是正在等待这个资源的进程。
接下来基于这种信号量,我们定义他的PV操作

void wait(semaphore s){
    s.value --;
    if(s.value < 0){
        block(s.L);
    }
}
void signal(semaphore s){
    s.value ++;
    if(s.value <= 0){
        wakeup(s.L)
    }
}

先要介绍一以下这里用到的block和wakeup函数,这两个函数也是原语操作,block操作用于把当前进程设置为阻塞态,并且插入到传入的这个队列中,而wakeup操作用于从传入的就绪队列中选择一个进程,将其设置为就绪态。
这种记录型信号量的解决方案中signal和wait操作看起来很对称,wait用于获取资源,所以s.value要减一,而signal用于释放资源,所以是s.value要加一,同时wait减一之后,如果s.value小于0,说明原来减一前的资源肯定比0要小,说明没有足够的资源分配给该进程,该进程就会将自己阻塞,并挂载到等待队列上去,而signal则在释放资源后按道理说资源增加了应该是正数,但如果s.value是0或者是负数,说明等待队列中至少有一个进程正在等待,此时就需要用wakeup进行唤醒。
这种机制将等待的进程丢到了一个队列里面去,避免了忙等情况,遵循的让权等待的原则,是一种比较完美的方案,也是一个重点,但在后续的学习中读者也会发现这种方案的问题。

默认情况下的信号量是指记录型信号量

用信号量机制来解决进程同步互斥问题

前面介绍了信号量的概念和PV操作都是以资源分配问题来理解的,这里就用信号量来解决前面的进程的同步互斥问题
首先来看互斥问题,进程的互斥问题就是同一时刻只允许一个进程进入临界区访问临界资源,所以我们可以认为将“能进入临界区的进程数量”看做一个资源,我们只允许一个进程进入,所以资源量初始值为1,进程进入临界区之前,消耗一个资源,进程出临界区的时候,释放一个资源
如下代码所示

semaphore mutex = 1;

P1(){
    P(mutex);
    //临界区
    V(mutex);
}

P2(){
    P(mutex);
    //临界区
    V(mutex);
}

我们在临界区之前执行P操作,在退出临界区的时候执行V操作,这样就实现了对一个临界区的互斥操作,当进程1进入临界区后,由于mutex的值变为了0,所以如果进程2要进入临界区,就会发现资源不够,就会阻塞等待该资源。
需要注意的是,P和V操作必须成对出现,一个信号量可以用来管理一个临界资源的,要想管理多个临界资源需要设置多个信号量。

接下里介绍进程同步,前面我们说过,进程同步问题就是某些代码需要再另一些代码执行完毕之后才能工作,PV也可以实现这种功能。
在使用PV操作实现同步问题的时候,我们需要将什么东西设置成信号量呢,前面提过信号量本身是用来表示资源的,其实我们可以把“是否允许程序继续运行”看成一个资源量,但这个资源量显然只有0和1两个值,如果为1,则表示允许,如果为0,则表示不允许,我们默认其为0。
这样一来,我们就可以实现同步互斥问题了,如下列代码所示

semaphore s = 0;

P1(){
    //code1
    //code2
    V(s);
    //code3
}

P2(){
    //code1
    //code2
    P(s);
    //code3
    //code4
}

在这个程序中,我要求P2进程的第14行和15行代码需要在P1进程的第5行代码运行结束之后才能运行,这个时候s这个信号量就表示P2进程的第14,15行代码是否可以运行,当P1进程执行到第6行代码后,表示第五行代码可以运行了,这个时候就使用V操作释放资源,让s置为1,表示P2可以运行,当P2执行到第13行代码后,会判断s的值来确定自己是否要继续运行,这样就解决了同步问题。
我们可以看到,一个信号量可以用来处理一个同步问题,要想处理更多的同步问题,需要设置更多的信号量。
如何快速记住这种方式呢,我们看到14,15行代码的运行其实是有条件的,就是第5行代码执行之后,我们自然就知道应该在第14行代码之前和第5行代码之后应该执行某些操作,对于14,15这种有条件的代码,我们就需要在前面加上P操作,因为P操作可以根据条件让其阻塞,相当于有一个条件判断。而第5行代码运行之后,意味着14,15行代码可以运行,此时就执行V操作释放这个条件,而V操作是永远都不可能阻塞的。
或者我们可以更简单的记忆,在前操作后面执行V操作,在后操作之前执行P操作。

前驱问题可以看做是多个同步问题的融合体,只要掌握了同步问题的解决思路,相信前驱问题读者也很容易解决

经典进程同步问题

生产者消费者问题

生产者消费者问题是一个经典的同步互斥问题,问题描述如下:
系统中有一堆生产者进程,还有一堆消费者进程,同时还有一个缓冲区,如果缓冲区没有满,生产者进程就会不定期向缓冲区放入一个产品,如果缓冲区没有空,那么消费者就会不定期从缓冲区取出产品并使用。
我们来分析一下这个问题,这个问题里的同步条件主要有两个

  • 缓冲区没有满 -> 生产者进程可以放入产品
  • 缓冲区没有空 -> 消费者进程可以取出产品

互斥问题主要有一个,由于缓冲区属于临界资源,如果让多个进程同时访问,可能会导致一些问题,例如进程A发现缓冲区a没有数据,这个时候调度到进程B也发现缓冲区a没有数据,此时A和B都会往缓冲区a放入数据,就会导致数据覆盖,我们不允许这种情况发生,所以需要解决缓冲区的互斥问题。
我们可以思考一下如何使用PV操作来实现,我们回顾一下PV操作,P操作消耗一个资源,如果没有资源,那么会被阻塞,V操作释放一个资源,或者说生产一个资源。从这里我们可以得到两个信息,第一个信息是P(a)会让a加一,并且V(a)会让a减一,还有一个信息是,P操作是可以根据条件来阻塞的,也就是根据条件来暂停执行,这一点很重要。
我们先来看消费者进程,如果缓冲区空了,那么不允许消费者获取产品,消费者进程需要阻塞,既然消费者进程有可能在这里阻塞,那么我们肯定要在消费者进程运行之前使用P操作来判断其是否需要暂停执行,而我们前面提到,P操作会消耗一个资源,如果没有资源,P操作就会暂停执行,很显然,消费者进程消耗的资源就是产品,所以我们使用一个full信号量来表示目前缓冲区内的产品数量,在消费者进程之前运行P(full),就可以根据full来判断是否需要阻塞。那么我们再想,PV操作肯定是成双成对出现的,或者说既然有了P(full),就一定会有一个V(full),这个V(full)的位置写在哪呢?V(full)用于生产一个资源,也就是让full加一,而在这个问题中,生产者进程就是负责生产产品的,所以自然要放在生产者进程生产产品之后。
接下来我们来看生产者进程,还是沿用前面的逻辑来分析,生产者进程也是有可能阻塞的,所以在前面也有一个P操作,生产者进程阻塞的条件是,缓冲区是否慢,似乎也是用缓冲区中的产品资源数量来作为判断条件的,但P操作每次都会减一,那么生产者进程会让什么减一呢?自然是缓冲区内的空闲区域个数,当空闲区域的个数大于0,则我们允许消费者生产,我们就可以用一个empty信号量来保存缓冲区内空闲区域个数,在生产者进程前面使用P(empty),就可以实现该功能,同样,PV操作成双成对,当消费者进程用完一个产品之后,会释放一个缓冲区,此时empty数量就应该加一,所以我们应该在消费者进程的最后加上V(empty)。
对于缓冲区,这是一个临界资源,所以我们需要让他互斥访问,互斥访问的问题在前面已经说过很多次了,只需要设置一个mutex信号量,将其初始值默认为1,在操作前使用P,操作后使用V即可,下面我们给出具体实现

semaphore mutex = 1;//互斥信号量
semaphore full = 0;//当前缓冲区产品数量,最开始没有产品,所以为0
semaphore empty = n;//当前空闲缓冲区数量,我们假设缓冲区大小为n,最开始都是空闲的,所以都是n

producer(){//生产者进程
    while(true){
        //生产一个商品
        P(empty);//生产者进程每次需要消耗一个空闲缓冲区
        P(mutex);//在访问临界区资源之前,需要上互斥锁
        //将产品放入缓冲区
        V(mutex);//访问完毕解除互斥锁
        V(full);//生产者进程生产完之后需要让缓冲区产品个数加一
    }
}

producer(){//消费者进程
    while(true){
        P(full);//生产者进程每次需要消耗一个产品
        P(mutex);//在访问临界区资源之前,需要上互斥锁
        //取出产品
        V(mutex);//访问完毕解除互斥锁
        V(empty);//消费进程取出产品之后需要让空闲缓冲区数量加一
        //使用产品
    }
}

接下来我们来考虑一个问题,就是第8,9行代码是否可以交换顺序,也就是像下面的代码一样

semaphore mutex = 1;//互斥信号量
semaphore full = 0;//当前缓冲区产品数量,最开始没有产品,所以为0
semaphore empty = n;//当前空闲缓冲区数量,我们假设缓冲区大小为n,最开始都是空闲的,所以都是n

producer(){//生产者进程
    while(true){
        //生产一个商品
        P(mutex);//在访问临界区资源之前,需要上互斥锁
        P(empty);//生产者进程每次需要消耗一个空闲缓冲区
        //将产品放入缓冲区
        V(mutex);//访问完毕解除互斥锁
        V(full);//生产者进程生产完之后需要让缓冲区产品个数加一
    }
}

producer(){//消费者进程
    while(true){
        P(mutex);//在访问临界区资源之前,需要上互斥锁
        P(full);//生产者进程每次需要消耗一个产品
        //取出产品
        V(mutex);//访问完毕解除互斥锁
        V(empty);//消费进程取出产品之后需要让空闲缓冲区数量加一
        //使用产品
    }
}

这样做其实是不可以的,我们假设此时缓冲区已经满了,有这样一个执行队列(数字表示代码行数):
8 -> 18 -> 9
我们看执行过程,首先,生产者进程会拿到互斥锁,此时18行代码就会让消费者进程阻塞等待生产者进程释放互斥锁,而接下来的第九行代码,当检测到当前缓冲区已经满了,他也会阻塞等待消费者进程消耗一个产品,现在这个程序就跑不动了,生产者进程等待消费者进程消费,消费者进程等待生产者进程释放互斥锁,这样的循环等待导致了我们的程序卡死在这了,我们称这种循环等待为死锁。
综上,这两行代码是不能随便换位置的,这是有讲究的,根据我个人的理解,互斥锁应该要紧挨着临界区,或者说,实现互斥的P操作需要放在实现同步的P操作的后面。

由于V操作不会进程阻塞,更谈不上死锁,所以V操作是顺序是可以随意改变的。

从这个问题中,我们可以总结以下经验

  • 在进程有可能阻塞的地方,需要使用P操作
  • P操作会消耗一个资源,所以我们需要分析每一个进程都需要消耗一个什么资源
  • P操作和V操作成双成对出现,对于同步问题,PV操作出现在不同的进程,对于互斥问题,PV操作出现在同一个进程
  • 实现同步的P操作需要先于实现互斥的P操作,否则容易产生死锁
多生产者多消费者问题

该问题比简单的生产者消费问题稍难一点,这里我使用王道书里的例子来描述该问题:
有四个人,分别代表四个进程,妈妈,爸爸,儿子,女儿,同时有一个盘子,妈妈会向盘子里放橘子,爸爸会向盘子里放苹果,儿子会从盘子里拿苹果,女儿会从盘子里拿橘子。只有盘子为空时,爸爸和妈妈才会往盘子里放东西,只有盘子里装的是苹果时,儿子才会从盘子里拿,只有盘子里装的是橘子时,女儿才会从盘子里拿。

我们分别分析者四个进程,首先是爸爸进程,根据描述,如果盘子有东西,爸爸是会阻塞的,所以爸爸进程放苹果之前可能会阻塞,所以就要有一个P操作,我们再来看爸爸进程放苹果这个操作会消耗什么资源,很显然,我们可以把盘子的空闲容量设置为信号量,我们设为empty,当爸爸放置苹果之前会执行P(empty)操作来检测盘子是否有空闲容量。
再来看妈妈进程,妈妈进程和爸爸进程同理,在放置橘子之前也会检查盘子是否为空,所以在妈妈进程执行操作之前也会执行一个P(empty)操作
接下来是儿子进程,如果儿子进程在取苹果之前,如果盘子里没有苹果,那么就会阻塞,这里儿子进程会发生阻塞,所以需要一个P操作,P操作会消耗一个资源,所以我们再看儿子进程拿苹果的操作会消耗一个什么,很显然我们可以用一个apple信号量来表示当前盘子里苹果的数量,儿子进程拿苹果之前使用P(apple)消耗一个苹果
最后是女儿进程,女儿进程和儿子进程同理,我们设置一个orange信号量保存当前盘子里橘子的数量,女儿拿橘子之前使用P(orange)消耗一个橘子。
我们前面建立了empty,orange,apple,三个信号量,这里我们统一分析以下对应的P操作,当爸爸进程放了一个苹果之后,会导致盘子里苹果数量加一,所以在爸爸放苹果之后要执行V(apple)操作让苹果加一,妈妈进程放了一个橘子之后会让盘子里橘子数量加一,所以在妈妈进程结束之后要执行V(orange)操作,女儿进程和儿子进程执行完拿苹果的操作之后,都会让盘子的空余数量加一,所以在这两个进程最后都要执行V(empty)操作
同时,由于盘子属于临界资源,我们不能让他们互斥访问,所以需要用一个mutex的信号量来实现互斥。
代码如下所示

semaphore empty = 1;
semaphore apple = 0;
semaphore orange = 0;
semaphore mutex = 1;

father(){
    while(true){
        P(empty);
        P(mutex);
        //放入一个苹果
        V(mutex);
        V(apple);
    }
}
mother(){
    while(true){
        P(empty);
        P(mutex);
        //放入一个橘子
        V(mutex);
        V(orange);
    }
}
son(){
    while(true){
        P(apple);
        P(mutex);
    	//吃一个苹果
        V(mutex);
        V(empty);
    }
}
daughter(){
    while(true){
        P(apple);
        P(mutex);
    	//吃一个橘子
        V(mutex);
        V(empty);
    }
}

值得注意的是,在这个例子中,哪怕我们不使用mutex,他也可以天然地实现互斥,这是由于盘子的容量只有1,读者可以自信分析一下,如果理解不了也没有关系,在实际考试中如果考到了我们完全可以以防万一加上这个mutex,不加mutex不一定错,但加上一定错不了。

吸烟者问题

首先来介绍一下吸烟者问题,吸烟者问题的描述如下:一个系统中有一个供应者进程和三个吸烟者进程,第一个抽烟者进程拥有纸,第二个抽烟者进程拥有胶水,第三个抽烟者进程拥有烟草,而供应者进程会将纸,胶水,烟草中的两种组合放到桌子上,当桌子上有纸和胶水的时候,第三个抽烟者进程就会结合自己手里的烟草,将烟卷起来抽掉,抽完之后告诉供应者进程,供应者进程就会把下一种组合放到桌子上。
接下来我们试着来分析一下,首先来看一种错误的分析方式,有三种材料,纸,胶,烟,我们为其各设立一个信号量。
对于供应者进程而言,他在供应之前需要检查桌子是否为空,只有为空的时候他才会放置一种组合,所以可以设置一个信号量来表示是否需要向桌子上放东西,默认为0就是不需要。
第一个抽烟者进程拥有纸,所以在其执行之前应该执行P(胶),P(烟)两个操作,如果读者这样分析这个问题,应该会写出以下逻辑

semaphore paper = 0;//盘子里纸的数量
semaphore glue = 0;//盘子里胶水的数量
semaphore smoke = 0;//盘子里烟草的数量
semaphore empty = 1;//表示盘子是否为空
smoke1(){
    P(glue);
    P(smoke);//拿两个资源
	//吸烟
    v(empty);//拿完之后盘子为空
}
smoke2(){
    P(smoke);
    P(paper);//拿两个资源
	//吸烟
    v(empty);//拿完之后盘子为空
}
smoke3(){
    P(glue);
    P(paper);//拿两个资源
	//吸烟
    v(empty);//拿完之后盘子为空
}
provider(){
    p(empty);//盘子为空才放资源
    int i = 0;//放置的第一个东西
    int j = 1;//一共放置两个东西
	put(i);
    put(j);//表示放到桌子上
    i = (i+1)%3;
    j = (j+1)%3;//下一次放置下一组
    v();//根据条件判断V
}

这样的代码看似没有什么逻辑问题,但非常容易导致死锁,是一个错误的代码,如果要按照这个逻辑去写,使用普通的PV操作是想不通的,需要用AND型信号量帮助实现,这里不展开,我们讨论如何用PV操作实现该功能。

我们应该把供应者进程提供的资源按组编号,他最多有三种可能,烟胶,烟纸,纸胶,我们把他们看做三个组合,当第一个抽烟者拿到1号组合的时候,就抽烟,当二号抽烟者拿到第二个组合的时候,就抽烟,当第三个抽烟者拿到第三个组合的时候,就抽烟,我们分别为这三个组合设置一个信号量,分别表示当前桌子上该组合的数量,或者也可以理解为表示当前桌子上的是不是该组合。
与此同时,由于供应者进程也会被阻塞,当供应者放完东西后就会被阻塞,所以我们可以再提供一个信号量,用于表示桌子上是否可以继续放东西。
分析完后,我们可以尝试实现一下:

semaphore group1 = 0;//吸烟者1需要的组合,表示这个组合在桌子上的数量,或者桌子上是否有该组合
semaphore group2 = 0;//吸烟者2需要的组合,表示这个组合在桌子上的数量,或者桌子上是否有该组合
semaphore group3 = 0;//吸烟者3需要的组合,表示这个组合在桌子上的数量,或者桌子上是否有该组合
semaphore empty = 1;//盘子是否为空,盘子为空时允许放置
semaphore mutex = 1;//互斥
int i = 0;//用于实现轮流供应
smoke1(){
    P(group1);//拿组合1
    P(mutex);
	//吸烟
    V(mutex);    
    v(empty);//抽烟完成
}
smoke2(){
    P(group2);//拿组合2
	 P(mutex);
	//吸烟
    V(mutex);    
    v(empty);//抽烟完成
}
smoke3(){
    P(group3);//拿组合3
	P(mutex);
	//吸烟
    V(mutex);    
    v(empty);//抽烟完成
}
provider(){
    if(i == 0){
        //放组合1
        V(goroup1);
    }else if(i == 1){
        V(goroup2);
    }else if(i == 2){
        V(goroup3);
    }
    i = (i+1)%3;//轮循
    P(empty);//等待抽烟完成,由于是放置之后等待,所以需要卸载这个位置
}
读者写者问题

读者写者问题描述如下:有读者和写者两组进程并发运行,由于读取文件和写入文件对文件的影响不一样,所以我们作出以下要求

  1. 允许多个读者进程同时读文件
  2. 只允许一个写者向文件中写信息
  3. 写者正在写文件的时候不允许其他读者写者工作
  4. 写者执行写操作之前,应该保证其他读者写者全部退出

读者可以看到,这是一个纯粹的互斥问题,没有任何同步问题,有的读者可能会这么理解,读进程需要在写进程结束之后才能读,所以会认为这是一个同步关系,这里来解释一下,我们其实应该再深刻理解一下同步和互斥
A代码需要再B代码之后执行,那这叫同步,那什么是互斥呢,我们以前描述为A和B代码不能同时执行,其实还有一种等价说法,A代码必须在B之后执行,并且B代码必须在A后执行,这就是互斥,所以互斥其实可以看做是一个双向的同步问题,这也是同步和互斥的关系。
这里我们分析一下会发现这是一个纯粹的互斥问题,读者和写者需要互斥,写者和写者需要互斥,所以这是两个互斥问题,我们尝试写一下

semaphore wmutex = 1;//是否允许写者进程执行
write(){
    P(wmutex);
    //写
    V(wmutex);
}
read(){
    P(wmutex);
    //读
    V(mutex);
}

如果代码长这样,我们在读取之前禁止写者进程运行,确实可以实现读者和写者的互斥,但是读者和读者之间又互斥了,这不符合我们的要求,我们可以考虑下面的方案,就是设置一个变量来记录当前读者进程的数量,进入的时候,如果是第一个读者进程,那么就给写者进程上锁,表示不让写者进程进入,退出时如果是最后一个读者进程,就让读者进程解锁,代码如下所示

semaphore wmutex = 1;//是否允许写者进程执行
int count =0;
write(){
    P(wmutex);
    //写
    V(wmutex);
}
read(){
    if(count == 0){
        P(wmutex);//如果是第一个读者进程,给写者进程上锁
    }
    count++;//读者进程+1
    //读
    count--;//读者进程-1
    if(count == 0){
        V(mutex);//如果当前退出的是最后一个读者进程,给写者进程解锁
    }
}

这种方案确实逻辑上没有问题,因为只要不是第一个读取文件的读进程,都会直接跳过对wmutex的P操作,从而避免了读进程的互斥,但是也不完善,因为读者进程之间可以是并发运行的,而count变量本身属于临界资源,如果第一个读者进程看到了count等于0,马上被调度走了,导致另一个读者进程也看到了count等于0,这个时候两个读者进程同时对wmutex执行P操作,必然会使得其中一个阻塞。
所以count本身其实属于临界资源,所以我们需要对其实现互斥操作,如下代码所示

semaphore wmutex = 1;//是否允许写者进程执行
semaphore rmutex = 1;//用于实现对count的互斥操作
int count =0;
write(){
    P(wmutex);
    //写
    V(wmutex);
}
read(){
    P(rmutex);
    if(count == 0){
        P(wmutex);//如果是第一个读者进程,给写者进程上锁
    }
    count++;//读者进程+1
    v(rmutex);
    //读
    P(rmutex);
    count--;//读者进程-1
    if(count == 0){
        V(mutex);//如果当前退出的是最后一个读者进程,给写者进程解锁
    }
    v(rmutex);
}

此时这个代码就可以称得上完善了,但这是一个读优先的读者写者问题,换句话说,如果读者源源不断地到来,那么写者进程会直接饿死,我们可以做以下改动实现写优先

semaphore wmutex = 1;//是否允许写者进程执行
semaphore rmutex = 1;//用于实现对count的互斥操作
semaphore w = 1;
int count =0;
write(){
    P(w);
    P(wmutex);
    //写
    V(wmutex);
    V(w);
}
read(){
    P(w);
    P(rmutex);
    if(count == 0){
        P(wmutex);//如果是第一个读者进程,给写者进程上锁
    }
    count++;//读者进程+1
    v(rmutex);
    V(w);
    //读
    P(rmutex);
    count--;//读者进程-1
    if(count == 0){
        V(mutex);//如果当前退出的是最后一个读者进程,给写者进程解锁
    }
    v(rmutex);
}

这个w信号量就可以用来实现写优先,当读者正在读文件的时候,如果写者来了,就会执行P(w),这个时候就阻止了下一个读者的进入,从而实现写优先。

这里其实并不是严格的写优先,而是相对公平的读写公平法

这种设计一个count的算法可以为我们解决类似问题提供思路,当我们需要一个进程和其他进程互斥,但是和自己不互斥,就可以考虑这个思路,并且对于变量的修改我们一般需要实现互斥

哲学家进餐问题

哲学家进餐问题的描述就是,有五个哲学家,他们坐在一个圆桌上,他们左右各有一只筷子,五个筷子对应了五个哲学家,哲学家除了吃饭就是思考,当哲学家饥饿的时候会拿起左右两边的筷子,只有同时拿到左右两边的筷子时哲学家才会开始进餐。
这个算法看起来很简单,我们来实现一下

semaphore chopstick[5]={1,1,1,1,1};//筷子
do{
wait(chopstick[i]);
wait(chopstick[(i+1)%5]);
//eat
signal(chopstick[i]);
signal(chopstick[(i+1)%5]);
}while(true);

我们用看似很简单就能实现,但实际上这种实现方式非常容易导致死锁,当五个哲学家依次拿起自己左边的筷子的时候,他们就都拿不起右手边的筷子,他们五个就会一起拿着左边的筷子干等,解决这个问题的方法有很多,这里提出常见的几个
通过限制哲学家同时进餐的人数实现

semaphore chopstick[5]={1,1,1,1,1};//筷子
semaphore w = 4;
do{
wait(w);
wait(chopstick[i]);
wait(chopstick[(i+1)%5]);
//eat
signal(chopstick[i]);
signal(chopstick[(i+1)%5]);
signal(w);
}while(true);

同时只能有四个人进餐,完美解决死锁
当且仅当左右两边都有筷子的时候,才拿筷子

semaphore chopstick[5]={1,1,1,1,1};//筷子
semaphore w = 1;//用于实现拿筷子的互斥
do{
wait(w);
if(chopstick[i] && chopstick[(i+1)%5]){
wait(chopstick[i]);
wait(chopstick[(i+1)%5]);
}
signal(w);
//eat
signal(chopstick[i]);
signal(chopstick[(i+1)%5]);
}

除此之外,还有很多方法,例如让奇数号哲学家先拿左边的筷子,让偶数号哲学家先拿右边的筷子。

管程

学习完经典同步问题之后,读者可能会发现,PV操作实现同步互斥机制似乎过于麻烦了,信号量一会儿用来表示资源量,一会儿又像是一个开关一样。为此我们引入管程的概念,管程也是一种解决同步互斥的方案,并且相对于信号量机制而言,更加简单,也不容易出错,接下来我们看一下管程的定义。
管程是一种特殊的软件模块,有这些部分组成:

  • 局部于管程的共享数据结构说明;
  • 对该数据结构进行操作的一组过程;
  • 对局部于管程的共享数据设置初始值的语句;
  • 管程有一个名字。

读者如果学过面向对象程序设计的话,可以感觉到,管程其实和类差不多,局部于管程的共享数据结构其实就是类里的属性,对该数据结构的一组操作过程其实就是类里的方法,而对共享数据的初始化语句其实就是类的构造函数,管程名其实就是类名,完全对应上了。
给出了管程的定义之后,为了使管程可以用于解决同步互斥问题,管程需要遵守下面的规则。

  • 局部于管程的数据只能被局部于管程的过程所访问;
  • 一个进程只有通过调用管程内的过程才能进入管程访问共享数据;
  • 每次仅允许一个进程在管程内执行某个内部过程。

这些规则其实很好理解,第一条是保证管程内数据变量的私有性,要保证管程内的变量不能在其他地方被访问到。第二条则是对修改管程内共享数据做了一些限制,要求对管程内共享数据的修改必须通过管程内的函数,其实就像类里面的get和set方法。第三条尤为重要,第三条要求每次仅允许一个进程在管程内执行某个内部过程。这句话由于笔者本人的水平限制,加上可参考的资料本身对这个问题的回答都存在差异,所以可能未能深入理解。但这句话表达的意思应该是以下两个意思之一

  • 每次只允许一个进程进入管程,这意味着两个进程调用管程中不同的两个过程也会导致阻塞。
  • 每次只允许一个进程进入管程中的某个方法,这意味着两个进程调用管程中两个不同的过程不会导致阻塞。

按照汤小丹版操作系统书中的理解应该是第一个意思,也就是只允许一个进程进入管程,但我在查阅资料的时候发现有的参考资料会允许两个进程进入管程,但不能执行同一个过程。
受笔者水平限制,读者查阅了大量的资料,姑且做以下解读,管程应该只要求同一个时刻只允许一个进程进入管程中的方法,也就是说两个进程进入不同的过程不会被阻塞,而如果将管程实现为同一时刻只允许一个进程进入管程的话,他天然满足管程的要求,并且要求更高,这种方式实现简单,但并发性可能欠佳。这一段纯属笔者个人猜想与解读。
但不管怎么理解,表达的其实都是一个意思,那就是管程本身具有互斥特性,这种互斥特性是由编译器负责实现的,程序员不需要关系,有了管程后,程序员只需要关心同步问题即可。
使用管程之后,我们就可以不用信号量来表示资源量了,我们的资源量可以直接在管程里使用一个int型变量来表示,而信号量仅仅当做条件变量使用,wait操作会让条件变量的值变为0,并让后续操作阻塞,signal会让条件变量的值变为1。

死锁

死锁的概念

死锁问题在前面的学习中已经接触到了,我们以五个哲学家就餐问题为例,如果五个哲学家同时拿起左手的筷子,那么他们此时就都在等待右手的筷子,但他们右手的筷子又被其他哲学家拿在手上,所以他们永远也等不到,所以他们五个永远也别想吃上饭,这就产生了死锁。
从这个例子,可以归纳出死锁的概念,死锁就是在并发环境中,因为资源竞争而产生的互相等待对方手里的资源导致互相等待的进程都阻塞,这种阻塞如果没有外界干预是无法向前推进的,这个程序就直接死了,所以称之为死锁。
前面我们还提到过一个饥饿的概念,饥饿进程也是永远都无法向前推进的进程,但读者务必区分清楚饥饿和死锁的区别,我们这里将死锁,饥饿,死循环三个概念来区分一下

  • 死锁:各进程互相等待对方手里的资源,导致各进程都阻塞,无法向前推进的现象。
  • 饥饿:由于长期得不到想要的资源,某进程无法向前推进的现象。
  • 死循环:某进程执行过程中一直跳不出某个循环的现象。

这三种情况都会导致进程无法向前推进,这里我们强调一下他们的区别,首先是死锁,死锁一定是多个进程互相等待对方手里的资源导致的各个进程均阻塞,所以死锁的进程至少都有两个,换句话说,如果发生死锁,那至少有两个进程同时发生死锁,并且死锁的进程一定处于阻塞态。
接下来是饥饿,饥饿是长期得不到资源的情况,饥饿的进程完全可能只有一个,饥饿进程如果是长期得不到处理机资源那就处于就绪态,如果长期得不到IO资源那就属于阻塞态。
最后是死循环,死循环和死锁和饥饿不同,死锁和饥饿是根本不上处理机运行的,而死循环是在处理机运行的过程中由于程序逻辑的错误导致的无法向前推进,饥饿和死锁是操作系统资源管理层面的问题,而死循环是程序员出现的问题。

在进行程序设计的时候,我们可能会故意设计一些死循环来完成一些特别的功能,这类死循环并不在我们讨论范围内,所以这里的死循环都是指由程序逻辑的错误导致的非正常的死循环

死锁的产生需要一定的条件,接下来介绍一下死锁产生的必要条件

  • 互斥条件:只有对互斥共享资源的竞争才会发生死锁,如果一个资源是大家都能并发来用的,那这就不可能产生资源循环等待的情况。
  • 不剥夺条件:进程占有的资源只有进程自己可以释放,不能由其他进程强行剥夺。这也很好理解,如果可以剥夺了,那资源请求不到直接剥夺过来不就好了,这样也不会发生死锁。
  • 请求和保持条件:进程已经请求并占有了至少一个资源,并且又提出了对其他资源的请求。由于死锁是资源循环等待导致的,所以要让别人等待你的资源,前提是自己手里得有资源吧。
  • 循环等待条件:系统中必须存在资源的循环等待链。

这四个是死锁的必要条件,并不是充分条件,也就是说死锁之后一定满足这四个条件,但满足这四个条件未必产生死锁。是的,哪怕有循环等待链也不一定产生死锁。这里举个例子,A等待B,B等待C,C等待A或D,这里A,B,C三者就有循环等待链,但由于资源量不唯一,A有的资源D也有,所以当D进程释放资源后C进程也可以运行,这样就不会产生死锁。
可以看到,这里之所以不会产生死锁,是因为资源量有多个,但如果系统中每类资源的资源量都只有一个,那么循环等待就可以成为死锁的充要条件。
以上介绍完了死锁相关概念,接下来考虑一下什么情况下会产生死锁,这里只列举一些常见的情况。

  • 对系统资源的竞争。这一点不需要过多解释。
  • 进程推进顺序非法。换句话说,一些死锁问题是可以修改进程推进顺序避免的,例如哲学家就餐问题,如果让奇数号哲学家先拿左边的筷子,让偶数号哲学家先拿右边的筷子,就可以避免死锁。
  • 信号使用不当。这一点我相信读者在前面的同步问题那里也有感触,我们说过,实现同步的P操作需要放在实现互斥P操作的前面,这就是为了避免死锁。

对于死锁的处理策略我们主要从下面三个角度来考虑

  • 预防死锁:破坏死锁产生的四个必要条件中的一个。
  • 避免死锁:运用某种算法在分配资源的时候防止死锁的发生。
  • 死锁的检测和解除:允许死锁发生,但是发生死锁之后操作系统会负责检查出死锁,并且解决它。

预防死锁和避免死锁是两个不同层面的概念,不可混淆,预防死锁是破坏死锁形成的条件,而避免死锁是在分配资源的层面避免死锁,具体一点就是如果分配该资源会导致系统死锁,那么我就不分配。

死锁的处理策略

预防死锁

预防死锁其实就是去破坏死锁的四个条件,我们一一列举

破坏互斥条件

破坏互斥条件,说直白点就是把互斥设备改造成共享的设备,这听起来似乎很难实现,但实际上是可以实现的,例如打印机就可以使用SPOOLing技术改造成共享设备。
接下来介绍一下SPOOLing技术,这种技术可以在打印机之前拦截请求进行处理,例如进程1和进程2同时发送了一个打印请求,这个请求会被拦截,交给输出进程进行处理,输出进程再将这些请求放到一个队列里面依次交给打印机,而在进程1和进程2看来,由于他们的请求被拦截了,所以他们认为打印机这个IO设备已经处理完成了,所以根本不会被阻塞。
这种策略的缺点也显而易见,之所以打印机可以根据SPOOLing技术改造成共享设备,本质是打印机不需要等待返回结果,对于需要等待返回结果的IO设备始终都是互斥设备,无法将他们改造成共享设备。并且为了系统安全, 很多地方还必须保护这种互斥性,所以大多数时候我们都是无法破坏这种互斥性的。

破坏不剥夺条件

破坏不剥夺条件就是运行设备在没有使用完资源之前被剥夺,这种解决策略主要有两种实现方式

  1. 当某个进程请求新的进行得不到满足的时候,必须释放其保持的所有资源,等待后续重新申请。
  2. 当某个进程需要的资源被其他进程占有的时候,可以由操作系统把他需要的资源从其他进程上强行抢过来,这种策略一般要考虑优先级,只有高优先级的进程可以剥夺低优先级的进程。

这种方式确实可以实习,但是也存在很多问题,首先看方案一,申请不到资源的时候,哪怕是自己已经拥有的资源也要被迫放弃,我们知道申请资源和释放资源是有开销的,不仅如此,我可能已经拿前面的资源干了一些事情了,现在要我放弃,我肯定需要把前面的工作成果保存起来,这也是有开销的,所以这种方式会导致开销增大,降低系统吞吐量,同时还会导致进程饥饿。
这种方式不仅有这些缺点,而且实现起来也很复杂。

破坏请求和保持条件

破坏请求和保持条件,就是让系统不存在占着茅坑不拉屎的现象存在。
我们可以采用静态分配的方法,对于一个进程而言,只有在这个进程所有资源都能得到满足的情况下才一次性为他分配所有资源,否则就不让他投入运行。
这种方式实现起来非常简单,其实只需要通过AND型信号量就可以实现,但是我们前面应该是没有讲过的,感兴趣的读者可以翻看一些书籍,但是缺点依旧明显,这会让进程运行过程中一直保持着所有资源,这会造成严重的进程资源浪费,并且对于资源量要求比较多的进程而言,极其容易产生饥饿。

破坏循环等待条件

破坏循环等待条件就是按照某种分配方式阻止循环等待链的形成,我们可以采用顺序资源分配方法。
该方法具体规则如下:我们把系统所有的资源进行编号,规定每个进程的申请顺序必须按照从小到大的顺序来,并且对于需要多个同类型的资源的情况,要一次性申请完。
这种方式可以实现这样一个功能,就是如果一个进程占用了大编号的资源,他不可能申请小编号的资源,所以操作系统中所有的进程里面,占用最大编号设备的那个进程必定可以执行完,也就不会产生死锁。
该策略说起来都很麻烦,实现起来其实会更麻烦,而且添加设备后需要重新编号,同时由于不同设备编号不相同,也会导致程序的可移植性变差。

避免死锁

首先我们来了解一下什么样的系统状态是安全的,我们认为,绝对不会发生死锁的系统是安全的,但凡有可能发生死锁的系统都是不安全的。
我们举以下例子,系统中某个资源的数量一共有100个,ABC三个进程都需要这个资源,其中ABC三个进程的最大需求量分别是70,40,50,而此时ABC三个进程已经申请了20,10,30这么多的资源,我们列出下表

进程最大需求已经占用最多还会占用
A702050
B401030
C503020

接下来操作系统中剩余的资源量还有100-20-10-30 = 40,这个时候的系统就是安全的,A如果要50个资源,我可能并没有足够的资源给他,但起码B我是完全可以满足的,哪怕B再给我提出30个资源的请求我也可以全部给他,等他把资源换回来了,我就有足够的资源分给A了,所以我们至少存在一个合法的运行队列B -> C -> A,这种系统是不会发生死锁的,是安全的,再看下面一个例子。

进程最大需求已经占用最多还会占用
A704030
B402020
C503020

此时系统的资源量只有10了,如果而ABC最多还会占用的资源量都高于这个值,所以如果A问系统要40的资源量,只能让他阻塞,B要20也只有阻塞,C要20也只有阻塞,这种情况就有可能发生死锁。

并不是一定会发生死锁,最大需求量只是最大值,有可能B进程使用了20个资源之后就运行结束了,就把资源换回来了,所以这里只是有可能发生死锁

再看接下来的例子

进程最大需求已经占用最多还会占用
A703040
B401030
C505010

如果是这种情况,系统中还剩下10个资源,如果B向我们要30个资源,只有让他阻塞,如果B要30个资源也会阻塞,但我们起码可以满足C,当C把资源还回来之后,我们就有足够的资源分配给A和B,所以我们依旧可以找到C -> B -> A这样一个分配序列,从而满足所有进程,这样的状态也是安全的。
其实上面这种按照某种顺序分配资源能够让系统内所有进程全部得到满足的这种序列,就是安全序列,当一个系统中存在安全序列那就是安全的系统,否则就是不安全的系统,银行家算法其实就是在分配资源之前检查分配完之后操作系统的状态是否安全。
接下来我们看一个实际的例子,假设有五个进程都需要分配三类资源,这三类资源的初始值为(10,5,7),此时操作系统状态如下

进程最大需求已分配最多还需要
P0(7,5,3)(0,1,0)(7,4,3)
P1(3,2,2)(2,0,0)(1,2,2)
P2(9,0,2)(3,0,2)(6,0,0)
P3(2,2,2)(2,1,1)(0,1,1)
P4(4,3,3)(0,0,2)(4,3,1)

此时减去分配给他们的资源,系统中剩余资源量为(3,3,2)
这个时候的系统显然是安全的,因为我们可以找到一个安全序列P3 -> P1 -> P4 -> P2 -> P0
这里我们来分析一下这个序列
给P3分配所有资源之后,它运行完之后会把资源还回来,此时操作系统剩下(5,4,3)
再给P1分配资源,运行玩后把资源还回来,剩下(7,4,3)
接下来是P4,P4释放资源之后剩下(7,4,5)
此时系统内资源可以满足P2,P2释放资源后剩下(10,4,7)
最后P0也是可以满足的
很显然,这种分配序列就是一个安全序列,此时系统就是安全的。
此时如果一个进程需要分配资源,我们可以先计算给他分配资源后操作系统是否安全,从而来决定要不要分配给他资源。
银行家算法要保存每个进程的最大需求,这需要一个二维数组,已分配的资源量,这也是一个二维数组,我们可以基于此算出最多还需要的资源量,用二维数组存起来,系统中还剩余的资源也可以用一个二维数组存起来,以下给出银行家算法的具体描述

  • 检查此次申请是否超过了该进程事先说好的最大声明量
  • 检查当前系统是剩余资源是否可以满足此次请求
  • 试探性分配给他,修改各个数据结构
  • 用前面提到的算法尝试找到一个安全序列(前面使用的找到安全序列的算法称为安全性算法)
  • 如果能找到一个安全序列,则分配,如果找不到安全序列,则不能分配

以上就是银行家算法的步骤,我们用流程图来表示

死锁的检测与解除
死锁的检测

为了对死锁进行检测,我们需要引入一个资源分配图的数据结构,如下图所示
1695919043.jpg
资源分配图有两类节点,分别是进程节点和资源节点,资源节点需要保存系统中该资源的总量。
这种结构中还有两种类型的边,分别是分配边和请求边,由进程指向资源的边就是请求边,表示该进程请求的资源量,由资源指向进程的边就是分配边,表示已经有多少个资源分配给了这个进程。
我们把资源和进程的关系抽象成这个图后,我们就可以来考虑如何检测死锁了,我们看上面的图,该图中P1进程向R2请求了一个资源,而R2一共有两个资源,分配了一个给P2,所以还剩一个,完全可以满足P1进程,所以P1进程其实是完全不会阻塞的会阻塞的只有P2进程,因为R1的资源安全被分配出去了,P2无法在请求R1资源,所以只有阻塞,由于P1进程不阻塞,所以我们可以让他运行完之后将资源还给R1,此时P2进程就可以正常运行了,所以本图中是不存在死锁的。
1695919612.jpg
我们再来看这个图,这个图中,P3是可以顺利执行的,我们让P3进程执行完,让他把资源还回来,相当于就是把P3的所有边消去,此时R2还剩下1个资源,但P1需要两个,所以阻塞,而R1没有剩下资源了,P2也要被阻塞,此时P1和P2就发生了死锁。
其实在这个过程中,我们已经描述了一遍死锁的检测算法,接下来我们更加完整地描述一遍

  1. 在资源分配图中,找出既不阻塞又不是孤点的进程 Pi(即找出一条有向边与它相连,且该有向边对应资源的申请数量小于等于系统中已有空闲资源数量)。消去它所有的请求边和分配边。
  2. 进程 Pi 所释放的资源可以唤醒某些因等待这些资源而阻塞的进程,原来的阻塞进程可能变为非阻塞进程。
  3. 继续根据 (1)中的方法进行一系列简化后,若能消去途中所有的边,则称该图是可完全简化的。

死锁定理:如果某时刻系统的资源分配图是不可完全简化的,那么此时系统死锁。
说的口语化一点,我们拿到一个资源分配图,先抛开孤立的节点,因为孤立的节点没有对资源进行请求,所以和死锁没关系,之后找到那些不会阻塞的进程,将这些进程节点的边全部消去,此时由于释放了资源,就可能会有其他进程节点可以正常运行,然后继续找不会阻塞的进程,再把他们的所有边消去,以此往复,如果最后能把所有的边消除干净,让所有的点都称为孤立的节点,那么说明不会死锁,否则就会产生死锁。

死锁的解除

接触死锁的方法主要有三个

  • 资源剥夺法
  • 撤销进程法(终止进程法)
  • 进程回退法

先来介绍资源剥夺法,资源剥夺法就是将某些死锁进程挂到外存上,并且剥夺他的所有资源,把这些资源分配给其他死锁进程,从而解决死锁,这种算法还要保证挂起到外存的进程不饥饿。
然后是撤销进程法,撤销进程法就是强制撤销进程,或者说杀死进程,这样剥夺资源的方式过于简单粗暴,但代价也会很大,比如这个进程运行了很久马上就要结束了,但是发生了死锁,如果直接终止他就很不划算。
最后是进程回退法,发生死锁后操作系统会选择一个进程让他往回退,退到不发生死锁的时候。很明显,这种算法需要操作系统记录每一个进程的历史状态,并合理设置还原点。
这三种方法都是会选择一个进程,要么挂起,要么终止,要么回退,那么又应该选择哪个进程呢,我们给出一些原则

  • 进程优先级低的进程优先被选择
  • 已执行时间短的进程优先被选择
  • 剩余执行时间长的进程优先被选择
  • 资源占用量大的进程优先被选择
  • 相对于交互式进程而言,批处理进程有限被选择
  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值