89 KiB
进程管理
进程与线程
进程的概念
- 程序指一个指令序列。
- 系统为每个运行的程序配置一个数据结构,称为进程控制块PCB,用来描述进程的各种信息,如程序代码存放位置。
- PCB、程序段、数据段三个部分构成了进程实体(进程映像),简称为进程。
- 创建进程就是创建PCB,销毁进程就是销毁PCB,PCB是进程存在的唯一标志。
- 进程是进程实体的运行过程,是系统进行资源分配和调度的一个独立单位。
进程的结构
- 程序段:包括程序代码,程序运行时使用、产生的运算数据。如全局变量、局部变量、宏定义的常量。
- 数据段:存放程序运行过程中处理的各种数据。
- PCB:操作系统用PCB来管理进程,所以PCB包含所有管理进程所需的信息。
- 进程描述信息:
- 进程标识符PID。
- 用户标识符UID。
- 进程控制和管理信息:
- 进程当前状态。
- 进程优先级。
- 资源分配清单:
- 程序段指针。
- 数据段指针。
- 键盘。
- 鼠标。
- 处理机相关信息:
- 各种寄存器值。
- 进程描述信息:
进程的特征
- 动态性:是进程最基本的特征,进程是程序的一次执行过程,是动态地产生、变化和消亡的。
- 并发性:内存中有多个进程实体,各进程可并发执行。
- 独立性:进程是资源分配、接受调度、能独立运行、独立获得资源、独立接受调度的基本单位。
- 异步性:各进程按各自独立的、不可预知的速度向前推进,操作系统要提供“进程同步机制”来解决异步问题。
- 结构性:每个进程都会配置一个PCB。结构上看,进程由程序段、数据段、PCB组成。
进程的组织
当一个系统中存在多格PCB时,需要以适当的方式组织PCB。
- 链接方式:
- 按照进程状态将PCB分为多个队列。如执行指针(指针数为最大执行并行数)、就绪队列指针(一般会把优先级高的进程放在队头)、阻塞队列指针(许多操作系统会根据阻塞原因的不同将其分为多个阻塞队列)。
- 操作系统持有指向各个队列的指针。
- 索引方式:
- 根据进程状态的不同建立索引表。如执行指针、就绪表指针、阻塞表指针。
- 操作系统持有指向各个索引表的指针。
进程的状态
基本进程状态
具有三种基本状态和其他两种状态:
- 运行态:占有CPU并已经在CPU上运行。
- 就绪态:已经具备运行条件,但是由于没有空闲CPU,导致暂时不能运行。
- 阻塞态(等待态):因等待某一事件而暂时不能运行,只有分配到位才能考虑分配CPU。
- 创建态(新建态):为进程分配所需的内存空间等系统资源,为其创建、初始化PCB。
- 终止态(结束态):进程运行结束或出现错误导致进程被撤销,操作系统需要回收进程资源,撤销PCB等工作,以防止内存泄漏。
- 挂起态:暂时不能获得服务,进程映像调到外存等待(阻塞态的进程映像还在内存中等待)。分为:
- 就绪挂起态。
- 阻塞挂起态。
进程状态的转换
- 创建态到就绪态:系统完成创建进程等工作并准备好处理机等资源。
- 就绪态到运行态:占有CPU资源,进程被调用。
- 运行态到就绪态:时间片到或处理机(包含CPU和内存的一系列硬件)被抢占。
- 运行态到阻塞态:进程主动用系统调用的方式申请某种系统资源,或请求等待某个事件发生。
- 阻塞态到就绪态:申请的资源被分配或等待的事件发生。
- 运行态到终止态:进程运行结束或运行过程中遇到不可修复的错误。
- 就绪挂起态到就绪态:激活。
- 就绪态到就绪挂起态:挂起。
- 阻塞挂起态到阻塞态:激活。
- 阻塞态到阻塞挂起态:挂起。
- 阻塞挂起态到就绪挂起态:阻塞事件发生。
- 运行态到就绪挂起态:运行中内存空间不足,或优先级更高的进程进入队列。
- 创建态到就绪挂起态:创建后发现内存空间不足。
进程的控制
进程控制的概念
- 进程控制指对系统中的所有进程实施有效的管理,具有创建新进程、撤销已有进程、实现进程状态转换功能。
- 如进程组织所说,通过将PCB指针放入各种状态的进程队列中转换进程状态来实现控制。
- 进程控制由原语实现。
- 特点是执行期间不能中断。
- 这种不能中断的操作就是原子操作。
- 原语采取“关中断指令”(不监听外部中断信息)和“开中断指令”(开始监听外部中断信息)实现。
- 关/开中断指令的权限很大,所以必然是核心态下执行的特权指令。从而原语必然运行在核心态。
进程控制的原语
- 过程的创建:
- 创建原语:
- 申请空白PCB。
- 为新进程分配所需资源。
- 初始化PCB。
- 将PCB插入就绪队列。
- 引起进程创建的事件:
- 用户登录:分时系统中,用户登录成功,系统会为其建立一个新进程。
- 作业调度:多道批处理系统中,有新的作业进入内存时,会为其创建一个新进程。
- 提供服务:用户向系统提出请求时,会创建一个新进程来处理请求。
- 应用请求:由用户进程主动请求创建一个子进程。
- 创建原语:
- 进程终止:
- 撤销原语:
- 从PCB集合中找到终止进程的PCB。
- 若进程正在运行,则立刻剥夺其CPU交给其他进程。
- 若是普通的终止,父进程终止时会将其子进程交给init进程收养。
- 若是整个进程组的进程,则终止其所有子进程。
- 将进程所有的资源交给父进程或操作系统。
- 删除PCB。
- 引起进程终止的事件:
- 正常结束。
- 异常结束。
- 外界干预。
- 撤销原语:
- 进程阻塞:
- 阻塞原语:
- 找到要阻塞的进程对应的PCB。
- 包含进程运行现场,将PCB状态信息设置为阻塞态,暂停进程。
- 将PCB插入事件等待队列。
- 引起进程阻塞的事件:
- 需要等待系统分配资源。
- 需要等待相互合作的其他进程完成工作。
- 阻塞原语:
- 进程唤醒:
- 唤醒原语:
- 在事件等待队列中找到PCB。
- 将PCB从等待队列中移除,设置进程为就绪态。
- 将PCB插入就绪队列,等待被调度。
- 引起进程唤醒的事件:
- 等待的事件的发生(因何事被阻塞因何事被唤醒)。
- 唤醒原语:
- 进程切换:
- 切换原语:
- 将运行环境信息存入PCB。
- PCB移入相应队列。
- 选择另一个进程执行,并更新其PCB。
- 根据PCB恢复新进程所需的运行环境。
- 引起进程切换的事件:
- 当前时间片到。
- 有更高优先级进程到达。
- 当前进程主动阻塞。
- 当前进程终止。
- 切换原语:
进程的通信
进程通信是进程之间的信息交换。
为了保证安全,一个进程不能直接访问另一个进程的地址空间。
- 共享存储:分配一个可以共同使用的共享空间,进程对其的使用必须是互斥的。
- 基于数据结构:共享空闲只能放固定的数据结构如数组等。速度慢,限制多,是低级通信方式。
- 基于存储取:内存中划出一块共享存储区,数据的形式、存放位置都由进程控制而非操作系统。速度更快,限制少,是高级通信方式。
- 管道通信:指用于链接读写进程的一个共享文件,又名pipe文件,其实就是内存中开辟一个大小固定的缓冲区。
- 只能使用半双工的通信,某一时间段内只能单向传输,若要双向同时通信则必须设置两个管道。
- 进程需要互斥访问管道。
- 数据以字符流的形式写入管道,当满时,写进程的write()系统调用将被阻塞,等待读进程将数据取走。当读进程将数据全部取走后,管道变空,此时读进程的read()系统调用将被阻塞。
- 如果没有写满则不允许读,如果没有读空则不允许写。
- 数据一旦被读出就被管道抛弃,所以读进程只能至多有一个,否则可能会读错。
- 信息传递:进程间的数据交换以格式化的消息为单位。进程通过操作系统提供的“发送消息/接收消息”两个原语进行数据交换。
- 消息包括消息头和消息体,消息头包括:发送进程ID、接受进程ID、消息类型、消息长度等格式化的信息。
- 消息传递包括:
- 直接通信方式:消息直接挂到接受进程的信息缓冲队列上。
- 间接通信方式:消息先发送到中间实体(信箱)中,因此也称为信箱通信方式,如计算机网络中的电子邮件系统。
线程
线程的概念
传统而看,进程是程序的一次执行,但是程序的多个功能不能由一个程序顺序处理就能完成,所以一个进程需要同时进行多个任务,所以就引入了线程来增加并发度。
线程是基本的CPU执行单元,是程序执行流的最小单位。进程只作为除CPU之外的系统资源的分配单元,即打印机等都是分配给进程而不是线程。
进入线程的变化
- 系统分配调度:
- 传统进程机制中,进程是资源分配、调度的基本单位。
- 引入线程后,进程是资源分配的基本单位,线程是调度的基本单位。
- 并发性:
- 传统进程机制中,只能进程间并发。
- 引入线程后,各线程间也能并发,提升了并发度。
- 系统开销:
- 传统的进程间并发,需要切换进程的运行环境,系统开销很大。
- 线程间并发,如果是同一进程内的线程切换,则不需要切换进程环境,系统开销小。
- 引入线程后,并发所带来的系统开销减小。
线程的属性
- 线程是处理机调度的单位。
- 多CPU计算机中,各个线程可占用不同的CPU。
- 每个线程都有一个线程ID、线程控制块(TCB)。
- 线程也有就绪、阻塞、运行三种基本状态。
- 线程几乎不拥有系统资源。
- 同一进程的不同线程间共享进程的资源。
- 由于共享内存地址空间,同-进程中的线程间通信甚至无需系统干预。
- 同一进程中的线程切换,不会引起进程切换。
- 不同进程中的线程切换,会引起进程切换。
- 切换同进程内的线程,系统开销很小。
- 切换进程,系统开销较大。
线程的实现
- 用户级线程:
- 用户级线程由应用程序通过线程库实现。所有的线程管理工作都由应用程序负责(包括线程切换)。
- 用户级线程中,线程切换可以在用户态下即可完成,无需操作系统干预。
- 在用户看来,是有多个线程。但是在操作系统内核看来,并意识不到线程的存在。用户级线程对用户不透明,对操作系统透明。
- “ 用户级线程”就是“从用户视角看能看到的线程”。
- 内核级线程(内核支持的线程):
- 内核级线程的管理工作由操作系统内核完成。
- 线程调度、切换等工作都由内核负责,因此内核级线程的切换必然需要在核心态下才能完成。
- “内核级线程” 就是“从操作系统内核视角看能看到的线程”。
- 由于操作系统只能看见内核级线程,所以只有内核级线程才是处理机分配的单位。
多线程模型
对于用户级线程映射到内核级线程的问题出现了“多线程模型”问题。
- 多对一模型:
- 多个用户及线程映射到一个内核级线程。每个用户进程只对应一个内核级线程。
- 优点:用户级线程的切换在用户空间即可完成,不需要切换到核心态,线程管理的系统开销小,效率高。
- 缺点:当一个用户级线程被阻塞后,整个进程都会被阻塞,并发度不高。多个线程不可在多核处理机上并行运行。
- 一对一模型:
- 一个用户及线程映射到一个内核级线程。每个用户进程有与用户级线程同数量的内核级线程。
- 优点:当一个线程被阻塞后,别的线程还可以继续执行,并发能力强。多线程可在多核处理机上并行执行。
- 缺点:一个用户进程会占用多个内核级线程,线程切换由操作系统内核完成,需要切换到核心态,因此线程管理的成本高,开销大。
- 多对多模型:
- 在同时支持用户级线程和内核级线程的系统中,可以使用二者结合的方式,将$n$个用户级线程映射到$m$个内核级线程上($n\geqslant m$)。
- 克服了多对一模型并发度不高的缺点,又克服了一对一模型中一个用户进程占用太多内核级线程,开销太大的缺点。
处理机调度
处理机调度的概念
- 处理机:包括中央处理器,主存储器,输入输出接口,加接外围设备就构成完整的计算机系统。处理机是处理计算机系统中存储程序和数据,并按照程序规定的步骤执行指令的部件。
- 处理机调度:在多道程序设计系统中,内存中有多道程序运行,他们相互争夺处理机这一重要的资源。处理机调度就是从就绪队列中,按照一定的算法选择一个进程并将处理机分配给它运行,以实现进程并发地执行。
处理机调度的层次
- 高级调度(作业调度):
- 由于内存空间有限,有时无法将用户提交的作业全部放入内存,因此就需要确定某种规则来决定将作业调入内存的顺序。一般一个作业包含多个进程。
- 高级调度按一定的原则从外存上处于后备队列的作业中挑选一个(或多个)作业,给他们分配内存等必要资源,并建立相应的进程(建立PCB),以使它(们)获得竞争处理机的权利。
- 高级调度是辅存(外存)与内存之间的调度。每个作业只调入一次,调出一次。作业调入时会建立相应的PCB,作业调出时才撤销PCB。
- 高级调度主要是指调入的问题,因为只有调入的时机需要操作系统来确定,但调出的时机必然是作业运行结束才调出。
- 中级调度(内存调度):
- 引入了虚拟存储技术之后,可将暂时不能运行的进程调至外存等待。等它重新具备了运行条件且内存又稍有空闲时,再重新调入内存。这么做的目的是为了提高内存利用率和系统吞吐量。
- 暂时调到外存等待的进程状态为挂起状态。值得注意的是,PCB并不会一起调到外存,而是会常驻内存。PCB中会记录进程数据在外存中的存放位置,进程状态等信息,操作系统通过内存中的PCB来保持对各个进程的监控、管理。被挂起的进程PCB会被放到的挂起队列中。
- 中级调度就是要决定将哪个处于挂起状态的进程重新调入内存。
- 一个进程可能会被多次调出、调入内存,因此中级调度发生的频率要比高级调度更高。
- 低级调度(进程调度):
- 其主要任务是按照某种方法和策略从就绪队列中选取一个进程,将处理机分配给它。
- 进程调度是操作系统中最基本的一种调度,在一般的操作系统中都必须配置进程调度。
- 进程调度的频率很高,一般几十毫秒一次。
| 工作内容 | 发生位置 | 发生频率 | 对进程状态的影响 | |
|---|---|---|---|---|
| 高级调度(作业调度) | 按照某种规则,从后备队列中选择合适的作业将其调入内存,并为其创建进程 | 外存→内存(面向作业) | 一个作业一个调入一次调出 | 无→创建态→就绪态 |
| 中级调度(内存调度) | 按照某种规则,从挂起队列中选择合适的进程将其数据调回内存 | 外存→内存(面向进程) | 中等 | 挂起态→就绪态(阻塞挂起→阻塞态) |
| 低级调度(进程调度) | 按照某种规则,从就绪队列中选择一个进程为其分配处理机 | 内存→CPU | 最高 | 就绪态→运行态 |
进程调度
进程调度是最低级的调度也是其他调度的基础。
进程调度的时机
需要进行进程调度和切换:
- 当前运行程序主动放弃处理机:
- 进程正常终止。
- 出现异常终止。
- 进程主动请求阻塞(如等待IO)。
- 当前运行程序被动放弃处理机:
- 分配时间片用完。
- 有更紧急的事件需要处理(如IO中断)。
- 优先级更高进程进入就绪队列。
不能进行进程调度和切换:
- 在处理中断的过程中。中断处理过程复杂,与硬件密切相关,很难做到在中断处理过程中进行进程切换。
- 进程在操作系统内核程序临界区中。
- 临界资源:一个时间段内只允许一个进程使用的资源。各进程需要互斥地访问临界资源。
- 临界区:访问临界资源的那段代码。
- 内核程序临界区一般是用来访问某种内核数据结构的,比如进程的就绪队列(由各就绪进程的PCB组成)。
- 进程访问时会上锁,而如果还没退出临界区(还没解锁)就进行进程调度但是进程调度相关的程序也需要访问就绪队列,但此时就绪队列被锁住了,因此无法顺利进行进程调度。
- 内核程序临界区访问的临界资源如果不尽快释放的话,极有可能影响到操作系统内核的其他管理工作。因此在访问内核程序临界区期间不能进行调度与切换。
- 而如果是普通程序临界区时,如在打印机打印完成之前,进程一直处于临界区内,临界资源不会解锁。但打印机又是慢速设备,此时如果一直不允许进程调度的话就会导致CPU一直空闲,所以为了保证效率进程在操作系统普通程序临界区时运行进程调度。
- 在原子操作过程中(原语)。原子操作不可中断,要一气呵成(如修改PCB中进程状态标志,并把PCB放到相应队列)。
进程调度的方式
针对操作系统是否可以剥夺进程处理机,进程调度方式分为:
- 非剥夺调度方式,又称非抢占方式:
- 只允许进程主动放弃处理机。在运行过程中即便有更紧迫的任务到达,当前进程依然会继续使用处理机,直到该进程终止或主动要求进入阻塞态。
- 实现简单,系统开销小但是无法及时处理紧急任务。
- 适合于早期的批处理系统。
- 剥夺调度方式,又称抢占方式:
- 当一个进程正在处理机上执行时,如果有一个更重要或更紧迫的进程需要使用处理机,则立即暂停正在执行的进程,将处理机分配给更重要紧迫的那个进程。
- 可以优先处理更紧急的进程,也可实现让各进程按时间片轮流执行的功能(通过时钟中断)。
- 适合于分时操作系统、实时操作系统。
进程切换与过程
进程调度与进程切换的区别:
- 狭义的进程调度指的是从就绪队列中选中一个要运行的进程。(这个进程可以是刚刚被暂停执行的进程,也可能是另一个进程,后一种情况就需要进程切换)。
- 进程切换是指一个进程让出处理机,由另一个进程占用处理机的过程。
- 广义的进程调度包含了选择一个进程和进程切换两个步骤。
进程切换的过程主要完成了:
- 对原来运行进程各种数据的保存
- 对新的进程各种数据的恢复。 (如:程序计数器、程序状态字、各种数据寄存器等处理机现场信息,这些信息一般保存在进程控制块)。
注意:进程切换是有代价的,因此如果过于频繁的进行进程调度、切换,必然会使整个系统的效率降低,使系统大部分时间都花在了进程切换上,而真正用于执行进程的时间减少。
调度算法
指作业与进程的调度算法。
- 批处理系统算法:
- FCFS。
- SJF/SPF/SRTN。
- HRRN。
- 交互式系统算法:
- RR。
- PS。
- MFQ。
算法评价指标
- CPU利用率:CPU忙碌时间占总时间的比例。其中利用率=忙碌时间/总时间。
- 系统吞吐量:单位时间内完成作业的数量。系统吞吐量=总共完成多少道作业/总时间。
- 周转时间:从作业被提交到系统开始到作业完成为止的时间间隔。
- 它包括四个部分:作业在外存后备队列上等待作业调度(高级调度)的时间、进程在就绪队列上等待进程调度(低级调度)的时间、进程在CPU上执行的时间、进程等待I/O操作完成的时间。后三项在一个作业的整个处理过程中,可能发生多次。
- (作业)周转时间=作业完成时间-作业提交时间。
- 平均周转时间=各作业周转时间之和/作业数。
- 带权周转时间=作业周转时间/作业实际运行的时间=(作业完成时间-作业提交时间)/作业实际运行的时间(≥1)。
- 平均带权周转时间=各作业带权周转时间之和/作业数。
- 等待时间:指进程或作业处于等待处理机状态时间之和。
- 对于进程来说,等待时间就是指进程建立后等待被服务的时间之和,在等待I/O完成的期间其实进程也是在被服务的,所以不计入等待时间。
- 对于作业来说,不仅要考虑建立进程后的等待时间,还要加上作业在外存后备队列中等待的时间。
- 一个作业总共需要被CPU服务多久,被I/O设备服务多久一般是确定不变的,因此调度算法其实只会影响作业或进程的等待时间。当然,与前面指标类似,也有“平均等待时间”来评价整体性能。
- 如果一个进程到达后要么等待要么运行,则等待时间=周转时间-运行时间。
- 如果一个进程又有计算又有I/O操作,则等待时间=周转时间-运行时间-I/O操作时间。
- 响应时间:从用户提交请求到首次产生响应所用的时间。
先来先服务
即FCFS算法。
- 算法思想:主要从“公平”的角度考虑。
- 算法规则:按照作业/进程到达的先后顺序进行服务。
- 用于作业/进程调度:用于作业调度时,考虑的是哪个作业先到达后备队列;用于进程调度时,考虑的是哪个进程先到达就绪队列。
- 是否可抢占:非抢占式的算法。
- 优缺点:
- 优点:公平、算法实现简单。
- 缺点:排在长作业(进程)后面的短作业需要等待很长时间,带权周转时间很大,对短作业来说用户体验不好。即FCFS算法对长作业有利,对短作业不利。
- 是否会导致饥饿:不会。
例题 各进程到达就绪队列的时间、需要的运行时间如下表所示。使用先来先服务调度算法,计算各进程的等待时间、平均等待时间、周转时间、平均周转时间、带权周转时间、平均带权周转时间。
| 进程 | 到达时间 | 运行时间 |
|---|---|---|
| P1 | 0 | 7 |
| P2 | 2 | 4 |
| P3 | 4 | 1 |
| P4 | 5 | 4 |
根据先来先服务的思想,根据到达的顺序,得到调度顺序为:P1、P2、P3、P4。
所以P1在0到达,0+7=7完成。P2在2到达,而7时P1完成。所以P2要等待7-2=5,所以P2从7开始,在7+4=11完成。P3在4到达,所以需要等待11-4=7,从11开始,在11+1=12结束。P4在5到达,需要等待12-5-7,从12开始,在12+4=16结束。
| 进程 | 到达时间 | 运行时间 | 等待时间 | 开始时间 | 完成时间 |
|---|---|---|---|---|---|
| P1 | 0 | 7 | 0 | 0 | 7 |
| P2 | 2 | 4 | 5 | 7 | 11 |
| P3 | 4 | 1 | 7 | 11 | 12 |
| P4 | 5 | 4 | 7 | 12 | 16 |
因为周转时间=完成时间-到达时间。所以P1=7-0=7,P2=11-2=9,P3=12-4=8,P4=16-5=11。
带权周转时间=周转时间/运行时间。所以P1=7/7=1,P2=9/4=2.25,P3=8/1=8,PR=11/4=2.75。
等待时间=周转时间-运行时间。根据表格可得。
所以平均周转时间=(7+9+8+11)/4=8.75。平均带权周转时间=(1+2.25+8+2.75)/4=3.5。平均等待时间=(0+5+7+7)/4=4.75。
短作业优先
即SJF算法。
- 算法思想:追求最少的平均等待时间,最少的平均周转时间、最少的平均平均带权周转时间。
- 算法规则:最短的作业/进程优先得到服务(所谓“最短”,是指要求服务时间最短)。
- 用于作业/进程调度:即可用于作业调度,也可用于进程调度。用于进程调度时称为“短进程优先(SPF, Shortest Process First)算法”。
- 优缺点:
- 优点:“最短的”平均等待时间、平均周转时间。
- 缺点:不公平。对短作业有利,对长作业不利。可能产生饥饿现象。另外,作业/进程的运行时间是由用户提供的,并不一定真实,不一定能做到真正的短作业优先。
- 是否可抢占:SJF和SPF是非抢占式的算法。但是也有抢占式的版本―——最短剩余时间优先算法(SRTN, Shortest Remaining Time Next)。
- 是否会导致饥饿:会。如果源源不断地有短作业/进程到来,可能使长作业/进程长时间得不到服务,产生“饥饿”现象 。如果一直得不到服务,则称为“饿死”
1.如果题目中未特别说明,所提到的“短作业/进程优先算法”默认是非抢占式的。2.很多书上都会说“SJF调度算法的平均等待时间、平均周转时间最少”,严格来说,这个表述是错误的,不严谨的。最短剩余时间优先算法得到的平均等待时间、平均周转时间还要更少。应该加上一个条件“在所有进程同时可运行时,采用SJF调度算法的平均等待时间、平均周转时间最少”,或者说“在所有进程都几乎同时到达时,采用SJF调度算法的平均等待时间、平均周转时间最少”。如果不加上述前提条件,则应该说“抢占式的短作业/进程优先调度算法(最短剩余时间优先, SRNT算法)的平均等待时间、平均周转时间最少”。 3.虽然严格来说,SJF的平均等待时间、平均周转时间并不一定最少,但相比于其他算法(如FCFS),SJF依然可以获得较少的平均等待时间、平均周转时间。 4.如果选择题中遇到“SJF算法的平均等待时间、平均周转时间最少”的选项,那最好判断其他选项是不是有很明显的错误,如果没有更合适的选项,那也应该选择该选项。
例题 各进程到达就绪队列的时间、需要的运行时间如下表所示。使用非抢占式短进程优先调度算法和抢占式短进程优先调度算法,分别计算各进程的等待时间、平均等待时间、周转时间、平均周转时间、带权周转时间、平均带权周转时间。
| 进程 | 到达时间 | 运行时间 |
|---|---|---|
| P1 | 0 | 7 |
| P2 | 2 | 4 |
| P3 | 4 | 1 |
| P4 | 5 | 4 |
如果使用非抢占式的,因为每次选择当前已经到达且运行时间最短的作业/进程,因为P1第一个到达,所以第一个开始,P1完成时是7,P2P3P4都到达了,所以依次选最短运行时间的开始,所以调度顺序为P1、P3、P2、P4。
所以周转时间为7、4、10、11。带权周转时间为1、4、2.5、2.75。等待时间为0、3、6、7。平均周转时间为8。平均带权周转时间为2.56。平均等待时间为4。
如果使用抢占式的,若新到达进程剩余时间要比当前运行的进程剩余时间更短,则由新进程抢占处理机。
0时P1到达,P1剩余时间为7,所以P1开始运行。2时P2到达,此时P1剩余时间为7-2=5,而P2剩余时间为4-0=4,4<5,所以P2抢占P1处理机。4时P3到达,此时P2剩余时间为4-2=2,而P3剩余时间为1-0=1,所以P3又抢占P2处理机。到5时P4到达,此时P3正好运行完,所以要考虑P1P2P4三个的剩余时间,分别为5、2、4,所以P2抢占处理机运行。7时P2运行完,P4抢占处理机。11时P4运行完,P1抢占处理机。最后16时P1运行完,作业全部结束。
所以周转时间为16、5、1、6。带权周转时间为2.28、1.25、1、1.5。等待时间分别为9、1、0、2。平均周转时间为7。平均带权周转时间为1.5。平均等待时间为3。
高响应比优先
即HRRN算法。
- 算法思想:要综合考虑作业/进程的等待时间和要求服务的时间。
- 算法规则:在每次调度时先计算各个作业/进程的响应比,选择响应比最高的作业/进程为其服务。响应比=(等待时间+要求服务时间)/要求服务时间(响应比≥1)。
- 用于作业/进程调度:即可用于作业调度,也可用于进程调度。
- 是否可抢占:非抢占式的算法。因此只有当前运行的作业/进程主动放弃处理机时,才需要调度,才需要计算响应比。
- 优缺点:
- 综合考虑了等待时间和运行时间(要求服务时间)。
- 等待时间相同时,要求服务时间短的优先(SJF的优点)。
- 要求服务时间相同时,等待时间长的优先(FCFS的优点)。
- 对于长作业来说,随着等待时间越来越久,其响应比也会越来越大,从而避免了长作业饥饿的问题。
- 是否会导致饥饿:不会。
例题 各进程到达就绪队列的时间、需要的运行时间如下表所示。使用高响应比优先调度算法,计算各进程的等待时间、平均等待时间、周转时间、平均周转时间、带权周转时间、平均带权周转时间。
| 进程 | 到达时间 | 运行时间 |
|---|---|---|
| P1 | 0 | 7 |
| P2 | 2 | 4 |
| P3 | 4 | 1 |
| P4 | 5 | 4 |
需要利用公式计算响应比,最重要的是等待时间和运行时间。当0时P1到达,P1运行。7时P1完成主动放弃处理机,此时P2P3P4全部到达了,而响应比分别为(5+4)/4=2.25、(3+1)/1=3、(2+4)/4=1.5,所以选择P3上处理机运行。8时,响应比分别为2.5、1.75,P2运行。12时P4运行。
时间片轮转
即RR算法。
-
算法思想:公平地、轮流地为各个进程服务,让每个进程在一定时间间隔内都可以得到响应。
-
算法规则:按照各进程到达就绪队列的顺序,轮流让各个进程执行一个时间片(如100ms)。若进程未在一个时间片内执行完,则剥夺处理机,将进程重新放到就绪队列队尾重新排队。
-
用于作业/进程调度:用于进程调度(只有作业放入内存建立了相应的进程后,才能被分配处理机时间片)。
-
是否可抢占:若进程未能在时间片内运行完,将被强行剥夺处理机使用权,因此时间片轮转调度算法属于抢占式的算法。由时钟装置发出时钟中断来通知CPU时间片已到。
-
优缺点:
- 优点:
- 公平。
- 响应快,适用于分时操作系统。
- 缺点:
- 由于高频率的进程切换,因此有一定开销。
- 不区分任务的紧急程度。
- 优点:
-
是否会导致饥饿:不会。
-
常用于分时操作系统,更注重响应时间,所以不怎么关系周转时间。
-
如果时间片太大,每个进程在一个时间片内就可以完完成,则时间片轮转算法会退化为先来先服务算法,增大进程响应时间,所以时间片不能太大。
-
如果时间片太小,进程切换会频繁发生,需要保存现场恢复环境,增加时间开销。
-
一般设计时间片段时要让切换进程的开销不超过1%。
例题 各进程到达就绪队列的时间、需要的运行时间如下表所示。使用时间片轮转调度算法,分析时间片大小为2时的进程运行状态。
| 进程 | 到达时间 | 运行时间 |
|---|---|---|
| P1 | 0 | 7 |
| P2 | 2 | 4 |
| P3 | 4 | 1 |
| P4 | 5 | 4 |
注意:当同一时刻既有时间片用完也有新进程到达时,默认新到达进程先进入队列,时间片用完的进程后进入。
若时间片为2:
当0时,因为只有P1到达就绪队列,所以P1运行一个时间片。
当2时,P1时间片运行完,余下5个时间。正好P2到达就绪队列,所以P1处理机被剥夺,重新放到就绪队列尾,让P2运行一个时间片。
当4时,P2时间片运行完,余下2个时间。正好P3到达就绪队列加入队尾,此时P1到达队头,所以P2处理机被剥夺,重新放到就绪队列尾,让P1运行一个时间片。
当5时,P4到达加入就绪队列。此时就绪队列上有P3、P2、P4。
当6时,P1时间片运行完,余下3个时间。此时P3到达队头,所以P1处理机被剥夺,重新放到就绪队尾,让P3运行。
当7时,虽然P3时间片没有用完,但是由于P3只需一个单位的时间,所以主动放弃处理机,发生调度,让P2运行一个时间片。
当9时,P2正好运行完也用完时间片,P4上处理机。
当11时,P4时间片用完,余下2个时间,此时P1到达队头,所以P4处理机被剥夺,重新放到就绪队尾,让P1运行。
当13时,P1时间片用完,余下1个时间,此时P4到达队头,所以P1处理机被剥夺,重新放到就绪队尾,让P4运行。
当15时,P4正好运行完也用完时间片,P4上处理机。
当16时,P1运行完,主动放弃处理机。
优先级
即PS算法。
-
算法思想:随着计算机的发展,特别是实时操作系统的出现,越来越多的应用场景需要根据任务的紧急程度来决定处理顺序。
-
算法规则:调度时选择优先级最高的作业/进程。
-
用于作业/进程调度:既可用于作业调度,也可用于进程调度。甚至,还会用于在之后会学习的I/O调度中。
-
是否可抢占:抢占式、非抢占式都有。做题时的区别在于:
- 非抢占式只需在进程主动放弃处理机时进行调度即可。
- 抢占式还需在就绪队列变化时,检查是否会发生抢占。
-
优缺点:
- 优点:用优先级区分紧急程度、重要程度,适用于实时操作系统。可灵活地调整对各种作业/进程的偏好程度。
- 缺点:若源源不断地有高优先级进程到来,则可能导致饥饿。
-
是否会导致饥饿:会。
-
优先数与优先级的关系要看具体情况,如Windows优先级与优先数成正比,UNIX中成反比。
-
优先级调度算法中就绪队列未必只有一个,可以按照不同优先级来组织。
-
可以把优先级更高的进程排在队头位置。
-
根据优先级是否可以动态改变,分为:
- 静态优先级:创建进程时确定,一直保持不变
- 动态优先级:创建进程时有初始值,之后根据情况动态调整优先级。
-
设置进程优先级:
- 系统进程高于用户进程。
- 前台进程高于后台进程。
- 操作系统更偏好I/O型进程(I/O繁忙型进程)而不是计算型进程(CPU繁忙型进程),I/O设备和CPU可以并行工作。如果优先让I/O繁忙型进程优先运行的话,则越有可能让I/O设备尽早地投入工作,则资源利用率、系统吞吐量都会得到提升。
-
调整动态优先级:
- 若进程在就绪队列中等待了很长时间,则提升其优先级。
- 若进程占用处理机很长时间,则降低其优先级。
- 若进程频繁进行I/O操作,则提升其优先级。
例题 各进程到达就绪队列的时间、需要的运行时间、进程优先数如下表所示。使用非抢占式与抢占式优先级调度算法,分析进程运行状态。(优先级越大,优先级越高)
| 进程 | 到达时间 | 运行时间 | 优先数 |
|---|---|---|---|
| P1 | 0 | 7 | 1 |
| P2 | 2 | 4 | 2 |
| P3 | 4 | 1 | 3 |
| P4 | 5 | 4 | 2 |
非抢占式:
0时只有P1到达,所以P1开始处理。
2时P2到达,4时P3到达,5时P4到达。
7时P1运行完,因为P3优先级更高,所以运行P3。
8时P3运行完,P2P4优先级相同,但是由于P2先到,所以P2上处理机。
12时P2运行完,P4上处理机。
16时P4运行完。
抢占式:
0时P1到达,P1上处理机。
2时P2到达,且P2优先级2大于P1优先级1,所以P2上处理机,P1余下5。
4时P3到达,且P3优先级3大于P2优先级2,所以P3上处理机,P2余下2。
5时P3运行结束,且P4到达,插入就绪队列队尾,P2进入队列时间更早,所以P2上处理机。
7时P2运行结束,且P4优先级4大于P1优先级1,所以P4上处理机。
11时P4运行结束,P1上处理机。
16时P1运行结束。
多级反馈队列
即MFQ算法。
- 算法思想:对其他调度算法的折中权衡。
- 算法规则:
- 设置多级就绪队列,各级队列优先级从高到低,时间片从小到大。
- 新进程到达时先进入第1级队列,按FCFS原则排队等待被分配时间片,若用完时间片进程还未结束,则进程进入下一级队列队尾如果此时已经是在最下级的队列,则重新放回该队列队尾。
- 只有第k级队列为空时,才会为k+1级队头的进程分配时间片
- 用于作业/进程调度:用于进程调度。
- 是否可抢占:抢占式的算法。在k级队列的进程运行过程中,若更上级的队列(1~k-1级)中进入了一个新进程,则由于新进程处于优先级更高的队列中,因此新进程会抢占处理机,原来运行的进程放回k级队列队尾。
- 优缺点:
- 对各类型进程相对公平(FCFS的优点)。
- 每个新到达的进程都可以很快就得到响应(RR的优点)。
- 短进程只用较少的时间就可完成(SPF的优点)。
- 不必实现估计进程的运行时间(避免用户作假)。
- 可灵活地调整对各类进程的偏好程度,比如CPU密集型进程、I/O密集型进程(拓展:可以将因I/O而阻塞的进程重新放回原队列,这样I/O型进程就可以保持较高优先级)
- 是否会导致饥饿:会。
例题 各进程到达就绪队列的时间、需要的运行时间如下表所示。使用多级反馈队列调度算法,分析进程运行状态。
| 进程 | 到达时间 | 运行时间 |
|---|---|---|
| P1 | 0 | 7 |
| P2 | 2 | 4 |
| P3 | 4 | 1 |
| P4 | 5 | 4 |
设置多级就绪队列,各级队列优先级从高到低,时间片从小到大。
新进程到达时先进入第1级队列,按FCFS原则排队等待被分配时间片。
若用完时间片进程还未结束,则进程进入下一级队列队尾。如果此时已经在最下级的队列,则重新放回最下级队列队尾。
只有第k级队列为空时,才会为k+1级队头的进程分配时间片。
被抢占处理机的进程重新放回原队列队尾。
定义多级就绪队列如下(优先级与优先数成正比):
| 队列序号 | 优先级 | 时间片大小 |
|---|---|---|
| 1 | 3 | 1 |
| 2 | 2 | 2 |
| 3 | 1 | 4 |
0时首先P1进入第一级队列,此时P1优先级为4,时间片为1,所以P1会运行1。
1时P1余下6,没有运行完,进入第二级队列的队尾。
2时P2进入第一级队列,优先级更高,所以P1被剥夺处理机,此时P1还余下5,回退第二级队列,P2开始运行。
3时P2运行一个时间片后余下3,加入第二级队列队尾。此时第二级队列头部为P1,所以P1开始运行。
4时P3进入第一级队列,优先级更高,所以P1被剥夺处理机,此时P1还余下4,回退第二级队列。
5时P4进入第一级队列,此时P3也正好运行完,处理机交给P4。
6时P4运行一个时间片后余下3,加入到第二级队列尾部。第二级队列此时顺序为P2(余3)、P1(余4)、P4(余3),所以P2开始运行。
8时P2运行一个时间片后余下1,加入到第三级队列中。P1开始占有处理机。
10时P1运行一个时间后余下2,加入到第三级队列中。P4开始占有处理机。
12时P4运行一个时间后余下1,加入到第三级队列中。第二级队列为空,开始运行第三级队列,P2开始占有处理机。
13时P2处理完,P1占有处理机。
15时P1处理完,P4占有处理机。
16时P4处理完,全部结束。
进程同步与互斥
进程同步与互斥的基本概念
进程同步
- 同步也称为直接制约关系。
- 在多道程序环境下,进程是并发执行的,不同进程之间存在着不同的相互制约关系。为了协调进程之间的相互制约关系,如等待、传递信息等,引入了进程同步的概念。进程同步是为了解决进程的异步问题。
- 异步性:进程具有异步性的特征。指各并发执行的进程以各自独立的、不可预知的速度向前推进。
进程互斥
- 互斥也称间接制约关系。
- 进程互斥指当一个进程访问某临界资源时,另一个想要访问该临界资源的进程必须等待。当前访问临界资源的进程访问结束,释放该资源之后,另一个进程才能去访问临界资源。
- 资源共享方式分为:
- 互斥共享方式:允许多个进程使用,但是同一个时间段内只允许一个进程访问该资源。
- 同时共享方式:允许一个时间短内由多个进程在宏观上同时对其访问。
临界资源
- 我们把一个时间段内只允许一个进程使用的资源称为临界资源。许多物理设备(比如摄像头、打印机)都属于临界资源。此外还有许多变量、数据、内存缓冲区等都属于临界资源。
- 对临界资源的访问,必须互斥地进行,从逻辑上分为四个部分:
- 进入区:负责检查是否可进入临界区,若可进入则应设置正在访问临界资源的标志(上锁),以阻止其他进程同时进入临界区。
- 临界区(临界段):实际访问临界资源的代码。
- 退出区:负责解除正在访问临界资源的标志(解锁)。
- 剩余区:做其他处理。
- 进入区和退出区是实现互斥的代码段。
互斥访问的原则
- 空闲让进。临界区空闲时,可以允许一个请求进入临界区的进程立即进入临界区。
- 忙则等待。当已有进程进入临界区时,其他试图进入临界区的进程必须等待。
- 有限等待。对请求访问的进程,应保证能在有限时间内进入临界区(保证不会饥饿)。
- 让权等待。当进程不能进入临界区时,应立即释放处理机,防止进程忙等待。
进程互斥的软件实现
单标志法
算法思想:两个进程在访问完临界区后会把使用临界区的权限转交给另一个进程。也就是说每个进程进入临界区的权限只能被另一个进程赋予。
// turn表示当前允许进入临界区的进程号
int turn = 0;
// P0进程
while (turn != 1); // ①
critical section; // ②
turn = 1; // ③
remainder section; // ④
// P1进程
while(turn != 0); // ⑤
critical section; // ⑥
turn = 0; // ⑦
remainder section;// ⑧
优点:
- turn的初值为0,即刚开始只允许0号进程进入临界区。
- 若P1先上处理机运行,则会一直卡在⑤,无法使用临界资源。
- 直到P1的时间片用完,发生调度,切换P0上处理机运行。
- 而代码①不会卡住P0,P0可以正常访问临界区,在P0访问临界区期间即时切换回P1,P1依然会卡在⑤。
- 只有P0在退出区将turn改为1后,P1才能进入临界区。
- 因此,该算法可以实现同一时刻最多只允许一个进程访问临界。
缺点:
- turn表示当前允许进入临界区的进程号,而只有当前允许进入临界区的进程在访问了临界区之后,才会修改turn的值。
- 也就是说,对于临界区的访问,一定是按P0→P1→P0→P1→……这样轮流访问。
- 如果P0进程一直不访问临界区,那么临界资源会被P0一直占用。
- 违背了空闲让进的原则。
双标志先检查法
算法思想:设置一个布尔型数组flag[],数组中各个元素用来标记各进程想进入临界区的意愿,比如flag[0] = ture意味着0号进程 P0现在想要进入临界区。每个进程在进入临界区之前先检查当前有没有别的进程想进入临界区,如果没有,则把自身对应的标志 flag[i]设为true,之后开始访问临界区。
// 表示进入临界区意愿的数组
bool flag[2];
//刚开始设置为两个进程都不想进入临界区
flag [0] = false;
flag [1] = false;
// P0进程
while (flag[1]); // ①
flag[0] = true; // ②
critical section; // ③
flag [0] = false; // ④
remainder section;
// P1进程
while (flag[0]); // ⑤
flag[1] = true; // ⑥
critical section; // ⑦
flag[1] = false; // ⑧
remainder section;
若按照①⑤②⑥③⑦……的顺序执行,P0和P1将会同时访问临界区。因此,双标志先检查法的主要问题是:违反忙则等待原则。
双标志后检查法
算法思想:双标志先检查法的改版。前一个算法的问题是先“检查”后“上锁”,但是这两个操作又无法一气呵成,因此导致了两个进程同时进入临界区的问题。因此,人们又想到先“上锁”后“检查”的方法,来避免上述问题。
// 表示进入临界区意愿的数组
bool flag[2];
//刚开始设置为两个进程都不想进入临界区
flag [0] = false;
flag [1] = false;
// P0进程
flag[0] = true; // ①
while (flag[1]); // ②
critical section; // ③
flag [0] = false; // ④
remainder section;
// P1进程
flag[1] = true; // ⑤
while (flag[0]); // ⑥
critical section; // ⑦
flag[1] = false; // ⑧
remainder section;
若按照①⑤②⑥③⑦……的顺序执行,P0和P1都不能访问临界区。
因此,双标志后检查法虽然解决了“忙则等待”的问题,但是又违背了“空闲让进”和“有限等待”原则,会因各进程都长期无法访问临界资源而产生“饥饿”现象。
Peterson算法
算法思想:双标志后检查法中,两个进程都争着想进入临界区,但是谁也不让谁,最后谁都无法进入临界区。Gary L.Peterson想到了一种方法,如果双方都争着想进入临界区,那可以让进程尝试“孔融让梨”,主动让对方先使用临界区。
// 表示进入临界区意愿的数组,初始值都是false
bool flag[2];
// turn表示优先让哪个进程进入临界区
int turn = 0;
// 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算法用软件方法解决了进程互斥问题,遵循了空闲让进、忙则等待、有限等待三个原则,但是依然未遵循让权等待的原则。
进程互斥的硬件实现
中断屏蔽
- 利用“开/关中断指令”实现(与原语的实现思想相同,即在某进程开始访问临界区到结束访问为止都不允许被中断,也就不能发生进程切换,因此也不可能发生两个同时访问临界区的情况)。
- 关中断后即不允许当前进程被中断,也必然不会发生进程切换。
- 直到当前进程访问完临界区,再执行开中断指令,才有可能有别的进程上处理机并访问临界区。
- 优点:简单、高效。
- 缺点:
- 不适用于多处理机。
- 只适用于操作系统内核进程,不适用于用户进程(因为开/关中断指令只能运行在内核态,这组指令如果能让用户随意使用会很危险)。
TS指令
简称TS指令,也有地方称TestAndSetLock指令,或TSL指令。
TSL指令是用硬件实现的,执行的过程不允许被中断,只能一气呵成。
// 布尔型共享变量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,则执行TLS后old返回的值为true,while循环条件满足,会一直循环,直到当前访问临界区的进程在退出区进行“解锁”。
相比软件实现方法,TSL指令把“上锁”和“检查”操作用硬件的方式变成了一气呵成的原子操作。
- 优点:
- 实现简单,无需像软件实现方法那样严格检查是否会有逻辑漏洞。
- 适用于多处理机环境。
- 缺点:
- 不满足“让权等待”原则。
- 暂时无法进入临界区的进程会占用CPU并循环执行TSL指令,从而导致“忙等”。
Swap指令
有的地方也叫Exchange指令,或简称XCHG指令。
Swap指令是用硬件实现的,执行的过程不允许被中断,只能一气呵成。
// 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指令,从而导致“忙等”。
信号量机制
之前软硬件实现的进程互斥都无法解决让权等待问题,所以Dijkstra提出实现进程互斥和同步的方法——信号量机制。
信号量机制的基础概念
- 用户进程可以通过使用操作系统提供的一对原语来对信号量进行操作,从而很方便的实现了进程互斥、进程同步。
- 信号量其实就是一个变量(可以是一个整数,也可以是更复杂的记录型变量),可以用一个信号量来表示系统中某种资源的数量,比如:系统中只有一台打印机,就可以设置一个初值为1的信号量。
- 原语是一种特殊的程序段,其执行只能一气呵成,不可被中断。原语是由关中断/开中断指令实现的。软件解决方案的主要问题是由“进入区的各种操作无法一气呵成”,因此如果能把进入区、退出区的操作都用“原语”实现,使这些操作能“一气呵成”就能避免问题。
- 一对原语: wait(S)原语和signal(S)原语,可以把原语理解为我们自己写的函数,函数名分别为wait和 signal,括号里的信号量s其实就是函数调用时传入的一个参数。
- wait、signal原语常简称为P、V操作(来自荷兰语proberen和verhogen)。因此,做题的时候常把wait(S)、signal(S)两个操作分别写为P(S)、V(S)。
整型信号量
用一个整数型的变量作为信号量,用来表示系统中某种资源的数量。
与普通整数变量的区别:对信号量的操作只有三种,即初始化、P操作、V操作。
// 加入某计算机系统中有一台打印机,打印机被争用,如果有n台打印机则S初始为为n
//初始化整型信号量s,表示当前系统中可用的打印机资源数
int S = 1;
// wait原语,相当于“进入区”
void wait (int &S){
while (S <= 0); // 如果资源数不够,就一直循环等待
S=S-1; // 如果资源数够,则占用一个资源
}
// signal原语,相当于“退出区”
void signal (int &S) {
S=S+1; // 使用完资源后,在退出区释放资源
}
// 进程
...
wait(S); // 进入区,申请资源
使用打印机资源... // 临界区,访问资源
signal(S); // 退出区,释放资源
“检查”和“上锁”一气呵成,避免了并发、异步导致的问题。
存在的问题:不满足“让权等待”原则,会发生“忙等”。
记录型信号量
整型信号量的缺陷是存在“忙等”问题,因此人们又提出了“记录型信号量”,即用记录型数据结构表示的信号量。
// 记录型信号量的定义
typedef struct {
int value;// 剩余资源数
struct process *L; // 等待队列
}semaphore;
// 某进程需要使用资源时,通过wait原语申请
void wait (semaphore S) {
S.value--;
// 如果剩余资源数不够,使用block原语使进程从运行态进入阻塞态,并把挂到信号量S的等待队列(即阻塞队列)中。
if (S.value < 0) {
block(S.L);
}
}
// 进程使用完资源后,通过signal原语释放
void signal (semaphore S) {
S.value++;
// 释放资源后,若还有别的进程在等待这种资源,则使用wakeup原语唤醒等德队列中一个进程,该进程从阻塞态变为就绪态
if (S.value <= 0) {
wakeup(S.L);
}
}
- 在考研题目中wait(S)、signal(S)也可以记为P(S)、V(S),这对原语可用于实现系统资源的“申请”和“释放”。
- S.value的初值表示系统中某种资源的数目。
- 对信号量S的一次Р操作意味着进程请求一个单位的该类资源,因此需要执行
S.value--,表示资源数减1,当S.value <0时表示该类资源已分配完毕,因此进程应调用block原语进行自我阻塞(当前运行的进程从运行态>阻塞态),主动放弃处理机,并插入该类资源的等待队列s.L中。可见,该机制遵循了“让权等待”原则,不会出现“忙等”现象。 - 对信号量S的一次V操作意味着进程释放一个单位的该类资源,因此需要执行
S.value++,表示资源数加1,若加1后仍是S.value<=0,表示依然有进程在等待该类资源,因此应调用wakeup原语唤醒等待队列中的第一个进程(被唤醒进程从阻塞态→就绪态)。
信号量机制实现进程互斥
- 分析并发进程的关键活动,划定临界区(如对临界资源打印机的访问就应放在临界区)。
- 设置互斥信号量mutex,初值为1。
- 互斥信号量取值为0或1,0代表上锁,1代表释放。
- 在临界区之前执行P(mutex)。
- 在临界区之后执行V(mutex)。
- 注意:对不同的临界资源需要设置不同的互斥信号量。
// 信号量机制实现互斥
// 要会自己定义记录型信号量,但如果题目中没特别说明,可以把信号量的声明简写成这种形式
semaphore mutex=1; // 初始化信号量
P1(){
...
P(mutex); // 使用临界资源前需要加锁
临界区代码段...
V(mutex); // 使用临界资源后需要解锁
...
}
P2(){
...
P(mutex);
临界区代码段...
V(mutex);
...
}
信号量机制实现进程同步
进程同步:要让各并发进程按要求有序地推进。
- 分析什么地方需要实现“同步关系”,即必须保证“一前一后”执行的两个操作(或两句代码)。
- 设置同步信号量S,初始为0。
- 同步信号量为整数,释放加一,占用减一。
- 在“前操作”之后执行V(S)。
- 在“后操作”之前执行P(S)。
// 信号量机制实现同步
// 初始化同步信号量,初始值为0
semaphore S=0;
P1(){
代码1;
代码2;
V(S);
代码3;
}
P2(){
P(S);
代码4;
代码5;
代码6;
}
- 若先进行进程P1:
- 执行到V(S)操作,则S++后S=1。
- 之后当进行P2,执行到P(S)操作时,由于S=1,表示有可用资源,会执行S--,S的值变回0,P2进程不会执行block原语,而是继续往下执行代码4。
- 若先进行进程P2:
- 执行到P(S)操作,由于S=0,S--后S=-1,表示此时没有可用资源,因此P操作中会执行block原语,主动请求阻塞。
- 时间片用完后进行进程P1,之后当执行完代码2,继而执行V(S)操作,S++,使S变回0。
- 由于此时有进程在该信号量对应的阻塞队列中,因此会在V操作中执行wakeup原语,唤醒P2进程。这样P2就可以继续执行代码4了。
信号量机制实现前驱关系
前驱关系其实是多组同步。
进程P1中有句代码S1,P2中有句代码S2……P6中有句代码S6。这些代码要求按如下前驱图所示的顺序来执行:
|-S4-|
|-S2-| |
S1-| |-S5-|-S6
| |
|----S3---|
其实每一对前驱关系都是一个进程同步问题(需要保证一前一后的操作)因此:
- 要为每一对前驱关系各设置一个同步变量。
- 在“前操作”之后对相应的同步变量执行V操作。
- 在“后操作”之前对相应的同步变量执行Р操作。
令S1-S2之间信号量为a=0,S1-S3之间的信号量b=0,S2-S4之间信号量为c=0,S2-S5之间信号量为d=0,S4-S6之间信号量为e=0,S5-S6之间信号量为f=0,S3-S6之间信号量为g=0。
每一条线段靠近根的对当前信号量进行V操作,靠近尾的对当前信号量进行P操作。
再将每个代码结点旁边的操作聚拢在一起,就是每个进程所应该执行的操作。
P1() {
...
S1;
V(a);
V(b);
...
}
P2() {
...
P(a);
s2;
V(c);
V(d);
...
}
P3() {
...
P(b);
S3;
V(g);
...
)
P4() {
...
P(c);
S4;
V(e);
...
}
P5() {
...
P(d);
S5;
V(f);
...
}
P6() {
...
P(e);
P(f);
P(g);
S6;
...
}
进程同步与互斥应用
PV操作题目分析步骤:
- 关系分析。找出题目中描述的各个进程,分析它们之间的同步、互斥关系。
- 整理思路。根据各进程的操作流程确定P、V操作的大致顺序。
- 设置信号量。设置需要的信号量,并根据题目条件确定信号量初值。
- 互斥信号量初值一般为1,同步信号量的初始值要看对应资源的初始值是多少。
生产者消费者问题
- 系统中有一组生产者进程和一组消费者进程,生产者进程每次生产一个产品放入缓冲区,消费者进程每次从缓冲区中取出一个产品并使用。(这里的“产品”理解为某种数据)
- 生产者、消费者共享一个初始为空、大小为n的缓冲区。
- 只有缓冲区没满时,生产者才能把产品放入缓冲区,否则必须等待。
- 只有缓冲区不空时,消费者才能从中取出产品,否则必须等待。
- 缓冲区是临界资源,各进程必须互斥地访问。
由缓冲区是临界资源,所以对其访问是互斥关系。
缓冲区满时,生产者要等待消费者取走产品,所以是同步关系。
缓冲区空时(即没有产品时),消费者要等待生产者放入产品,所以是同步关系。
所以分析得到生产者每次要消耗(P)一个空闲缓冲区,并生产(V)一个产品。消费者每次要消耗(P)一个产品,并释放一个空闲缓冲区V。往缓冲区放入/取走产品需要互斥。
所以设置三个变量,不能合并:
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); // 增加一个空闲缓冲区
使用产品;
}
}
若调换P操作顺序会怎么样?
- 若此时缓冲区内已经放满产品,则empty=o,full=n。
- 则生产者进程执行
P(mutex)使mutex变为0上锁,再执行P(empty),由于已没有空闲缓冲区,因此生产者被阻塞。 - 由于生产者阻塞,因此切换回消费者进程。消费者进程执行
P(mutex),由于mutex为0,即生产者还没释放对临界资源的“锁”,因此消费者也被阻塞。 - 这就造成了生产者等待消费者对产品消费来释放空闲缓冲区,而消费者又等待生产者解锁临界区的情况,生产者和消费者循环等待被对方唤醒,出现“死锁”。
- 同样的,若缓冲区中没有产品,即full=0,empty=n,按先消费者后生产者的顺序执行也会发生死锁。因此,实现互斥的P操作一定要在实现同步的P操作之后。
而由于V操作是释放不会导致进程阻塞,所以两个V操作可以交换顺序。
- 生产者消费者问题是一个互斥、同步的综合问题。
- 对于初学者来说最难的是发现题目中隐含的两对同步关系。
- 有时候是消费者需要等待生产者生产,有时候是生产者要等待消费者消费,这是两个不同的“一前一后问题”,因此也需要设置两个同步信号量。前生产者V后消费者P就是full,前消费者V后生产者者P就是empty。
多生产者多消费者问题
桌子上有一只盘子,每次只能向其中放入一个水果。爸爸专向盘子中放苹果,妈妈专向盘子中放橘子,儿子专等着吃盘子中的橘子,女儿专等着吃盘子中的苹果。只有盘子空时,爸爸或妈妈才可向盘子中放一个水果。仅当盘子中有自己需要的水果时,儿子或女儿可以从盘子中取出水果。用PV操作实现上述过程。
所以盘中是一个大小为1,初始为空的缓冲区。父母分别为两个生产者进程,子女分别为两个消费者进程,其消费的产品类别不同。
对缓冲区盘子需要互斥使用。
只有父亲放入苹果女儿才能取出,所以是同步关系。
只有母亲放入橘子儿子才能取出,所以是同步关系。
只有盘子空时,父亲或母亲才能放水果,所以也是同步关系。
所以对于互斥关系设置一个互斥信号量mutex=1,对于苹果设置为apple=0,对于橘子设置为orange=0,对于向拍子放水果设置plate=1(也可以设置为0,后面的处理不同)。
// 实现互斥访问盘子(缓冲区)
semaphore mutex = 1;
// 盘子中有几个苹果
semaphore apple = 0;
// 盘子中有几个橘子
semaphore orange= 0;
// 盘子中还可以放多少个水果
semaphore plate = 1;
dad () {
while (1){
准备一个苹果;
P(plate);
P(mutex);
把苹果放入盘子;
V(mutex);
}
}
mom() {
while (1){
准备一个橘子;
P(plate);
P(mutex);
把橘子放入盘子;
V(mutex);
}
}
daughter() {
while (1){
P(apple);
P(mutex);
从盘中取出苹果;
V(mutex);
V(plate);
吃掉苹果;
}
}
son(){
while (1){
P(orange);
P(mutex);
从盘中取出橘子;
V(mutex);
V(plate);
吃掉橘子;
}
}
如果不使用互斥量会怎么样?
-
刚开始,儿子、女儿进程即使上处理机运行也会被阻塞。如果刚开始是父亲进程先上处理机运行,则父亲P(plate),可以访问盘子→母亲P(plate),阻塞等待盘子→父亲放入苹果V(apple),女儿进程被唤醒,其他进程即使运行也都会阻塞,暂时不可能访问临界资源(盘子)→女儿P(apple),访问盘子,V(plate),等待盘子的母亲进程被唤醒→母亲进程访问盘子(其他进程暂时都无法进入临界区)。
-
即使不设置专门的互斥信号量mutex,也不会出现多个进程同时访问盘子的现象。
-
原因在于本题中的缓冲区大小为1,在任何时刻,apple、 orange、 plate三个同步信号量中最多只有一个是1。因此在任何时刻,最多只有一个进程的P操作不会被阻塞,并顺利地进入临界区,此时互斥关系全部变成同步关系。
-
而如果缓冲区大于1,则父母两个可以同时访问临界区,可能导致数据覆盖,所以必须使用互斥信号量。
-
解决“多生产者-多消费者问题”的关键在于理清复杂的同步关系。
-
在分析同步问题(一前一后问题)的时候不能从单个进程行为的角度来分析,要把“一前一后”发生的事看做是两种“事件”的前后关系。
-
比如,如果从单个进程行为的角度来考虑的话,我们会有以下结论:
-
如果盘子里装有苹果,那么一定要女儿取走苹果后父亲或母亲才能再放入水果如果盘子里装有橘子,那么一定要儿子取走橘子后父亲或母亲才能再放入水果。
-
这么看是否就意味着要设置四个同步信号量分别实现这四个“一前一后”的关系了?
-
正确的分析方法应该从“事件”的角度来考虑,我们可以把上述四对“进程行为的前后关系”抽象为一对“事件的前后关系”。
-
盘子变空事件→放入水果事件。“盘子变空事件”既可由儿子引发,也可由女儿引发;“放水果事件"既可能是父亲执行,也可能是母亲执行。这样的话,就可以用一个同步信号量解决问题了。
抽烟者问题
假设一个系统有三个抽烟者进程和一个供应者进程。每个抽烟者不停地卷烟并抽掉它,但是要卷起并抽掉一支烟,抽烟者需要有三种材料:烟草、纸和胶水。三个抽烟者中,第一个拥有烟草、第二个拥有纸、第三个拥有胶水。供应者进程无限地提供三种材料,供应者每次将两种材料放桌子上,拥有剩下那种材料的抽烟者卷一根烟并抽掉它,并给供应者进程一个信号告诉完成了,供应者就会放另外两种材料再桌上,这个过程一直重复(让三个抽烟者轮流地抽烟)。
桌子可以抽象为容量为1的缓冲区,要互斥访问。
组合一:纸+胶水;组合二:烟草+胶水;组合三:烟草+纸。
同步关系(从事件的角度来分析):桌上有组合一→第一个抽烟者取走东西;桌上有组合二→第二个抽烟者取走东西;桌上有组合三→第三个抽烟者取走东西;发出完成信号→供应者将下一个组合放到桌上 。
对于同步关系分别设置offer1=0、offer2=0、offer3=0、finish=0。
semaphore offer1 = 0; // 桌上组合一的数量
semaphore offer2 = 0; // 桌上组合二的数量
semaphore offer3 = 0; // 桌上组合三的数量
semaphore finish = 0; // 抽烟是否完成
int i = 0; // 用于实现三个抽烟者轮流抽烟
provider() {
while(1){
// 根据i选择提供材料
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);
}
}
- 吸烟者问题可以为我们解决“可以生产多个产品的单生产者”问题提供一个思路。
- 值得吸取的精华是“轮流让各个吸烟者吸烟”必然需要“轮流的在桌上放上组合一、二、三”,注意体会我们是如何用一个整型变量i 实现这个“轮流”过程的。
- 若一个生产者要生产多种产品(或者说会引发多种前驱事件),那么各个V操作应该放在各自对应的“事件”发生之后的位置。
读者写者问题
有读者和写者两组并发进程,共享一个文件,当两个或两个以上的读进程同时访问共享数据时不会产生副作用,但若某个写进程和其他进程(读进程或写进程)同时访问共享数据时则可能导致数据不一致的错误。因此要求:
- 允许多个读者可以同时对文件执行读操作。
- 只允许一个写者往文件中写信息。
- 任一写者在完成写操作之前不允许其他读者或写者工作写。
- 写者执行写操作前,应让已有的读者和写者全部退出。
具有两类进程:写进程、读进程。
互斥关系:写进程-写进程、写进程-读进程。读进程与读进程不存在互斥问题。
写者进程和任何进程都互斥,设置一个互斥信号量rw,在写者访问共享文件前后分别执行P、V操作。
读者进程和写者进程也要互斥,因此读者访问共享文件前后也要对rw执行P、V操作。
如果所有读者进程在访问共享文件之前都执行P(rw)操作,那么会导致各个读进程之间也无法同时访问文件。所以读者写者问题的核心思想――怎么处理该读者共享的问题呢?
P(rw)和V(rw)其实就是对共享文件的“加锁”和“解锁”。既然各个读进程需要同时访问,而读进程与写进程又必须互斥访问,那么我们可以让第一个访问文件的读进程“加锁”,让最后一个访问完文件的读进程“解锁”。可以设置一个整数变量count来记录当前有几个读进程在访问文件。
// 用于实现对文件的互斥访问。表示当前是否有进程在访问共享文件,1代表空闲
semaphore rw = 1;
// 记录当前有几个读进程在访问文件
int count = 0;
// 用于保证对count变量的互斥访问
semaphore mutex = 1;
// 用于实现"写优先"
semaphore w=1;
writer() {
while(1){
P(w);
P(rw); // 写之前”加锁”
写文件...
V(rw); // 写之后"解锁”
}
}
reader() {
while(1){
P(w);
P(mutex); // 各读进程互斥访问count
if(count==0){
P(rw); //第一个读进程负责"加锁”
}
count++; // 访问文件的读进程数+1
V(mutex);
V(w);
读文件....
P(mutex); // 各读进程互斥访问count
count--; // 访问文件的读进程数-1
if(count==0){
v(rw); // 最后一个读进程负责”解锁"
}
V(mutex);
}
}
- 读者写者问题为我们解决复杂的互斥问题提供了一个参考思路。
- 其核心思想在于设置了一个计数器count用来记录当前正在访问共享文件的读进程数。我们可以用count的值来判断当前进入的进程是否是第一个/最后一个读进程,从而做出不同的处理。
- 另外,对count变量的检查和赋值不能一气呵成导致了一些错误,如果需要实现“一气呵成”,自然应该想到用互斥信号量。
- 最后,还要认真体会我们是如何解决“写进程饥饿”问题的。
- 绝大多数的考研PV操作大题都可以用之前介绍的几种生产者消费者问题的思想来解决,如果遇到更复杂的问题,可以想想能否用读者写者问题的这几个思想来解决。
哲学家进餐问题
一张圆桌上坐着5名哲学家,每两个哲学家之间的桌上摆一根筷子,桌子的中间是一碗米饭。哲学家们倾注毕生的精力用于思考和进餐,哲学家在思考时,并不影响他人。只有当哲学家饥饿时,才试图拿起左、右两根筷子(一根一根地拿起)。如果筷子已在他人手上,则需等待。饥饿的哲学家只有同时拿起两根筷子才可以开始进餐,当进餐完毕后,放下筷子继续思考。
系统中有5个哲学家进程,5位哲学家与左右邻居对其中间筷子的访问是互斥关系。
这个问题中只有互斥关系,但与之前遇到的问题不同的是,每个哲学家进程需要同时持有两个临界资源才能开始吃饭。如何避免临界资源分配不当造成的死锁现象,是哲学家问题的精髓。
信号量设置。定义互斥信号量数组 chopstick[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; // 互斥地取筷子
//i号哲学家的进程
Pi() {
while(1){
P(mutex);
P(chopstick[i]); // 拿左
P(chopstick[(i+1)%5]); // 拿右
V(mutex);
吃饭...
V(chopstick[i]); // 放左
V(chopstick[(i+1)%5]); // 放右
思考...
}
}
- 哲学家进餐问题的关键在于解决进程死锁。
- 这些进程之间只存在互斥关系,但是与之前接触到的互斥关系不同的是,每个进程都需要同时持有两个临界资源,因此就有“死锁”问题的隐患。
- 如果在考试中遇到了一个进程需要同时持有多个临界资源的情况,应该参考哲学家问题的思想,分析题中给出的进程之间是否会发生循环等待,是否会发生死锁。
管程
此前一般使用信号量机制来完成进程互斥同步,但是编写程序困难,易出错。
管程的概念
管程是一种特殊的软件模块,有这些部分组成:
- 局部于管程的共享数据结构(临界区)说明。
- 对该数据结构进行操作的一组过程。
- 对局部于管程的共享数据设置初始值的语句。
- 管程有一个名字。
管程的基本特征:
- 局部于管程的数据只能被局部于管程的过程所访问。
- 一个进程只有通过调用管程内的过程才能进入管程访问共享数据。(即管程中定义的共享数据结构只能被管程定义的函数所修改)
- 每次仅允许一个进程在管程内执行某个内部过程。(在一个时刻内一个函数只能被一个进程使用)
处理生产者消费者问题
- 需要在管程中定义共享数据(如生产者消费者问题的缓冲区)。
- 需要在管程中定义用于访问这些共享数据的“入口”—―其实就是一些函数(如生产者消费者问题中,可以定义一个函数用于将产品放入缓冲区,再定义一个函数用于从缓冲区取出产品)。
- 只有通过这些特定的“入口”才能访问共享数据。
- 管程中有很多“入口”,但是每次只能开放其中一个“入口”,并且只能让一个进程或线程进入(如生产者消费者问题中,各进程需要互斥地访问共享缓冲区。管程的这种特性即可保证一个时间段内最多只会有一个进程在访问缓冲区。注意:这种互斥特性是由编译器负责实现的,程序员不用关心)。
- 可在管程中设置条件变量及等待/唤醒操作以解决同步问题。可以让一个进程或线程在条件变量上等待(此时,该进程应先释放管程的使用权,也就是让出“入口”)。可以通过唤醒操作将等待在条件变量上的进程或线程唤醒。
死锁
死锁的概念
死锁的定义
在并发环境下,各进程因竞争资源而造成的一种互相等待对方手里的资源,导致各进程都阻塞,都无法向前推进的现象,就是“死锁”发生死锁后若无外力干涉,这些进程都将无法向前推进。
死锁、饥饿、死循环
都是进程无法顺利向前推进的现象(故意设计的死循环除外)。
| 区别 | |
|---|---|
| 死锁 | 死锁一定是“循环等待对方手里的资源”导致的,因此如果有死锁现象,那至少有两个或两个以上的进程同时发生死锁。另外,发生死锁的进程一定处于阻塞态 |
| 饥饿 | 可能只有一个进程发生饥饿。发生饥饿的进程既可能是阻塞态(如长期得不到需要的I/O设备),也可能是就绪态(长期得不到处理机) |
| 死循环 | 可能只有一个进程发生死循环。死循环的进程可以上处理机运行(可以是运行态),只不过无法像期待的那样顺利推进。 |
死锁和饥饿问题是由于操作系统分配资源的策略不合理导致的,而死循环是由代码逻辑的错误导致的。
死锁和饥饿是管理者(操作系统)的问题,死循环是被管理者的问题。
死锁发生的条件
产生死锁必须同时满足一下四个条件,只要其中任一条件不成立,死锁就不会发生:
- 互斥条件:只有对必须互斥使用的资源的争抢才会导致死锁(如哲学家的筷子、打印机设备)。像内存、扬声器这样可以同时让多个进程使用的资源是不会导致死锁的(因为进程不用阻塞等待这种资源)。
- 不剥夺条件:进程所获得的资源在未使用完之前,不能由其他进程强行夺走,只能主动释放。
- 请求和保持条件:进程已经保持了至少一个资源,但又提出了新的资源请求,而该资源又被其他进程占有,此时请求进程被阻塞,但又对自己己有的资源保持不放。
- 循环等待条件:存在一种进程资源的循环等待链,链中的每一个进程己获得的资源同时被下一个进程所请求。
- 发生死锁时一定有循环等待,但是发生循环等待时未必死锁(循环等待是死锁的必要不充分条件)。
- 如果同类资源数大于1,则即使有循环等待,也未必发生死锁。但如果系统中每类资源都只有一个,那循环等待就是死锁的充分必要条件了。
死锁发生的情况
- 对系统资源的竞争。各进程对不可剥夺的资源(如打印机)的竞争可能引起死锁,对可剥夺的资源(CPU)的竞争是不会引起死锁的。
- 进程推进顺序非法。请求和释放资源的顺序不当,也同样会导致死锁。例如,并发执行的进程P1、P2分别申请并占有了资源R1、R2,之后进程P1又紧接着申请资源R2,而进程P2又申请资源R1,两者会因为申请的资源被对方占有而阻塞,从而发生死锁。
- 信号量的使用不当也会造成死锁。如生产者-消费者问题中,如果实现互斥的P操作在实现同步的Р操作之前,就有可能导致死锁。(可以把互斥信号量、同步信号量也看做是一种抽象的系统资源)
总之,对不可剥夺资源的不合理分配,可能导致死锁。
死锁的处理策略
- 预防死锁。破坏死锁产生的四个必要条件中的一个或几个。
- 避免死锁。用某种方法防止系统进入不安全状态,从而避免死锁(银行家算法)。
- 死锁的检测和解除。允许死锁的发生,不过操作系统会负责检测出死锁的发生,然后采取某种措施解除死锁。
预防死锁
预防死锁是不允许死锁发生的静态策略。
破坏互斥条件
如果把只能互斥使用的资源改造为允许共享使用,则系统不会进入死锁状态。
比如:SPOOLing技术。操作系统可以采用SPoOLing技术把独占设备在逻辑上改造成共享设备。比如,用SPooLing技术将打印机改造为共享设备,将多个进程的请求合并为一个输出进程。
该策略的缺点:并不是所有的资源都可以改造成可共享使用的资源。并且为了系统安全,很多地方还必须保护这种互斥性。因此,很多时候都无法破坏互斥条件。
破坏不剥夺条件
- 当某个进程请求新的资源得不到满足时,它必须立即释放保持的所有资源,待以后需要时再重新申请。也就是说,即使某些资源尚未使用完,也需要主动释放,从而破坏了不可剥夺条件。
- 当某个进程需要的资源被其他进程所占有的时候,可以由操作系统协助,将想要的资源强行剥夺。这种方式一般需要考虑各进程的优先级(比如剥夺调度方式,就是将处理机资源强行剥夺给优先级更高的进程使用)。
该策略的缺点:
- 实现起来比较复杂。
- 释放已获得的资源可能造成前一阶段工作的失效。因此这种方法一般只适用于易保存和恢复状态的资源,如CPU。
- 反复地申请和释放资源会增加系统开销,降低系统吞吐量。
- 若采用方案一,意味着只要暂时得不到某个资源,之前获得的那些资源就都需要放弃,以后再重新申请。如果一直发生这样的情况,就会导致进程饥饿。
破坏请求和保持条件
可以采用静态分配方法,即进程在运行前一次申请完它所需要的全部资源,在它的资源未满足前,不让它投入运行。一旦投入运行后,这些资源就一直归它所有,该进程就不会再请求别的任何资源了。
该策略实现起来简单,但也有明显的缺点:
- 有些资源可能只需要用很短的时间,因此如果进程的整个运行期间都一直保持着所有资源,就会造成严重的资源浪费,资源利用率极低。
- 该策略也有可能导致某些进程饥饿。
破坏循环等待条件
可采用顺序资源分配法。首先给系统中的资源编号,规定每个进程必须按编号递增的顺序请求资源,同类资源(即编号相同的资源)一次申请完。
原理分析:一个进程只有已占有小编号的资源时,才有资格申请更大编号的资源。按此规则,已持有大编号资源的进程不可能逆向地回来申请小编号的资源,从而就不会产生循环等待的现象。
该策略的缺点:
- 不方便增加新的设备,因为可能需要重新分配所有的编号。
- 进程实际使用资源的顺序可能和编号递增顺序不一致,会导致资源浪费。
- 必须按规定次序申请资源,用户编程麻烦。
避免死锁
预防死锁是不允许死锁发生的动态策略。
安全序列与不安全状态
- 所谓安全序列,就是指如果系统按照这种序列分配资源,则每个进程都能顺利完成。只要能找出一个安全序列,系统就是安全状态。当然,安全序列可能有多个。
- 如果分配了资源之后,系统中找不出任何一个安全序列,系统就进入了不安全状态。这就意味着之后可能所有进程都无法顺利的执行下去。当然,如果有进程提前归还了一些资源,那系统也有可能重新回到安全状态,不过我们在分配资源之前总是要考虑到最坏的情况。
- 如果系统处于安全状态,就一定不会发生死锁。如果系统进入不安全状态,就可能发生死锁(处于不安全状态未必就是发生了死锁,但发生死锁时一定是在不安全状态)。
银行家算法
可以在资源分配之前预先判断这次分配是否会导致系统进入不安全状态,以此决定是否答应资源分配请求。这也是“银行家算法”的核心思想。
例题 系统中有5个进程P0到P4,3种资源ROR到2,初始数量为(10,5,7),某一时刻的情况可表示如下:
| 进程 | 最大需求 | 已分配 |
|---|---|---|
| P0 | (7,5,3) | (0,1,0) |
| P1 | (3,2,2) | (2,0,0) |
| P2 | (9,0,2) | (3,0,2) |
| P3 | (2,2,2) | (2,1,1) |
| P4 | (4,3,3) | (0,0,2) |
将已分配的部分全部加起来得到(7,2,5),还剩余资源数(3,3,2)。
将每个进程的最大需求减去已分配,会得到最多还需要的资源数量:
| 进程 | 最大需求 | 已分配 | 最多还需要 |
|---|---|---|---|
| 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)与各个进程的最多还需要值对比,如果剩余资源数每个资源值都大于该进程的最多还需要的资源值,就代表这个进程可以分配资源。
对比得到P1和P3可以分配。
其中P1分配完归还资源后可用资源为(3,3,2)+(1,2,2)=(5,3,2),然后使用该可用资源序列进行下一轮的分配,直到五次循环检查后五个进程都加入了安全序列中,就得到了一个最终的序列。该算法称为安全性算法。
若每一轮检查都从最小编号开始会更快得到安全序列。
同时还有一个更快的方法得到安全序列:将剩余资源数与最多还需要对比,满足条件的进程全部加入安全序列(而非一个个),然后把归还的资源相加,进行下一轮的比较。
使用代码实现:
假设系统中有n个进程,m种资源。
每个进程在运行前先声明对各种资源的最大需求数,则可用一个n*m的矩阵(可用二维数组实现)表示所有进程对各种资源的最大需求数。不妨称为最大需求矩阵Max,Max[i,j]=K表示进程Pi最多需要K个资源Rj。
同理,系统可以用一个n*m的分配矩阵Allocation表示对所有进程的资源分配情况。Max-Allocation=Need矩阵,表示各进程最多还需要多少各类资源。
另外,还要用一个长度为m的一维数组 Available表示当前系统中还有多少可用资源。
某进程Pi向系统申请资源,可用一个长度为m的一维数组Ri表示本次申请的各种资源量。
可用银行家算法预判本次分配是否会导致系统进入不安全状态:
- 如果Ri[j]≤Need[i,j](O≤j≤m)则转向步骤二,否则因为其需要的资源数已经大于最大值,认为出错。
- 如果Ri[j]≤Available[i](0≤j≤m),便转向步骤三看,否则表示尚无足够资源,Pi必须等待。
- 系统试探着把资源分配给进程Pi,并修改相应的数据(并非真的分配,修改数值只是为了做预判)。
Available = Available - Ri;Allocation[i,j] = Allocation[i,j] + Ri[j];Need[i,j] = Need[i,j] - Ri[j];
- 操作系统执行安全性算法,检查此次资源分配后,系统是否处于安全状态。若安全,才正式分配,否则,恢复相应数据,让进程阻塞等待。
检测解除死锁
允许死锁的产生。
检测死锁
为了能对系统是否已发生了死锁进行检测,必须:
- 某种数据结构来保存资源的请求和分配信息。
- 提供一种算法,利用上述信息来检测系统是否已进入死锁状态。
数据结构——资源分配图:
-
两种结点:
- 进程结点:对应一个进程。
- 资源结点:对应一类资源,一类资源可能有多个。
-
两种边:
- 进程结点——>资源结点(请求边):表示进程想申请几个资源(每条边代表一个)。
- 资源节点——>进程结点(分配边):表示已经为进程分配了几个资源(每条边代表一个)。
-
如果系统中剩余的可用资源数足够满足进程的需求,那么这个进程暂时是不会阻塞的,可以顺利地执行下去。
-
如果这个进程执行结束了把资源归还系统,就可能使某些正在等待资源的进程被激活,并顺利地执行下去。
-
相应的,这些被激活的进程执行完了之后又会归还一些资源,这样可能又会激活另外一些阻塞的进程。
-
如果按上述过程分析,最终能消除所有边,就称这个图是可完全简化的。此时一定没有发生死锁(相当于能找到一个安全序列)。
-
如果最终不能消除所有边,那么此时就是发生了死锁。
-
最终还连着边的那些进程就是处于死锁状态的进程。
总结上面的描述,所以检测死锁的算法:
- 在资源分配图中,找出既不阻塞又不是孤点的进程Pi(即找出一条有向边与它相连,且该有向边对应资源的申请数量小于等于系统中已有空闲资源数量。若所有的连接该进程的边均满足上述条件,则这个进程能继续运行直至完成,然后释放它所占有的所有资源)。消去它所有的请求边和分配变,使之称为孤立的结点。
- 进程Pi所释放的资源,可以唤醒某些因等待这些资源而阻塞的进程,原来的阻塞进程可能变为非阻塞进程。根据步骤一中的方法进行一系列简化后,若能消去途中所有的边,则称该图是可完全简化的。
- 死锁定理:若是不能完全简化,则系统死锁。
解除死锁
一旦检测出死锁的发生,就应该立即解除死锁。
并不是系统中所有的进程都是死锁状态,用死锁检测算法化简资源分配图后,还连着边的那些进程就是死锁进程。
解除死锁的主要方法有:
- 资源剥夺法:挂起(暂时放到外存上)某些死锁进程,并抢占它的资源,将这些资源分配给其他的死锁进程。但是应防止被挂起的进程长时间得不到资源而饥饿。
- 撤销进程法(或称终止进程法):强制撤销部分、甚至全部死锁进程,并剥夺这些进程的资源。这种方式的优点是实现简单,但所付出的代价可能会很大。因为有些进程可能已经运行了很长时间,已经接近结束了,一旦被终止可谓功亏一篑,以后还得从头再来。
- 进程回退法:让一个或多个死锁进程回退到足以避免死锁的地步。这就要求系统要记录进程的历史信息,设置还原点。
确定挂起或撤销的进程的指标:
- 进程优先级。
- 已执行多长时间。
- 还要多久能完成。
- 进程已经使用了多少资源
- 进程是交互式的还是批处理式的。