• 一. 操作系统概述1. 操作系统提供的服务2. 操作系统特征3. 操作系统的发展与分类4. 操作系统的运行机制5. 中断和异常5.1 内中断5.2 外中断5.3 中断分类5.4 中断机制的基本原理6. 系统调用7. 操作系统体系结构7.1 大内核与微内核7.2 其他结构体系8. 操作系统引导9. 虚拟机二. 进程管理1. 进程的状态与转换1.1 进程状态切换1.2 进程的组织1.3 进程的控制创建原语撤销原语阻塞原语唤醒原语切换原语2. 进程通信2.1 共享存储2.2 消息传递2.3 管道通信3. 线程3.1 线程的实现方式及多线程模型线程的实现方式多线程模型3.2 线程的状态与转换4. 调度4.1 进程调度的时机、切换与过程、方式进程调度时机进程调度的方式进程的切换与过程4.2 调度器和闲逛进程4.3 调度算法评价指标4.4 调度算法先来先服务(FCFS)短作业优先(SJF)高响应比优先(HRRN)时间片轮转(RR)优先级调度算法多级反馈队列5.进程同步和进程互斥5.1 进程互斥软件实现单标志法双标志先检查法双标志后检查法Peterson算法5.2 进程互斥的硬件实现中断屏蔽法TestAndSet指令Swap指令5.3 互斥锁5.4 信号量机制整型信号量记录型信号量5.5 信号量机制实现进程同步和互斥信号量机制实现进程互斥信号量机制实现进程同步5.6 经典进程同步互斥问题生产者消费者问题多生产者多消费者问题吸烟者问题读者写者问题哲学家进餐问题6. 管程6.1 管程解决生产者消费者问题6.2 Java中类似的管程机制7. 死锁7.1 死锁的处理策略:预防死锁7.2 死锁的处理策略:避免死锁7.3 死锁的处理策略:检测和解除

    一. 操作系统概述

    计算机层次结构:裸机(指纯硬件,包含CPU、内存、硬盘和主板等)、操作系统、应用程序和用户。结构依次往上。

    计算机系统层次结构

    操作系统(Operating System,OS) 是指控制和管理整个计算机系统的硬件和软件资源,并合理地组织调度计算机的工作和资源的分配;以提供给用户和其他软件方便的接口和环境;它是计算机系统中最基本的系统软件。

    从上面定义可以得到操作系统以下特点:

    1. 操作系统是系统资源的管理者。这里的系统资源包含硬件和软件资源。
    2. 向上层提供方便易用的服务。上层即用户和应用程序。
    3. 是最接近硬件的一层软件。

    1. 操作系统提供的服务

    这一门课主要学习的是操作系统是系统资源的管理者,即操作系统会提供:处理机(CPU)管理、存储器管理、文件管理和设备管理。

    向上层提供方便易用的服务主要是用户和应用程序不需要直接对硬件进行繁琐复杂的操作,而是通过操作系统简化这些操作。这其实体现了封装思想,即操作系统把一些丑陋的硬件功能封装成简单易用的服务,使用户能更方便地使用计算机,用户无需关心底层硬件的原理,只需要对操作,系统发出命令即可。操作系统提供的易用服务有:

    这里的命令接口和程序接口也可以统称为用户接口。

    计算机系统层次结构

    所以上图用户和应用程序与操作系统之间的有接口,用户通过命令接口可以直接和操作系统进行交互。应用程序可以通过程序接口与操作系统进行交互。

    最后操作系统作为最接近硬件的层次需要实现对硬件机器的拓展。如果一个计算机中没有任何软件支持的计算机成为裸机。在裸机上安装的操作系统,可以提供资源管理功能和方便用户的服务功能,将裸机改造成功能更强、使用更方便的机器。通常把覆盖了软件的机器称为扩充机器,又称之为虚拟机。

    操作系统对硬件机器的拓展是将CPU、 内存、磁盘、显示器、键盘等硬件合理地组织起来,让各种硬件能够相互协调配合,实现更多更复杂的功能。

    2. 操作系统特征

    操作系统有并发、共享、虚拟和异步四个特征。其中共享和并发是两个最基本的特征,二者互为存在条件。

    并发和共享之间的关系:并发性指计算机系统中同时存在着多个运行着的程序。共享性是指系统中的资源可供内存中多个并发执行的进程共同使用。

    还是通过上面发送文件例子来解释并发与共享的关系。使用QQ发送文件A,同时使用微信发送文件B

    1. 两个进程正在并发执行(并发性)

      如果失去并发性,则系统中只有一个程序正在运行,则共享性失去存在的意义。

    2. 需要共享地访问硬盘资源(共享性)

      如果失去共享性,则QQ和微信不能同时访问硬盘资源,就无法实现同时发送文件,也就无法并发。

    所以并发性和共享性是互为存在的关系。

    显然,如果失去了并发性,则一个时间段内系统中只需运行一道程序,那么就失去了实现虚拟性的意义了。因此,没有并发性,就谈不上虚拟性。

    如果失去了并发性,即系统只能串行地运行各个程序,那么每个程序的执行会一贯到底。只有系统拥有并发性,才有可能导致异步性。

    总结:并发和共享互为存在条件。没有并发和共享,就谈不上虚拟和异步,因此并发和共享是操作系统的两个最基本的特征。

    3. 操作系统的发展与分类

    主要讲述操作系统在各个阶段的主要解决的问题及各自的优缺点。

    4. 操作系统的运行机制

    由程序员编写的程序是应用程序。而操作系统内核是由一个一个内核程序组成的,所以操作系统内核简称内核。内核是操作系统最核心的部分,也是最接近硬件的部分。甚至可以说,一个操作系统只需要内核就足够了,如Docker技术就仅需要Linux内核。而操作系统的功能也未必都在内核中,如GUI。

    操作系统内核作为"管理者",有时会让CPU执行一些特权指令,如:内存清零指令。这些指令影响重大,只允许"管理者",即操作系统内核来使用。

    普通的应用程序只能使用"非特权指令",如:加法指令、减法指令等。

    在CPU设计和生产的时候就划分了特权指令和非特权指令,因此CPU执行一条指令前就能判断出其类型。但CPU无法区分特权指令是应用程序的指令还是内核程序的指令。为了能让CPU区分CPU会分为两种状态:内核态(管态或核心态)和用户态(目态)。

    CPU中有一个寄存器叫程序状态字寄存器(PSW),其中有个二进制位,1表示内核态,0表示用户态。

    内核态与用户态的切换:

    1. 刚开始时,CPU为内核态,操作系统内核程序先在CPU上运行。
    2. 开机完成后,如果操作系统上运行应用程序,此时内核程序会执行一条特权指令,这个特权指令会把PSW标志位从内核态转换为用户态。这样就完成了CPU状态切换
    3. 接着操作系统内核会让出CPU使用权,让应用程序在CPU上运行。
    4. 假如当前应用程序中有一条特权指令,CPU可以识别出这个特权指令,但此时CPU处于用户态。所以会引发一个中断信号。
    5. 处于用户态的CPU会接受中断信号,之后会立即变为核心态,并停止运行当前应用程序,转而运行处理中断信号的内核程序。
    6. 这个中断使操作系统再次夺回CPU控制权。
    7. 之后操作系统会对引发中断的事件进行处理,处理完了再把CPU使用权交给别的应用程序。

    所以内核态转换为用户态:执行一条特权指令,即修改PSW的标志位为"用户态",这个动作意味着操作系统将主动让出CPU使用权

    而用户态转换为内核态:由"中断"引发,硬件自动完成变态过程,触发中断信号意味着操作系统将强行夺回CPU的使用权

    除了非法使用特权指令之外,还有很多事件会触发中断信号。一个共性是,但凡需要操作系统介入的地方,都会触发中断信号

    5. 中断和异常

    CPU上会运行两种程序,一种是操作系统内核程序,一种是应用程序。内核是整个系统管理者,在合适的情况下,操作系统内核会把CPU的使用权主动让给应用程序,这个应用程序运行过程中会发生中断,中断是让操作系统内核夺回CPU使用权的唯一途径。结合上一节内容这里不难理解中断重要性。

    中断作用:让操作系统内核强行夺回CPU的控制权。使CPU从用户态变为内核态。

    中断分为两种类型:内中断和外中断。

    5.1 内中断

    内中断产生和当前执行的指令有关,中断的信号来源于CPU内部。

    如上面提到的例子,一个应用程序应用在用户态,假如这个应用程序有一个特权指令,CPU执行这条特权指令时发现此时正在处于用户态。于是这个非法的事件会触发一个中断信号,CPU会拒绝执行这条指令。接着CPU会自动切换到内核态开始处理中断信号相关的内核程序。

    内中断例子

    有时候即使用户执行的是非特权指令也会发生中断。如执行除法指令时发现除数为0

    另一个例子,一个应用程序运行在用户态,有时候应用程序想请求操作系统内核的服务,此时会执行一条特殊的指令,即陷入指令,该指令会引发一个内部中断信号。接着CPU会转向处理中断信号的内核程序。所以可以看出当一个应用程序执行陷入指令时,就意味着这个应用程序主动把CPU使用权还给操作系统内核,想让操作系统内核为其提供一些服务。之前提到的系统调用就是通过陷入指令完成的。

    需要强调的是陷入指令是一个特殊指令,不是特权指令。

    5.2 外中断

    外中断产生和当前执行的指令无关,中断信号来源于CPU外部。

    典型的例子是时钟中断,即由硬件时钟部件发送来的中断信号。这个部件会每隔一段时间给CPU发送一个时钟中断信号,通过时钟中断信号就可以实现多道程序并发运行了。

    外中断例子

    假设此时系统当中想要并发运行两个应用程序。而时钟部件每个50ms给CPU发送一个时钟中断信号。

    除了由时钟部件发出的中断信号,有时候也会由I/O设备发出中断信号。根据之前学习的计算机组成原理可以直到CPU在每个执行周期末尾都会检查有没有外部中断信号。

    5.3 中断分类

    有的教材会称内中断为异常或例外。外中断叫中断

    异常可以分为三类:陷阱(陷入)、故障、终止

    1. 陷阱

      由陷入指令引发,是应用程序故意引发的,即当一个应用程序想要操作系统提供服务时候,就会故意引发这个异常。其实这也是系统调用的原理。

    2. 故障

      是一种由错误条件引起的,可能被内核程序修复。内核程序修复故障后会把CPU使用权还给应用程序,让它继续执行下去。 如:整数除0、缺页故障。

    3. 终止

      由致命错误引起,内核程序无法修复该错误,因此一般不再将CPU使用权还给引发终止的应用程序,而是直接终止该应用程序。如:非法使用特权指令。

    5.4 中断机制的基本原理

    不同的中断信号,需要用不同的中断处理程序来处理。当CPU检测到中断信号后,会根据中断信号的类型去查询"中断向量表",以此来找到相应的中断处理程序在内存中的存放位置。

    中断向量表

    显然这里的中断处理程序就是一种内核程序,需要运行在内核态。

    总结:

    中断总结

    6. 系统调用

    操作系统作为用户和计算机硬件之间的接口,需要向上提供一些简单易用的服务。主要包括命令接口和程序接口。其中,程序接口由一组系统调用组成。

    所以"系统调用"是操作系统提供给应用程序(程序员/编程人员)使用的接口,可以理解为一种可供应用程序调用的特殊函数,应用程序可以通过系统调用来请求获得操作系统内核的服务。

    这个平时编程时使用的函数调用其实是很类似的。但还是有区别,系统调用是比高级语言提供的库函数更为底层的一个接口。

    系统调用和库函数区别

    另外一些库函数不涉及系统调用,如取绝对值函数。设计系统调用有创建一个新文件函数等。

    有一种情况,当打印时,如果两个进程可以随意地、并发地共享打印机资源这样会得到错误的打印内容。所以系统调用是必须的。即解决方法是由操作系统内核对共享资源进行统一的管理, 并向上提供"系统调用",用户进程想要使用打印机这种共享资源,只能通过系统调用向操作系统内核发出请求。内核会对各个请求进行协调处理。

    系统调用分类:

    系统调用分类

    应用程序通过系统调用请求操作系统的服务。而系统中的各种共享资源都由操作系统内核统一掌管, 因此凡是与共享资源有关的操作(如存储分配、I/O操作、文件管理等),都必须通过系统调用的方式向操作系统内核提出服务请求,由操作系统内核代为完成。这样可以保证系统的稳定性和安全性,防止用户进行非法操作。

    假设一个应用程序想要进行系统调用,这个调用过程如下:

    总的来说系统调用过程是:传递系统调用参数执行陷入指令/trap指令/访管指令(用户态)执行相应的内请求核程序处理系统调用(核心态)返回应用程序

    系统调用过程5

    注意:

    1. 陷入指令是在用户态执行的,执行陷入指令之后立即引发一个内中断,使CPU进入核心态
    2. 发出系统调用请求是在用户态,而对系统调用的相应处理在核心态下进行。

    7. 操作系统体系结构

    经过之前的学习计算机系统结构如下:

    计算机系统层次结构

    操作系统的内部可以进一步进行划分:一部分是内核功能(如时钟管理,中断处理),另一部分是非内核功能(如GUI)。

    操作系统内核

    这里的时钟管理就是利用时钟中断实现的计时功能。原语是一种特殊的程序,具有原子性。也就是说,这段程序的运行必须一气呵成,不可被"中断"。Ubuntu、CentOS 的开发团队,其主要工作是实现非内核功能,而内核都是用了Linux内核。

    7.1 大内核与微内核

    总之内核是操作系统最基本、最核心的部分。实现操作系统内核功能的那些程序就是内核程序。还有一种划分方式由于对进程管理、存储器管理、设备管理等功能属于对数据结构的操作,不会直接涉及硬件,所以这些不属于内核。按照这样思路划分如下:

    计算机系统层次结构划分

    上面这种划分方式会对系统性能造成一定的影响。

    操作系统体系结构

    假设现在有个应用程序想要请求操作系统的服务,这个服务的处理同时涉及到进程管理、存储管理、设备管理。

    因此采用大内核结构只需要两次变态即可。如果采用微内核体系结构整个过程就需要六次变态。而需要注意的是变态的过程是有成本的,要消耗不少时间,频繁地变态会降低系统性能。

    操作系统体系结构总结:

    操作系统体系结构总结

    7.2 其他结构体系

    操作系统体系结构还有分层结构、模块化结构和外核结构。

    8. 操作系统引导

    当新硬盘安装好操作系统后,磁盘分区如下:

    磁盘分区表

    可以看到磁盘开头会留出一片区域,用于存储主引导记录(MBR)。其中包含两个重要部分,一部分是磁盘引导程序,另一部分是分区表。

    分区表其实就是一个数据结构,这个数据结构说明了磁盘每个分区分别占多大空间,以及每个分区的地址范围。这些分区里最重要的是C盘。C盘是磁盘活动分区,其中安装了操作系统。C盘内部结构如下:

    C盘内部结构

    可以看到除了根目录还有一个引导记录(PBR),这个引导记录负责找到启动管理器。

    启动过程是主板上的BIOS自举程序(本质上属于主存),会将磁盘的主引导记录读入内存。主引导记录包含磁盘引导程序,之后主存会执行磁盘引导程序,这个引导程序会根据分区表找到C盘所处位置。接着读入C盘中的引导记录,这个引导记录本质上也是一个程序,之后CPU执行引导记录程序知道启动管理器。这个启动管理器本质上也是一个程序,存放在C盘根目录下的某个位置。在根目录找到启动管理器后会放入主存,最后CPU再执行启动管理程序,完成一系列的初始化工作。

    操作系统引导开机总结:

    1. CPU从一个特定主存地址开始,取指令,执行ROM中的引导程序(先进行硬件自检,再开机)
    2. 将磁盘的第一块主引导记录读入内存,执行磁盘引导程序,扫描分区表
    3. 从活动分区( 又称主分区,即安装了操作系统的分区)读入分区引导记录,执行其中的程序
    4. 从根目录下找到完整的操作系统初始化程序(即启动管理器)并执行,完成"开机"的一系列动作

    操作系统引导开机过程

    9. 虚拟机

    虚拟机:使用虚拟化技术,将一台物理机器虚拟化为多台虚拟机器(Virtual Machine, VM),每个虚拟机器都可以独立运行一个操作系统。

    同义术语:虚拟机管理程序/虚拟机监控程序/NVirtual Machine Monitor/Hypervisor

    虚拟机管理程序分为两类:直接运行在硬件上、运行在宿主操作系统上。

    由于两个操作系统实现方式各不相同,会造成一些特性上的差异。

    两类虚拟机对比

    上面提到指令的特权级,实际上在CPU中特权指令是有分级的

    指令分级

    上图Ring3最低权限的指令,而Ring0是最高权限的指令。这样划分为更多级别是有好处的,如第一类VMM当上层操作系统使用低一级别的特权指令虚拟机管理程序不需要做中断处理。除非要使用少数最高权限的指令,虚拟机管理程序才会介入。

    二. 进程管理

    要了解进程之前先看看程序的概念:程序是静态的,就是个存放在磁盘里的可执行文件,就是一系列的指令集合。

    进程:进程是动态的,是程序的一次执行过程。而同一个程序多次执行会对应多个进程。

    当这些进程被创建的时候,操作系统会给该进程分配一个分配一个唯一的、不重复的"身份证号"PID (Process ID,进程ID)。而操作系统不止要记录这个程序对应的进程PID,进程所属用户(UID)等;还要为了实现操作系统对资源的管理,要记录给进程分配了哪些资源(如:分配了多少内存、正在使用哪些I/O设备、正在使用哪些文件)以及为了实现实现操作系统对进程的控制、调度,还要记录进程的运行情况(如:CPU使用时间、磁盘使用情况、网络流量使用情况等)。

    这些信息都被保存在一个数据结构PCB ( Process Control Block)中,即进程控制块。操作系统需要对各个并发运行的进程进行管理,但凡管理时所需要的信息,都会被放在PCB中。

    所以操作系统的进程控制块很重要,因为PCB是进程存在的唯一标志,当进程被创建时,操作系统为其创建PCB,当进程结束时,会回收其PCB。PCB要保存内容如下:

    PCB

    除了PCB之外进程还有两个很重要组成部分:程序段和数据段。

    进程的组成

    PCB是给操作系统用的。而程序段、数据段是给进程自己用的。

    当系统执行可执行文件(如exe)时,会先将这个可执行文件中保存的指令序列读入内存中,并且操作系统会建立一个与之相对应的进程,也就是要创建相对应的PCB。除了PCB这个可执行程序的一些列指令序列也要读入内存当中。而这一系列的指令序列被称为程序段。执行过程就是CPU从程序段中一条一条读入指令并执行。可执行程序中处理有指令之外,还有可能存在数据(如变量),这些变量的内容也需要放到内存当中,存放数据的区域就叫做数据段。

    程序运行

    所以一个进程实体(进程映像)由PCB、程序段、数据段组成。进程是动态的,而进程实体(进程映像)是静态的。可以把进程实体理解为进程在动态执行过程当中某一时刻的快照。进程实体能够反映其在某一个时刻的状态(如:x++后,x=2)。所以更准确的说应该是进程实体是由PCB、程序段和数据段组成。

    引入进程实体的概念后,可把进程定义为:进程是进程实体的运行过程,是系统进行资源分配和调度的一个独立单位。

    上面的调度就是指操作系统决定让哪个进程上CPU运行。假如同时挂三个QQ号,会对应三个QQ进程,它们的PCB、数据段各不相同,但程序段的内容都是相同的(都是运行着相同的QQ程序)

    程序是静态的,进程是动态的,相比于程序,进程拥有以下特征:

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

    1. 进程的状态与转换

    进程的状态有:创建状态、就绪状态、运行状态、阻塞状态、终止状态

    1.1 进程状态切换

    综上所述,如果一个进程正在被创建,此时就会处于创建态。当进程被创建后就具备让CPU执行的条件,这个时候进程就进入就绪态。如果处于就绪态的进程被操作系统调度,那这个进程就可以在CPU上运行,当进程在CPU上运行时,就处于运行态。而有的时候正在运行的进程会请求等待某些事件的发生,在这个事件发生之前,这个进程是没法继续执行的,这种情况下该进程会进入阻塞态。如果处于阻塞态的进程等待的事件发生了,这个进程就可以重新回到就绪态。而当进程进程运行结束,或运行过程中遇到不可修复的错误,就会处于终止态。

    进程状态的转换

    运行态到阻塞态的转换是一种进程自身做出的主动行为。而阻塞态到就绪态的转换并不是进程自身能够控制的,是一种被动行为。

    注意:不能由阻塞态直接转换为运行态,也不能由就绪态直接转换为阻塞态(因为进入阻塞态是进程主动请求的,必然需要进程在运行时才能发出这种请求)。

    有的时候进程可以直接从运行态转换为就绪态。如操作系统给进程分配的时间片用完,进程就会从运行态转化就绪态。

    进程的整个生命周期中,大部分时间都处于运行态、就绪态、阻塞态三种基本状态。单核CPU情况下,同一时刻只会有一个进程处于运行态,多核CPU情况下,可能有多个进程处于运行态。

    操作系统记录这些状态方法是在进程PCB中,会有一个变量state来表示进程的当前状态。如:1表示创建态、2表示就绪态、3表示运行态。另外为了对同一个状态下的各个进程进行统一的管理, 操作系统会将各个进程的PCB组织起来。

    1.2 进程的组织

    PCB组织也称进程组织,其组织方式有两种:链接方式和索引方式。

    大部分操作系统使用链式方式。

    1.3 进程的控制

    进程控制的主要功能是对系统中的所有进程实施有效的管理,它具有创建新进程、撤销已有进程、实现进程状态转换等功能。

    进程控制简单理解就是要实现进程状态转换。进程控制实现需要用到"原语"。原语是一种特殊的程序,它的执行具有原子性。也就是说,这段程序的运行必须一气呵成,不可中断。

    假设PCB中的变量state表示进程当前所处状态,1表示就绪态,2表示阻塞态。如果一个进程处于就绪态state=1,那么这个进程的PCB需要挂在就绪队列中。而如果state=2,这个进程就应该被挂在阻塞队列中。

    进程控制实现

    假设当前进程处于阻塞,而等待事件发生,则操作系统中,就需要将该进程从阻塞态转换为就绪态。负责进程控制的内核程序至少需要做这样两件事才能完成转换:

    1. 将PCB的state值改为1
    2. 将PCB从阻塞队列放到就绪队列

    上面两步转换需要有原子性。假如现在将一个处于阻塞进程PCB2的PCB的state值修改为1,之后CPU突然检测到一个中断信号,需要对中断进行处理。而这个时候,PCB2的state值是1代表就绪态,但此时所处的队列仍是阻塞队列,这就导致state变量和所处队列产生了不一致问题。所以这个转换步骤不能一气呵成就会出现这种问题。

    上面提到过原语具有原子性的特征,其可以使用关中断和开中断这两个特权指令实现原子性。CPU执行了关中断指令之后,就不再例行检查中断信号,直到执行开中断指令之后才会恢复检查。这样,关中断与开中断之间的这些指令序列就是不可被中断信号中断的,从而就实现了"原子性"。

    原子性

    所以进程状态转换必须要一气呵成。可以用原语实现,原语的实现又需要开中断和关中断指令来配合完成。

    创建原语

    如果一个进程需要创建就需要用到创建原语。过程是要先申请一个空白的PCB,另外再给新进程分配所需资源(如内存空间等)。之后对PCB内容进行初始化工作。最后将PCB插入到就绪队列。

    引起创建原语事件有:

    1. 用户登录

      分时系统中,用户登录成功,系统会建立为其建立一个新的进程

    2. 作业调度

      多道批处理系统中,有新的作业放入内存时,会为其建立一个新的进程。这里的作业指的是此时还放在外存中还没有投入运行的程序。

    3. 提供服务

      用户向操作系统提出某些请求时,会新建一个进程处理该请求

    4. 应用请求

      由用户进程主动请求创建一个子进程

    创建原语

    撤销原语

    终止一个进程时使用。使用撤销原语会让进程从某一个状态转换为终止态,最终进程从系统中消失。

    撤销过程是:

    1. 从PCB集合中找到终止进程的PRCB
    2. 若进程正在运行,立即剥夺CPU,将CPU分配给其他进程
    3. 同时会终止其所有子进程。进程间的关系是树形结构所以有父进程和子进程。
    4. 将该进程拥有的所有资源归还给父进程或操作系统
    5. 删除该进程的PCB

    引起进程终止事件:

    1. 正常结束

      一进程自己请求终止 (exit系统调用)

    2. 异常结束 整数除以0、非法使用特权指令,然后被操作系统强行杀掉

    3. 外界干预 Ctrl+Alt+delete,用户选择杀掉进程

    撤销原语

    阻塞原语

    有的时候一个进程会从运行态转换为阻塞态。这情况下操作系统会执行阻塞原语实现这个切换。

    阻塞过程:

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

    引起进程阻塞事件:

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

    唤醒原语

    如果一个阻塞进程等待的事件发生后,操作系统会让这个阻塞进程从阻塞态转换为就绪态。

    唤醒过程:

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

    引起唤醒事件是阻塞进程等待的事件发生。要注意的是一个进程因何事阻塞,就应由何事唤醒。

    所以阻塞原语和唤醒原语必须成对使用。

    阻塞原语和唤醒原语

    切换原语

    会让处于运行态的进程切换为就绪态,接着再让让处于就绪态的原语切换为运行态。所以会改变两个进程状态。

    切换执行过程:

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

    这里运行环境是指当程序在运行时会将数据保存在寄存器中,此时如果另一个程序用到寄存器则会覆盖原来程序存在寄存器中的数据。所以解决办法是在进程切换时先在PCB中保存这个进程的运行环境(只保存一些必要的寄存器信息)。当原来的进程再次投入运行时,可以通过PCB恢复它的运行环境。

    总之运行环境就是进程运行过程中寄存器存放的数据。当一个进程下处理机时需要把该线程的运行环境存入PCB中,而当一个进程需要重新回到处理机运行时,就可以从PCB中恢复之间的运行环境。所以保存进程的运行环境和恢复进程的运行环境是实现进程并发执行很关键的技术。

    引起进程切换事件:

    1. 当前进程时间片到.
    2. 有更高优先级的进程到达
    3. 当前进程主动阻塞
    4. 当前进程终止

    切换原语

    无论哪个进程控制原语,要做的无非三类事情:

    1. 更新PCB中的信息。主要是修改进程状态(state)或者是往PCB中保存/恢复运行环境
    2. 将PCB插入合适的队列
    3. 进程创建和终止时,还要分配/回收资源

    2. 进程通信

    进程间通信(Inter-Process Communication,IPC)是指两个进程之间产生数据交互。

    进程之间的通信需要操作系统内核的支持。原因如下:

    进程是分配系统资源的单位(包括内存地址空间),因此各进程拥有的内存地址空间相互独立。

    进程通信

    如上图进程P不能访问进程Q地址空间。这样的规定是处于安全考虑,因此两个进程之间的通信需要操作系统的支持。

    通信方式有:共享存储、消息传递和管道通信。

    2.1 共享存储

    各个进程虽然只能访问自己的存储空间。但是如果操作系统支持共享存储方式,那么一个进程可以申请一片共享存储区。

    共享存储

    上图进程P与进程Q可以通过共享存储区进行通信。Linux实现共享内存方式如下:

    可以通过"增加页表项/段表项"即可将同一片共享内存区映射到各个进程的地址空间中(第三章内容)

    为避免出错,各个进程对共享空间的访问应该是互斥的。即共享存储区一次只能供一个进程进行访问。各个进程可使用操作系统内核提供的同步互斥工具(如P、V操作)。

    上面共享存储方式是基于存储区的共享:操作系统在内存中划出一块共享存储区,数据的形式、存放位置都由通信进程控制,而不是操作系统。这种共享方式速度很快,是一种高级通信方式。

    还有一种存储共享方式是基于数据结构的共享:比如共享空间里只能放一个长度为10的数组。这种共享方式速度慢、限制多,是一种低级通信方式。

    2.2 消息传递

    进程间的数据交换以格式化的消息(Message) 为单位。进程通过操作系统提供的"发送消息/接收消息"两个原语进行数据交换。

    格式化的消息由两个部分组成:消息头+消息体。其中消息头包括:发送进程ID、接受进程ID、消息长度等格式化的信息。

    消息头

    这种消息传递方式又可以详细划分为:直接通信方式和间接通信方式。

    2.3 管道通信

    可以从管道一端写入数据,另一端读数据。这个管道的数据流向只能是单向的。

    管道通信示意图

    站在操作系统的层面,这里提到的管道是一个特殊的共享文件,又名pipe文件。其实就是在内存中开辟一个大小固定的内存缓冲区。

    如果两个进程之间要用管道通信进程进程通信,首先需要通过某个进程进行系统调用的方式来申请一个管道文件。操作系统会新建这个管道文件,其实就是在内存中开辟了一个大小固定的内存缓冲区。然后两个进程可以往这个管道中写数据或者读数据。数据的读写具有先进先出(FIFO)特性。

    这种方式与共享传递区别在于在共享传递中,两个进程开辟的共享空间可以在共享空间的内部任意位置写入或读出数据,没有任何限制。但是管道通信的方式其本质是一个循环队列,一端写入数据后另一端读出数据必须从队头开始读取,写入数据也必须从队头开始依次往后添加。

    管道通信特点:

    1. 管道只能采用半双工通信,某一时间段内只能实现单向的传输。如果要实现双向同时通信,则需要设置两个管道。

      全双工通信

    2. 各进程要互斥的访问管道(由操作系统实现)

    3. 当管道写满时,写进程将阻塞,直到读进程将管道中的数据取走,即可唤醒写进程。

    4. 当管道读空时,读进程将阻塞,直到写进程往管道中写入数据,即可唤醒读进程。

    5. 管道中的数据一旦被读出,就彻底消失。因此,当多个进程读同一个管道时,可能会错乱。对此,通常有两种解决方案:

      ①一个管道允许多个写进程,一个读进程(2014年408真题高教社官方答案) ; ②允许有多个写进程,多个读进程,但系统会让各个读进程轮流从管道中读数据(Linux的方案)。

    写进程往管道写数据,即便管道没被写满,只要管道没空,读进程就可以从管道读数据

    读进程从管道读数据,即便管道没被读空,只要管道没满,写进程就可以往管道写数据

    进程通信总结

    进程通信总结

    3. 线程

    操作系统在引入了进程之后可以实现多个程序并发运行。进程是程序的一次执行。但如QQ视频、文字聊天、文件传送等功能显然不可能是由一个程序顺序处理就能实现的。所以引入线程,来增加并发度。

    在传统的进程机制当中,CPU会轮流为各个进程进行服务,那么这些进程就可以并发进行。所以在传统的进程机制当中,进程是程序执行流的最小单位。

    传统进程机制

    之后为了满足这种"同时"运行很多程序,又引入线程机制,用来增加系统的并发度。引入了线程机制后,CPU的服务对象不再是进程而是进程当中的一个一个线程。每个进程当中可能包含多个线程,CPU经过算法处理会轮流为这些线程进行服务。这样同一个进程被分为多个线程,像QQ视频和文件传送这两个事情,如果想要并发执行,就可以把这两件事情对应的处理程序放到两个不同的线程下,这两个不容的线程可以并发地执行,自然这两件事也可以并发完成。

    线程

    所以再引入线程机制之后,线程就成了程序执行流的最小单位。在没有引入线程之前,一个进程就对应一份代码,这些代码只能顺序地依次往下执行。但是在引入线程之后,每一个进程可以有多个线程,并且这些线程可以有各自功能,这些功能可以相同,并且都会被CPU并发处理掉。所以线程可以理解为一种轻量级进程。

    引入线程

    总之线程是一个基本的CPU执行单元,也是程序执行流的最小单位。在引入线程之后,不仅是进程之,间可以并发,进程内的各线程之间也可以并发,从而进一步提升了系统的并发度,使得一个进程内也可以并发处理各种任务(如QQ视频、文字聊天、传文件)。

    引入线程后,进程只作为除CPU之外的系统资源的分配单元(如打印机、内存地址空间等都是分配给进程的而不是线程)。

    外部设备分配给进程

    引入线程后带来的变化:

    线程属性如下:

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

    3.1 线程的实现方式及多线程模型

    线程的实现方式分为:用户级线程和内核级线程。

    多线程模型:一对一模型、多对一模型、多对多模型。

    线程的实现方式

    多线程模型

    在支持内核级线程的操作系统中再引入线程库,就可以实现把若干个用户级线程映射到某一个内核级线程。那么根据用户级线程和内核级线程的映射关系,可以划分为几种多线程模型:

    再区分一下用户级线程和内核级线程:用户级线程是"代码逻辑"的载体。内核级线程是"运行机会"的载体,因此操作系统在分配处理机资源时,是以内核级线程为单位的。所以一段"代码逻辑"只有获得了"运行机会"才能被CPU执行。这种情况下内核级线程才是处理机分配的单位。例如:多核CPU环境下,上图这个进程最多能被分配两个核心。

    内核级线程中可以运行任意一个有映射关系的用户级线程代码,只有两个内核级线程中正在运行的代码逻辑都阻塞时,这个进程才会阻塞

    3.2 线程的状态与转换

    线程的状态与转换与进程的几乎一样。

    线程的状态转换最核心的是就绪态、运行态和阻塞态之间的转换。它们之间的转换和进程之间的转换完全一致。

    线程的状态与转换

    而线程的组织与控制也与进程相似。对于进程来说,每个进程会建立一个与之对应的PCB模块,而进程也是一样,会建立TCB(线程控制模块)。每个TCB中包含的内容有:

    TCB

    有了TCB之后每一个TCB就可以表示一个线程,多个线程的TCB组织起来就是一个线程表。组织方式根据不同系统会有不同的策略。

    线程表

    4. 调度

    调度是指当有一堆任务要处理,但由于资源有限,这些事情没法同时处理。这就需要确定某种规则来决定处理这些任务的顺序,这就是"调度"研究的问题。

    程序发生调度情况,情况可以分为三个层次:高级调度(作业调度)、中级调度(内存调度)、低级调度(进程调度)。

    三层调度的联系、对比:

    三层调度的联系和对比

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

    进程调度(低级调度),就是按照某种算法从就绪队列中选择一个进程为其分配处理机。

    进程调度时机

    需要进行进程调度与切换的情况:

    而进程调度也不是随时都可以进行的,一下情况不能进行调度:

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

    补充:进程在操作系统内核程序临界区不能进行调度与切换这是正确的表述。进程处于临界区不能进行处理机调度是错误的表述。

    先来简单看一下临界资源:一个时间段内只允许一个进程使用的资源。各进程需要互斥地访问临界资源。所以内核程序临界区和临界区含义如下:

    当一个进程此时处于内核程序临界区,并且临界区是要访问就绪队列的情况下,在访问之前会将就绪队列上锁。如果进程还没退出临界区 (还没解锁)就进行进程调度,但是进程调度相关的程序也需要访问就绪队列,但此时就绪队列被锁住了,因此又无法顺利进行进程调度。所以内核程序临界区访问的临界资源(种内核数据结构)如果不尽快释放的话,极有可能影响到操作系统内核的其他管理工作。因此在访问内核程序临界区期间不能进行调度与切换。

    另一种情况当一个进程要访问打印机(外部设备),在打印机打印完成之前,进程一直处于临界区内,临界资源不会解锁。但打印机又是慢速设备,此时如果一直不允许进程调度的话就会导致CPU一直空闲。所以进程在访问普通临界区访问的临界资源不会直接影响操作系统内核的管理工作。因此在访问普通临界区时可以进行调度与切换。

    进程调度的方式

    有的系统中,进程可以主动放弃处理机,当有更紧急的任务需要处理时,也会强行剥夺处理机(被动放弃)。所以根据当前运行的进程是否可以被强行剥夺处理及资源这个问题可以引出进程调度方式。

    进程调度方式可以分为两种:

    进程的切换与过程

    既然选择一个进程要为其分配处理机,进程与进程切换的过程中会发生以下情况。

    首先看看"狭义的进程调度"与"进程切换"的区别:

    1. 狭义的进程调度指的是从就绪队列中选中一个要运行的进程。(这个进程可以是刚刚被暂停执行的进程,也可能是另一个进程,后一种情况就需要进程切换)
    2. 进程切换是指一个进程让出处理机,由另一个进程占用处理机的过程。

    广义的进程调度包含了选择一个进程和进程切换两个步骤。

    而进程切换过程主要是以下两步骤:

    1. 对原来运行进程各种数据(运行环境)的保存。将环境保存到PCB中。

    2. 对新的进程各种数据(运行环境)的恢复。从进程PCB中读出环境数据。

      如:程序计数器、程序状态字、各种数据寄存器等处理机现场信息,这些信息一般保存在PCB中

    注意:进程切换是有代价的,因此如果过于频繁的进行进程调度、切换,必然会使整个系统的效率降低,使系统大部分时间都花在了进程切换上,而真正用于执行进程的时间减少。

    总结:

    进程调度总结

    4.2 调度器和闲逛进程

    调度程序是操作系统内核重要程序模块。一个程序会在就绪、运行和阻塞三个状态切换。

    调度器

    上面②③之间状态的转换就是由调度程序决定的。操作系统要决定下面两件事情:

    1. 让谁运行?

      具体看调度算法

    2. 运行多长时间?

      具体看时间片大小

    什么事件会触发调度程序(调度时机):

    上面的是进程,如果一个操作系统支持的不仅是进程,还支持线程,那么调度程序调度对象就是线程。

    程序调度

    闲逛进程:如果就绪队列中没有其他就绪进程时,调度程序就会选中闲逛进程上处理机运行。

    闲逛进程的特性:

    1. 优先级最低
    2. 可以是0地址指令,占一个完整的指令周期(指令周期末尾例行检查中断)
    3. 能耗低

    末尾理性检查中断会周期性唤醒调度程序,调度程序会检查当前是否有就绪进程,如果有就会让闲逛程序下处理机,就绪进程上处理机。

    4.3 调度算法评价指标

    调度算法评价指标主要从CPU利用率、系统吞吐量、周转时间、等待时间和响应时间五个方面评价。

    由于早期的CPU造价极其昂贵,因此人们会希望让CPU尽可能多地工作。

    调度算法评价指标总结:

    调度算法的评价指标

    4.4 调度算法

    调度算法有先来先服务(FCFS)、短作业优先(SJF)、高响应比优先(HRRN)、时间片轮转(RR)、优先级调度、多级反馈队列

    先来先服务(FCFS)

    算法思想:主要从"公平"的角度考虑(类似于我们生活中排队买东西的例子)

    算法规则:按照作业/进程到达的先后顺序进行服务,事实上就是等待时间越久的越优先得到服务。

    用于作业/进程调度:用于作业调度时,考虑的是哪个作业先到达后备队列;用于进程调度时,考虑的是哪个进程先到达就绪队列。

    这种先来先服务算法一般是非抢占式算法,也就是说对于当前正在占用处理机的进程(作业),之有这个进程主动放弃处理机时,才会进行调度,才会用调度算法规则选择下一个上处理机运行的进程。

    例题:各进程到达就绪队列的时间、需要的运行时间如下表所示。使用先来先服务调度算法,计算各进程的等待时间、平均等待时间、周转时间、平均周转时间、带权周转时间、平均带权周转时间。

    先来先服务

    上图可知,调度顺序为: P1P2P3P4。各个进程运行时间如下:

    先来先服务运行时间

    1. 周转时间=完成时间到达时间

      故P1=70=7;P2=112=9;P3=124=8;P4=165=11

    2. 带权周转时间=周转时间/运行时间

      故P1=7/7=1;P2=9/4=2.25;P3=8/1=8;P4=11/4=2.75

    3. 等待时间=周转时间运行时间

      P1=77=0;P2=94=5;P3=81=7;P4=114=7

      注意:本例中的进程都是纯计算型的进程,一个进程到达后要么在等待,要么在运行。如果是又有计算、又有I/O操作的进程,其等待时间就是周转时间运行时间I/O操作的时间t

    4. 平均周转时间=(7+9+8+11)/4=8.75

      平均带权周转时间=(1+2.25+8+2.75)/4=3.5

      平均等待时间=(0+5+7+7)/4=4.75

    可以看到上面P3的周转时间比运行时间多了8倍。

    算法优点:公平、算法实现简单

    缺点:排在长作业(进程)后面的短作业需要等待很长时间,带权周转时间很大,对短作业来说用户体验不好。即,FCFS算法对长作业有利,对短作业不利(如:排队买奶茶你只买一杯,但前面有个人买20杯)

    如果某个进程/作业长期得不到服务,则称为"饥饿"。显然先来先服务算法不会导致饥饿。因为前面的进程给总会被处理完毕。

    短作业优先(SJF)

    可以看出FCFS对于带权周转时间、平均等待时间这些指标不算优秀,为了追求最少的平均等待时间,最少的平均周转时间、最少的平均平均带权周转时间提出了短作业优先(SJF)算法。

    算法规则:最短的作业/进程优先得到服务(所谓"最短",是指要求服务时间最短)。

    该算法即可用于作业调度,也可用于进程调度。用于进程调度时称为"短进程优先(SPF, Shortest Process First)算法"。

    SJF和SPF是非抢占式的算法。但是也有抢占式的版本:最短剩余时间优先算法(SRTEN, Shortest Remaining Time Next)

    例题:各进程到达就绪队列的时间、需要的运行时间如下表所示。使用短作业优先调度算法,计算各进程的等待时间、平均等待时间、周转时间、平均周转时间、带权周转时间、平均带权周转时间。

    先来先服务

    上图可知,当P1进程到达时会直接运行,运行时间为7,则P1进程运行结束时,P2、P3、P4进程都会到达,则按照运行时间最短及运行时间相同按先到先服务原则,调度顺序为: P1P3P2P4。各个进程运行时间如下:

    短作业优先调度算法

    1. 周转时间=完成时间到达时间

      故P1=70=7;P3=84=4;P2=122=10;P4=165=11

    2. 带权周转时间=周转时间/运行时间

      故P1=7/7=1;P3=4/1=4;P2=10/4=2.5;P4=11/4=2.75

    3. 等待时间=周转时间运行时间

      P1=77=0;P3=41=3;P2=104=6;P4=114=7

    4. 平均周转时间=(7+4+10+11)/4=8

      平均带权周转时间=(1+4+2.5+2.75)/4=2.56

      平均等待时间=(0+3+6+7)/4=4

    对比FCFS算法的结果,显然SPF算法的平均等待/周转/带权周转时间都要更低

    当上题改为用最短剩余时间优先算法(SRTEN, Shortest Remaining Time Next),每当有进程加入就绪队列改变时就需要调度,如果新到达的进程剩余时间比当前运行的进程剩余时间更短,则由新进程抢占处理机,当前运行进程重新回到就绪队列。另外,当一个进程完成时也需要调度。

    故需要注意的是,当有新进程到达时就绪队列就会改变,就要按照上述规则进行检查。各个时刻的情况如下:

    0时刻(P1到达):P1(7)

    2时刻(P2到达):P1(5)、P2(4)

    4时刻(P3到达):P1(5)、P2(2)、P3(1)

    5时刻(P3完成且P4刚好到达):P1(5)、P2(2) 、P4(4)

    7时刻(P2完成) : P1 (5)、P4(4)

    11时刻(P4完成) : P1(5)

    最短剩余时间优先算法

    1. 周转时间=完成时间到达时间

      故P1=160=16;P2=72=5;P3=54=1;P4=115=6

    2. 带权周转时间=周转时间/运行时间

      故P1=16/7=2.28;P2=5/4=1.25;P3=1/1=1;P4=6/4=1.5

    3. 等待时间=周转时间运行时间

      P1=167=9;P2=54=1;P3=11=0;P4=64=2

    4. 平均周转时间=(16+5+1+6)/4=7

      平均带权周转时间=(2.28+1.25+1+1.5)/4=1.50

      平均等待时间=(9+1+0+2)/4=3

    对比非抢占式的短作业优先算法,显然抢占式的这几个指标又要更低。

    注意:

    1. 如果题目中未特别说明,所提到的"短作业/进程优先算法"默认是非抢占式的

    2. 很多书上都会说"SJF调度算法的平均等待时间、平均周转时间最少"。

      严格来说,这个表述是错误的,不严谨的。之前的例子表明,最短剩余时间优先算法得到的平均等待时间、平均周转时间还要更少。所以应该加上一个条件"在所有进程同时可运行时,采用SJF调度算法的平均等待时间、平均周转时间最少";或者说"在所有进程都几乎同时到达时,采用SJF调度算法的平均等待时间、平均周转时间最少"。

      如果不加上述前提条件,则应该说"抢占式的短作业/进程优先调度算法(最短剩余时间优先,SRNT算法)的平均等待时间、平均周转时间最少"。

    3. 虽然严格意义来说,SJF的平均等待时间、平均周转时间并不一定最少,但相比于其他算法(如FCFS),SJF依然可以获得较少的平均等待时间、平均周转时间。

    短作业优先算法优点:"最短的"平均等待时间、平均周转时间。

    缺点:不公平。对短作业有利,对长作业不利。可能产生饥饿现象。另外,作业/进程的运行时间是由用户提供的,并不一定真实,不一定能做到真正的短作业优先。

    所以这种算法会产生"饥饿"。即如果源源不断地有短作业/进程到来,可能使长作业/进程长时间得不到服务,产生"饥饿"现象。如果一直得不到服务,则称为"饿死"。

    高响应比优先(HRRN)

    FCFS算法是在每次调度的时候选择一个等待时间最长的作业(进程)为其服务。但是没有考虑到作业的运行时间,因此导致了对短作业不友好的问题。

    SJF算法是选择-一个执行时间最短的作业为其服务。但是又完全不考虑各个作业的等待时间,因此导致了对长作业不友好的问题,甚至还会造成饥饿问题。

    而高响应比优先(HRRN)算法,即考虑到各个作业的等待时间,也能兼顾运行时间。

    算法思想:要综合考虑作业/进程的等待时间和要求服务的时间

    算法规则:在每次调度时先计算各个作业/进程的响应比,选择响应比最高的作业/进程为其服务。响应比计算公式如下:

    =+

    该算法即可用于作业调度,也可用于进程调度。并且是非抢占式的算法。因此只有当前运行的作业/进程主动放弃处理机时(正常/异常完成,或主动阻塞),才需要调度,才需要计算响应比。

    例题:各进程到达就绪队列的时间、需要的运行时间如下表所示。使用高响应比优先(HRRN)算法,计算各进程的等待时间、平均等待时间、周转时间、平均周转时间、带权周转时间、平均带权周转时间。

    先来先服务

    各个时刻的情况如下:

    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)、 P4(1.75)

    12时刻(P2完成):就绪队列中只剩下P4

    高响应比优先(HRRN)

    高响应比优先算法优点:

    1. 综合考虑了等待时间和运行时间(要求服务时间)
    2. 等待时间相同时,要求服务时间短的优先(SJF的优点)
    3. 要求服务时间相同时,等待时间长的优先(FCFS的优点)

    对于长作业来说,随着等待时间越来越久,其响应比也会越来越大,从而避免了长作业饥饿的问题。

    三个调度算法总结:

    三个调度算法总结

    注:这几种算法主要关心对用户的公平性、平均周转时间、平均等待时间等评价系统整体性能的指标,但是不关心"响应时间",也并不区分任务的紧急程度,因此对于用户来说,交互性很糟糕。因此这三种算法一般适合用于早期的批处理系统,当然,FCFS 算法也常结合其他的算法使用,在现在也扮演着很重要的角色。而适合用于交互式系统的调度算法将在下面介绍。

    时间片轮转(RR)

    算法思想:公平地、轮流地为各个进程服务,让每个进程在一定时间间隔内都可以得到响应。

    算法规则:按照各进程到达就绪队列的顺序,轮流让各个进程执行一个时间片(如100ms)。若进程未在一个时间片内执行完,则剥夺处理机,将进程重新放到就绪队列队尾重新排队。

    这种算法是用于进程调度的(只有作业放入内存建立了相应的进程后,才能被分配处理机时间片)

    若进程未能在时间片内运行完,将被强行剥夺处理机使用权,因此时间片轮转调度算法属于抢占式的算法。由时钟装置发出时钟中断来通知CPU时间片已到。

    例题:各进程到达就绪队列的时间、需要的运行时间如下表所示。使用时间片轮转调度算法,分析时间片大小分别是2时的进程运行情况。

    时间片轮转算法例题

    注意:常用于分时操作系统,更注重"响应时间",因而此处不计算周转时间。

    各个时刻的情况如下:

    时间片轮转算法例题10

    如果时间片太大,使得每个进程都可以在一个时间片内就完成,则时间片轮转调度算法退化为先来先服务调度算法,并且会增大进程响应时间。因此时间片不能太大。

    时间片太大太小影响:

    上面"增大进程响应时间":假如系统中有10个进程在并发执行,如果时间片为1秒,则一个进程被响应可能需要等9秒。也就是说,如果用户在自已进程的时间片外通过键盘发出调试命令,可能需要等待9秒才能被系统响应。

    另一方面,进程调度、切换是有时间代价的(保存、恢复运行环境),因此如果时间片太小,会导致进程切换过于频繁,系统会花大量的时间来处理进程切换,从而导致实际用于进程执行的时间比例减少。可见时间片也不能太小。

    一般来说,设计时间片时要让切换进程的开销占比不超过1%

    时间片轮转算法优点:公平、响应快,适用于分时操作系统。

    缺点:由于高频率的进程切换,因此有一定开销;且不区分任务的紧急程度。

    这种算法不会导致饥饿出现。

    优先级调度算法

    算法思想:随着计算机的发展,特别是实时操作系统的出现,越来越多的应用场景需要根据任务的紧急程度来决定处理顺序

    算法规则:每个作业/进程有各自的优先级,调度时选择优先级最高的作业/进程。

    这种算法既可用于作业调度,也可用于进程调度。甚至,还会用于在之后会学习的I/O调度中。

    优先级调度算法抢占式和非抢占式都有。做题时的区别在于:非抢占式只需在进程主动放弃处理机时进行调度即可,而抢占式还需在就绪队列变化时,检查是否会发生抢占。

    例题:各进程到达就绪队列的时间、需要的运行时间、进程优先数如下表所示。使用非抢占式的优先级调度算法,分析进程运行情况。(注: 优先数越大,优先级越高)

    优先级调度算法

    非抢占式的优先级调度算法:每次调度时选择当前已到达且优先级最高的进程。当前进程主动放弃处理机时发生调度。

    各个时刻的情况如下:

    优先级调度算法2

    如果采用抢占式的优先级调度算法:每次调度时选择当前已到达且优先级最高的进程。当前进程主动放弃处理机时发生调度。另外,当就绪队列发生改变时也需要检查是会发生抢占。各个时刻的情况如下:

    优先级调度算法3

    补充:就绪队列未必只有一个,可以按照不同优先级来组织。另外,也可以把优先级高的进程排在更靠近队头的位置。根据优先级是否可以动态改变,可将优先级分为静态优先级和动态优先级两种:

    1. 静态优先级:创建进程时确定,之后一直不变。
    2. 动态优先级:例建进程时有一个初始值,之后会根据情况动态地调整优先级。

    设置各类进程优先级原则:系统进程优先级高于用户进程、前台进程优先级高于后台进程、操作系统更偏好I/O型进程(或称I/O繁忙型进程)。这里偏好I/O型进程的原因是I/O设备和CPU可以并行工作。如果优先让I/O繁忙型进程优先运行的话,则越有可能让I/O设备尽早地投入工作,则资源利用率、系统吞吐量都会得到提升。

    注意:与I/O型进程相对的是计算型进程(或称CPU繁忙型进程)。

    当采用动态优先级策略时,调整情况可以从追求公平、提升资源利用率等角度考虑。比如某进程在就绪队列中等待了很长时间,则可以适当提升其优先级。如果某进程占用处理机运行了很长时间, 则可适当降低其他先级。另外比如发现一个进程频繁地进行I/O操作, 则可适当提升其优先级。

    优先级调度算法优点:用优先级区分紧急程度、重要程度,适用于实时操作系统。可灵活地调整对各种作业/进程的偏好程度。

    缺点:若源源不断地有高优先级进程到来,则可能导致饥饿。

    多级反馈队列

    之前的FCFS算法的优点是公平、SJF算法的优点是能尽快处理完短作业,平均等待/周转时间等参数很优秀、而时间片轮转调度算法可以让各个进程得到及时的响应。而刚刚介绍的优先级调度算法可以灵活调整各种进程被服务的机会。而本次要介绍的多级反馈队列调度算法是对以上算法的折中。

    算法思想:对其他调度算法的折中权衡。

    算法规则:

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

    该算法主要用于进程调度,而且是抢占式算法在k级队列的进程运行过程中,若更上级的队列(1k1)中进入了一个新进程,则由于新进程处于优先级更高的队列中,因此新进程会抢占处理机,原来运行的进程放回k级队列队尾。

    例题:各进程到达就绪队列的时间、需要的运行时间如下表所示。使用多级反馈队列调度算法,分析进程运行的过程。

    多级反馈队列调度算法

    首先会设置多级就绪队列,各级队列优先级从高到低时间片从小到大

    多级反馈队列调度算法例题

    新进程到达时先进入第1级队列,按FCFS 原则排队等待被分配时间片。若用完时间片进程还未结束,则进程进入下一级队列队尾。如果此时已经在最下级的队列,则重新放回最下级队列队尾。

    多级反馈队列调度算法例题2

    只有第k级队列为空时,才会为k+1级队头的进程分配时间片

    多级反馈队列调度算法例题3

    另外被抢占处理机的进程重新放回原队列队尾。上题各个时刻的情况如下:

    多级反馈队列优点:对各类型进程相对公平(FCFS的优点)。每个新到达的进程都可以很快就得到响应(RR的优点)。短进程只用较少的时间就可完成(SPF的优点)。不必实现估计进程的运行时间(避免用户作假)可灵活地调整对各类进程的偏好程度,比如CPU密集型进程、I/O密集型进程(拓展:可以将因I/O而阻塞的进程重新放回原队列,这样I/O型进程就可以保持较高优先级)。

    这种算法会导致饥饿出现。因为如果有源源不断地新进程到达,更低级队列的进程就有可能长期得不到服务(饥饿)。

    多级调度算法在计算机中的应用:系统中按进程类型设置多个级别队列,进程创建成功后插入某个队列。

    多级队列调度算法应用

    队列之间可采取固定优先级,或时间片划分固定优先级:高优先级空时低优先级进程才能被调度。时间片划分:如100ms的时间三个队列分配时间50%40%10%

    各个队列可采用不同的调度策略,如:系统进程队列采用优先级调度、交互式队列采用RR、批处理队列采用FCFS。

    三种交互式调度算法总结:

    三种调度算法总结

    注:比起早期的批处理操作系统来说,由于计算机造价大幅降低,因此之后出现的交互式操作系统(包括分时操作系统、实时操作系统等)更注重系统的响应时间、公平性、平衡性等指标。而这几种算法恰好也能较好地满足交互式系统的需求。因此这三种算法适合用于交互式系统。(比如UNIX使用的就是多级反馈队列调度算法)

    5.进程同步和进程互斥

    进程同步:

    之间介绍过进程具有异步性的特征。异步性是指,各并发执行的进程以各自独立的、不可预知的速度向前推进。

    而异步性会出现很多问题,如有时候一个变量必须等待用户进行I/O输入之后,才能进行读取,必须按照方式。即必须要保证各个进程之间的推进次序必须是合理次序。这就是进程同步问题。而操作系统需要提供这种进程同步的机制。

    同步亦称直接制约关系,它是指为完成某种任务而建立的两个或多个进程,这些进程因为需要在某些位置上协调它们的工作次序而产生的制约关系。进程间的直接制约关系就是源于它们之间的相互合作。

    进程同步又被称为进程间的直接制约关系。即进程之间是有直接合作的。

    进程互斥:

    在之前的学习中可以知道进程的"并发"需要"共享"的支持。各个并发执行的进程不可避免的需要共享一些系统资源(比如内存,又比如打印机、摄像头这样的I/O设备)。

    之前介绍过两种资源共享方式:互斥共享方式和同时共享方式。其中互斥共享指的是系统中的某些资源,虽然可以提供给多个进程使用,但一个时间段内只允许一个进程访问该资源。同时共享方式是指系统中的某些资源,允许一个时间段内由多个进程"同时"对它们进行访问。

    把一个时间段内只允许一个进程使用的资源称为临界资源。许多物理设备(比如摄像头、打印机)都属于临界资源。此外还有许多变量、数据、内存缓冲区等都属于临界资源。对临界资源的访问,必须互斥地进行。互斥,亦称间接制约关系。进程互斥指当一个进程访问某临界资源时,另一个想要访问该临界资源的进程必须等待。当前访问临界资源的进程访问结束,释放该资源之后,另一个进程才能去访问临界资源。

    对临界资源的互斥访问,可以在逻辑上分为如下四个部分:

    注意:临界区是进程中访问临界资源的代码段。进入区和退出区是负责实现互斥的代码段。临界区也可称为"临界段"。

    为了实现对临界资源的互斥访问,同时保证系统整体性能,需要遵循以下原则:

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

    进程互斥又被称为进程间的间接制约关系。因为进程之间并没有直接的合作关系,只是想要互斥的使用某种系统临界资源。

    同步互斥

    5.1 进程互斥软件实现

    如果一个系统中没有进程互斥,假设进程A、进程B在系统中并发地运行:

    进程互斥

    先调度A上处理机运行当A在使用打印机的过程中,分配给它的时间片用完了,接下来操作系统调度B让它上处理机运行,进程B也使用到打印机。所以由于A进程打印机使用到一半时,B进程也开始使用,结果会导致A,B两个进程的内容会打印在一起混淆。

    如果两个进程可以互斥访问打印机,即只有A进程使用完打印机后,B才可以接着访问打印机。这种情况下就不会出现上面的问题。

    可以用软件来实现互斥。

    单标志法

    算法思想:两个进程在访问完临界区后会把使用临界区的权限转交给另-一个进程。也就是说每个进程进入临界区的权限只能被另一个进程赋予。

    首先设置一个变量turn,这个变量表示当前允许进入临界区的进程号。如turn=0表示当前允许进程0进入临界区。

    单标志法

    两个进程对临界区访问代码如上图。

    每个进程都会在进入区(while)判断此时是不是允许自己进入,即把turn值和自己的编号进行对比。如果turn的值不等于自己的编号,那么此时只允许另一个进程访问临界区。

    上面代码种turn的初值为0,即刚开始只允许0号进程进入临界区。若P1先上处理机运行,则会一直卡在⑤。直到P1的时间片用完,发生调度,切换P0上处理机运行。代码①不会卡住P0,P0可以正常访问临界区,在P0访问临界区期间即时切换回P1,P1依然会卡在⑤。只有P0在退出区将turn改为1后,P1才能进入临界区。

    因此,该算法可以实现"同一时刻最多只允许一个进程访问临界区"。

    可以看出这个算法只能按P0P1P0P1这样轮流访问。这种必须"轮流访问"带来的问题是,如果此时允许进入临界区的进程是P0,而P0一直不访问临界区,那么虽然此时临界区空闲,但是并不允许P1访问。因此,单标志法存在的主要问题是违背"空闲让进"的原则。

    双标志先检查法

    算法思想:设置一个布尔型数组flag[],数组中各个元素用来标记各进程想进入临界区的意愿,比如flag[0]=ture意味着0号进程P0现在想要进入临界区。每个进程在进入临界区之前先检查当前有没有别的进程想进入临界区,如果没有,则把自身对应的标志flag[i]设为true,之后开始访问临界区。

    双标志检查法

    在并发运行的环境下若按照的顺序执行,P0和P1将会同时访问临界区。因此,双标志先检查法的主要问题是违反"忙则等待"原则。而发生这种情况原因在于,进入区的"检查①"和"上锁②"两个处理不是一气呵成的。"检查"后,"上锁"前可能发生进程切换。

    双标志后检查法

    算法思想:双标志先检查法的改版。前一个算法的问题是先"检查"后"上锁",但是这两个操作又无法一气呵成,因此导致了两个进程同时进入临界区的问题。因此,人们又想到先"上锁"后"检查"的方法,来避免上述问题。

    双标志后检查法

    在并发运行的环境下若按照的顺序执行,P0和P1将会无法访问临界区。因此,双标志后检查法虽然解决了"忙则等待"的问题,但是又违背了"空闲让进"和"有限等待"原则,会因各进程都长期无法访问临界资源而产生"饥饿"现象。

    Peterson算法

    算法思想:结合双标志法、单标志法的思想。如果双方都争着想进入临界区,那可以让进程尝试谦让。做一个有礼貌的进程。

    Peterson算法

    可以看到上面代码按照的顺序执行不会违背任何原则。

    Peterson算法用软件方法解决了进程互斥问题,遵循了空闲让进、忙则等待、有限等待三个原则,但是依然未遵循让权等待的原则,因为没有进入临界区的进程会一直卡在while循环处,不能释放处理机资源。

    5.2 进程互斥的硬件实现

    之前的四种软件实现互斥的方法中,双标志先检查法只要配合硬件完成"检查"和"上锁"一气呵成就可以完美解决这个问题。

    中断屏蔽法

    利用"开/关中断指令"实现(与原语的实现思想相同,即在某进程开始访问临界区到结束访问为止都不允许被中断,也就不能发生进程切换,因此也不可能发生两个同时访问临界区的情况)。

    中断屏蔽

    这种方法优点:简单、高效、逻辑清晰。

    缺点:不适用于多处理机;只适用于操作系统内核进程,不适用于用户进程(因为开/关中断指令只能运行在内核态,这组指令如果能让用户随意使用会很危险)。

    TestAndSet指令

    简称TS指令,也有地方称为TestAndSetLock指令,或TSL指令。

    TSL指令是用硬件实现的,执行的过程不允许被中断,只能一气呵成。以下是用C语言描述的逻辑

    指令实现的逻辑是当前进程在main函数中的循环会检查有没有上锁,如果上锁,即TestAndSet返回true,会一直循环,一直到某个使用临界区的进程在使用完后将lock修改为false,此时循环就会结束,进而执行当前进程的临界区代码。

    相比软件实现方法,TSL 指令把“上锁”和“检查”操作用硬件的方式变成了一气呵成的原子操作。

    优点:实现简单,无需像软件实现方法那样严格检查是否会有逻辑漏洞;适用于多处理机环境。

    缺点:不满足"让权等待"原则,暂时无法进入临界区的进程会占用CPU并循环执行TSL指令,从而导致"忙等"。

    Swap指令

    有的地方也叫Exchange指令,或简称XCHG指令。

    Swap指令是用硬件实现的,执行的过程不允许被中断,只能一气呵成。以下是用C语言描述的逻辑

    逻辑上来看Swap和TSL并无太大区别,都是先记录下此时临界区是否已经被上锁(记录在old变量上),再将上锁标记lock设置为true,最后检查old,如果oldfalse则说明之前没有别的进程对临界区上锁,则可跳出循环,进入临界区。

    优点:实现简单,无需像软件实现方法那样严格检查是否会有逻辑漏洞;适用于多处理机环境。

    缺点:不满足"让权等待"原则,暂时无法进入临界区的进程会占用CPU并循环执行TSL指令,从而导致"忙等"。

    5.3 互斥锁

    锁是一种实现的互斥的方法。而解决临界区最简单的工具就是互斥锁(mutexlock)。一个进程在进入临界区时应获得锁;在退出临界区时释放锁。函数acquire()获得锁,而函数release()释放锁。

    每个互斥锁有一个布尔变量available,表示锁是否可用。如果锁是可用的,调用acqiure()会成功,且锁不再可用。当一个进程试图获取不可用的锁时,会被阻塞(进入while循环),直到锁被释放。

    acquire()release()的执行必须是原子操作,因此互斥锁通常采用硬件机制来实现。

    互斥锁的主要缺点是忙等待,当有一个进程在临界区中,任何其他进程在进入临界区时必须连续循环调用acquire()。当多个进程共享同一CPU时,就浪费了CPU周期。因此,互斥锁通常用于多处理器系统,一个线程可以在一个处理器上等待,不影响其他线程的执行。

    需要连续循环忙等的互斥锁,都可称为自旋锁(spinlock) ,如TSL指令、 swap指令、单标志法

    互斥锁特性:

    1. 需忙等,等待过程,进程时间片用完依然会下处理机,违反"让权等待"。
    2. 优点:等待期间不用切换进程上下文,多处理器系统中,若上锁的时间短,则自旋等待代价很低
    3. 常用于多处理器系统,一个核忙等,其他核照常工作,并快速释放临界区
    4. 不太适用于单处理机系统,忙等的过程中不可能解锁

    互斥锁

    5.4 信号量机制

    信号量机制会介绍两种:整型信号量和记录型信号量。

    之前学习过进程互斥的四种软件实现方式(单标志法、双标志先检查、双标志后检查、Peterson算法)和进程互斥的三种硬件实现方式(中断屏蔽方法、TS/TSL指令、Swap/XCHG指令)。

    单标志法、双标志先检查、双标志后检查存在问题较大,并且在双标志先检查法中,进入区的"检查"、"上锁"操作无法一气呵成,从而导致了两个进程有可能同时进入临界区的问题。而剩下的四种方法问题较小但都无法实现"让权等待"的问题。

    为了解决上面两问题,在1965年,荷兰学者Dijkstra提出了一种卓有成效的实现进程互斥、同步的方法:信号量机制

    信号量机制:用户进程可以通过使用操作系统提供的一对原语来对信号量进行操作,从而很方便的实现了进程互斥、进程同步。

    信号量其实就是一个变量(可以是一个整数,也可以是更复杂的记录型变量),可以用一个信号量来表示系统中某种资源的数量,比如:系统中只有一台打印机,就可以设置一个初值为1的信号量。

    上面的一对原语:指wait(S)原语和signal(S)原语,可以把原语理解为我们自己写的函数,函数名分别为wait和signal,括号里的倍号量S其实就是函数调用时传入的一个参数。wait(S)原语和signal(S)原语两个操作也可以简称为P(S)和V(S)操作。

    由于信号量是一个变量(可以是一个整数,也可以是更复杂的记录型变量),所以信号量机制可以分为整型信号量和记录型信号量。

    整型信号量

    即用一个整数型的变量作为信号量,用来表示系统中某种资源的数量。

    不同于普通的整型变量,对信号量的操作只有三种:即初始化、P操作、V操作。

    假如某计算机系统中有一台打印机,有n个进程需要用到打印机

    整型信号量

    由于只有一个打印机,所以整型信号量S=1,代码如下:

    当进程P0调用wait函数时,如果当前信号量S<=即打印机处于上锁状态就会一直进入循环,其他进程也一样。假设这里打印机处于空闲状态,P0进程会使用打印机资源。其他进程进入等待,当P0使用完资源后执行signal函数会把S信号量恢复,接下来的一个进程就可以跳出循环使用打印机。

    由于使用原语所以上面的"检查"和"上锁"一气呵成,避免了并发、异步导致的问题。

    但是这个方法还是会出现"忙等"现象,不满足"让权等待"原则。因为剩下进程会一wait函数的循环中。

    记录型信号量

    为了解决上面"让权等待"原则,提出记录型信号量。即用记录型数据结构表示信号量。

    对应的waitsignal函数原语如下:

    wait函数中如果剩余资源数不够S.valut<0,使用block原语使进程从运行态进入阻塞态,并把当前进程挂到信号量S的等待队列(即阻塞队列)*L中。

    signal函数中释放资源后s.value++,若还有别的进程在等待(阻塞队列中有进程)这种资源S.value<=0,则使用wakeup原语唤醒等待队列中的一个进程,该进程从阻塞态转换为就绪态。

    例子:某计算机系统中有2台打印机,则可在初始化信号量S时将S.value的值设为2,队列S.L设置为空。假设当前有四个进程:

    记录型信号量

    如果当前先执行的是P0进程,wait函数的S.value减为1,所以会把其中一个打印机分配给P0。之后切换到P1进程同样会分配一个打印机,此时S.value=0,没有处于空闲的打印机了。接着CPU再为P2进程服务,P2同样要使用打印机,所以调用wait函数后value值同样会减一变为-1,此时block(S.L);函数会将P2进程挂到阻塞队列*L中。S.value=-1表明当前有一个进程在等待队列。接着CPU为P3进程服务,和P2一样,S.value=-2,将P3放入阻塞队列*L当中。

    接着再为P0服务,当前P0正好使用完打印机执行signal原语,此时S.value++=-1,小于0,则signal原语会主动执行wakeup原语,唤醒等待队列中的一个进程,这里是队头进程P2,此时P2会从阻塞队列转换为就绪队列,之后打印机会分配给P2进程。同样P3一样。

    总结:在考研题目中wait(S)signal(S)也可以记为P(S)、V(S),这对原语可用于实现系统资源的"申请"和"释放"。

    S.value的初值表示系统中某种资源的数目。对信号量S的一次P操作意味着进程请求一个单位的该类资源,因此需要执行S.value--,表示资源数减1,当S.value<0时表示该类资源已分配完毕,因此进程应调用block原语进行自我阻塞(当前运行的进程从运行态转换为阻塞态),主动放弃处理机,并插入该类资源的等待队列S.L中。可见,该机制遵循了"让权等待"原则,不会出现"忙等"现象。

    对信号量S的一次V操作意味着进程释放一个单位的该类资源,因此需要执行S.value++,表示资源数加1,若加1后仍是S.value<=0,表示依然有进程在等待该类资源,因此应调用wakeup原语唤醒等待队列中的第一个进程(被唤醒进程从阻塞态转换为就绪态)。

    5.5 信号量机制实现进程同步和互斥

    信号量的值=这种资源的剩余数量(信号量的值如果小于0,说明此时有进程在等待这种资源)

    P(S):即申请一个资源S,如果资源不够就阻塞等待。

    V(S):释放一个资源S,如果有进程在等待该资源,则唤醒一个进程。

    可以使用信号量机制实现进程的同步和互斥。

    信号量机制实现进程互斥

    系统当中的某一些资源必须进程互斥,而访问互斥资源的代码叫做临界区。这样就表明头一时刻只能有一个进程进入临界区代码。

    所以首先要做的是分析并发进程的关键活动,划定临界区(如:对临界资源打印机的访问就应放在临界区)。另外还要设置互斥信号量mutex, 初值为1mutex表示可以进入临界区的进程名额。

    信号机制实现互斥代码如下:

    这里的semaphore mutex=1;实际上不能这么赋值,但要会自己定义记录型(semaphore)信号量,但如果题目中没特别说明,可以把信号量的声明简写成这种形式semaphore mutex=1

    如果多个进程访问的临界资源不同,则要给不同临界区设置不同的信号量。

    信号量机制实现互斥

    另外需要注意的是P、V操作必须成对出现。缺少P(mutex)就不能保证临界资源的互斥访问。缺少V(mutex)会导致资源永不被释放,等待进程永不被唤醒。

    信号量机制实现进程同步

    进程同步值得是要让各并发进程按要求有序地推进。

    比如,P1、P2 并发执行,由于存在异步性,因此二者交替推进的次序是不确定的。

    信号量机制实现同步

    若P2的"代码4"要基于P1的"代码1"和"代码2"的运行结果才能执行,那么我们就必须保证"代码4"一定是在"代码2"之后才会执行。这就是进程同步问题,让本来异步并发的进程互相配合,有序推进。

    用信号量实现进程同步:

    1. 分析什么地方需要实现"同步关系",即必须保证"一前一后"执行的两个操作(或两句代码)
    2. 设置同步信号量S,初始为0

    假如有以下两个进程:先初始化同步信号量,初始值为0semaphore S=0;

    信号量机制实现同步2

    若先执行到V(S)操作,则S++S=1。之后当执行到P(S)操作时,由于S=1,表示有可用资源,会执行S--, S的值变回0,P2进程不会执行block原语,而是继续往下执行代码4。

    若先执行到P(S)操作,由于S=0S--S=-1,表示此时没有可用资源,因此P操作中会执行block原语,主动请求阻塞。之后当执行完代码2,继而执行V(S)操作,S++,使S变回0,由于此时有进程在该信号量对应的阻塞队列中,因此会在V操作中执行wakeup原语,唤醒P2进程。这样P2就可以继续执行代码4了。

    信号量机制实现同步3

    这里保证了代码4一定是在代码2之后执行。

    更简单的理解此时的信号量S代表"某种资源",刚开始是没有这种资源的。P2需要使用这种资源,而又只能由P1产生这种资源。

    例题:进程P1中有句代码S1,P2 中有句代码S2,P3中有句代码S3P6中有句代码S6。这些代码要求按如下前驱图所示的顺序来执行:

    信号量机制实现同步例题

    步骤:

    以上两个操作统称为"前V后P",即S2执行需要S1执行完毕,则在S1执行最后设置V操作,在S2执行代码的开头执行P操作。结果如下:

    信号量机制实现同步例题3

    假如CPU先执行P5,执行P(d)后P5进程进入阻塞队列。接着假如执行P2,执行P(a)后P2进程进入阻塞队列。CPU接着执行P1,当P1中的S1执行完毕后执行V(a),此时会唤醒P2进程,当P2执行V(d)后又会唤醒P5进程。其他进程执行过程分析原理类似。

    信号量机制实现同步互斥总结:

    信号量机制实现同步互斥总结

    最后的进程前驱关系实际上就是上面的例题。这里除了互斥、同步问题外,还会考察有多个资源的问题:有多少资源就把信号量初值设为多少。申请资源时进行P操作,释放资源时进行V操作即可。

    5.6 经典进程同步互斥问题

    会上面介绍的信号量机制处理一些经典的进程同步互斥问题。

    生产者消费者问题

    问题描述:系统中有一组生产者进程和一组消费者进程,生产者进程每次生产一个产品放入缓冲区,消费者进程每次从缓冲区中取出一个产品并使用。(注: 这里的产品理解为某种数据)。生产者、消费者共享一个初始为空、大小为n的缓冲区。只有缓冲区没满时,生产者才能把产品放入缓冲区,否则必须等待(缓冲区没满生产者生产)。

    生产者问题

    当缓冲区满时,生产者必须等待。只有缓冲区不空时,消费者才能从中取出产品,否则必须等待(缓冲区没空消费者消费)。

    生产者问题2

    缓冲区是临界资源,各进程必须互斥地访问(互斥关系)。因为假如有两个生产者进程,都同时往缓冲区的同一块内存写数据,则会出现覆盖。

    PV操作题目分析步骤:

    1. 关系分析。找出题目中描述的各个进程,分析它们之间的同步、互斥关系。

    2. 整理思路。根据各进程的操作流程确定P、V操作的大致顺序。

      根据题目,只有当缓冲区有产品,消费者进程才可以消费。另外只有缓冲区没满,生产者进程才可以生产,所以这样一前一后的关系就需要设置一个同步信号量,并且在前面动作完成之后需要执行V操作,后面动过开始之前执行P操作。

      生产者问题3

    3. 设置信号量。并根据题目条件确定信号量初值。(互斥信 号量初值一般为1,同步信号量的初始值要看对应资源的初始值是多少)

      消费者进程在消费之前需要消耗的是产品,所以P操作是在申请一个产品(数据)。所以full信号量对应的是非空缓冲区的数据。且由题中信息可知,缓冲区初始化时是空的。而这种进程只有生产者生产了商品之后才能接着往下执行。

      对于同步信号量empty,生产者进程每生产一个进程,就需要消耗一个空闲的缓冲区,因此这个信号量对应的是就是缓冲区的数量。当一个消费者进程取走了产品,就可以释放一个空闲缓冲区(empty)。且由题可知空闲缓冲区的初始值是n

      各个信号量初始状态如下:

    通过上面分析可以知道生产者进程在把产品放入缓冲区之前,需要申请一个空闲的缓冲区,因此在放入之前需要对empty这个信号量进行P操作,之后再对缓冲区资源量+1

    消费者进程分析也类似,当消费者从缓冲区取走一个产品之前,需要对full进程P操作。也就是要申请消耗一个产品资源。而当取走一个产品之后,空闲缓冲区的数量会+1。即进行一个V操作增加一个缓冲区。

    由于缓冲区是邻接资源,所以还需要对产品的取出和放入进行P和V操作。

    上面代码不能改变相邻P、V的操作顺序,假如改变:

    改变PV顺序

    若此时缓冲区内已经放满产品,则empty=0full=n

    则生产者进程执行使mutex变为0,再执行,由于已没有空闲缓冲区,因此生产者被阻塞。由于生产者阻塞,因此切换回消费者进程。消费者进程执行,由于mutex为0,即生产者还没释放对临界资源的"锁",因此消费者也被阻塞。这就造成了生产者等待消费者释放空闲缓冲区,而消费者又等待生产者释放临界区的情况,生产者和消费者循环等待被对方唤醒,出现"死锁"。

    同样的,若缓冲区中没有产品,即full=0empty=n。 按的顺序执行就会发生死锁。因此,实现互斥的P操作一定要在实现同步的P操作之后

    而V操作不会导致进程阻塞,因此两个V操作顺序可以交换。并且还要保证临界区代码足够短。所以上面的生产一个产品使用产品不能放在临界区中。

    多生产者多消费者问题

    桌子上有一只盘子,每次只能向其中放入一个水果。爸爸专向盘子中放苹果,妈妈专向盘子中放橘子,儿子专等着吃盘子中的橘子,女儿专等着吃盘子中的苹果。只有盘子空时,爸爸或妈妈才可向盘子中放一个水果。仅当盘子中有自己需要的水果时,儿子或女儿可以从盘子中取出水果。用PV操作实现上述过程。

    多生产者多消费者问题

    上节的问题生产者和消费者都是同一种东西,而这里的问题是生产者们和消费者们生产和消费的东西是不一样。

    分析步骤:

    1. 关系分析。找出题目中描述的各个进程,分析它们之间的同步、互斥关系。

      互斥关系:对于缓冲区(盘子)的访问要互斥的进行。

      同步关系:父亲将苹果放入盘子后,女儿才能取苹果。母亲将橘子放入盘子后,儿子才能取橘子。并且只有盘子为空时,父亲或母亲才能放入水果。而"盘子为空"这个事件可以由儿子或女儿触发,事件发生后才允许父亲或母亲放水果。

    2. 整理思路。根据各进程的操作流程确定P、V操作的大致顺序。

      多生产者多消费者问题2

    3. 设置信号量。设置需要的信号量,并根据题目条件确定信号量初值。(互斥信号量初值一般为1,同步信号量的初始值要看对应资源的初始值是多少)

      父亲进程需要把苹果放入盘子,女儿才能取到苹果,所以需要一个同步信号量apple=0用来实现这个同步关系。并且刚开始盘子中没有苹果所以初始值为0

      同样的对于母亲进程,也需要设置一个同步信号量orange=0

      另外当盘子为空的时候父亲和母亲进程才能往盘子中放水果。然而刚开始盘子本来都是空的,所以两个进程在刚开始就可以将水果放入盘子。所以需要一个plate=1的同步信号量用来表示此时盘子是否为空。代码声明如下:

    父亲和母亲进程在准备好水果后,需要检查盘子中是否为空进行P(plate)操作。如果水果已经放入进程,那么需要对水果进行V操作告诉女儿和儿子此时盘子中已经有了水果。同时两个进程在实现访问盘子时需要进行互斥操作。

    女儿进程和儿子进程,在取出自己喜欢的水果之前分别需要检查此时盘子中是否已经有自己喜欢的水果,所以先进行P操作。之后要进行V操作告诉父进程和母进程已经盘子已经为空。同时两个进程在实现访问盘子时需要进行互斥操作。

    多生产者多消费者问题4

    假如这里不用互斥信号:

    多生产者多消费者问题3

    分析:刚开始,儿子、女儿进程即使上处理机运行也会被阻塞。如果刚开始是父亲进程先上处理机运行,则父亲P(plate),可以访问盘子,母亲P(plate),阻塞等待盘子。而父亲放入苹果V(apple),女儿进程被唤醒,其他进程即使运行也都会阻塞,暂时不可能访问临界资源(盘子),之后女儿P(apple),访问盘子V(plate)。等待盘子的母亲进程被唤醒,母亲进程访问盘子(其他进程暂时都无法进入临界区)之后会重复唤醒儿子进程。

    所以可以得出一个结论:即使不设置专门的互斥变量mutex,也不会出现多个进程同时访问盘子的现象。原因在于本题中的缓冲区大小为1,在任何时刻,apple、orange、plate三个同步信号量中最多只有一个是1。因此在任何时刻,最多只有一个进程的P操作不会被阻塞,并顺利地进入临界区。而当缓冲区的大小为2时,没有互斥信号就会产生数据覆盖现象。所以如果缓冲区大小大于1,就必须专门设置一个互斥信号量mutex来保证互斥访问缓冲区。

    建议:在考试中如果来不及仔细分析,可以加,上互斥信号量,保证各进程一定会互斥地访问缓冲区。但需要注意的是,实现互斥的P操作一定要在实现同步的P操作之后,否则可能引起"死锁"。

    在分析同步问题(一前一后问题)的时候不能从单个进程行为的角度来分析,要把"一前一后"发生的事看做是两种"事件"的前后关系。

    如:盘子变空事件导致放入水果事件。"盘子变空事件"既可由儿子引发,也可由女儿引发。"放水果事件"既可能是父亲执行,也可能是母亲执行。这样的话,就可以用一个同步信号量解决问题了。

    多生产者多消费者问题5

    吸烟者问题

    假设一个系统有三个抽烟者进程和一个供应者进程。每个抽烟者不停地卷烟并抽掉它,但是要卷起并抽掉一支烟,抽烟者需要有三种材料:烟草、纸和胶水。三个抽烟者中,第一个拥有烟草、第二个拥有纸、第三个拥有胶水。供应者进程无限地提供三种材料,供应者每次将两种材料放桌子上,拥有剩下那种材料的抽烟者卷一根烟并抽掉它, 并给供应者进程一个信号告诉完成了,供应者就会放另外两种材料再桌上,这个过程一直重复 (让三个抽烟者轮流地抽烟)。

    抽烟者问题

    本质上这题也属于"生产者和消费者"问题,更详细的说应该是"可生产多种产品的单生产者和多消费者"。

    1. 关系分析。找出题目中描述的各个进程,分析它们之间的同步、互斥关系。

      互斥关系:

      这里供应者提供的材料会有三种组合:+胶水。烟草+胶水。烟草+

      每次供应者会将一种组合放到桌子上。所以桌子可以抽象为容量为1的缓冲区,要互斥访问

      同步关系:

      桌上有组合导致第一个抽烟者取走东西 桌上有组合导致第二个抽烟者取走东西 桌上有组合导致第三个抽烟者取走东西

      对于这三种以前一后关系需要设置三个同步信号量。另外题目还指出"拥有剩下那种材料的抽烟者卷一根烟并抽掉它, 并给供应者进程一个信号告诉完成了"。所以发出完成信号会导致供应者将下一个组合放到桌上。

    2. 整理思路。根据各进程的操作流程确定P、V操作的大致顺序。

    3. 设置信号量。设置需要的信号量,并根据题目条件确定信号量初值。(互斥信号量初值一般为1,同步信号量的初始值要看对应资源的初始值是多少)

      桌子上有组合一,是第一个抽烟者取走东西之前发生,所以设置同步信号量offer1=0。第二个和第三个关系一样。

      抽烟者问题2

      发出完成信号这个时间要发生在,供应者将组合放到桌子上之前,而发出完成信号这个事件可以由三个吸烟者当中任何一个产生。所以设置同步信号量finish=0

      抽烟者问题3

      代码定义如下:

      不用设置互斥信号量是因为容器的容量为1

    首先供应者需要提供三种组合,并且没提供一种组合要执行V操作,通知吸烟者。最后还要进行P操作等待吸烟者拿走材料。

    各个吸烟者从桌子上拿走材料之前,需要检查是不是自己所需要的材料。当拿走材料之后还需要通知供应者可以放下一个材料。

    读者写者问题

    有读者和写者两组并发进程,共享一个文件,当两个或两个以上的读进程同时访问共享数据时不会产生副作用,但若某个写进程和其他进程(读进程或写进程)同时访问共享数据时则可能导致数据不一致的错误。因此要求:①允许多个读者可以同时对文件执行读操作;②只允许一个写者往文件中写信息;③任一写者在完成写操作之前不允许其他读者或写者工作;④写者执行写操作前,应让已有的读者和写者全部退出。

    由于多个进程进行读文件操作并不会更改文件数据信息,所以多个读进程同时读文件是可以被允许的。与消费者进程不同的是消费者进程读文件的时候是取走数据。而这里是只读。

    后面三个要求概况来说就是当一个写者进程对文件进行写操作时,其他进程是不能访问这个文件的。或者也可以认为一个进程想要对一个文件进行写操作时,必须先等到其他进程对这个文件的操作结束后才能开始写入。

    读者写者问题

    1. 关系分析。找出题目中描述的各个进程,分析它们之间的同步、互斥关系。

      对于共享文件资源的访问,读者和读者之间不存在互斥可以同时访问。但是写者进程之间是存在互斥的。而写者进程和读者进程之间也需要实现互斥。

    2. 整理思路。根据各进程的操作流程确定P、V操作的大致顺序。

    3. 设置信号量。设置需要的信号量,并根据题目条件确定信号量初值。(互斥信号量初值一般为1,同步信号量的初始值要看对应资源的初始值是多少)

      可以设置一个rw信号量用于实现各个进程对共享文件的互斥访问。同时为了实现读者和读者之间能够同时访问共享文件代码,还需要设置count信号量记录当前有几个进程在访问文件。最后为了保证count变量能够一气呵成,话要设置mutex变量。声明如下:

    写者进程要做的就是不断写文件。由于写者和读者之间需要互斥的访问文件资源。所以写者在写文件之间需要对rw进行P操作。写完文件之后再进行V操作解锁。这样可以实现读写之间互斥的操作。

    同样读者进程也一样,读之前要进行P操作加锁,读之后进行V操作解锁。这样可以实现读写之间互斥的操作。同时为了实现读者和读者之间能够同时访问共享文件代码,还需要在加锁之前判断是不是第一个读的进程,由第一个读进程进行加锁操作。最后还要判断是不是最后一个访问完的进程,如果是最后一个访问完的进程需要进行解锁操作。

    思考:若两个读进程并发执行,则count=0时两个进程也许都能满足if条件,都会执行P(rw),从而使第二个读进程阻塞的情况。而出现上述问题的原因在于对count变量的检查和赋值无法一气呵成,因此可以设置另一个互斥信号量来保证各读进程对count的访问是互斥的,即设置互斥信号量mutex

    这样还是有一个潜在的问题,即只要有读进程还在读,写进程就要一直阻塞等待,如果有源源不断地读进程,那么写进程就可能"饿死"。因此,可以说这种算法读进程是优先的。

    可以再设置一个信号量w表示写优先。代码声明如下:

    修改后的读者写者代码如下:

    P(w)工作情况:假设两个读者进程读者1和读者2在并发运行。读者1P(w)P(v)之间进行了进程调度切换为读者2进程,这个读者2进程会阻塞在P(w)处。知道第一个读者进程读者1进行了V(w)操作,读者2进程会被唤醒,这样读者2进程在P(mutex)处也不会阻塞,可以实现多个进程共同访问文件。

    假设两个写者进程写者1和写者2并发运行。写者1P(w)后切换写者2,此时写者2会阻塞在P(w)这里。直到第一个写进程写者1写完文件,写者2进程才能进行。实现了互斥访问。

    假如写者1和读者1并发运行。刚开始处理机上运行的是写者1,当写者正在写文件时P(w)=0,所以当切换到读者1进程进行读文件时会被阻塞在P(w)处。直到写者1进程执行了V(w)之后,读者1进程才能运行。实现写者读者互斥。

    假如读者1、写者1和读者2并发运行。假设读者1上处理机运行,在执行了P(w)V(w)后此时w=1,之后开始读文件。在读文件期间切换为写者1,由于w=1,写者1P(w)不会被阻塞可以执行,而在P(rw)处,由于读者1执行后rw=0所以写者1进入阻塞。之后切换读者2进程会阻塞在P(w)处。所以只有当读者1在执行V(rw)后会唤醒写者1进程,之后就可以写文件。写文件之后执行V(w),此时会唤醒读者2进程。

    所以利用P(w)V(w)两对操作解决了写者可能会饥饿的问题。

    结论:在这种算法中,连续进入的多个读者可以同时读文件;写者和其他进程不能同时访问文件;写者不会饥饿,但也并不是真正的"写优先",而是相对公平的先来先服务原则。有的书上把这种算法称为"读写公平法"。

    读者与写者问题为我们解决复杂的互斥问题提供了一个参考思路。其核心思想在于设置了一个计数器count用来记录当前正在访问共享文件的读进程数。我们可以用count的值来判断当前进入的进程是否是第一个/最后一个读进程,从而做出不同的处理。另外,对count变量的检查和赋值不能一气呵成导致了一些错误,如果需要实现"一气呵成",自然应该想到用互斥信号量。最后,还要认真体会我们是如何解决"写进程饥饿"问题的。

    正常考试中当遇到同步问题时,可以参考生产者与消费者问题。而当遇到复杂的互斥问题时,应该参考读者写者问题。

    哲学家进餐问题

    一张圆桌上坐着5名哲学家,每两个哲学家之间的桌上摆一根筷子, 桌子的中间是一碗米饭。哲学家们倾注毕生的精力用于思考和进餐,哲学家在思考时,并不影响他人。只有当哲学家饥饿时,才试图拿起左、右两根筷子(一根一根地拿起)。如果筷子已在他人手上,则需等待。饥饿的哲学家只有同时拿起两根筷子才可以开始进餐,当进餐完毕后,放下筷子继续思考。

    哲学家进餐问题

    与之前不同的是每位哲学家需要拿起两根筷子才能吃饭,而之前的互斥问题中每一个进程只需要持有一个临界资源就可以进行。

    1. 关系分析。

      由于系统中有5个哲学家进程,5位哲学家与左右邻居对其中间筷子的访问是互斥关系。

    2. 整理思路。

      这个问题中只有互斥关系,但与之前遇到的问题不同的事,每个哲学家进程需要同时持有两个临界资源才能开始吃饭。如何避免临界 资源分配不当造成的死锁现象,是哲学家问题的精髓。

    3. 信号量设置。

      定义互斥信号量数组chopstick[5]={1,1,1,1,1}用于实现对5个筷子的互斥访问。并对哲学家按04编号,哲学家i左边的筷子编号为i,右边的筷子编号为(i+1)%5

      哲学家进餐问题2

    哲学家要做的事情只有两件,要么思考要么吃饭。当吃饭时候哲学家会拿起左边的筷子执行P(chopstick[i])操作,接着拿起右边的筷子执行P(chopstick[(i+1)%5])操作。如果此时五个进程同时运行,则会出现所有哲学家都拿起左边筷子,执行P(chopstick[i])操作,但当拿起右边筷子时所有的哲学家进程都会被阻塞。每位哲学家循环等待右边的人放下筷子(阻塞)发生"死锁"。

    防止死锁发生方法有如下几种:

    1. 可以对哲学家进程施加一些限制条件,比如最多允许四个哲学家同时进餐。这样可以保证至少有一个哲学家是可以拿到左右两只筷子的

    2. 要求奇数号哲学家先拿左边的筷子,然后再拿右边的筷子,而偶数号哲学家刚好相反。用这种方法可以保证如果相邻的两个奇偶号哲学家都想吃饭,那么只会有其中一个可以拿起第一只筷子,另一个会直接阻塞。从而避免了占有一支后再等待另一只的情况。

    3. 仅当一个哲学家左右两支筷子都可用时才允许他抓起筷子。

      可以设置一个互斥信号量mutex,之后在哲学拿起筷子前和拿起筷子后进行PV操作。

    采用方法三,各哲学家拿筷子这件事必须互斥的执行。这就保证了即使一个哲学家在拿筷子拿到一半时被阻塞,也不会有别的哲学家会继续尝试拿筷子。这样的话,当前正在吃饭的哲学家放下筷子后,被阻塞的哲学家就可以获得等待的筷子了。

    哲学家进餐问题的关键在于解决进程死锁。这些进程之间只存在互斥关系,但是与之前接触到的互斥关系不同的是,每个进程都需要同时持有两个临界资源,因此就有"死锁"问题的隐患。如果在考试中遇到了一个进程需要同时持有多个临界资源的情况,应该参考哲学家问题的思想,分析题中给出的进程之间是否会发生循环等待,是否会发生死锁。可以参考哲学家就餐问题解决死锁的三种思路。

    6. 管程

    在管程引入之前进程的同步和互斥主要使用信号量机制实现。信号量存在编写程序困难、易出错的问题。所以在1973年,Brinch Hansen首次在程序设计语言(Pascal)中引入了"管程"成分,即一种高级同步机制。

    管程与之前的PV操作一样,也是用于实现进程的同步和互斥的。管程是一种特殊的软件模块。为了实现一些资源对共享资源的同步和互斥访问,管程需要有以下部分组成:

    1. 局部于管程的共享数据结构说明。如生产者和消费者问题中的生产者和消费者都需要共享访问的缓冲区可以使用一种数据结构表示。
    2. 对该数据结构进行操作的一组过程。即编写一组可以用来操作上面数据结构的函数。
    3. 对局部于管程的共享数据设置初始值的语句。要对上面数据结构进行初始化。
    4. 管程有一个名字。

    可以看出管程的定义和面向对象中的"类"很类似。

    同时为了用管程实现进程之间的互斥和同步,管程有以下特征:

    6.1 管程解决生产者消费者问题

    有以下一段伪代码

    上面的ProducerConsumer相当于类名。之前进行生产操作时,生产者进程需要进行一堆PV操作,较为麻烦,用管程可以简化代码:

    同样消费者进程也可以很简单的调用管程中的函数,就可以实现从缓冲区取出一个产品。

    在定义管程之后可以由编译器负责实现各进程互斥地进入管程中的过程。每次仅允许一个进程在管程内执行某个内部过程。 假如有两个生产者进程并发执行,依次调用了insert过程。第一个生产者进程在执行一半时候,第二个生产者进程开始执行,那么由于编译器实现的功能会暂时阻止第二个生产者进程,所以会把第二个进程阻塞在insert函数后面,类似于一个排队器。

    管程解决消费者生产者问题

    可以看出开发者在开发时,不需要再关心PV操作。引入管程的目的无非就是要更方便地实现进程互斥和同步。

    管程实现如下:

    1. 需要在管程中定义共享数据(如生产者消费者问题的缓冲区)
    2. 需要在管程中定义用于访问这些共享数据的"入口",其实就是一些函数(如生产者消费者问题中,可以定义一个函数用于将产品放入缓冲区,再定义一个函数用于从缓冲区取出产品)
    3. 只有通过这些特定的"入口"才能访问共享数据。
    4. 管程中有很多"入口",但是每次只能开放其中一个"入口",并且只能让一个进程或线程进入(如生产者消费者问题中,各进程需要互斥地访问共享缓冲区。管程的这种特性即可保证一个时间段内最多只会有一个进程在访问缓冲区。注意:这种互斥特性是由编译器负责实现的,程序员不用关心)
    5. 可在管程中设置条件变量及等待/唤醒操作以解决同步问题。可以让一个进程或线程在条件变量上等待( 此时,该进程应先释放管程的使用权,也就是让出"入口");可以通过唤醒操作将等待在条件变量上的进程或线程唤醒。

    程序员可以用某种特殊的语法定义一个管程(比如:monitor ProducerConsumer ... end monitor;),之后其他程序员就可以使用这个管程提供的特定"入口"很方便地使用实现进程同步/互斥了。这也就是程序设计中"封装"思想。

    6.2 Java中类似的管程机制

    Java中,如果用关键字synchronized来描述一个函数, 那么这个函数同一时间段内只能被一个线程调用。

    每次只能有一个线程进入insert函数,如果多个线程同时调用insert函数,则后来者需要排队等待。

    管程总结:

    管程总结

    7. 死锁

    之前的哲学家进餐问题中,如果5位哲学家进程并发执行,都拿起了左手边的筷子,每位哲学家都在等待自己右边的人放下筷子,这些哲学家进程都因等待筷子资源而被阻塞。即发生"死锁"。

    死锁

    死锁问题指的是在并发环境下,各进程因竞争资源而造成的一种互相等待对方手里的资源,导致各进程都阻塞,都无法向前推进的现象,就是"死锁"发生死锁后若无外力干涉,这些进程都将无法向前推进。

    死锁、饥饿和死循环的区别如下:

    死锁饥饿死循环的区别:

    死锁饥饿死循环的区别

    死锁产生的必要条件有四个。只要其中任一条件不成立,死锁就不会发生。

    1. 互斥条件

      只有对必须互斥使用的资源的争抢才会导致死锁(如哲学家的筷子、打印机设备)。像内存、扬声器这样可以同时让多个进程使用的资源是不会导致死锁的(因为进程不用阻塞等待这种资源)。

    2. 不剥夺条件

      进程所获得的资源在未使用完之前,不能由其他进程强行夺走,只能主动释放。

    3. 请求和保持条件

      进程已经保持了至少一个资源,但又提出了新的资源请求,而该资源又被其他进程占有,此时请求进程被阻塞,但又对自己已有的资源保持不放。

    4. 循环等待条件

      存在一种进程资源的循环等待链,链中的每一个进程已获得的资源同时被下一个进程所请求。

    注意:发生死锁时一定有循环等待,但是发生循环等待时未必死锁(循环等待是死锁的必要不充分条件)。如果同类资源数大于1,则即使有循环等待,也未必发生死锁。但如果系统中每类资源都只有一个,那循环等待就是死锁的充分必要条件了。

    发生死锁情况:

    1. 对系统资源的竞争。各进程对不可剥夺的资源(如打印机)的竞争可能引起死锁,对可剥夺的资源(CPU)的竞争是不会引起死锁的。
    2. 进程推进顺序非法。请求和释放资源的顺序不当,也同样会导致死锁。例如,并发执行的进程P1、P2分别申请并占有了资源R1、R2,之后进程P1又紧接着申请资源R2,而进程P2又申请资源R1,两者会因为申请的资源被对方占有而阻塞,从而发生死锁。
    3. 信号量的使用不当也会造成死锁。如生产者与消费者问题中,如果实现互斥的P操作在实现同步的P操作之前,就有可能导致死锁。(可以把互斥信号量、同步信号量也看做是一种抽象的系统资源)。

    总之,对不可剥夺资源的不合理分配,可能导致死锁。死锁的处理策略如下:

    1. 预防死锁。

      破坏死锁产生的四个必要条件中的一个或几个。

    2. 避免死锁。

      用某种方法防止系统进入不安全状态,从而避免死锁(银行家算法)

    3. 死锁的检测和解除。

      允许死锁的发生不过操作系统会负责检测出死锁的发生,然后采取某种措施解除死锁。

    7.1 死锁的处理策略:预防死锁

    预防死锁:破坏死锁产生的四个必要条件中的一个或几个。

    预防死锁总结:

    预防死锁总结

    7.2 死锁的处理策略:避免死锁

    假如你是一位成功的银行家,手里掌握着100个亿的资金。有三个企业想找你贷款,分别是企业B、企业A、企业T,为描述方便,简称BAT。B会借70亿、A会借40亿、T会借50亿。然而如果你借给企业的钱总数达不到企业提出的最大要求,那么不管你之前给企业借了多少钱,那些钱都拿不回来了。刚开始BAT三个企业分别从你这儿借了20、10、30亿。

    银行家问题

    此时三个企业共借走60亿,所以手上还有40亿。

    所以给B借30亿是不安全的。之后手里只剩10亿,如果BAT都提出再借20亿的请求,那么任何一个企业的需求都得不到满足。而给A借20亿是安全的,因为存在TBA这样的安全序列。

    所谓安全序列,就是指如果系统按照这种序列分配资源,则每个进程都能顺利完成。只要能找出一个安全序列,系统就是安全状态。当然,安全序列可能有多个。

    如果分配了资源之后,系统中找不出任何一个安全序列,系统就进入了不安全状态。这就意味着之后可能所有进程都无法顺利的执行下去。当然,如果有进程提前归还了一些资源,那系统也有可能重新回到安全状态(如上面的TBA),不过我们在分配资源之前总是要考虑到最坏的情况。

    如果系统处于安全状态,就一定不会发生死锁。如果系统进入不安全状态,就可能发生死锁(处于不安全状态未必就是发生了死锁,但发生死锁时一定是在不安全状态)

    因此可以在资源分配之前预先判断这次分配是否会导致系统进入不安全状态,以此决定是否答应资源分配请求。这也是"银行家算法"的核心思想。

    银行家算法是荷兰学者Dijkstra 为银行系统设计的,以确保银行在发放现金贷款时,不会发生不能满足所有客户需要的情况。后来该算法被用在操作系统中,用于避免死锁。

    核心思想:在进程提出资源申请时,先预判此次分配是否会导致系统进入不安全状态。如果会进入不安全状态,就暂时不答应这次请求,让该进程先阻塞等待。

    上面的BAT的例子中,只有一种类型的资源,即钱。但是在计算机系统中会有多种多样的资源,可以把单维的数字拓展为多维的向量。比如:系统中有5个进程P0P43种资源R0R2,初始数量为(10,5,7),则某一时刻的情况可表示如下:

    银行家问题4

    可以把系统已分配的资源按照列加起来,每列资源初始数量再减去已分配的资源数量,就可以得到此时总共分配出去(7,2,5)资源,还剩余(3,3,2)资源。可把最大需求、已分配的数据看作矩阵,两矩阵相减,就可算出各进程最多还需要多少资源了。

    银行家问题5

    可以尝试找到一个安全序列。由于系统中剩余的资源是(3,3,2),可以用这个数字和每个进程最多还需要多少资源这个数字进行对比。

    实际做题(手算)时可用更快速的方法找到一个安全序列:

    经对比发现(3,3,2)可满足P1、P3,说明无论如何,这两个进程的资源需求一定是可以依次被满足的,因此P1、P3一定可以顺利的执行完,并归还资源。可把P1、P3先加入安全序列。(2,0,0)+(2,1,1)+(3,3,2)=(7,4,3)。剩下的P0、P2、P4都可被满足。同理,这些进程都可以加入安全序列。于是,5个进程全部加入安全序列,说明此时系统处于安全状态,暂不可能发生死锁。

    再看一个找不到安全序列的例子: 经对比发现(3,3,2)可满足P1、P3。说明无论如何,这两个进程的资源需求一定是可以依次被满足的,因此P1、P3一定可以顺利的执行完,并归还资源。可把P1、P3先加入安全序列。(2,0,0)+(2,1,1)+(3,3,2)=(7,4,3)

    银行家问题9

    剩下的P0需要(8,4,3),P2需要(6,5,0),P4需要(4,3,4)任何一个进程都不能被完全满足。于是,无法找到任何一个安全序列,说明此时系统处于不安全状态,有可能发生死锁。

    算法实现:

    假设系统中有n个进程,m种资源每个进程在运行前先声明对各种资源的最大需求数,则可用一个n*m的矩阵(可用二维数组实现)表示所有进程对各种资源的最大需求数。不妨称为最大需求矩阵MaxMax[i,j]=K表示进程Pi最多需要K个资源Rj。同理,系统可以用一个n*m的分配矩阵Allocation表示对所有进程的资源分配情况。Max-Allocation=Need矩阵,表示各进程最多还需要多少各类资源。另外,还要用一个长度为m的一维数组Available表示当前系统中还有多少可用资源。某进程Pi向系统申请资源,可用一个长度为m的一维 数组Request,来表示本次申请的各种资源量。

    银行家问题代码实现

    上图Available=(3,3,2)表示现在进程剩余(3,3,2)的空闲资源。进程P0向系统申请了Request_0=(2,1,1)资源。

    可用银行家算法预判本次分配是否会导致系统进入不安全状态:

    1. 如果Requesti[j]Need[i,j](0jm)便转向2;否则认为出错(因为它所需要的资源数已超过它所宣布的最大值)。

    2. 如果Requesti[j]Available[j](0jm),便转向3;否则表示尚无足够资源,Pi必须等待。

    3. 系统试探着把资源分配给进程Pi,并修改相应的数据(并非真的分配,修改数值只是为了做预判):

      Available=AvailableRequesti;Allocation[i,j]=Allocation[i,j]+Requesti[j];Need[i,j]=Need[i,j]Requesti[j]
    4. 操作系统执行安全性算法,检查此次资源分配后,系统是否处于安全状态。若安全,才正式分配;否则, 恢复相应数据,让进程阻塞等待。

    上面Request0=(2,1,1)Need(5,3,2)。所以进入第二步:Request0=(2,1,1)Available=(3,3,2)。所以进入第三步:

    Available(3,3,2)Request0(2,1,1)=Available(1,2,1);Allocation(0,1,0)+Request0(2,1,1)=Allocation(2,2,1);Need(7,4,3)Request0(2,1,1)=Need(5,3,2)

    银行家问题代码实现2

    之后进入第四步:系统尝试找到一个安全序列,如果说安全才可以真正分配给进程。如果出现系统不安全情况,需要将上一步修改的数据回退,并让进程阻塞等待。

    总结:

    银行家问题代码实现总结

    系统处于不安全状态未必死锁,但死锁时一定处于不安全状态。系统处于安全状态一定不会死锁。

    7.3 死锁的处理策略:检测和解除

    如果系统中既不采取预防死锁的措施,也不采取避免死锁的措施,系统就很可能发生死锁。在这种情况下,系统应当提供两个算法:

    1. 死锁检测算法:用于检测系统状态,以确定系统中是否发生了死锁。
    2. 死锁解除算法:当认定系统中已经发生了死锁,利用该算法可将系统从死锁状态中解脱出来。

    死锁检测解除总结:

    死锁检测解除总结

    死锁检测算法可能与数据结构一起考察,要实现。