2.1 进程与线程
进程的概念、组成、特征
进程的概念
- 程序是静态的,就是一个存放在磁盘里的可执行文件,就是一系列的指令集合。
- 进程:是动态的,是程序的依次执行过程。(同一个程序的多次执行会对应多个进程)
进程的组成
PCB
- 当进程被创建时,OS会为该进程分配一个唯一的、不重复的“身份证号”——PID(Process ID, 进程ID)
- OS要记录的信息:
- 进程描述信息:
PID、进程所属用户ID(UID):
基本的进程描述信息,可以让OS区分各个进程。 - 进程控制和管理信息:
记录给进程分配了哪些资源(如:分配了多少内存,正在使用哪些I/O设备、正在使用哪些文件):
这些内容可用于实现OS对资源的管理。 - 资源分配清单:
记录进程的运行情况(CPU使用时间、磁盘使用情况、网络流量使用情况):
可用于实现OS对进程的控制调度。 - 处理机相关信息(PSW、PC等各种寄存器的值):
用于实现进程切换。
- 进程描述信息:
- 这些信息都被保存在一个数据结构PCB(Process Control Block)中,即进程控制块。
- OS需要对各个并发运行的进程进行管理,但凡管理使所需要的信息,都会被放在PCB中。
- PCB是进程存在的唯一标志,当进程被创建时,OS为其创建PCB,当进程结束时,会回收其PCB。
- PCB主要是便于OS管理使用。
程序段
程序的代码(指令序列),进程自己使用。
数据段
运行过程中产生的各种数据(如:程序中定义的变量),进程自己使用。
程序是如何运行的
- 程序写完之后经过编译、链接等步骤最后会生成一个可执行文件。这个可执行文件存在硬盘中,运行前需要把程序放入内存。
- 一个程序开始运行前,需要创建对应的进程,也就是要创建相应的PCB
- 程序的指令序列(程序段)也需要读到内存中。
- 内存中还要存数据段:用来放运行过程中产生的各种数据。
- 总结:一个进程尸体(进程映像)由PCB、程序点、数据段组成。进程是动态的,进程实体是静态的。进程实体可以理解为进程在执行过程中某一时刻的副本。反映进程在某一时刻的状态。进程是进程实体的运行过程,是系统进行资源分配和调度(一个进程被“调度”,就是指OS决定让这个程序上CPU运行)的一个独立单位。
进程的特征
- 动态性:
进程是程序的依次执行过程,是动态的产生、变化和消亡的。
动态性是进程最基本的特征。 - 并发性:
内存中有多个进程实体,各进程可并发执行。 - 独立性:
进程是独立运行、独立获得资源、独立接受调度的基本单位。 - 异步性:
各进程按各自独立的、不可预知的速度向前推进,OS要提供“进程同步机制”来解决异步问题。
异步性会导致并发程序执行结果的不确定性。 - 结构性:
每个进程都会配置一个PCB。结构上看,进程由程序段、数据段和PCB组成。
进程的状态与转换
进程的状态
- 创建态(新建态):进程正在被创建时,它的状态是“创建态”,在这个阶段OS会为进程分配资源,初始化PCB
- 就绪态:当进程创建完成后,就进入“就绪态”,处于就绪态的进程已经具备运行条件,但由于没有CPU空闲,暂时不能运行。
系统中可能会有很多处于就绪态的进程。
当CPU空闲时,OS就会选择一个就绪的进程让他上处理机运行。 - 运行态:在CPU上运行的进程处于“运行态”
CPU会这姓该进程对应的程序(执行指令序列) - 阻塞态(等待态):在进程运行的过程中,可能会请求等待某个事件的发生(如等待某种系统资源的分为,或者等他其他进程的响应)。 在这个事件发生前,进程无法继续往下执行,此时OS会让这个进程下CPU,并让它进入“阻塞态”。
当阻塞态等待的事件发生后,就回到就绪态,如果上CPU运行,又回到运行态。 - 终止态(结束态):一个进程可以执行exit系统调用,请求OS终止该进程。
此时该进程会进入“终止态”,OS会让该进程下CPU,并回收内存空间,最后还要回收该进程PCB。
进程状态的转换
- 创建态 系统完成创建进程的一系列操作 就绪态
- 就绪态 进程被调度 运行态
- 运行态 进程用“系统调用”的方式申请某种系统资源,或者请求等待某个事件发生 阻塞态
运行态到阻塞态的转换是进程自身做出的主动行为 - 阻塞态 申请的资源被分配,或等待的事件发生 就绪态
阻塞态到就绪态的转变不是进程自身能控制的,是一种被动行为。 - 运行态 进程运行结束,或运行过程中遇到不可修复的错误 终止态
- 运行态 时间片用完了,或处理机被抢占 就绪态
注:一个进程不可能直接从阻塞态转变为运行态,也不能由就绪态直接转换为阻塞态(因为进入阻塞态是进程主动请求的,必然需要进程在运行时才能发出这种请求。)
进程的PCB中,会有一个变量state来表示进程当前状态。
为了对同一个状态下的各个进程进行统一的管理,OS会将各个进程的PCB组织起来。
进程的组织
链接方式
OS会管理一个队列,执行指针指向当前正在执行的进程队列。就绪队列指针指向就就绪态进程队列;阻塞队列指针指向阻塞态进程队列……
为了让OS更好的调度,通常会把优先级更高的进程放在队头。
有些OS会根据阻塞原因的不同把阻塞队列细分成一些队列(等待打印机阻塞队列、等待磁盘阻塞队列)
索引方式
进程会给每个状态建立索引表,每个索引表的表项指向具体进程。
进程控制
- 什么是进程控制?
进程控制的主要功能就是对系统中的搜游进程实施有效的管理,它具有创建新进程、撤销已有进程、实现进程状态转换等功能。
简化理解:进程控制就是在实现进程的状态转换。 - 如何实现进程控制?
用原语实现(原语是一种特殊的程序,它的执行具有原子性。也就是说,这段程序的运行必须是一气呵成的) - 为什么进程控制(状态转换)要一气呵成?
在进程控制的过程中,状态转换至少要做两件事:更改state的值和改变状态队列。如果这个过程不是一气呵成的,那么第一步更改state的值后如果遇到中断,那么这个进程的状态队列就是错的。
总的来说,如果不能一气呵成,就可能有OS中的某些关键数据结构不统一的情况,这会影响OS进程别的管理工作。 - 如何实现原语的原子性?
可用“关中断指令”和“开中断”指令这两个特权指令实现原子性。
CPU执行了关中断指令之后,就不再例行检查中断信号,直到执行开中断指令之后才会回复检查。
注:原语的执行是在内核态。
进程控制相关原语
- 创建原语:
OS创建一个进程时使用的原语。- 创建原语:
- 申请空白PCB
- 为新进程分配所需资源
- 初始化PCB
- 将PCB插入就绪队列。
-引起进程创建的事件: - 用户登录:分时系统中,用户登录成功,系统会为其建立一个新的进程
- 作业调度:多道批处理系统中,有新的作业放入内存时,会为其建立一个新的进程
- 提供服务:用户向OS提出某些请求时,会新建一个进程处理该请求
- 应用请求:有用户进程主动请求创建一个子进程
- 创建原语:
- 撤销原语:
使用撤消原语可以把就绪态/阻塞态/运行态的进程转变为终止态,最后产出PCB- 撤消原语:
- 从PCB集合中找到终止进程的PCB
- 若进程正在运行,立即剥夺CPU,将CPU分配给其他进程
- 终止其所有子进程
(进程间的关系是树形结构) - 将该进程拥有的所有资源归还给父进程或OS
- 删除PCB
- 引起进程终止的事件:
- 正常结束:进程自己请求终止(exit系统调用)
- 异常结束:整数除以0、非法使用特权指令,然后被OS系统强行杀掉
- 外界干预:Ctrl+Alt+delete,用户选择杀掉进程
- 撤消原语:
- 阻塞原语:
运行态 -> 阻塞态- 阻塞原语:
- 找到要阻塞的进程对应的PCB
- 保护进程运行现场,将PCB状态信息设置为“阻塞态”,暂时停止进程运行
- 将PCB插入相应事件的等待队列
- 引起进程阻塞的事件:
- 需要等到系统分配某种资源
- 需要等待相互合作的其他进程完成工作
- 阻塞原语:
- 唤醒原语:
阻塞态 -> 就绪态- 唤醒原语:
- 在事件等待队列中找到PCB
- 将PCB从等待队列移除,设置进程为就绪态
- 将PCB插入就绪队列,等待被调度
- 引起进程唤醒的事件:
- 等待的事件发生(因何事阻塞,就应由何时唤醒)
- 唤醒原语:
- 切换原语:
运行态 -> 就绪态
就绪态 -> 运行态- 切换原语:
- 将运行环境信息存入PCB
- PCB移入相应队列
- 选择另一个进程执行,并更新其PCB
- 根据PCB回复新进程所需的运行环境
- 引起进程切换的事件:
- 当前进程时间片完了
- 有更优先级的进程到达
- 当前进程主动阻塞
- 当前进程终止
- 切换原语:
进程通信
进程间通信(Inter-Process Communication, IPC)是指两个进程之间产生数据交互
- 为什么进程通信需要OS支持?
进程是分配系统资源的单位(包括内存地址空间),因此各进程拥有的内存地址空间相互独立。
为了保证安全,一个进程不能直接访问另一个进程的地址空间。
共享存储
要互斥地访问共享空间,由通信进程自己负责互斥
- 基于存储区的共享:
一个进程可申请一块共享存储区,这个共享存储区其他进程也有访问权限。
为了避免出错,各个进程对共享空间的访问应该是互斥的。
各个进程可使用OS内核提供的同步互斥工具(如P、V操作)
OS在内存中划出一块共享存储区,数据的形式、存放位置都用通信进程控制,而不是OS。这种共享方式速度很快,是一种高级通信方式。 - 基于数据结构的共享:
基于数据结构的共享:比如共享空间里只能放一个长度为10的数组。这种共享方式速度慢、限制多,是一种低级通信方式。
消息传递
进程间的数据交换会议格式化的消息(Message)为单位。进程通过OS提供的“发送消息/接收消息”两个原语进行数据交换。
- 格式化的消息:
包括消息头和消息体。- 消息头包括:发送进程ID、接收进程ID、消息长度等格式化的信息
- 消息体包括:具体传送的数据。
- 消息传递的方式分为直接通信方式和间接通信方式。
- 直接通信方式:消息发送进程要指明接收进程的ID
- 间接通信方式:通过“信箱”间接地通信。因此又称“信箱通信方式”
- 直接通信方式:
进程P给进程Q发送消息。P自己完善消息头、消息体,整个消息封装为msg,使用发送原语send(Q, msg)。
OS内核接受到这个消息,并把消息挂到进程Q的消息队列里。消息队列在OS内核区
进程Q使用接收原语receive(P, &msg)接收P发来的msg,OS会在Q的消息队列里查找哪个消息是P发来的。找到后把这个消息复制到进程Q的PCB中。 - 间接通信方式
进程P给进程Q发送消息,可以先通过系统调用在OS内核区申请一个邮箱A(也可申请多个邮箱)。
进程P在自己的地址空间内完善消息msg。之后使用发送原语send(A, msg)往信箱A发送消息(这里没说往Q发,直说发给A)。
进程Q使用接收原语receive(A, &msg)从信箱A中接收消息,OS会把A中的消息复制到进程Q的PCB中。
可以多个进程往同一个信箱里send消息,也可以多个进程从同一个信箱中receive消息。
管道通信
数据流向只能是单向的。
管道是一个特殊的共享文件,又名pipe文件。其实就是在内存中开辟一个大小固定的内存缓冲区。
管道通信要求数据的读写满足先入先出。数据只能单向传递,读数据的进程不能随机读取。
- 管道只能采用半双工通信,某一时间内只能实现单向传输。如果要实现双向同时通信,则需要设置两个管道。
- 各个进程要互斥地访问管道(由OS实现)
- 当管道写满时,写进程将阻塞,直到读道中的数据取走,即可唤醒写进程。
- 当管道读空时,读进程将阻塞,直到写进程往管道中写入数据,即可唤醒读进程。
- 管道中的数据一旦被读出,就会彻底消失。因此,当多个进程读同一个管道时,可能会错乱。对此,通常有两种解决方案:
- 一个管道允许多个写进程,一个读进程(考试标准答案版)
- 允许有多个写进程、多个读进程,但系统会让各个读进程轮流从管道中读数据。(Linux)
线程的概念
- 什么是线程,为什么要引入线程?
在进程没有引入之前,系统中各个程序只能串行执行。引入了进程之后就可以并行执行程序。
现在的计算机可以同时进行视频、文字聊天、传送文件。
但是进程是程序的一次执行。但这些功能显然不可能是由一个程序处理就能实现的。
有的进程可能需要“同时”做很多事情,而传统的进程只能串行地执行一系列程序。为此,引入了“线程”,来增加并发度。 - 传统的进程是程序执行流的最小单位。
引入线程后,线程成为了程序执行流的最小单位。 - 可以把线程理解为“轻量级进程”,线程是一个基本的CPU执行单元,也是程序执行流的最小单位。
引入线程之后,不仅进程之间可以并发,进程内的各线程之间也可以并发,从而进一步提升了系统的并发度,使得一个进程内也可以并发处理各种任务。 - 引入线程后,进程只作为除了CPU之外的系统资源的分配单元(如打印机、内存地址空间等都是分配给进程的),进程不再是CPU调度的基本单位。
- 引入线程后,有什么变化?
进程之间的切换需要切换进程的运行环境,系统开销很大。
同一进程之间的线程切换不需要切换进程环境,系统开销小。 - 线程的属性:
- 线程是处理机调度的单位
- 多CPU计算机中,各个线程可占用不同的CPU
- 每个线程都有一个线程ID、线程控制块TCB
- 线程也有就绪、阻塞、运行三种状态
- 线程几乎不拥有系统资源
- 同一进程的不同线程之间共享进程的资源
- 由于共享内存地址空间,同一进程中的线程间通信甚至无需系统干预
- 同一进程中的线程切换不会引起进程切换
- 不同进程中的线程切换,会引起进程切换
- 切换同进程内的线程,系统开销很小
- 切换线程,系统开销很大
线程的实现方式和多线程模型
线程的实现方式
用户级线程
- 历史背景:早期OS只支持进程,不支持线程。当时的“线程”是由线程库实现的。
- 对于QQ同时进行视频聊天、文字聊天和文件传输的例子,可以用最简单的while循环实现一个简单的对线程的调度:
从代码的角度,线程其实就是一段代码逻辑。下面的代码逻辑上可以看作三个线程。while循环就是一个简易的“线程库”,线程库完成了对现成的管理工作(如调度)
很多编程语言提供了强大的线程库,可以实现线程的创建、销毁、调度等功能。
int main() {
int i = 0;
while(true) {
if(i == 0) {
处理视频聊天代码;
}
if(i == 1) {
处理文字聊天代码;
}
if(i == 2) {
处理文件传输的代码;
}
i = (i + 1) % 3;
}
}
- 几个问题:
- 线程的管理工作由谁来完成?
用户级线程的管理是由应用程序通过线程库完成的。 - 线程切换是否需要CPU变态?
线程切换在用户态就可以实现,不需要CPU变态。 - OS是否能意识到用户级线程的存在?
意识不到。 - 这种实现方式的优缺点:
- 优点:用户级线程的切换在用户空间即可完成,不需要切换到核心态,线程管理的系统开销小,效率高。
- 缺点:当一个用户级线程被阻塞后,整个进程都会被阻塞,并发度不高。多个线程不可在多核处理机上并发运行。
内核级线程
内核级线程,又称内核支持的线程,由OS支持的线程。
大多数现代OS都实现了内核级线程,如Windows、Linux。
- 几个问题:
- 线程的管理工作由谁来完成?
OS - 线程的切换是否需要CPU变态?
是 - OS是否能意识到内核级线程的存在?
能 - 这种线程实现方式的优缺点:
- 优点:内核级线程是处理机调度的基本单位。当一个线程被阻塞后,别的线程还可以继续执行,并发能力强。多线程可在多核处理机上并行执行。
- 缺点:一个用户进程会占用多个内核级线程,线程切换由OS内核完成,需要切换到核心态,因此线程管理的成本高,开销大。
多线程模型
在支持内核级线程的系统中,根据用户级线程和内核级线程的映射关系,可以划分为几种多线程模型:
一对一模型
- 一个用户级线程映射到一个内核级线程。每个用户进程有与用户级线程同数量的内核级线程。
- 优点:当一个线程被阻塞后,别的线程还可以继续执行,并发能力强。多线程可在多核处理机上并行执行。
- 缺点:一个用户进程会占用多个内核级线程,线程切换由OS内核完成,需要切换到核心态,因此线程管理的成本高,开销大。
多对一模型
- 多个用户级线程映射到一个内核级线程。且一个进程只被分配给一个内核级线程。
- 优点:用户级线程的切换在用户空间即可完成,不需要切换到核心态,线程管理的系统开销小,效率高。
- 缺点:当一个用户级线程被阻塞后,整个进程都会被阻塞,并发度不高。多个线程不可在多核处理机上并行运行。
OS只能看见内核级线程,因此只有内核级线程才是处理机分配的单位
多对多模型
n个用户级线程映射到m个内核级线程(n >= m)。每个用户进程对应m个内核级线程。
克服了多对一模型并发度不高的缺点(一个阻塞全体阻塞),又克服了一对一模型中一个用户级线程占用太多内核级线程,开销太大的缺点。
- 简便理解方式:
用户级线程是“代码逻辑”的载体
内核级线程是“运行机会”的载体
内核级线程是处理机分配的单位。一段“代码逻辑”只有获得了运行机会才能被CPU执行。
线程的状态与转换、组织与控制
线程的状态与转换
线程的状态与转换和进程的状态与转换类似,通常人们只关注线程状态中的就绪态、运行态和阻塞态。它们之间的转换关系如图所示:
线程的组织与控制
线程对应的数据结构是TCB(线程控制块)
线程切换时要保存/恢复:
- 线程标识符:TID,与PID类似
- 程序计数器PC:线程目前执行到哪里
- 其他寄存器:线程运行的中间结果
- 堆栈指针:堆栈保存函数调用信息、局部变量等。
- 线程运行状态:运行/就绪/阻塞
- 优先级:线程调度、资源分配的参考
多个线程的TCB组织起来就是线程表
线程表的组织方式有很多种,可以一个进程组织一张线程表;也可以系统中所有线程组织一张线程表,还可以不同状态的线程组织一张线程表。
2.2 处理机调度
调度的概念、层次
调度的基本概念
当有一堆任务要处理,由于资源有限,这些事情没法同时处理。这就需要确定某种规则来决定处理这些任务的顺序,这就是“调度”研究的问题。
调度的三个层次
高级调度
- 作业:一个具体任务
- 用户向系统提交一个作业大致相当于用户让OS启动一个程序(来处理一个具体任务)
- 由于内存空间有限,有时无法将用户提交的作业全部放入内存
- 高级调度(作业调度):按一定的原则从外存的作业后备队列中挑选一个作业调入内存,并创建进程。每一个作业只调入一次,调出一次。作业调入时会建立PCB,调出时才撤销PCB。
简化理解:好几个程序需要启动,到底先启动哪个好。
低级调度
- 低级调度也叫(进程调度/处理机调度)
- 低级调度指按照某种策略从就绪队列中选取一个进程,将处理机分配给它。
- 进程调度是OS中的最基本的一种调度,在一般的OS中都必须配置进程调度。
中级调度
- 内存不够时,可将某些进程的数据调出外存。等内存空闲或者进程需要运行时再重新调入内存。
- 暂时调到外存等待的进程状态为挂起状态。被挂起的进程PCB会被组织成挂起队列。
- 中级调度(内存调度):按某种策略决定将哪个处于挂起状态的进程重新调入内存。
- 一个进程可能会被多次调出、调入内存,因此中级调度发送的频率要比高级调度更高。
补充知识:进程的挂起状态与七状态模型
暂时调到外存等待的进程状态为挂起状态(挂起态,suspend)
挂起态又可以进一步细分为就绪挂起和阻塞挂起两种状态。
所以进程的五状态模型可以扩展为七状态模型如下图所示:
注意:“挂起”和“阻塞”的区别,两种状态都是暂时不能获得CPU服务,但挂起态是将进程映像调到外存去了,而阻塞态下进程影响还在内存中。
有的OS会把就绪挂起、阻塞挂起分为两个挂起队列,甚至会根据阻塞原因不同再把阻塞挂起进程进一步细分为多个队列。
三层调度的联系、对比
要做什么 | 调度发生在哪里 | 发生频率 | 对进程状态的影响 | |
---|---|---|---|---|
高级调度(作业调度) | 按照某种规则、从后备队列中选择合适的作业将其调入内存,并为其创建进程 | 外存->内存(面向作业) | 最低 | 无->创建态->就绪态 |
中级调度(内存调度) | 按照某种规则,从挂起队列中选择合适的进程将其数据调回内存 | 外存->内存(面向进程) | 中等 | 挂起态->就绪态(阻塞挂起->阻塞态) |
低级调度(进程调度) | 按照某种规则,从就绪队列中选择一个进程为其分配处理机 | 内存->CPU | 最高 | 就绪态->运行态 |
进程调度的时机、切换与过程角度方式
进程调度的时机
- 什么时候需要进行进程调度与切换?
- 当前运行的进程主动放弃处理机。
分为三种情况:
进程正常终止
运行过程中发生异常而终止
进程主动请求阻塞(如 等待I/O) - 当前运行的进程被动放弃处理机。
分为三种情况:
分给进程的时间片用完
有更紧急的事需要处理(如I/O中断)
有更高优先级的进程进入就绪队列
- 不能进程进程调度与切换的情况:
- 在处理中断的过程中。中断处理过程复杂,与硬件密切相关,很难做到在中断处理过程中进行进程切换。
- 进程在OS内核程序临界区中
- 在原子操作过程中(原语)。原子操作不可中断,要一气呵成
- “进程在OS内核程序临界区中不能进行调度与切换。”这句话是正确的。但是“进程处于临界区时不能进行处理机调度”这句话是错误的。
这里补充一些概念:
临界资源:指一个时间段内只允许一个进程使用的资源。各进程需要互斥的访问临界资源。
临界区:访问临界资源的那段代码。
内核程序临界区一般是用来访问某种内核数据结构的,比如进程的就绪队列(由各就绪进程的PCB组成)
由于内核资源临界区的代码是互斥执行的,过程中临界资源是互斥的访问的。所以为了能尽快的把临界资源交给别的需要使用的进程,在执行内核程序临界区的代码时系统规定不能进行调度与切换。
但如果一个进程在访问一个普通的临界资源(如打印机),如果不能进行调度的切换那进程会一直等待这个资源同时霸占着CPU,造成CPU空闲。所以进程处于临界区时可以进行处理机调度。
进程调度方式
非剥夺调度方式
- 又称非抢占方式。即,只允许进程主动放弃处理机。在运行过程中即便有更紧迫的任务到达,当前进程依然会继续使用处理机,直到该进程终止或主动要求进入阻塞态。
- 实现简单,系统开销小但是无法及时处理紧急任务,适合于早期的批处理系统。
剥夺调度方式
又称抢占方式。当一个进程正在处理机上执行时,如果有一个更重要或更紧迫的进程需要使用处理机,则立即暂停正在执行的进程,将处理机分配给更重要紧迫的那个进程。
- 可以优先处理更紧急的进程,也可实现让各进程按时间片轮流执行的功能(通过时钟中断)。适合于分时OS、实时OS
进程的切换与过程
- “狭义的进程调度”与“进程切换”的区别:
狭义的进程调度指的是从就绪队列中选中一个要运行的进程。(这个进程可以是刚刚被暂停执行的进程,也可以是另一个进程,后一种情况就需要进程切换) - 进程切换指一个进程让出处理机,由另一个进程占用处理机的过程。
- 广义的进程调度包含了选择一个进程和进程切换两个步骤。
- 进程切换的过程主要完成了:
- 对原来运行进程各种数据的保存
- 对新的进程各种数据的恢复(程序计数器、程序状态字、各种数据寄存器等处理机现场信息,这些信息一般保存在进程控制块中)
注意:进程切换时有代价的,因此如果过于频繁的进程进程调度、切换,必然会使整个系统效率降低,是系统大部分时间都花在了进程切换上,而真正用于执行进程的时间减少。
调度器、闲逛进程
调度器
- 进程会在就绪、运行、阻塞3个状态之间来回横跳,而就绪态和运行态之间的转换就是由调度程序引起的。
- 调度程序决定:
让谁运行?——调度算法
运行多长时间?——时间片大小 - 调度时机——什么事件会触发“调度程序”?
- 创建新进程
- 进程退出
- 运行进程阻塞
- I/O中断发生(可能唤醒某些阻塞进程)
- 非抢占式调度策略,只有运行进程阻塞或退出才触发调度程序工作
- 抢占式调度策略,每个时钟中断或k个时钟中断会触发调度程序工作。
- 如果一个OS不仅支持进程,还支持线程,那么内核级线程就是调度程序的处理对象。(线程是资源分配的基本单位,而内核级线程是调度的基本单位)
闲逛进程
- 调度程序永远的备胎,没有其他就绪进程,运行闲逛进程(idle)。
- 闲逛进程的特性:
- 优先级最低
- 可以是0地址指令,占一个完整的指令周期(指令周期末位例行检查中断)
- 能耗低
调度算法的评价指标
CPU利用率
- 由于早期的CPU造价极其昂贵,因此人们会希望让CPU尽可能多的工作。
- CPU利用率:之CPU“忙碌”的时间占总时间的比例
- 公式:\(利用率 = \cfrac {忙碌时间}{总时间}\)
- 有的题目还会要求计算某种设备的利用率
- e.g. 某计算机只支持单道程序,某个作业刚开始需要在CPU上运行5秒,在用打印机打印输出5秒,之后再执行5秒,才能结束。在此过程中,CPU利用率、打印机利用率分别是多少?
答:CPU利用率 = (5+5)/(5+5+5) = 66.67%
打印机利用率 = 5 / (5 + 5 + 5) = 33.33%
系统吞吐量
- 对于计算机来说,希望能尽用可能少的时间 处理完成尽可能多的作业
- 系统吞吐量:单位时间内完成作业的数量
- 公式:\(系统吞吐量 = \cfrac {总共完成了多少道作业}{总共花了多少时间}\)
- e.g. 某计算机系统处理完10道作业,共花费100秒,则系统吞吐量为?
答:10/100 = 0.1道/秒
周转时间
- 对于计算机的用户来说,他很关心自己的作业从提交到完成花了多少时间。
- 周转时间,是指从作业被提交给系统开始,到作业完成为止的这段时间间隔。
- 它包括4个部分:作业在外存后备队列上等待作业调度(高级调度)的时间、进程在就绪队列上等待进程调度(低级调度)的时间、进程在CPU上执行的时间、进程等待IO操作完成的时间。后三项(就绪态、运行态、阻塞态)在一个作业的整个处理中,可能发生多次。
- 公式:
\((作业)周转时间 = 作业完成时间 - 作业提交时间\)
$ 平均周转时间 = \cfrac{各作业周转时间之和}{作业数}$ - 有的作业运行时间短,有的作业运行时间长。在周转时间相同的情况下,运行时间长的作业,用户体验感更好。
- 带权周转时间:
\(带权周转时间 = \cfrac {作业周转时间}{作业实际运行时间} = \cfrac {作业完成时间 - 作业提交时间}{作业实际运行时间}\) - 对于实际运行时间相同的两个作业,周转时间短的带权周转时间更小,用户满意度更高。
- 平均带权周转时间:
\(平均带权周转时间 = \cfrac {各作业带权周转时间之和}{作业数}\)
等待时间
- 计算机的用户希望自己的作用尽可能少的等待处理机
- 等待时间,指进程/作业处于等待处理机状态时间之和,等待时间越长,用户满意度越低。
- 作业在后备队列等待被服务(调度);作业调入内存后,建立对应的进程。这个进程会被CPU服务、会被I/O设备服务,当然也会有等待被服务的时候。
- 对于进程来说,等待时间就是指进程建立后等待被服务的时间之和,在等待I/O完成的期间其实进程也是在被服务的,所以不计入等待时间。
- 对于作业来说,不仅要考虑建立进程后的等待时间,还要加上作业在外存后备队列中的等待时间。
- 一个作业总共需要被CPU服务多久,被I/O设备服务多久一般是确定不变的,因此调度算法其实只会影响作业/进程的等待时间。当然,与前面指标类似,也有“平均等待时间”来评价整体性能。
响应时间
- 对于计算机用户来说,会希望自己的提交的请求(比如通过键盘输入了一个调试命令)尽早地开始被系统服务、回应。
- 响应时间,指从用户提交请求到首次产生响应所用的时间。
调度算法
先来先服务(FCFS)
算法思想
主要从“公平”角度考虑,(类似于生活中排队买东西的例子)
算法规则
按照作业/进程到达的先后顺序进行服务
进程调度or作业调度
用于作业调度时,考虑的是哪个作业先到达后备队列;
用于进程调度时,考虑的是哪个进程先到达就绪队列。
抢占式or非抢占式
非抢占式算法
优缺点
优点:公平、算法实现简单
缺点:排在长作业(进程)后面的短作业需要等待很长时间,带权周转时间很大,对短作业来说用户体验不好。即,FCFS算法对长作业有利,对短作业不利。
是否会导致饥饿
饥饿,指某进程/作业长时间得不到服务。
不会。
例子
各进程到达就绪队列的时间、需要运行时间如下表所示。使用先来先服务调度算法,计算个进程的等待时间、平均等待时间、周转时间、平均周转时间、带权周转时间、平均带权周转时间。
进程 | 到达时间 | 运行时间 |
---|---|---|
P1 | 0 | 7 |
P2 | 2 | 4 |
P3 | 4 | 1 |
P4 | 5 | 4 |
解:先来先服务调度算法:按照到达的先后顺序调度,事实上就是等待时间越久的越优先得到服务。
因此调度顺序为P1->P2->P3->P4
运行时间如下图:
周转时间 = 完成时间 - 到达时间
带权周转时间 = (完成时间 - 到达时间) / 运行时间
所以,题目中要求的各项指标如下表所示:
进程 | 等待时间 | 周转时间 | 带权周转时间 |
---|---|---|---|
P1 | 0 | 7 | 1 |
P2 | 5 | 9 | 9/4 |
P3 | 7 | 8 | 8 |
P4 | 7 | 11 | 11/4 |
平均等待时间 = (0 + 5 + 7 + 7) / 4 = 4.75
平均周转时间 = (7 + 9 + 8 + 11)/ 4 = 8.75
平均带权周转时间 = (1 + 9/4 + 8 + 11/4) / 4 = 3.5
注:本例中的进程都是纯计算型的进程,一个进程到达后要么在等待,要么在运行。如果是又有计算、又有IO操作的进程,其等待时间就是周转时间 - 运行时间 - IO操作时间。
短作业优先(SJF)
算法思想
追求最少的平均等待时间,最少的平均周转时间,最少的平均带权周转时间。
算法规则
最短的作业/进程优先得到服务(最短,指要求服务时间最短)
进程调度or作业调度
既可用于作业调度,又可用于进程调度。
用于进程调度时称为“短进程优先(SPF, Shortest Process First)算法”
抢占式or非抢占式
SJF和SPF是非抢占式的算法。但是也有抢占式的版本——最短剩余时间优先算法(SRTN,Shortest Remaining Time Next)
优缺点
- 优点:“最短的”平均等待时间、平均周转时间
- 缺点:不公平。对短作业有利,对长作业不利。可能产生饥饿现象。另外,作业/进程的运行时间是由用户提供的,并不一定真实,不一定能做到真正的短作业优先。
是否会导致饥饿
会。如果源源不断的有短作业/进程到来,可能使长作业/进程长时间得不到服务,产生“饥饿”现象。如果一直得不到服务,则称为“饿死”。
注
- 如果题目中未特别说明,所提到的“短作业优先算法”默认是非抢占式的。
- “SJF调度算法的平均等待时间、平均周转时间最少”这个说法严格来说是错的。最短剩余时间算法得到的平均周转时间和平均等待时间更少。
想要这句话正确应该再加一个条件:“在所有进程同时可允许”或“在所有进程几乎同时到达时,采用SJF调度算法”
如果不加这个条件,则应该说“抢占式短作业优先调度算法”的平均等待时间、平均周转时间最少。 - 虽然严格地说,SJF的平均等待时间、平均周转时间不一定最少,但相比于其他算法(如FCFS),SJF依然可以获得最少的平均等待时间、平均周转时间。
- 如果选择题中遇到“SJF算法的平均等待时间、平均周转时间最少”的选项,那最好判断一下其他选项是不是有明显的错误,如果没有更合适的选项,那也应该选择该选项。
例1
同样的例子改成非抢占式的短作业优先调度算法。
各进程到达就绪队列的时间、需要运行时间如下表所示。使用非抢占式的短作业优先调度算法,计算个进程的等待时间、平均等待时间、周转时间、平均周转时间、带权周转时间、平均带权周转时间。
进程 | 到达时间 | 运行时间 |
---|---|---|
P1 | 0 | 7 |
P2 | 2 | 4 |
P3 | 4 | 1 |
P4 | 5 | 4 |
解: 短作业优先算法每次调度时选择当前已经到达且运行时间最短的作业/进程。
因此,调度顺序为:P1->P3->P2->P4
运行时间情况如下:
由此可得,每个进程的等待时间、周转时间和带权周转时间如下表:
进程 | 等待时间 | 周转时间 | 带权周转时间 |
---|---|---|---|
P1 | 0 | 7 | 1 |
P2 | 6 | 10 | 2.5 |
P3 | 3 | 4 | 4 |
P4 | 7 | 11 | 2.75 |
由此可得平均等待时间 = 4
平均周转时间 = 8
平均带权周转时间 = 2.5625
对比FCFS算法的结果,显然SPF算法的平均等待/周转/带权周转时间都要更低。
例2
同样的例子改成抢占式的短作业优先调度算法。
各进程到达就绪队列的时间、需要运行时间如下表所示。使用抢占式的短作业优先调度算法(也叫最短剩余时间优先算法SRTN),计算个进程的等待时间、平均等待时间、周转时间、平均周转时间、带权周转时间、平均带权周转时间。
进程 | 到达时间 | 运行时间 |
---|---|---|
P1 | 0 | 7 |
P2 | 2 | 4 |
P3 | 4 | 1 |
P4 | 5 | 4 |
解:最短剩余时间优先算法:每当有进程加入就绪队列改变时就需要调度,如果新到达的进程剩余时间比当前运行进程的剩余时间更短,则由新进程抢占处理机,当前运行进程重新回到就绪队列。另外,当一个进程完成时也需要调度。
下面以Pn(m)表示当前Pn进程剩余时间为m。各时刻的情况如下:
时刻 | 事件 | 进程剩余时间 |
---|---|---|
0 | P1到达 | P1(7) |
2 | P2到达 | P1(5) P2(4) |
4 | P3到达 | P1(5) P2(2) P3(1) |
5 | P4到达、P3结束 | P1(5) P2(2) P4(4) |
7 | P2结束 | P1(5) P4(4) |
11 | P4结束 | P1(5) |
16 | P1结束 |
各进程的调度如图所示:
由此可知各进程的等待时间、周转时间、和带段周转时间如下表:
进程 | 等待时间 | 周转时间 | 带权周转时间 |
---|---|---|---|
P1 | 9 | 16 | 2.28 |
P2 | 1 | 5 | 1.25 |
P3 | 0 | 1 | 1 |
P4 | 2 | 6 | 1.5 |
总是,平均等待时间 = 3
平均周转时间 = 7
平均带权周转时间 = 1.50
高响应比优先(HRRN)
算法思想
要综合考虑作业/进程的等待时间和要求服务的时间
算法规则
在每次调度时先计算各个作业/进程的响应比,选择响应比最高的作业/进程为其服务。
\(响应比 = \cfrac {等待时间 + 要求服务时间}{要求服务时间}\)
进程调度or作业调度
既可以用于作业调度,也可用于进程调度
抢占式or非抢占式
非抢占式算法。因此只有当前运行的作业/进程主动放弃处理机时,才需要调度,才需要计算响应比。
优缺点
综合考虑了等待时间和运行时间(要求服务时间)
等待时间相同时,要求服务时间短的优先(SJF的优点)
要求服务时间相同时,等待时间长的优先(FCFS优点)
对于长作业来说,随着等待时间越来越久,其响应比也会越来越大,从而避免了长作业饥饿问题。
是否会导致饥饿
不会。
例
同样的例子改成抢占式的短作业优先调度算法。
各进程到达就绪队列的时间、需要运行时间如下表所示。使用高响应比优先(HRRN)算法,计算个进程的等待时间、平均等待时间、周转时间、平均周转时间、带权周转时间、平均带权周转时间。
进程 | 到达时间 | 运行时间 |
---|---|---|
P1 | 0 | 7 |
P2 | 2 | 4 |
P3 | 4 | 1 |
P4 | 5 | 4 |
解:高响应比优先算法:非抢占式的调度算法,只有当运行的进程主动放弃CPU时(正常/异常完成,或主动阻塞),才需要进行调度,调度时计算所有就绪进程的响应比,选响应比最高的进程上处理机。
\(响应比 = \cfrac {等待时间 + 要求服务时间}{要求服务时间}\)
0时刻:只有P1到达,P1上处理机。
7时刻(P1主动放弃CPU):就绪队列中P2的响应比为(5+4)/4 = 2.25、P3的响应比=(3+1)/1 = 4、P4的响应比=(2+4)/4 = 1.5
8时刻(P3完成):P2(2.5) P3(1.75)
12时刻(P2完成):就绪队列中只剩下P4
时间片轮转调度算法(RR)
算法思想
公平的、轮流的为各个进程服务,让每个进程在一定时间间隔内都可以得到响应
算法规则
按照各进程到达就绪队列的顺序,轮流让各个进程执行一个时间片(如100ms)。若进程未在一个时间片内执行完则剥夺处理机,将进程重新放回就绪队列尾重新排队
作业调度or进程调度
用于进程调度(只有作业放入内存建立了相应的进程后,才能被分配处理机时间片)
抢占式or非抢占式
若进程未能在时间片内运行完,将被强行剥夺处理机使用权,因此时间片轮转调度算法属于抢占式的算法。由时钟装置发出时钟中断来通知CPU时间片已到。
优缺点
优点:公平;响应快,是用于分时OS
缺点:由于高频率的进程切换,因此有一定开销;不区分任务的紧急程度。
是否会导致饥饿
不会
例
各进程到达就绪队列的时间、需要的运行时间如下表所示。使用时间片轮转调度算法,分析时间片大小分别为2、5时的进程运行情况。
进程 | 到达时间 | 运行时间 |
---|---|---|
P1 | 0 | 5 |
P2 | 2 | 4 |
P3 | 4 | 1 |
P4 | 5 | 6 |
解:时间片大小为2:
时间片大小为5:
优先级调度算法
算法思想
随着计算机发展,特别是实时OS的出现,越来越多的应用场景需要根据任务的紧急程度来决定处理顺序。
算法规则
每个作业/进程有各自的优先级,调度时选择优先级最高的作业/进程。
作业调度or进程调度
既可用于作业调度,也可用于进程调度。甚至,还会用于在之后会学习的IO调度中。
抢占式or非抢占式
抢占式、非抢占式都有。做题时的区别在于:非抢占式只需在进程主动放弃处理机时进程调度即可,而抢占式还需要在就绪队列变化时,检擦是否会发生抢占。
优缺点
优点:用优先级区分紧急程度,重要程度,是用于实时OS。可灵活地调整各种作业/进程的偏好程度。
缺点:若源源不断地有高优先级进程到来,则可能导致饥饿。
是否会导致饥饿
会
例1
各进程到达就绪队列的时间、需要的运行时间、进程优先数如下表。使用非抢占式的优先级调度算法,分析进程运行情况(优先数越大,优先级越高)
进程 | 到达时间 | 运行时间 | 优先数 |
---|---|---|---|
P1 | 0 | 7 | 1 |
P2 | 2 | 4 | 2 |
P3 | 4 | 1 | 3 |
P4 | 5 | 4 | 2 |
解:非抢占式优先级调度算法:每次调度时选择当前已到达且优先级最高的进程。当前进程主动放弃处理机时发生调度。
例2
同样例题采用抢占式优先级调度算法:
解:抢占式优先级调度算法:每次调度时选择当前已到达且优先级最高的进程。当前进程主动放弃处理机时发生调度。另外,当就绪队列发生改变时也需要检查是否会发生抢占。
0时刻P1上处理机。
2时刻P2到达,P2优先级更高,所以P2上处理机
4时刻,P3到达,优先级更高,P3上处理机
5时刻,P3结束,P4到达,P2和P4的优先级相同,但是P2更早进入就绪队列,所以P2上处理机。
7时刻,P2结束,P4上处理机
11时刻,P4结束,P1上处理机
多级反馈队列调度算法
算法思想
对其他调度算法的折中权衡
算法规则
- 设置多级就绪队列,各级队列优先级从高到低,时间片从小到大
- 新进程到达时先进入第一级队列,按FCFS原则排队等待被分配时间片,若用完时间片进程还未结束,则进程进入下一级队列队尾。如果此时已经是在最下级的队列,则重新放回该队列队尾。
- 只有第k级队列为空时,才会为k+1级队头的进程分配时间片。
作业调度or进程调度
用于进程调度
抢占式or非抢占式
抢占式的算法。在k级队列的进程运行过程种,若更上级的队列种进入了一个新进程,则由于新进程处于优先级更高的队列中,因此新进程会抢占处理机,原来运行的进程放回K级队列的队尾。
优缺点
优点:对各类进程相对公平(FCFS优点);每个新到达的进程都可以很快就得到响应(RR优点);短进程只用较少的时间就可完成(SPF优点);不必实现估计进程的运行时间(避免用户作假);可灵活地调整对各类进程的偏好程度,比如CPU密集型进程、IO密集型进程(拓展:可以将因IO而阻塞的进程重新放回原队列,这样IO型进程就可以保持较高的优先级)
是否会导致饥饿
会
例
各进程到达就绪队列的时间、需要的运行时间如下表。使用多级反馈队列调度算法,分析进程运行的过程:
进程 | 到达时间 | 运行时间 |
---|---|---|
P1 | 0 | 8 |
P2 | 1 | 4 |
P3 | 5 | 1 |
解:设置多级就绪队列,各级队列优先级从高到低,时间片从小到大
新进程到达时先进入第一级队列,按FCFS排队等待被分配时间片。
被抢占处理机的进程重新放回原队列队尾。
0时刻:P1到达进入第一级队列,分配一个时间片
1时刻:P1时间片用完进入第二级队列,P2到达,分配一个时间片。
2时刻:P2时间片用完进入第二级队列,给P1分配2个时间片。
4时刻:P1时间片用完进入第三级队列,给P2分配2个时间片
5时刻:P3到达,进入第一级队列,P2被抢占,放回第二级队列队尾。给P3一个时间片。
6时刻:P3运行结束,P2上处理机
8时刻:P2运行结束,P1上处理机,分配4个时间片
12时刻:P1时间片用完,但是此时没有其他就绪队列,P1继续运行。
13时刻:全部运行结束。
多级队列调度算法
系统中按进程类型设置多个队列,进程创建成功后插入某个队列。
队列之间可采取固定优先级,或时间片划分
固定优先级:高优先级空时低优先级进程才能被调度
时间片划分:如三个队列分配时间50%、40%、10%
各队列可采用不同的调度策略。如:系统进程队列采用优先级调度;交互式队列采用RR;批处理队列采用FCFS
补充
- FCFS算法是在每次调度的时候选择一个等待时间最长的作业(进程)为其服务。但是没有考虑到作业的运行时间,因此导致了对短作业不友好的问题。
- SJF算法是选择一个执行时间最短的作业为其服务。但是又完全不考虑各个作业的等待时间,因此导致了对长作业不友好的问题,甚至还会造成饥饿问题。
- FCFS、SJF、HRRN算法主要关心对用户的公平性、平均周转时间、平均等待时间等评价系统整体性能的指标,但不关心“响应时间”,也并不区分任务的紧急程度。因此对于用户来说,交互性很糟糕。因此这三种算法一般适合于早期的批处理系统。当然FCFS算法也常结合其他算法使用,在现在也扮演着很重要的角色。
- 对于时间片轮转调度算法:
如果时间片太大,使得每个进程都可以在一个时间片内完成,则时间片轮转调度算法退化为先来先服务调度算法,并且会增大进程响应时间。因此时间片不能太大。
另一方面,进程调度、切换是有时间代价的(保存、恢复运行环境),因此如果时间片太小,会导致进程切换过于频繁,系统会花大量时间来处理进程切换,从而导致实际用于进程执行的时间比例减少,所以时间片也不能太小。
一般来说,设计时间片时要让切换进程的开销占比不超过1% - 优先级调度算法:
- 就绪队列未必只有一个,可以按照不同优先级来组织。另外,也可以把优先级高的进程排在更靠近队头的位置。
- 根据优先级是否可以动态改变,可将优先级分为静态优先级和动态优先级两种。
- 静态优先级:创建进程时确定,之后一直不变。
- 动态优先级:创建进程时有一个初始值,之后会根据情况动态地调整优先级。
- 如何合理地设置各类进程的优先级?
通常:- 系统进程优先级高于用户进程
- 前台进程优先级高于后台进程
- OS更偏好IO型进程(或称IO繁忙型进程)
与IO进程相对的是计算型进程(或称CPU繁忙型进程)IO设备和CPU可以并行工作。如果优先让IO繁忙型进程优先运行的话,则越有可能让IO设备尽早地投入工作,则资源利用率、系统吞吐量都会得到提升。
- 如果采用动态优先级,应该什么时候调整优先级呢?
可从追求公平、提升资源利用率等角度考虑。
如果某进程在就绪队列中等待了很长时间,则可以适当提升其优先级
如果某进程占用处理机运行了很长时间,则可适当降低其优先级。
如果发现一个进程频繁地进行IO操作,则可适当提升其优先级。
- RR、优先级调度算法、多级反馈队列可以比较好地满足交互式系统(包括分时OS、实时OS等)的需求。这些系统更注重响应时间、公平性、平衡性等指标。
2.3 同步与互斥
进程同步、进程互斥
- 什么是进程同步?
知识点回顾:进程具有异步性的特征。异步性是指,各并发执行的进程以各自独立的、不可预知的速度向前推进。
在管道通信过程中,读进程和写进程并发地运行,由于并发必然导致异步性,因此“写数据”和“读数据”两个操作执行的先后顺序是不确定的。而实际应用中,又必须按照“写数据->读数据”的顺来执行。
同步亦称直接制约关系,它是指为完成某种任务而建立的两个或多个进程,这些进程因为需要在某些位置上协调它们的工作次序而产生的制约关系。进程间的直接制约关系就是源于它们之间的相互合作。 - 什么是进程互斥?
进程的“并发”需要“共享”的支持。各个并发执行的进程不可避免的需要共享一些系统资源(比如内存,又比如打印机、摄像头这样的IO设备)
我们把一个时间段内只允许一个进程使用的资源称为临界资源。许多物理设备(比如摄像头、打印机)都属于临界资源。此外还有许多变量、数据、内存缓冲区等都属于临界资源。
对临界资源的访问,必须互斥地进行。互斥,亦称简介制约关系。进程互斥指当一个进程访问临界资源时,另一个想要访问该临界资源的进程必须等待。当前访问临界资源的进程访问结束,释放该资源后,另一个进程才能去访问临界资源。
对临界资源的互斥访问,可以在逻辑上分为如下四个部分:
do{
entry section; //进入区
critical section; //临界区
exit section; //退出区
remainder section; //剩余区
} while(true)
进入区:负责检查是否可进入临界区,若可进入,则应设置正在访问临界资源标志(可理解为上锁),以阻止其他进程同时进入临界区。
临界区:访问临界资源的那段代码
退出区:负责解除正在访问临界资源的标志(可理解为解锁)。
剩余区:做其他处理
注意:
临界区是进程中访问临界资源的代码段
进入区和退出区是负责实现互斥的代码段
临界区也可称为“临界段”
- 如果一个进程暂时不能进入临界区,那么该进程是否应该一直占着处理机?该进程有没有可能一直进不了临界区?
为了实现对临界资源的互斥访问,同时保证系统整体性能,需要遵循以下原则:
- 空闲让进。临界区空闲时,可以允许一个请求进入临界区的进程立即进入临界区。
- 忙则等待:。当已有进程进入临界区时,其他试图进入临界区的进程必须等待。
- 有限等待。对请求访问的进程,应保证能在有限时间内进入临界区(保证不会饥饿)。
- 让权等待。当进程不能进入临界区时,应立即释放处理机,防止进程忙等待(进程进行不下去了还在CPU上)。
进程互斥的软件实现方法
单标志法
算法思想
两个进程在访问完临界资源区后会把使用临界区的权限转交给另一个进程。也就是说每个进程进入临界区的权限只能被另一个进程赋予。
代码
int turn = 0; //turn表示当前允许进入临界区的进程号
p0进程:
while(turn != 0); //进入区
critical section; //临界区
turn = 1; //退出区
remainder section; //剩余区
P1进程:
while(turn != 1); //进入区
critical section; //临界区
turn = 0; //退出区
remainder section; //剩余区
若turn的初值为0,P1线上处理机运行,则会一直卡在进入区。直到P1的时间片用完,发生调度,切换P0上处理机运行。P0的进入区不会被卡住,P0可以正常访问临界区,在P0访问临界区期间即使切换回P1,P1依然会卡在进入区。只有P0在退出区把turn改为 1后,P1才能进入临界区。
不足
如果A、B轮流使用临界资源,此时A使用完毕后更改turn的值,此时B不需要使用资源。如果一段时间后A又要使用资源,turn的值为B,A无法使用。违法了空闲让进原则
双标志先检查
算法思想
设置一个布尔型数组flag[],数组中各个元素来标记各进程想进入临界区的意愿,比如"flag[0] = true"意味着0号进程P0现在想要进入临界区。每个进程在进入临界区之前先检查当前有没有别的进程想进入临界区,如果没有,则把自身对应的标志flag[i]设为true,之后开始访问临界区。
代码
bool flag[2]; //表示进入临界区意愿的数组
flag[0] = false;
flag[1] = false; //刚开始设置为两个进程都不想进入临界区
进程P0:
while(flag[1]); //如果此时P1想进入临界区,P0就一直循环等待
flag[0] = true; //标记为P0进程想要进入临界区
critical section; //访问临界区
flag[0] = false; //访问完临界区,修改标记为P0不想使用临界区
remainder section; //剩余区
P1进程:
while(flag[0]); //如果此时P0想进入临界区,P1就一直循环等待
flag[1] = true; //标记为P1进程想要进入临界区
critical scetion; //访问临界区
flag[1] = false; //访问完临界区,修改标记为P1不想使用临界区
remainder section; //剩余区
不足
如果两个进程并发执行,可能会出现先访问的进程还没来得及上锁,后访问的进程就判断空闲,两个进程都上锁的状况。违法忙则等待原则
双标志后检查
算法思想
双标志先检查法的改版。前一个算法的问题是先“检查”后“上锁”,但这两个操作又无法一气呵成,因此导致了两个进程同时进入临界区的问题。因此,人们又想到先“上锁”后“检查”的方法,来避免上述问题。
代码
bool flag[2]; //表示进入临界区意愿的数组
flag[0] = false;
flag[1] = false; //刚开始设置为两个进程都不想进
P0进程:
flag[0] = true; //上锁
while(flag[1]); //若P1已上锁,P0循环等待
critical section; //访问临界区
flag[0] = false; //解锁
remainder section; //剩余区
P1进程:
flag[1] = true; //上锁
while(flag[0]); //若P0已上锁,则P1循环等待
critical section; //访问临界区
flag[1] = false; //访问完临界区,解锁
remainder section; //剩余区
不足
两个进程都给临界区上锁,可能导致都卡在循环等待上,虽然解决了“忙则等待”的问题,但是又违背了“空闲让进”和“有限等待”原则,会因各进程都长期无法访问临界资源而产生“饥饿”现象。
Peterson算法
算法思想
结合双标志法、单标志法的思想。如果双方都争着想进入临界区,那可以让进程尝试谦让。
代码
bool flag[2]; //处置都为false
int turn = 0; //turn表示优先让哪个进程进入临界区
P0进程:
flag[0] = true;
turn = 1;
while(flag[1] && turn == 1); //进入区
critical section;
flag[0] = false;
remainder section;
P1进程:
flag[1] = true; //表示自己想进入临界区
turn = 0; //可以优先让对方进入临界区
while(flag[0] && turn == 0);
//对方想进,且最后是自己表示了谦让,那就循环等待
critical section;
flag[1] = false;//表示不想用临界区了
remainder section;
特点
Peterson算法用软件方法解决了进程互斥问题,遵循了空闲让进、忙则等待、有限等待三个原则。但是依然未遵循让权等待的原则。
进程互斥的硬件实现方法
中断屏蔽法
- 利用“开/关中断指令”实现(与原语的实现思想相同,即在某个进程开始访问临界区到结束访问为止不允许被中断,也就是不能发生进程切换,因此可不可能发生两个同时访问临界区的情况)
- 优点:简单、高效
- 缺点:不适合用多处理机;只适用于OS内核进程,不适用于用户进程(因为开/关中断指令只能运行在内核态,这组指令如果能让用户随意使用会很危险)
TestAndSet指令
- 简称TS指令,也有地方称为TestAndSetLock指令,或TSL指令。
- TSL指令使用硬件实现的,执行的过程中不允许被中断,只能一气呵成。以下是用C语言描述的逻辑:
//布尔型共享变量lock表示当前临界区是否被加锁
//true表示已加锁,false表示未加锁
bool TestAndSet(bool *lock) {
bool old;
old = *lock;//old用来存放lock原来的值
*lock = true;//无论之前是否已加锁,都将lock设为true
return old;//返回lock原来的值
}
//以下是使用TSL指令实现互斥的算法逻辑
while(TestAndSet(&lock));//"上锁"并"检查"
临界区代码段...
lock = false; //解锁
剩余区代码段...
若刚开始lock是false,则TSL返回old值为false,while循环条件不满足,直接跳过循环,进入临界区。若刚开始lock是true,则执行TSL后old返回的值为true,while循环条件满足,会一直循环,直到当前访问临界区的进程在退出区进行“解锁”。
相比软件实现方法,TSL指令把“上锁”和检查操作用硬件方式变成了一气呵成的原子操作。
- 优点:实现简单,无需像软件实现方法那样严格检查是否会有逻辑漏洞;适用于多处理机环境。
- 缺点:不满足“让权等待”原则,暂时无法进入临界区的进程会占用CPU并循环执行TSL指令,从而导致“忙等”。
Swap指令
优点地方叫Exchange指令,或简称XCHG指令。
Swap指令是用硬件实现的,执行的过程不允许被中断,只能一气呵成。以下是用C语言描述的逻辑:
//Swap指令的作用是交换两个变量的值
Swap(bool *a, bool *b) {
bool temp;
temp = *a;
*a = *b;
*b = temp;
}
//以下是用Swap指令实现互斥的算法逻辑
//lock表示当前临界区是否被加锁
bool old = true;
while(old == true)
Swap(&lock, &old);
临界区代码段...
lock = false;
剩余区代码段...
逻辑上来看,Swap和TSL并无太大区别,都是先记录下此时临界区是否已经被上锁(记录在old变量上),再将上锁标记lock设置为true,最后检查old,如果old为false则说明之前没有别的进程对临界区上锁,则可跳出循环,进入临界区。
- 优点:实现简单,无需像软件实现方法那样严格检查是否会有逻辑漏洞;适用于多处理机环境。
- 缺点:不满足“让权等待”原则,暂时无法进入临界区的进程会占用CPU并循环执行TSL指令,从而导致“忙等”。
互斥锁
- 互斥锁:
解决临界区最简单的工具就是互斥锁。一个进程在进入临界区时应获得锁;在退出临界区时释放锁。函数acquire()获得锁,函数release()释放锁。
每一个互斥锁有一个布尔变量available,表示锁是否可用。如果锁是可用的,调用acqiure()会成功,且锁不再可用。当一个进程试图获取不可用的锁时,会被阻塞,直到锁被释放。
acquire() {
while(!available) //忙等待
;
available = false; //获得锁
}
release() {
available = true; //释放锁
}
acquire()和release()的执行必须是原子操作,因此互斥锁通常采用硬件机制来实现。
互斥锁的主要缺点是忙等待,当有一个进程在临界区中,任何其他进程在进入临界区时必须连续循环调用acquire().当多个进程共享同一CPU时,就浪费了CPU周期。因此,互斥锁通常用于多处理器系统,一个线程可以在一个处理器上等待,不影响其他线程的执行。
需要连续循环忙等的互斥锁,都可称为自旋锁,如TSL指令、swap指令,单标志法。
2. 特性:
- 需忙等,进程时间片用完才下处理机,违反“让权等待”
- 优点:等待期间不用切换进程上下文,多处理器系统中,若上锁的时间短,则等待代价很低
- 常用于多处理器系统,一个核忙等,其他核照常工作,并快速释放临界区。
- 不太适用于单处理机系统,忙等的过程中不可能解锁。
信号量机制
用户进程可以通过使用OS提供的一对原语来对信号量进行操作,从而很方便的实现了进程互斥、进程同步。
信号量其实就是一个变量(可以是一个整数,也可以是更复杂的记录型变量),可以用一个信号量来表示系统中某种资源的数量,比如:系统中只有一台打印机,就可以设置初值为1的信号量。
原语是一种特殊的程序段,其执行只能一气呵成,不可被中断。原语是由关中断/开中断指令实现的。软件解决方案的主要问题是由“进入区的各种操作无法一气呵成”,因此如果能把进入区、退出区的存在都用原语实现,这些操作能一气呵成,就能避免问题。
一对原语:wait(S)原语和Signal(S)原语,可以把原语理解为我们自己写的函数,函数名分别为wait和signal,括号里的信号量S其实就是函数调用时传入的一个参数。
wait、signal原语常简称P、V操作(来自荷兰语proberen和verhogen)。因此,做题的时候常把wait(S)、signal(S)两个操作分别写为P(S)、V(S)
整型信号量
- 用一个整数表示系统资源的数量。
与普通整数变量的区别:对信号量的操作只有三种,即初始化、P操作、V操作。 - e.g. 某计算机系统中有一台打印机
int S = 1;//初始化整型信号量S,表示当前系统中可用的打印机资源数。
void wait(int S) {//wait原语,相当于“进入区”
while(S <= 0);//如果资源数不够,就一直循环等待
S = S - 1; //如果资源数够,则占用一个资源
}
void signal(int S) {//signal原语,相当于“退出区”
S = S + 1;//使用完资源后,在退出区释放资源
}
PV操作的使用过程(对于进程P0):
...
wait(S); //进入区,申请资源
使用打印机资源... //临界区,访问资源
signal(S); //退出区,释放资源
...
- “检查”和“上锁”一气呵成,避免了并发、异步导致的问题。
- 存在问题:不满足“让权等待”,会发生“忙等”
记录型信号量
整型信号量的缺陷是存在“忙等”问题,因此人们又提出了“记录型信号量”,即用记录型数据结构表示的信号量。
//记录型信号量的定义
typedef struct {
int vlaue; //剩余资源数
struct process *L; //等待队列
} semaphore;
//某进程需要使用资源时,通过wait原语申请
void wait(semaphore S) {
S.value--;
if(S.value < 0) {
block (S.L);
}
}//如果剩余资源数不够,使用block原语使进程从运行态进入阻塞态,并把进程挂到信号量S的等待队列(即阻塞队列)中。
//进程使用完资源后,通过signal原语释放
void signal(semaphore S) {
S.value++;
if(S.value <= 0) {
wakeup(S.L);
}
}//释放资源后,若还有别的进程在等待这种资源,则使用wakeup原语唤醒等待队列中的一个进程,该进程从阻塞态变为就绪态。
例如某计算机系统中有2台打印机,则可在初始化信号量S时将S.value的值设为2,队列S.L设置为空。
typedef struct {
int vlaue; //设置为2
struct process *L; //初始为null
} semaphore;
//4个进程:
//P0进程:
...
wait(S);
使用打印机...
signal(S);
...
//P1进程:
...
wait(S);
使用打印机...
signal(S);
...
//P2进程:
...
wait(S);
使用打印机...
signal(S);
...
//P3进程:
...
wait(S);
使用打印机...
signal(S);
...
总结:
wait、signal也可记为P(S)、V(S),这对原语可用于实现资源的“申请”和“释放”。
S.value的初值表示系统中某种资源的数目。
对信号量S的一次P操作意味着进程请求一个单位的该类资源,因此需要执行S.value--,表示资源数减一,当S.value < 0时表示该类资源已分配完毕,因此进程应调用block原语进程自我阻塞(当前运行的进程从运行态->阻塞态),主动放弃处理机,并插入该类资源的等待队列S.L中。可见,该机制遵循了“让权等待”原则,不会出现忙等现象。
对信号量S的一次V操作意味着进程释放一个单位的该类资源,因此需要执行S.value++,表示资源数+1,若加1后仍是S.value <= 0,表示依然有进程在等待该类资源,因此应调用wakeup指令唤醒等待队列中的第一个进程(被唤醒进程从阻塞态->就绪态)
用信号量机制实现进程互斥、同步、前驱关系
信号量机制实现进程互斥
- 分析并发进程的关键活动,划定临界区(如:对临界资源打印机的访问就是放在临界区)
- 设置互斥信号量mutex,初值为1
- 在进入区P(mutex)——申请资源
- 在退出区V(mutex)——释放资源
注:对不同的临界资源需要设置不同的互斥信号量。
P、V操作必须成对出现。缺少P就不能保证临界资源互斥访问。缺少V会导致资源永不被释放,等待进程永不被唤醒。
信号量机制实现进程同步
进程同步:要让各并发进程按照要求有序地进行。
比如下面代码:
P1() {
代码1;
代码2;
代码3;
}
P2() {
代码4;
代码5;
代码6;
}
设P1、P2并发执行,由于存在异步性,因此二者交替推进的次序是不确定的。
若P2的“代码4”要基于P1的“代码1”和“代码2”的运行结果才能执行,那么我们就必须保证“代码4”一定在“代码2”之后才会执行。
这就是进程同步问题,让本来异步并发的进程互相配合,有序推进。
用信号量实现进程同步:
- 分析什么地方需要实现“同步关系”,即必须保证“一前一后”执行两个操作(或两句代码)
- 设置同步信号量S,初始为0
- 在“前操作”之后执行V(S)
- 在“后操作”之前执行P(S)
信号量机制实现前驱关系
进程P1中有句代码S1,P2中有句代码S2,P3中有句代码S3......P6中有句代码S6。这些代码要求按如下前驱图所示的顺序来执行:
分析:其实每一对前驱关系都是一个进程同步问题(需要保证一前一后的操作)
因此:
- 要为每一对前驱关系各设置一个同步信号量
- 在“前操作”之后对相应的同步信号量执行V操作
- 在“后操作”之前对相应的同步信号量执行P操作
生产者-消费者问题
问题描述
系统中有一组生产者进程和一组消费者进程,生产者进程每次生成一个产品放入缓冲区,消费者进程每次从缓冲区中取出一个产品并使用。(这里的“产品”理解为某种数据)
生产者、消费者共享使用一个初值为空、大小为n的缓冲区。
问题分析
只有缓冲区没满的时候,生产者才能把产品放入缓冲区,否则必须等待。
只有缓冲区不空时,消费者才能从中取出产品,否则必须等待。
缓冲区是一种临界资源,各进程必须互斥地访问缓冲区。
PV操作题目分析步骤
- 关系分析。找出题目中描述的各个进程,分析它们之间的同步、互斥关系。
- 整理思路。根据各进程的操作流程确定P、V操作的大致顺序。
- 设置信号量。并根据题目条件确定信号量初值。(互斥信号量初值一般为1,同步信号量的初始值要看对应资源的初始值是多少)
semaphore mutex = 1; //互斥信号量,实现对缓冲区的互斥访问
semaphore empty = n; //同步信号量,表示空闲缓冲区的数量
semaphore full = 0; //同步信号量,表示产品的数量,也即非空缓冲区的数量
代码实现
semaphore mutex = 1;
semaphore empty = n;
semaphore full = 0;
producer() {
while(1) {
生成一个产品;
P(empty);//消耗一个空闲缓冲区
P(mutex);//保证缓冲区互斥访问
把产品放入缓冲区;
V(mutex);
V(full); //增加一个产品
}
}
consumer() {
while(1) {
P(full);//消耗一个产品(非空缓冲区)
P(mutex);
从缓冲区取出一个产品;
V(mutex);
V(empty);//增加一个空闲缓冲区
使用产品;
}
}
实现互斥是在同一进程中进行一对PV操作。
实现两进程的同步关系,是在其中一个进程中执行P,另一个进程中执行V。
思考,能否改变相邻PV操作顺序?
交换P(empty)和P(mutex)还有P(mutex)和P(full)的顺序:
producer() {
while(1) {
生成一个产品;
P(mutex); //1
P(expty); //2
把产品放入缓冲区;
V(mutex);
V(full); //增加一个产品
}
}
consumer() {
while(1) {
P(mutex); //3
P(full); //4
从缓冲区取出一个产品;
V(mutex);
V(empty);//增加一个空闲缓冲区
使用产品;
}
}
若此时缓冲区内已经放满了产品,则empty = 0,full = n。
则生产者执行1时使mutex变为0,再执行2,由于已没有空闲缓冲区,因此生产者被阻塞。
由于生产者阻塞,因此切换回消费者进程。消费者进程执行3,由于mutex为0,即生产者还没释放对临界资源的“锁”,因此消费者也被阻塞。
这就造成了生产者等待消费者释放空间缓冲区,而消费者又等待生产者释放临界区的情况,生产者和消费者循环等待被对方唤醒,出现“死锁”。
同样的,若缓冲区中没有产品,即full = 0, empty = n。按3、4、1的顺序执行就会发生死锁。
因此,实现互斥的P操作一定要在实现同步的P操作之后。
V操作不会导致进程阻塞,因此两个V操作顺序可以交换
理论上,生产一个产品和使用产品这部分代码可以放在PV操作之间,但缺点是会导致临界区代码更长,会使资源利用率降低。因此,逻辑上这部分代码可以放在临界区,但现实中不这样用。
多生产者——多消费者
问题描述
桌子上有一只盘子,每次只能向其中放入一个水果。父亲只往里面放苹果,母亲只往里面放橘子,儿子只吃盘子里的橘子,女儿只吃盘子里的苹果。只有盘子空时,父母才可以向盘子中放一个水果。仅当盘子中有自己需要的水果时,孩子可以从盘子中取出水果。用PV操作实现上述过程:
问题分析
- 关系分析。找出题目中描述的各个进程,分析它们之间的同步、互斥关系。
题目中,四个人各自为一个进程,盘子为缓冲区。
互斥关系:
对缓冲区(盘子)的访问要互斥地进行
同步关系(一前一后):- 父亲将苹果放入盘子后,女儿才能取苹果
- 母亲将橘子放入盘子后,儿子才能取橘子
- 只有盘子为空时,父母才能放入水果
(盘子为空这个事件可以由孩子触发,事件发生后才允许父母放水果)
- 整理思路。根据各进程的操作流程确定P、V操作的大致顺序。
互斥:在临界区前后分别PV
同步:前V后P - 设置信号量。设置需要的信号量,并根据题目条件确定信号量初值。(互斥信号量初值一般为1,同步信号量初值要看对应资源的初值是多少)
- 信号量的设置如下:
代码实现
semaphore mutex = 1; //实现互斥访问盘子(缓冲区)
semaphore apple = 0; //盘子里有几个苹果
semaphore orange = 0; //盘子里有几个橘子
semaphore plate = 1; //盘子中还可以放多少个水果
dad() {
while(1) {
准备一个苹果;
P(plate);
//P(mutex);
把苹果放入盘子;
//V(mutex);
V(apple);
}
}
mom() {
while(1) {
准备一个橘子;
P(plate);
//P(mutex);
把橘子放入盘子;
//V(mutex);
V(orange);
}
}
daughter() {
while(1) {
P(apple);
//P(mutex);
从盘中取出苹果;
//V(mutex);
V(apple);
吃掉苹果;
}
}
son() {
while(1) {
P(orange);
//P(mutex);
从盘中取出橘子;
//V(mutex);
P(orange);
吃掉橘子;
}
}
分析:刚开始,儿子、女儿进程即使上处理机运行也会被阻塞。如果刚开始是父亲进程线上处理机运行,则父亲P(plate),可以访问盘子,母亲P(plate),阻塞等待盘子。父亲放入苹果V(apple), 女儿进程被唤醒,其他进程即使运行也会被阻塞,暂时不能访问临界资源(盘子)。女儿P(apple),访问盘子,V(plate), 等待盘子的母亲进程被唤醒,母亲进程访问盘子(其他进程都暂时无法进入临界区)......
所以,即使不设置mutex,也不会出现多个进程同时访问盘子的现象。(原因,本题中缓冲区的容量为1,如果容量大于1,则必须设置mutex)
吸烟者问题
问题描述
假设一个系统中有三个抽烟者进程和一个供应者进程。每个抽烟者不停地卷烟并抽掉它,但是要卷起和抽掉一支烟,抽烟者需要三种材料:烟草、纸和胶水。三个抽烟者中,第一个拥有烟草、第二个拥有纸、第三个拥有胶水。供应者进程无限的提供这三种材料。供应者每次将两种材料放在桌子上,拥有剩下那种材料的抽烟者卷一根烟并抽掉它,并给供应者进程一个信号告诉完成了,供应者就会放另外两种材料再桌上,这个过程一直重复(让三个抽烟者轮流抽烟)
问题分析
本质上这个问题也属于“生产者-消费者”问题,准确地说应该是“可生产多种产品的单生产者-多消费者”
- 互斥关系:桌子可抽象为容量为1(3种组合:组合一:纸+胶水;组合二:烟草+胶水;组合三:烟草+纸),初始为空的缓冲区,需要互斥访问。
- 同步关系:
桌上有组合一 -> 抽烟者A取走东西;
桌上有组合二 -> 抽烟者B取走东西;
桌上有组合三 -> 抽烟者C取走东西;
发出完成信号 -> 供应者将下一组东西放到桌上; - 设置信号量:
代码实现
semaphore offer1 = 0; //桌上组合一的数量
semaphore offer2 = 0; //桌上组合二的数量
semaphore offer3 = 0; //桌上组合三的数量
semaphore finish = 0; //释放可以接着放东西
int i = 0; //用于实现“三个抽烟者轮流抽烟”
provider() {
while(1) {
if(i == 0) {
将组合一放桌上;
V(offer1);
} else if(i == 1) {
将组合二放桌上;
V(offer2);
} else if(i == 2) {
将组合三放桌上;
V(offer3)
}
i = (i + 1) % 3;
P(finish);
}
}
smoker1() {
while(1) {
P(offer1);
从桌上拿走组合一;
卷烟;
抽掉;
V(finish);
}
}
smoker2() {
while(1) {
P(offer2);
从桌上拿走组合二;
卷烟;
抽掉;
V(finish);
}
}
smoker3() {
while(1) {
P(offer3);
从桌上拿走组合三;
卷烟;
抽掉;
V(finish);
}
}
缓冲区(桌子)的容量为1,不需要专门设一个信号量实现互斥。
读者-写者问题
问题描述
有读者和写者两组并发进程,共享一个文件,当两个或者两个以上的读进程同时访问共享数据时不会产生副作用,但若某个写进程和其他进程(读进程或写进程)同时访问共享数据时,则可能导致数据不一致的错误。因此要求:
- 允许多个读者可以同时对文件执行读操作;
- 只允许一个写者往文件中写信息;
- 任一写者在完成操作之前不允许其他读者或者写者工作;
- 写者执行写操作前,应让已有的读者和写者全部退出。
与消费者进程不同,读者进程在读数据后并不会将数据清空,并不会改变数据。因此多个读者可同时访问共享数据。
读进程和写进程同时共享数据,可能导致读出数据不一致的问题。
两个写进程同时共享数据,可能导致数据错误覆盖的问题。
问题分析
互斥关系:
写进程-写进程、写进程-读进程
代码实现
semaphore re = 1; //用于实现对共享文件的互斥访问
int count = 0; //记录当前有几个读进程在访问文件
semaphore mutex = 1; //*用于保证对count变量的互斥访问
writer() {
while(1) {
P(rw); //写之前“加锁”
写文件;
V(rw); //写完了“解锁”
}
}
reader() {
while(1) {
P(mutex); //*各进程互斥访问count
if(count == 0) {//由第一个读进程负责加锁
P(rw);//读之前“加锁”
}
count++;//访问文件的读进程数+1
V(mutex); //*
读文件;
P(mutex) //*
count--;//访问文件的读进程数-1
if(count == 0) { //有最后一个读进程负责
V(rw);//读完了解锁
}
V(mutex); //*
}
}
如果两个读进程并发执行,则count = 0时两个进程也许都满足if条件,都会执行P(rw),从而使第二个读进程阻塞。问题的关键在于对count变量的检查和赋值没有一气呵成,因此可以设置另外一个互斥信号量来保证各进程对count的访问是互斥的。具体代码见上方标*部分。
潜在问题:
只要有进程还在读,写进程就要一直阻塞等待,可能“饿死”。因此这种算法中,读进程是优先的。
如何解决写进程饿死的问题呢?
semaphore re = 1; //用于实现对共享文件的互斥访问
int count = 0; //记录当前有几个读进程在访问文件
semaphore mutex = 1; //*用于保证对count变量的互斥访问
semaphore w = 1; //用于实现“写优先”
writer() {
while(1) {
P(w);
P(rw); //写之前“加锁”
写文件;
V(rw); //写完了“解锁”
V(w);
}
}
reader() {
while(1) {
P(w);
P(mutex);
if(count == 0) {//由第一个读进程负责加锁
P(rw);//读之前“加锁”
}
count++;//访问文件的读进程数+1
V(mutex);
V(w);
读文件;
P(mutex)
count--;//访问文件的读进程数-1
if(count == 0) { //有最后一个读进程负责
V(rw);//读完了解锁
}
V(mutex);
}
}
加入w的作用是,当有进程按照“读1->写->读2”的顺序执行的时候,读1对rw进行P操作后开始读文件,此时w已经被释放,写者被阻塞到P(rw)这一步,之后第二个读进程会被阻塞在P(w)这一步。当第一个读进程处理完毕时,第二个读进程还在被阻塞,所以读1释放rw,写进程被唤醒,由此相对公平地实现了先来先服务原则。有的书上把这种算法称为“读写公平法”
总结
读者-写者问题为我们解决复杂的互斥问题提供了一个参考思路:
其核心思想在于设置了一个计数器count用来记录当前正在访问共享文件的读进程数。我们可以用count的值来判断当前进入的进程是否是第一个/最后一个读进程,从而做出不同的处理。
哲学界进餐问题
问题描述
一张圆桌上坐着5名哲学家,每两个哲学家之间的桌上摆一根筷子,桌子的中间是一碗米饭。哲学家们在思考时不影响其他人,只有当哲学家饥饿时 ,才试图拿起左右两根筷子(一根一根地拿起)。如果筷子已经在他人手上,则需等待。饥饿的哲学家只有同时拿起两个筷子才可以开始进餐,当进餐完毕后,放下筷子继续思考。
问题分析
- 关系分析:系统中有5个哲学家进程,5位哲学家与左右邻居对其中间筷子的访问是互斥关系。
- 整理思路。这个问题中只有互斥关系,但与之前遇到的问题不同的是,每个哲学家进程需要同时持有两个临界资源才能开始吃饭。如何避免临界资源分配不当造成的死锁现象,是哲学家问题的精髓。
- 信号量设置:
定义互斥信号量数组chopsticks[5] = {1, 1, 1, 1, 1}用于实现对5个筷子的互斥访问。并对哲学家按0~4编号,哲学家i左边的筷子编号为i,右边的筷子编号为(i + 1)%5 - 如果每个哲学家都拿起自己左边的筷子,就会循环等待,出现死锁。
- 如何防止死锁的发生呢?
- 方案一:可以对哲学家进程施加一些限制条件,比如最多允许四个哲学家同时进餐。这样可以保证至少有一个哲学家是可以拿到左右两只筷子的。
- 方案二:要求奇数号的哲学家先拿左边的筷子,然后再拿右边的筷子,而偶数号哲学家刚好相反。用这种方式可以保证如果相邻的两个奇偶号科学家都想吃饭,那么只会有其中一个可以拿起第一只筷子,另一个会直接阻塞。
- 方案三:仅当一个哲学家左右两只筷子都可用时才会允许他抓起筷子。
代码实现
方案三:
semaphore chopstick[5] = {1, 1, 1, 1, 1};
semaphore mutex = 1; //互斥地取筷子
Pi() {
while(1) {
P(mutex);
P(chopstick[i]);
P(chopstick[(i + 1) % 5]);
V(mutex);
吃饭;
V(chopstick[i]);
V(chopstick[(i + 1) % 5]);
思考;
}
}
管程
为什么要引入管程?
- 信号量机制存在问题:编写程序困难,易出错,可能出现死锁。
- 1973年,Pascal语言中首次出现了“管程”成分——一种高级同步机制。
管程的定义和基本特征
- 管程是一种特殊的软件模块,由以下部分组成(管程的定义有点像OO里的类):
- 局部于管程的共享数据结构说明
- 对该数据结构进程操作的一组过程(函数)
- 对局部于管程的共享数据设置初始值的语句
- 管程有一个名字
- 管程的基本特征:
- 局部于管程的数据只能被局部于管程的过程访问。
- 一个进程只有通过调用管程内的过程才能进入管程访问共享数据
- 每次仅允许一个进程在管程内执行某个内部过程
拓展1:用管程解决生产者消费者问题
monitor ProducerConsumer
condition full, empty;//条件变量用来实现同步(排队)
int count = 0;//缓冲区中的产品数
void insert(Item item) {//吧产品item放入缓冲区
if(count == N)
wait(full);
count++;
insert_item(item);
if(count == 1)
signal(empty);
}
Item remove () {//从缓冲区中取出一个产品
if(count == 0) {
wait(empty);
}
count--;
if(count == N-1)
signal(full);
return remove_item();
}
end monitor;
在使用中生产者消费者的代码变得简单:
producer() {
while(1) {
item = 生产一个产品;
ProducerConsumer.insert(item);
}
}
consumer() {
while(1) {
item = ProducerConsumer.remove();
消费产品item;
}
}
由编译器负责实现各进程互斥地进入管程中的过程。
引入管程的目的是要更方便地实现进程互斥和同步。
- 需要在管程中定义共享数据(如生产者消费者问题的缓冲区)
- 需要在管程中定义用于访问这些共享数据的“入口”——其实就是一些函数。
- 只有通过这些特定的“入口”才能访问共享数据。
- 管程中有很多“入口”,但是每次只能开放一个“入口”,并且只能让一个进程或线程进入(这种互斥性是由编译器负责实现的,程序员不用关心)
- 可在管程上设置条件变量即等待/唤醒操作以解决同步问题。可以让一个进程或线程在条件变量上等待(此时,该进程应先释放管程的使用权,也就是让出“入口”);可以通过唤醒操作将等待在条件变量上的进程或线程唤醒。
拓展2:Java中类似于管程的机制
Java中,如果用关键字synchronized来描述一个函数,那么这个函数同一时间段内只能被一个线程调用。
2.4 死锁
死锁的概念
什么是死锁?
死锁指在并发环境下,各进程因竞争资源而造成的一种互相等待对方手里的资源,导致各进程都阻塞,都无法向前推进的现象。发生死锁后若无外力干涉,这些进程都将无法向前推进。
死锁、饥饿、死循环的区别
- 死锁:
指在并发环境下,各进程因竞争资源而造成的一种互相等待对方手里的资源,导致各进程都阻塞,都无法向前推进的现象。发生死锁后若无外力干涉,这些进程都将无法向前推进。 - 饥饿:由于长时间得不到想要的资源,某进程 无法向前推进的现象。
- 死循环:某进程执行过程中一直挑不出某个循环的现象。有时是因为程序逻辑bug导致的,有时是程序员故意设计的。
死锁产生的必要条件
- 互斥条件:
只有对必须互斥使用的资源的争抢才会导致死锁。像内存、扬声器这样可以同时让多个进程使用的资源不会导致死锁(因为进程不用阻塞等待这种资源)。 - 不可剥夺条件:
进程所获得的资源在未使用完之前,不能由其他进程强行夺走,只能主动释放。 - 请求和保持条件:
进程已经保持了至少一个资源,但又提出了新的资源请求,而该资源又被其他进程占有,此时请求进程被阻塞,但又对自己已有资源保持不放。 - 循环等待条件:
存在一种进程资源的循环等待链,链中的每个进程以获得资源的同时被下一个进程所请求。
注:发生死锁时一定有循环等待,但发生循环等待时未必死锁。如果同类资源数大于1,则即使有循环等待,也未必发生死锁。但如果系统中每类资源都只有一个,那循环等待就是死锁的充要条件。
什么时候会发生死锁
- 对系统资源的竞争。各进程对不可剥夺的资源(如打印机)的竞争可能引起死锁,对可剥夺资源(CPU)的竞争不会引起死锁。
- 进程推进顺序非法。请求和释放资源的顺序不当,也会导致死锁。
例如,并发执行的进程P1、P2分别会申请并占有了资源R1、R2,之后进程P1又紧接着申请资源R2,而进程P2又申请资源R1,两者会因为申请的资源被对方占有而阻塞,从而发生死锁。 - 信号量使用不当,也会导致死锁。如生产者-消费者问题中,如果实现互斥的P操作在实现同步的P操作之前,就可能导致死锁。
总之,对不可剥夺资源的不合理分配,可能导致死锁。
死锁的处理策略
- 预防死锁。破坏死锁产生的四个必要条件中的一个或几个。
- 避免死锁。用某种方法防止系统进入不安全状态,从而避免死锁(银行家算法)
- 死锁的检测和解除。允许死锁的发生,不过OS会复杂检测出死锁的发生,然后采取某种措施解除死锁。
死锁的处理策略
预防死锁
核心思想:破坏死锁发生的四个必要条件中的一个或几个。
破坏互斥条件
互斥条件:只有对必须互斥使用的资源的争抢才会导致死锁。
如果把只能互斥使用的资源改造为允许共享使用,则系统不会进入死锁状态。比如SPOOLing技术。S可以采用SPOOLing技术把独占设备在逻辑上改造成共享设备。比如用SPOOLing技术将打印机改造成共享设备:
该策略的缺点:并不是所有资源都可以改造成可共享使用的资源。并且为了系统安全,很多地方必须保护这种互斥性。因此,很多时候都无法破坏互斥条件。
破坏不剥夺条件
不剥夺条件:进程多获得的资源在未使用完之前,不能由其他资源强行夺走,只能主动释放。
破坏不剥夺条件:
- 方案一:当某个进程请求新的资源得不到满足时,它必须立即释放保持的所有资源,待以后需要时再重新申请。也就是说,即使某些资源尚未使用完,也需要主动释放,从而破坏了不可剥夺条件。
- 方案二:当某个进程需要的资源被其他进程占有的时候,可以由OS协助,将想要的资源强行剥夺。这种方式一般需要考虑各进程的优先级(比如:剥夺调度方式,就是将处理机资源强行剥夺给优先级更高的进程使用)。
- 缺点:
- 实现起来比较复杂
- 释放以获得的资源可能造成前一阶段工作的失效。因此这种方法一般只适用于易保存和恢复状态的资源,如CPU。
- 反复地申请和释放资源会增加系统开销,降低系统吞吐量
- 若采用方案一,意味着只要暂时得不到某个资源,之前获得的那些资源就都要放弃,以后再重新申请。如果一直发生这样的情况,会导致进程饥饿。
破坏请求和保持条件
请求和保持条件:进程已经保持了至少一个资源,但又提出了新的资源请求,而该资源又被其他进程占有,此时请求进程被阻塞,但又对自己已有的资源保持不放。
可才有静态分配方法,即进程在运行前一次申请完它所需要的全部资源,在它的资源为满足前,不让它投入运行。一旦投入运行后,这些资源就一直归它所有,该进程就不会再请求别的任何资源了。
该策略实现起来简单,但也有明显缺点:
有些资源可能只需要很短的时间,因此如果进程的整个运行时间都一直保持着所有资源,就会造成严重的浪费,资源利用率低。另外,该资源也有可能导致某些进程饥饿。
破坏循环等待条件
循环等待条件:操作一种进程资源的循环等待链,链中的每一个进程以获得的资源同时被下一个进程所请求。
可采用顺序资源分配法。首先给系统中的资源编号,规定每个进程必须按编号递增的顺序请求资源,同类资源(即编号相同的资源)一次申请完。
原理分析:一个进程只有已占有的小编号的资源时,才有资格申请更大编号的资源。按此规则,已持有大编号资源的进程不可能逆向地回来申请小编号资源,从而就不会产生循环等待现象。
缺点:
- 不方便增加新的设备,因为可能需要重新分配所有的编号。
- 进程实际使用资源的顺序可能和编号递增顺序不一致,会导致资源浪费。
- 必须按照规定次序申请资源,用户编程麻烦。
避免死锁
避免死锁:用某种方法防止系统进入不安全状态,从而避免死锁
什么是安全序列
安全序列,指如果系统按照这种序列分配资源,则每个进程都能顺利完成。只要能找出一个安全序列,系统就是安全状态。安全序列也可能有多个。
如果分配了资源之后,系统中找不出任何一个安全序列,系统就进入了不安全状态。这就意味着之后可能所有进程都无法顺利的执行下去。当然,如果系统提前归还了一些资源,那系统也可能重新回到 安全状态,不过我们在分配资源之前总是要考虑最坏的情况。
如果系统处于安全状态,就一定不会发生死锁。如果系统进入不安全状态,就可能发生死锁(处于不安全状态未必就是发生了死锁,但发生死锁时一定是在不安全状态下)
因此,可在资源分配前预先判断这次分配是否会导致系统进入不安全状态,以次决定是否答应资源分配请求。这也是“银行家算法”的核心思想。
银行家算法
核心思想:在进程提出资源申请时,先预判此分配是否会导致系统进入不安全状态。如果会进入不安全状态,就暂时不答应这次请求,让该进程先阻塞等待。
银行家算法规则大致为银行总资产一定,向不同的用户贷款,如果不能满足用户的最大需求,就会收不回成本。所以为了保证能收回成本,银行家需要按照一定带块顺序,保证每个用户的最大可能需求都被满足。
对于有多种资源的情况,可以用多维向量来进行表示。例如某系统中有5个进程P0P4,3种资源R0R2,初始数量为(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) |
总共分配了(7, 2, 5), 还剩(3, 3, 2)。可把最大需求、已分配的数据看作矩阵,两矩阵相减,就可算出各进程最多还需多少资源了。
依次检查剩余资源(3, 3, 2)是否能满足各个进程的需求
可满足P1需求,将P1加入安全序列,并更新剩余可用资源为(5, 3, 2).
之后再依次检查剩余资源(5, 3, 2)是否能满足各个进程的需求
以此类推,共五次循环检查可将5各进程都加入安全序列中,最终可得一个安全系列。该算法称为安全性算法。可以很方便地用代码实现以上流程,每一轮检查都从编号较小的进程开始检查。
也有可能找不到完整的安全序列,此时系统处于不安全状态。
实现方式
假设系统中有n个进程,m中资源
每个进程在运行前先声明对各种资源的最大需求数,则可用一个n*m的矩阵(可用二维数组实现)表示所有进程对各种资源的最大需求数。不妨称为最大需求矩阵Max,Max[i, j] = K表示进程Pi最多需要K个资源Rj。同理,系统可以用一个n*m的分配矩阵(Allocation)表示对所有进程的资源分配情况。Max - Allocation = Need矩阵,表示个进程还需要多少各类资源。
另外,还要用一个长度为m的一维数组Available表示当前系统中还有多少可用资源。
某进程Pi向系统申请资源,可用一个长度为m的一维数组\(Request_i\)表示本次申请的各种资源量。
可用银行家算法预判本次分配是否会导致系统进入不安全状态。
- 如果\(Request_i\)[i] <= Need[i, j],转向2,否则认为出错(因为它所需要的资源数已经超过它所宣布的最大值)
- 如果\(Request_i\)[j] <= Available[j], 转向3;否则表示尚无足够资源,Pi必须等待。
- 系统试探着把资源分配给Pi,并修改相应的数据
- OS执行安全性算法,检查此次资源分配后,系统是否处于安全状态。若安全,才正式分配;否则,恢复相应数据,让进程阻塞等待。
检测和解除
死锁的检测
为了能对系统是否发生了死锁进行检测,必须:
- 用某种数据结构来保持资源的请求和分配信息。
- 提供一种算法,利用上述信息来检测系统是否已经进入死锁状态。
一般用矩形表示资源结点,矩形中的小圆代表该类结点的数量。
如果系统中剩余的可用资源数足够满足进程的需求,那么这个进程暂时是不会阻塞的,可以顺利地执行下去。
如果这个进程执行结束了把资源归还系统,就可能使某些正在等待资源的进程被激活,并顺利地进行下去。
相应的,这些被激活的进程执行完了之后又会归还一些资源,这样可能又会激活另外一些阻塞的进程。
如果按上述过程分析,最终能消除所有边,就称这个图是可完全简化的。此时一定没有发生死锁(相当于找到一个安全序列)
如果最终不能消除所有边,那么此时就是发生了死锁。
最终还连着边的那些进程就是处于死锁状态的进程。
死锁的解除
一旦检测出死锁的发生,就应该立即解除死锁。
注:这里的死锁并不是说系统中的所有进程都是死锁状态,用死锁检测算法简化资源分配图后,还连着边的那些进程就是死锁进程。
解除死锁的主要方法有:
- 资源剥夺法。挂起(暂时放到外存上)某些死锁进程,并抢占他的资源,将这些资源分配给其他的死锁进程。但是应防止被挂起的进程长时间得不到资源而饥饿。
- 撤销进程法(或称终止进程法)。强制撤销部分、甚至全部死锁进程,并剥夺这些进程的资源。这种方式的优点是实现简单,但是付出的代价可能会很大,因为有些进程可能已经运行了很长时间,已经接近结束了,一旦被终止就功亏一篑,后面还需重新运行。
- 进程回退法。让一个或多个死锁进程回退到足以避免死锁的地步。这就要求系统要记录进程的历史信息,设置还原点。
如何决定牺牲哪个进程?
- 进程优先级
- 已执行多长时间
- 还要多久能完成
- 进程已经使用了多少资源
- 进程是交互式的还是批处理式的