[toc]
程序是静态的。一个指令序,就是个存放在磁盘里的可执行文件,就是一系列的指令集合。
进程是动态的。程序的一次执行过,同一个程序多次执行,也会有多个进程。
- 顺序性
- 封闭性
- 可再现性
- 间断性
- 失去封闭性
- 不可再现性
C语言编写的程序在使用内存时候,一般分三个段:
- 正文段:
- 代码:二进制代码
- 赋值数据段:常量、全局变量
- 数据堆段:
- 动态分配的存储区
- 数据栈段:临时使用的变量:
- 未赋值的局部变量
- 实参传递
为了使程序可以并发执行,并且可以对并发的程序进行描述和控制。
当进程被创建,操作系统会为该进程分配一个唯一的、不重复的“身份证号”PID(Process ID,进程ID)。PID存放在内存中,所以一个计算机中进程数的最大数受内存大小的影响。
3个定义:
-
进程是程序的依次执行。
-
进程是一个程序及其数据在处理机上的顺序执行时发生的活动。
**【注意】**并发进程的运行结果具有不可再现性(每次都不一样)。
-
进程是具有独立功能的程序在一个数据集上的执行过程,它是系统进行资源分配和调度的一个独立的单位。
进程是动态的; 进程实体(进程映像)是静态的。
一个进程实体(进程映像)由PCB、程序段、数据段组成。进程实体反应了进程在某一时刻的状态。进程实体可以看作进程在某一时刻的快照。
进程是进程实体的运行过程,是系统进行资源分配和调度的一个独立单位。
PCB是给操作系统用的。程序段、数据段是给进程自己用的,与进程自身的运行逻辑有关。
程序的代码,静态的(指令序列)。
运行过程中产生的各种数据(如:程序中定义的变量)
process control block,PCB
创建进程实质上就是创建PCB,撤销进程就是撤销PCB。它是进程存在的==唯一标志==。
-
进程描述
基本的进程描述信息,可以让操作系统区分各个进程。
- 进程标识符PID
- 用户标识符UID
-
进程调度(资源分配)信息
给进程分配了哪些资源,可用于实现操作系统对资源的管理。
- 在使用哪些内存区域(分配了多少内存)
- 正在使用哪些I/O设备
- 正在使用哪些文件
- 代码段、数据段、堆栈段指针
- 文件描述符
-
进程控制和管理信息
-
进程当前状态
- 就绪态
- 阻塞态
- 稳定态
-
进程优先级
-
代码运行入口地址
-
程序外存地址
-
进入内存时间
-
CPU、磁盘、网络使用情况
-
信号量使用
-
-
处理机状态
进程的运行情况,可用于实现操作系统对进程的控制、调度、进程切换。
- 通用寄存器、地址寄存器、控制寄存器PC、标志寄存器PSW
- 状态字
可以查看Linux源码中的
task struct
:Linux的进程控制块。
使一个在多道程序环境下不能独立运行的程序(含数据),成为一个能够独立运行的基本单位,即一个能与其他进程并发执行的进程。
- 线性表
- 链接队列
- 索引方式
为什么OS引入进程的概念?
为了使程序可以并发执行,并且可以对并发的程序进行描述和控制。
- 动态性:(进程基本特征)
- 进程是程序的一次执行过程,是动态地产生、变化和消亡的。
- 并发性:
- 内存中有多个进程实体,各进程可并发执行。
- 独立性:
- 进程是能独立运行、独立获得资源、独立接受调度的基本单位。
- 异步性:
- 各进程按各自独立的、不可预知的速度向前推进,会导致并发程序执行结果的不确定性。操作系统要提供"进程同步机制"来解决异步问题。
- 结构性:
- 每个进程都会配置一个PCB。结构上看,进程由程序段、数据段、PCB组成。
3种基本状态:
-
就绪态(ready)- 就绪队列。
-
执行态(running,运行态):单核的活就只能同时有一个在执行。
-
阻塞态(blocked/waiting,等待态)- 阻塞队列。
阻塞态是进程为了I/O或其他资源主动发起的。
加入创建终止:
- 创建态(new,新建态):分配资源、初始化PCB。
【注意】进程创建态→就绪态的工作是由**高级调度(作业调度)**完成的。
- 终止态(terminated,结束态):一个进程可以执行exit系统调用,请求操作系统终止该进程。此时该进程会进入“终止态”,OS会让该进程下CPU,并回收内存空间、PCB等资源。终止工作完成进程彻底消失。
【考点】
- 如果系统中没有运行的进程,那么就一定没有就绪的进程。
- 如果系统中没有运行的进程,也没有就绪的进程,那么也不一定就说明系统没有进程。因为可能发生“死锁”,所有进程都在阻塞等待。
- 采用优先级系统调度时,运行的进程不一定是系统中优先级最高的。因为高优先级的进程可能在等待队列(阻塞)。
挂起:将内存中的进程放入外存。
-
挂起原语suspend
-
激活原语active
加入创建终止:
进程控制的主要功能是对系统中的所有进程实施有效的管理,它具有创建新进程、撤销已有进程、实现进程状态转换等功能。
【简化理解】进程控制就是要实现进程状态转换。
- 如何实现进程控制?
使用“原语”实现。
原语具有原子性,在执行期间不允许中断,一气呵成。采用关中断和开中断这两个特权指令实现原子性。
创建步骤(创建原语):
- 申请空白PCB;
- 申请资源。运行所需要的物理、逻辑资源;
- 初始化PCB。把资源分配给PCB;
- 如果进程就绪队列能够接纳新进程,就把新进程插入就绪队列。
引起创建的事件:
-
用户登录:分时系统中,用户登录成功,系统会建立为其建立一个新的进程。
-
作业调度(高级调度):多道批处理系统中,有新的作业放入内存时,会为其建立一个新的进程。
-
提供服务:用户向操作系统提出某些请求,系统会处理用户的请求,会新建一个进程处理该请求。
-
应用请求:由用户进程主动请求创建一个子进程。
终止过程(撤销原语):
- 根据被终止进程的标识符PID,从PCB集合中检索出该进程的PCB,从该进程PCB中读出该进程的状态;
- 若进程是running,则立即终止进程执行。调度标志为真,指示该进程被终止后应该重新调度;
- 若有子进程,应终止所有子进程,防止它们变成不可控进程;
- 被终止的进程拥有的所有资源,全部归还给父进程或者系统;
- 将被终止的进程的PCB从队列或链表中移除。
引起终止的事件:
- 正常结束:进程自己请求终止(exit系统调用)。
- 异常结束:整数除以0、非法使用特权指令,然后被操作系统强行杀掉。
- 外界干预:Ctrl+Alt+delete,用户选择杀掉进程。
阻塞、唤醒就是一正一反,必须成对使用。
阻塞原语:
- 找到要阻塞的进程对应的PCB;
- 保护进程运行现场,将PCB状态信息设置为“阻塞态",暂时停止进程运行;
- 将PCB插入相应事件的等待队列。
唤醒原语:
-
在事件等待队列中找到PCB;
-
将PCB从等待队列移除,设置进程为就绪态;
-
将PCB插入就绪队列,等待被调度。
引起的事件:
- 向系统请求共享资源失败(如:缺页异常)
- 等待某种操作的完成
- 等待新数据到达
- 等待新任务到达
把运行态的进程进行切换。
切换原语:
- 将运行环境(寄存器存放的内容环境)信息存入PCB1;
- PCB1移入相应队列;
- 选择另一个进程执行,并更新其PCB2;
- 根据PCB2恢复新进程所需的运行环境。
引起的事件:
- 当前进程时间片到
- 有更高优先级的进程到达
- 当前进程主动阻塞
- 当前进程终止
进程间通信(Inter-Process Communication,IPC)是指两个进程之间产生数据交互。进程之间的信息交换(通信),通常有低级、高级之分。
一个进程不被允许直接访问其他进程的地址空间。
shared-memory system
速度最快的进程间通信。
两个进程有一个共享空间。两者对其的访问是互斥的(如PV操作)。
注:通过“增加页表项/段表项”即可将同一片共享内存区映射到各个进程的地址空间中(第三章内容)。
-
基于数据结构共享
比如共享空间里只能放一个长度为10的数组。这种共享方式速度慢、限制多,是一种低级通信方式
-
低级通信
-
在这个空间只能固定存储一种数据结构,速度慢。
-
-
基于存储区共享
操作系统在内存中划出一块共享存储区,数据的形式、存放位置都由通信进程控制,而不是操作系统。这种共享方式速度很快,是一种高级通信方式。
- 高级通信
- 存储更自由,速度更快。
“管道”是一个特殊的共享文件,又名pipe文件。
-
管道是一种存储在内存中的、大小固定的内存缓冲区,管道的大小通常是内存的一页,和磁盘大小没有关系。(2014年408真题)
-
管道只能采用半双工通信,某一时间段内只能实现单向的传输。如果要实现双向同时通信(全双工),则需要设置两个管道。(2014年408真题)
-
各进程要互斥地访问管道。
-
数据以字符流的形式写入管道,读操作写操作都可能会被阻塞。(2014年408真题)
- 当管道写满时,写进程的write()系统调用将被阻塞,等待读进程将数据取走。
- 当读进程将数据全部取走后,管道变空,此时读进程的read()系统调用将被阻塞。
-
管道中的数据一旦被读出,就彻底消失。因此,当多个进程读同一个管道时,可能会错乱。对此,通常有两种解决方案:
- 一个管道允许多个写进程,一个读进程(2014年408真题高教社官方答案);
- 允许有多个写进程,多个读进程,但系统会让各个读进程轮流从管道中读数据(Linux的方案)。
【注意】只要没满,就可以写;只要没空,就可以读。
进程间的数据交换以格式化的消息(Message)为单位。进程通过操作系统提供的“发送消息send / 接收消息receive”两个原语进行数据交换。
-
直接通信方式
发送进程利用OS所提供的发送原语,直接把消息发送给目标进程。
-
间接通信方式:信箱通信
发送进程、接收进程都通过**共享中间体(信箱)**的方式进行消息的发送、接收,完成进程间通信。
线程(thread)是操作系统能够进行运算调度的最小单位。用于处理相同程序的不同片段的并发执行。
它被包含在进程之中,是进程中的实际运作单位。一条线程指的是进程中一个单一顺序的控制流,一个进程中可以并发多个线程,每条线程并行执行不同的任务。
在Unix System V及SunOS中也被称为轻量进程(lightweight processes),但轻量进程更多指内核线程(kernel thread),而把用户线程(user thread)称为线程。
父进程可以打开子进程或者线程,使用线程共享的数据更多,占用空间更小。(2020年408真题)
- 父进程、子进程可以并发执行。
- 父进程、子进程共享一部分资源,但是不能共享虚拟地址空间。 父进程创建子进程时,会为子进程分配资源,就如虚拟存储空间。
- 父进程、子进程有不同的PCB。
- 父进程、子进程也不能同时访问临界资源。
【注意】线程没有自己独立的地址空间,它共享其所属的进程的地址空间。所以也就不可能各自拥有不同的地址空间(2021年408真题)。
进程的PCB、打开的文件、全局变量都是线程共享的,但是唯有进程中线程的栈指针(在TCB中)是属于线程的,是线程独享的,不共享。(2011年408真题)
引入线程之后变化:
-
资源分配、调度
- 传统进程机制,进程是资源分配、调度的基本单位。
- 引入线程之后,
- 线程是资源分配、调度的基本单位,线程成为了一个基本的CPU执行单元,程序执行流的最小单位。
- 进程成为了除CPU外系统资源的分配单元(如打印机、内存地址空间等都是分配给进程的)。
-
并发性
- 传统进程机制,只能进程间并发。
- 引入线程之后,各线程间也能并发,提高了系统并发度。
-
系统开销
- 传统的进程间并发,需要切换进程的运行环境,系统开销很大。
- 引入线程之后,线程间并发,如果是同一进程内的线程切换,则不需要切换进程环境,节约系统资源,系统开销小。
-
系统进程通信
- 由于所有线程共享同一地址空间,线程间的通信可以通过直接读写共享内存区域来实现,无需经过操作系统的中介,便于线程通信。(有时写“进程通信”)
-
线程的属性:
- 线程是处理机调度的单位
- 多CPU计算机中,各个线程可占用不同的CPU
- 每个线程都有一个线程ID、线程控制块(TCB)
- 线程也有就绪、阻塞、运行三种基本状态
- 线程几乎不拥有系统资源
- 同一进程的不同线程间共享进程的资源
- 由于共享内存地址空间,同一进程中的线程间通信甚至无需系统干预
- 同一进程中的线程切换,不会引起进程切换
- 不同进程中的线程切换,会引起进程切换
- 切换同进程内的线程,系统开销很小
- 切换进程,系统开销较大
线程的组织和进程一样。进程有进程控制块PCB,线程有线程控制块(TCB,thread control block)。
包含的内容:
其中程序计数器PC、其他寄存器、堆栈指针是线程切换时要保存/恢复。
- 线程标识符:TID,与PID类似
- 程序计数器PC:线程目前执行到哪里
- 其他寄存器:线程运行的中间结果
- 堆栈指针:堆栈保存函数调用信息、局部变量等
- 线程运行状态:运行/就绪/阻塞
- 优先级:线程调度、资源分配的参考
多个TCB(线程)组织成一张线程表(thread table),构成进程。
控制就是不同线程间的状态转换,也是就绪态、运行态、阻塞态之间的转换,和进程一样。
用户级线程(User-Level thread,ULT)
历史背景:早期的操作系统(如:早期Unix)只支持进程,不支持线程。当时的“线程”是由线程库实现的。
用户可以看到的线程。
- 线程的管理工作由谁来完成?
应用程序通过线程库完成,不是操作系统。所以没有OS创建的线程控制块TCB。
用户级线程ULT的控制块一般是用户空间的线程库进行维护,数据结构格式一般是链表或数组。
- 线程切换是否需要CPU变态(用户态→内核态)?
不需要。因为应用程序是在用户态的,没有涉及内核态的操作系统。
- 操作系统是否能意识到用户级线程的存在?
不能。操作系统不知道用户级线程的存在,只能看到进程。
-
优点:
- 用户级线程的切换在用户空间即可完成,不需要切换到核心态,线程管理的系统开销小,效率高。
- 允许每个进程定制自己的调度算法,不同的应用程序使用不同的调度算法,线程管理灵活。(KLT不具有)
- 在不同的操作系统上,不经修改就可以直接运行。
-
缺点:当一个用户级线程被阻塞后,整个进程都会被阻塞,并发度不高。多个线程不可在多核处理机上并行运行。
内核级线程(Kernel-Level thread,KLT)也叫内核级支持线程(kernel supported thread,KST)
如现在的Windows、Linux。
在核心态完成,==内核级线程==才是处理机分配的单位(多线程情况下也是)。
内核级线程在内核态,OS可见,所以OS会创建线程控制块TCB,而用户级线程就不会。
- 优点:当一个线程被阻塞后,别的线程还可以继续执行,并发能力强。多线程可在多核处理机上并行执行。
- 缺点:一个用户进程会占用多个内核级线程,线程切换由操作系统内核完成,需要切换到核心态,因此线程管理的成本高,开销大,效率低。
在支持内核级线程的系统中,根据用户级线程和内核级线程的映射关系(将ULT和KLT结合),可以划分为几种多线程模型。
- 多对一
多个ULT映射到1个KLT中,但看一个KLT就是普通的ULT。
-
系统开销小,效率高。ULT的切换只需要在用户空间就可以实现,不需要切换到核心态。
-
并发度不高。一个ULT阻塞,整个KLT也会被阻塞。
- 一对一
变成了纯粹的内核级线程
- 线程管理成本高,开销大。一个用户进程会占用一个KLT,每次都要切换到操作系统内核完成。
- 并发性高。
- 多对多
克服了并发度不高,解决了开销太大。
有3个用户级线程,映射到了2个内核级线程(KLT),那么就是2个线程单位,4核处理机最多分配它2核。
多线程是指:在一个程序中可以定义多个线程并同时运行它们,每个线程可以执行不同的任务。
多线程和多任务的区别:
多任务是针对操作系统而言的,代表操作系统可以同时执行的程序个数;
线程是针对一个程序而言的,代表一个程序可以同时执行的线程的个数,每个线程完成不同的任务。
上下文 context:就是一个环境。
上下文简单说来就是一个环境,相对于进程而言,就是进程执行时的环境。 具体来说就是各个变量和数据,包括所有的寄存器变量、进程打开的文件、内存信息等。
进程上下文:当一个进程在执行时,CPU的所有寄存器中的值、进程的状态、堆栈中的内容被称为该进程的上下文。
也可以说:所谓的“进程上下文”,可以看作是用户进程传递给内核的这些参数以及内核要保存的那一整套的变量和寄存器值和当时的环境等。
-
进程上文:其是指进程由用户态切换到内核态是需要保存用户态时CPU寄存器中的值、进程状态、堆栈上的内容,即保存当前进程的进程上下文,以便再次执行该进程时,能够恢复切换时的状态,继续执行。
-
进程下文:其是指切换到内核态后执行的程序,即进程运行在内核空间的部分。
进程上下文有三个部分:用户级上下文、寄存器上下文、系统级上下文。
- 用户级上下文:正文、数据、用户堆栈以及共享存储区;
- 寄存器上下文:通用寄存器、程序寄存器(IP)、处理器状态寄存器(EFLAGS)、栈指针(ESP);
- 系统级上下文:进程控制块task_struct、内存管理信息(mm_struct、vm_area_struct、pgd、pte)、内核栈。
- 进程切换:
当内核需要切换到另一个进程时,它需要保存当前进程的所有状态,即保存当前进程的上下文,以便在再次执行该进程时,能够必得到切换时的状态执行下去。
- 上下文切换:
当发生进程调度时,进行进程切换就是上下文切换(context switch)。 操作系统必须对上面提到的全部信息进行切换,新调度的进程才能运行。
程序在执行过程中通常有用户态和内核态两种状态,CPU对处于内核态根据上下文环境进一步细分,因此有了下面三种状态:
- 内核态,运行于进程上下文,内核代表进程运行于内核空间;
- 内核态,运行于中断上下文,内核代表硬件运行于内核空间;
- 用户态,运行于用户空间。
硬件通过触发信号,导致内核调用中断处理程序,进入内核空间。这个过程中,硬件的一些变量和参数也要传递给内核,内核通过这些参数进行中断处理。
中断上下文:就是硬件传递过来的这些参数和内核需要保存的一些其他环境(主要是当前被打断执行的进程环境)。
特点:中断上下文也称原子上下文,该上下文中执行的代码不可阻塞。
- 中断上文:硬件通过中断触发信号,导致内核调用中断处理程序,进入内核空间。这个过程中,硬件的一些变量和参数也要传递给内核,内核通过这些参数进行中断处理。中断上文可以看作就是硬件传递过来的这些参数和内核需要保存的一些其他环境(主要是当前被中断的进程环境。
- 中断下文:执行在内核空间的中断服务程序。
在发生中断时,内核就在被中断进程的上下文中,在内核态下执行中断服务例程。但同时会保留所有需要用到的资源,以便中继服务结束时能恢复被中断进程的执行。
运行于进程上下文的内核代码是可抢占的,但中断上下文则会一直运行至结束,不会被抢占。所以中断处理程序代码要受到一些限制,在中断代码中不能出现实现下面功能的代码:
-
不能-睡眠或者放弃CPU。 因为内核在进入中断之前会关闭进程调度,一旦睡眠或者放弃CPU,这时内核无法调度别的进程来执行,系统就会死掉。牢记:中断服务子程序一定不能睡眠(或者阻塞)。
-
不能-尝试获得信号量 如果获得不到信号量,代码就会睡眠,导致(1)中的结果。
-
不能-执行耗时的任务 中断处理应该尽可能快,因为如果一个处理程序是IRQF_DISABLED类型,他执行的时候会禁止所有本地中断线,而内核要响应大量服务和请求,中断上下文占用CPU时间太长会严重影响系统功能。中断处理程序的任务尽可能放在中断下半部执行。
-
不能-访问用户空间的虚拟地址 因为中断运行在内核空间。
在现在操作系统中,内核功能模块运行在内核空间,而应用程序运行在用户空间。现代的CPU都具有不同的操作模式,代表不同的级别,不同的级别具有不同的功能,其所拥有的资源也不同;在较低的级别中将禁止使用某些处理器的资源。Linux系统设计时利用了这种硬件特性,使用了两个级别,最高级别和最低级别,内核运行在最高级别(内核态),这个级别几乎可以使用处理器的所有资源,而应用程序运行在较低级别(用户态),在这个级别的用户不能对硬件进行直接访问以及对内存的非授权访问。内核态和用户态有自己的内存映射,即自己的地址空间。
当工作在用户态的进程想访问某些内核才能访问的资源时,必须通过系统调用或者中断切换到内核态,由内核代替其执行。进程上下文和中断上下文就是完成这两种状态切换所进行的操作总称。我将其理解为保存用户空间状态是上文,切换后在内核态执行的程序是下文。
具体Linux的函数接口:
上下文切换定义:上下文切换,即从一个可执行进程切换到另一个可执行进程。由 context_switch() 函数负责处理。
上下文切换过程:每当一个新的进程被选出来准备投入运行的时候, schedule() 就会调用该函数,它完成两项基本的工作。
-
切换虚拟内存:调用 switch_mm(),把虚拟内存从上一个进程映射切换到新进程中
-
切换处理器:调用 switch_to(),从上一个进程的处理器状态切换到新进程的处理器状态。包括保存、恢复栈信息和寄存器信息,以及其他任何与体系结构相关的状态信息。
进程调度是有代价的,频繁进行进程调度就会把时间用在进程的调度上,而真正用户数据的处理的时间就变少了,就使整个系统的效率下降。
【注意】处于临界区的进程,也可能因为中断、抢占而被调度。
长程调度、作业调度
调度的对象是作业,外存静态的作业。
过程:外存有很多个作业(程序),按一定的原则从外存的作业后备队列中挑选一个作业调入内存,并创建进程。每个作业只调入一次,调出一次。作业调入时会建立PCB,调出时撤销PCB。
用户向系统提交一个作业 = 用户让操作系统启动一个程序(来处理一个具体的任务)。
中程调度、内存调度
调度的对象是挂起的进程。
目的:提高内存利用率和系统的吞吐量。
暂时调到外存等待的进程状态,就是挂起。但是PCB还是会在内存。
短程调度、进程调度、处理机调度
调度的对象是进程、LWP。
进程调度是操作系统中最基本的一种调度,在一般的操作系统中都必须配置进程调度。
进程调度的频率很高,一般几十毫秒一次。
作业调度(高级调度) | 内存调度(中级调度) | 进程调度(低级调度) | |
---|---|---|---|
发生频率 | 低 | 中 | 高 |
发生方式 | 外存->内存(作业) | 外存->内存(进程) | 内存->CPU |
对进程状态的影响 | 无->创建态->就绪态 | 挂起态->就绪态 | 就绪态->运行态 |
- 可以进行调度
- 进程主动放弃处理机(有的系统中只允许主动放弃处理机)
- 进程正常终止
- 进程发生异常终止
- 进程主动请求阻塞(如等待I/O)
- 进程被动放弃处理机
- 进程时间片用完
- 有更紧急的处理(如I/O中断)
- 有更高优先级的进程进入就绪队列
- 进程主动放弃处理机(有的系统中只允许主动放弃处理机)
- 不可以进行调度
- 在处理中断的过程。中断处理很复杂,与硬件密切相关,很难进行进程切换。
- 进程在操作系统内核程序临界区。(但是普通临界区可以调度、切换)
- 在进行原语操作。
临界资源:一个时间段内只允许一个进程使用的资源。各进程需要互斥地访问临界资源。
临界区:访问临界资源的那段代码。
内核程序临界区:一般是用来访问某种内核数据结构的,比如进程的就绪队列(由各就绪进程的PCB组成)。
(2012联考)进程处于临界区时不能进行处理机调度(×)
这里的临界区时普通的临界区,他不会影响操作系统内核的工作,所以可以进行进程调度。
非抢占方式、非剥夺调度方式。
只允许进程主动放弃处理机。在运行过程中即便有更紧迫的任务到达,当前进程依然会继续使用处理机,直到该进程终止或主动要求进入阻塞态。
引起它调度的原因有:
- 进程运行完毕
- 主动放弃CPU进行阻塞(I/O请求)
- 在进程通信、同步的过程中执行了某种原语。
特点:实现简单,系统开销小但是无法及时处理紧急任务,适合于早期的批处理系统。
抢占方式、剥夺调度方式。
当一个进程正在处理机上执行时,如果有一个更重要或更紧迫的进程需要使用处理机,则立即暂停正在执行的进程,将处理机分配给更重要紧迫的那个进程。
原则:
- 优先级原则
- 短进程优先原则
- 时间片原则
特点:可以优先处理更紧急的进程,也可实现让各进程按时间片轮流执行的功能(通过时钟中断)。适合于分时操作系统、实时操作系统。
- “广义的进程调度”、“狭义的进程调度”、“进程切换”的区别:
进程切换:指一个进程让出处理机,由另一个进程占用处理机的过程。
狭义的进程调度:指的是从就绪队列中选中一个要运行的进程。(这个进程可以是刚刚被暂停执行的进程,也可能是另一个进程,后一种情况就需要进程切换)
广义的进程调度:包含了选择一个进程和进程切换两个步骤。
进程切换的过程主要完成了:
- 对原来进程保存CPU现场信息(给PCB);
- 对新的进程各种数据进行恢复。
(如:程序计数器PC、程序状态字PSW、各种数据寄存器等处理机现场信息,这些信息一般保存在进程控制块PCB)
【注意】进程切换是有代价的,因此如果过于频繁的进行进程调度、切换,必然会使整个系统的效率降低,使系统大部分时间都花在了进程切换上,而真正用于执行进程的时间减少。
调度程序:控制让哪个程序运行,运行多久。
调度时机――什么事件会触发“调度程序”?
- 创建新进程。
- 进程退出。
- 运行进程阻塞。
- I/O中断发生(可能唤醒某些阻塞进程)。
- 非抢占式调度策略,只有运行进程阻塞或退出才触发调度程序工作。
- 抢占式调度策略,每个时钟中断或k个时钟中断会触发调度程序工。
CPU不会彻底闲下来,调度程序永远的备胎,没有其他就绪进程时,运行闲逛进程(idle)。
闲逛进程的特性:
- 优先级最低
- 可以是0地址指令,占一个完整的指令周期(指令周期末尾例行检查中断)
- 能耗低
处理机调度算法的共同目标:
- 资源利用率
- 公平性
- 平衡性
- 策略强制执行
批处理系统的目标:
- 平均作业周转时间短
- 系统吞吐量高
- 处理机利用率高
分时操作系统目标:
- 保证响应时间快
- 保证均衡性
实时操作系统目标:
- 保证满足截止时间要求
- 保证可预测性
CPU利用率 $$ CPU利用率=\frac{有效工作时间}{总工作时间(有效+空闲等待)} $$ 系统吞吐量:单位时间完成的作业数量。 $$ 系统吞吐量=\frac{总共完成的作业数}{总共花费的时间} $$ 周转时间:作业从提交的系统到作业完成是时间(越小越好)。 $$ \color{red}(作业)周转时间=作业完成时间刻-作业提交时间刻 \ =等待时间+运行时间(理解,但这个不常用) $$ 平均周转时间: $$ \color{red} 平均周转时间=\frac{周转时间之和}{作业数} $$
带权周转时间一定是**>=1**的。(越小越好)。 $$ 带权周转时间=\frac{周转时间}{作业实际运行的时间} $$
等待时间:在等待处理机的时间。调度算法一般影响的就是等待时间。
响应时间:用户从提交请求到首次产生响应的时间。
相应比: $$ 相应比 = \cfrac{等待时间+要求服务时间}{要求服务时间} $$
这几种算法主要关心对用户的公平性、平均周转时间、平均等待时间等评价系统整体性能的指标,但是不关心“响应时间”,也并不区分任务的紧急程度,因此对于用户来说,交互性很糟糕,不适合用于现在交互式系统的调度算法。
因此这三种算法一般适合用于早期的批处理系统,当然,FCFS算法也常结合其他的算法使用,在现在也扮演着很重要的角色。
first come first server
按照作业/进程到达的先后顺序进行服务。
用于作业、进程调度。
优点:公平,算法实现简单,系统开销小。
缺点:排在长作业后面的短作业的带权周转时间大。对长作业有利,对短作业不利。
长作业是占用很长时间CPU,少用I/O的进程,可以理解成CPU繁忙型。
不会造成饥饿。
short job first
短作业优先级高(先运行):先来的作业先进行,进行完了之后,使用SJF挑选最短的作业放入后面。
用于进程时叫短进程优先SPF。SJF和SPF都是非抢占式的,但是**最短剩余时间优先(SRTN)**是抢占式的。
最短剩余时间优先(SRTN):每次进入一个新的作业(进程),会重新计算每一个作业剩余的时间有多少,重新规划下一个剩余时间最短的作业放入后面。
优点:==最短的平均等待时间==。
缺点:对短作业有利,对长作业不利。
可能会导致长作业饥饿
【注意】
- 如果题目中未特别说明,所提到的“短作业/进程优先算法”默认是非抢占式的。
- 在所有进程都几乎同时到达时,采用SJF调度算法的平均等待时间、平均周转时间最少(可以抢占的话那就SRTN,所有实际是不一定)。
highest response ratio next
第一个进程先进行,完成之后主动放弃,这时候计算作业的响应比(等待时间是:不同的进程此时已经等待的时间)。响应比(response ratio)大的优先。
相应比一定>=1。 $$ 相应比(>=1)=\frac{已经等待时间+要求服务时间(运行时间)}{要求服务时间} $$ 用于作业、进程调度。
优点:综合考虑了等待时间和运行时间,从而避免了长作业饥饿的问题。
不会造成饥饿。
交互式系统
round robin
用于分时操作系统。
将就绪进程按照FCFS排成一个就绪队列,设置一个时间片(分割为大小相同的时间片),一个进程在时间片内未执行完,剥夺处理机(时钟中断),并把它重新放到队尾。
仅用于进程调度。
优点:公平、响应快,适合分时操作系统,人机交互。
缺点:高频率进程切换,开销太大。不区分任务紧急程度。
不会造成饥饿。
【2010年408真题】进程时间片用完是降低进程优先级的合理时机。
如果时间片太大,使得每个进程都可以在一个时间片内就完成,则时间片轮转调度RR算法退化为先来先服务FCFS调度算法,并且会增大进程响应时间。因此时间片不能太大。
另一方面,进程调度、切换是有时间代价的(保存、恢复运行环境),因此如果时间片太小,会导致进程切换过于频繁,系统会花大量的时间来处理进程切换,从而导致实际用于进程执行的时间比例减少。可见时间片也不能太小。
一般来说,设许时间片时要让切换进程的开销占比不超过1%。
priority-scheduling algorithm
可以抢占也可以不抢占。
实时系统的进程调度是抢占式优先级高优先算法。
用于作业、进程调度。
根据优先级是否可以动态改变,可将优先级分为静态优先级和动态优先级两种:
- 静态优先级:优先级在创建进程时确定,保持不变。
- 动态优先级:在进程创建初赋予一个优先级,后续会随着进程或时间改变而改变。
优点:用优先级区分紧急程度,可以灵活地调整进程。
缺点:如果一直有高优先级的进程进入,那么低优先级就饥饿。
低优先级可能会造成饥饿。
【注意】就绪队列未必只有一个,可以按照不同优先级来组织。也可以把优先级高的进程排在更靠近队头的位置。
- 如何设置优先级:
系统进程优先级高于用户进程
前台进程优先级高于后台进程
I/O型进程(I/O繁忙型进程)优先级高于计算型进程(CPU繁忙型进程)
因为I/O尽早的开始工作,资源利用率、系统吞吐量越高
- 如何调整动态优先级:
可以从追求公平、提升资源利用率等角度考虑:
- 如果某进程在就绪队列中等待了很长时间,则可以适当提升其优先级
- 如果某进程占用处理机运行了很长时间,则可适当降低其优先级
- 如果发现一个进程频繁地进行l/O操作,则可适当提升其优先级
multilevel feedback queue
FCFS算法的优点是公平。 SJF算法的优点是能尽快处理完短作业,平均等待/周转时间等参数很优秀。 RR算法可以让各个进程得到及时的响应。 优先级调度算法可以灵活地调整各种进程被服务的机会。
综合上述5种算法,得到综合表现最好的算法:多级反馈队列调度算法MLFQ。
设计多级反馈队列调度算法,需要考虑的是:(2020年408真题)
- 就绪队列的数量;
- 就绪队列的优先级;
- 各级就绪队列的调度算法;
- 进程在就绪队列的迁移时间。
流程:划分多个队列,每个队列内部采用FCFS,队列1的进程执行完成之后进入队列2的队尾。当队列1有进程时,队列2内进程无法进行,上一级队列空,下一级队列开始进行。
当发生更高优先级进程进入,会剥夺当前进程的处理机,放入它当前出发的队列(而不是下一级队列),然后进程新来的优先级高的进程。
时间片越往下越大,优先级越往下越小。
用于进程调度。目前公认最好。
可能会造成饥饿。
缺点:系统开销大。
【注意】各队列可采用不同的调度策略,如:
- 系统进程队列采用优先级调度
- 交互式队列采用RR
- 批处理队列采用FCFS
进程具有特征异步性:各进程按各自独立的、不可预知的速度向前推进,会导致并发程序执行结果的不确定性。
进程同步:在异步环境下,一组并发进程因直接制约(协调)而互相发送消息、互相合作、互相等待,使得各进程按一定的速度执行的过程,称为进程同步。(同步也称直接制约关系)
一般有2种形式的制约关系:同步关系、互斥关系。
【注意】只有同一个进程内不同线程之间对全局共享变量才可能有互斥访问。不同进程的线程不存在互斥访问的问题。(2016年408)
一般是指由若干条指令组成的程序段,用来实现某个特定功能,在执行过程中不可被中断。(原语一旦开始执行,就要连续执行完,不允许中断。)
所谓“忙等”,是指“不让权”的等待,也就是说,进程因为某事件的发生而无法继续执行,仍然不释放处理器,并通过不断执行循环检测指令来等待该事件的完成以便能够继续执行。
如打印机等,进程在使用它们时需要采用互斥的方式,这样的资源称为临界资源(critical resource),它们可以是硬件也可以是软件,比如文件。
临界区定义:不论软硬件临界资源,多个进程必须互斥地对他们进行访问。人们把在每个进程中访问==临界资源==的那段代码称为临界区。
【注意】
- 临界区是进程中的==代码==。
- 正在访问临界资源的进程由于等待I/O而被中断,这时候,是允许其他进程抢占CPU的,但是不允许进入临界区。
- 非共享资源不是临界资源。它都不共享,那就不需要互斥访问。共享的才是临界的。
- 空闲让进
- 忙则等待
- 有限等待:对要求访问的进程,保证它在有限的时间内进入临界区,防止“死等”(饥饿)。
- 让权等待(原则遵循,但不是必须遵循):当进程不能进入临界区,应该立即释放处理机,防止进程“忙等”。(2020年408真题)
【注意】可以实现“让权等待”的是 信号量机制 及其后面的管程。(2018年408真题)
do{
entry section //进入区(上锁)
critical section //临界区(访问资源)
exit section //退出区(解锁)
remainder section //剩余区
} while(true)
下面两个方面(软件、硬件)实现进程互斥:
两个进程访问完临界区后,把临界区交给另一个进程。即进程进入临界区的权限是被另一个进程赋予的。
int turn = 0; //当前允许进入的进程号
P0 进程: P1 进程:
while (turn != 0); while (turn != 1); // 进入区,这里使用的就是死等,一直等直到turn==1才结束循环
critical section; critical section; // 临界区
turn = 1; turn = 0; // 退出区
remainder section; remainder section; // 剩余区
违背了空闲让进的原则。p1必须在p0结束后才能进入临界区,但是p0如果一直不进入临界区,那么虽然临界区空闲,但是p1仍然不被允许访问。会饥饿。
比如:现在是桌上只有一双筷子,有A跟B两个人,一开始先把筷子给A,A吃完后直接就把筷子洗干净给B了,然后说你吃完再把筷子洗干净给我,结果B无语了,他也没说要用筷子吃东西,然后A就是说不管,你必须吃完过后再把筷子给我,结果A自己又想吃的时候结果没有筷子用,因为筷子还在B那里呢,B还在纳闷A怕不是有什么大病。
设置一个数组flag[2],这里与前面不同之处就是,先设置自己的标志位,再检测对方的标志状态,若对方的标志位为true则等待。
//双标志先检查法
bool flag[2]= {false,false}; //表示想要进去临界区的组数,这里开始都不想
P0 进程: P1 进程:
while (flag[1]); while (flag[0]); // 进入区,检查对方是否想使用,不想(false)则可以不用
flag[0] = true; flag[1] = true; // 进入区
critical section; critical section; // 临界区
flag[0] = false; flag[1] = false; // 退出区
remainder section; remainder section; // 剩余区
//双标志后检查法
bool flag[2]= {false,false}; //表示想要进去临界区的组数,这里开始都不想
P0 进程: P1 进程:
flag[0] = true; flag[1] = true; // 进入区
while (flag[1]); while (flag[0]); // 进入区
critical section; critical section; // 临界区
flag[0] = false; flag[1] = false; // 退出区
remainder section; remainder section; // 剩余区
违背了忙则等待和空闲让进、有权等待。可能两个进程同时进入临界区,也可能两个进程都进入不了临界区的"饥饿"现象。
原因在于,进入区的“检查”和“上锁”两个处理不是一气呵成的。“检查”后,“上锁”前可能发生进程切换。
比如:现在还是桌上只有一双筷子,但是现在就不是A跟B了,换成孔融1号和孔融2号,为什么给他们这样取名字呢,后面就知道啦!现在这两个人呢,在想用筷子的时候都会先说出来表明自己的态度,然后再看对方会不会想要先用筷子,然后再判断下一步是使用筷子还是接着等待。如果一开始两个人同时表明自己想要筷子的话,对方都会考虑到礼仪问题,谦让给对方用,毕竟谁叫他们叫孔融呢,但是这样出现的问题就是明明有筷子可以用但是因为谦让而僵持住。结果两个人就只能饿着了,在操作系统里面这里就出现了"死等",即会存在进程产生"饥饿"。
设置一个数组flag[2],这里与前面不同之处就是,先设置自己的标志位,再检测对方的标志状态,若对方的标志位为true则等待呗。
Peterson 算法实际上同时结合了单标志法和双标志后检查法。
它的核心就是:在一开始还是和后检查法一样,抢先进行“上锁”,但是上锁之后又将 turn 置为对方线程,表示自己虽然想要进入临界区,但是不介意“将这个机会让给对方”(所以turn是保存了最后一个谦让)。尽管如此,由于 while 的限制条件增加了,而 turn 又是公用的,所以保证了最后只会有一方的 while 满足条件。既做到了互斥访问资源,也避免了双方都访问不到资源。
【考点】turn变量的作用的表示轮到哪个进程进入临界区。
int turn; //当前允许进入的进程
bool flag[2]; //谁是true,表示谁想进入临界区
P0 进程: P1 进程:
//下面turn = 1是允许对方先进入临界区的谦让
flag[0] = true; flag[1] = true; // 进入区
turn = 1; turn = 0;
while (flag[1] && turn == 1); while (flag[0] && turn == 0);// 进入区
critical section; critical section; // 临界区
flag[0] = false; flag[1] = false; // 退出区
remainder section; remainder section; // 剩余区
不遵循"让权等待"原则,会发生“忙等"。
关中断就是禁止处理机响应中断源的请求,与原语类似。
关中断;
临界区;
开中断;
优点:关中断是最简单、高效的实现互斥的方法之一。
缺点:不适用于多处理机。只适用于操作系统内核进程,不适用于用户进程。因为开、关中断的指令只能运行在内核态,允许用户随意使用会很危险。
- 【2021年408真题】用户是否能够使用开/关中断指令实现临界区互斥?为什么?
不能。因为开/关中断指令是特权指令,运行在内核态,不能在用户态执行。
TS指令(也称Test-And-Set-Lock,TSL指令,测试并建立)是用硬件实现的,在执行的过程中不允许被中断。
若刚开始lock
是false,则TSL返回的old
值为false,while循环条件不满足,直接跳过循环,进入临界区。若刚开始lock
是true,则执行TLS后old
返回的值为true,while循环条件满足,会一直循环,直到当前访问临界区的进程在退出区进行“解锁”。
相比软件实现方法,TSL指令把“上锁”和“检查”操作用硬件的方式变成了一气呵成的原子操作。
//布尔型共享变量lock表示当前临界区是否加锁
//true表示已加锁. false表示未加锁
bool TS(bool *lock){
bool old;
old = *lock; //存放原来的lock值
*lock = true; //资源正在被使用,上锁,关闭临界区
return old; //返回lock原来的值
}
do{
while( TS(&lock) ); //忙等,检查上锁
critical section;
lock = false; //解锁
remainder section;
}while(true);
*lock=false
表示资源空闲。
优点:实现简单,无需像软件实现方法那样严格检查是否会有逻辑漏洞;适用于多处理机环境。
缺点:不满足“让权等待”原则,暂时无法进入临界区的进程会占用CPU并循环执行TSL指令,从而导致“忙等”。
有的地方叫exchange指令,在Intel 80x86中叫XCHG指令,所有它是交换指令。
逻辑上来看Swap和TSL并无太大区别,都是先记录下此时临界区是否已经被上锁(记录在old变量上),再将上锁标记 lock 设置为 true,最后检查 old,如果 old 为 false 则说明之前没有别的进程对临界区上锁,则可跳出循环,进入临界区。
bool lock; //lock是全局变量
void swap(bool *a, bool *b){
bool temp;
temp = *a;
*a = *b;
*b = temp;
}
do{
bool old = true; //局部变量old,表示想用
do{
swap(&lock, &old); //把lock的值放到old中判断是否上锁
}while(old == true); //上锁true就一直忙等
critical section;
lock = false;
remainder section;
}while(true);
lock=false
表示资源空闲,没有被上锁。
old=false
表示之前没有被上锁。
优点:实现简单,无需像软件实现方法那样严格检查是否会有逻辑漏洞;适用于多处理机环境。
缺点:不满足“让权等待”原则,暂时无法进入临界区的进程会占用CPU并循环执行TSL指令,从而导致“忙等”。
- 【2023年408真题】2题(b)图中给出了两个变量值的函数
newSwap()
的代码是否可以用函数调用语句newSwap(&key, &lock)
代替指令Swap key lock
以实现临界区的互斥?为什么?
不能。因为多个线程可以并发地行newSwap()
,它执行时传递给形参b的是共享变量lock的地址,在newSwap()
中对 lock 既有读操作又有写操作,并发执行时不能保证实现两个变量值的原子交换,从而导致并发执行的线程同时进入临界区。
例如,线程A和线程B并发执行,初始时lock值为FALSE,当线程A执行完*a=*b后发生了进程调度,切换到线程B执行,线程B执行完newSwap()
后发生线程切换,此时线程A和B都能进入临界区,不能实现互斥访问。
1965年荷兰学者迪杰斯特拉特出信号量(semaphores)机制。利用一对原语解决检查和上锁这两个操作无法同时进行的问题。
一对原语wait(S)和signal(S),它们可以简写成P(S),V(S),源于荷兰语(proberen,verhogen)。
信号量其实就是一个变量(可以是一个整数,也可以是更复杂的记录型变量),可以用一个信号量来表示系统中某种资源的数量,比如:系统中只有一台打印机,就可以设置一个初值为1的信号量。
【注意】PV操作是一种低级进程通信原语,不是系统调用。
int S = 1; //表示有一个资源
- S>0:有资源
- S<0:有|S|个等待队列进程
- S=0:无资源
void wait(int S){ //wait原语,相当于进入区
while(S <= 0); //如果资源不够,就一直循环等待
S--; //资源够了,就占用一个资源
}
void signal(int S){ //signal原语,退出区
S++; //释放资源
}
进程:
...
wait(S); //进入区,申请资源
critical section; //临界区,使用资源
signal(S); //退出区,释放资源
...
- 【2021年408真题】为什么在
wait()
和signal()
操作中对信号量S的访问必须互斥执行?
信号量S是能被多个进程共享的变量,多个进程都可以通过wait()
和 signal()
对S进行读写,那么wait()
和 signal()
操作中对S的访问就必须是互斥的。
整型信号量的缺陷是存在“忙等”问题,因此人们又提出了“记录型信号量”,即用记录型数据结构表示的信号量。
有缓冲区(等待队列)
/*定义记录型信号量 结构体*/
typedef struct {
int value; //剩余资源数量
struct process *L; //等待队列
} semaphore;
void wait(semaphore S){
S.value--;
//如果剩余资源不足,就使用block原语(阻塞)使进程主动放弃处理机,并把S放到阻塞队列中
if(S.value < 0){
block(S.L);
}
}
void signal(semaphore S){
S.value++;
//释放资源后,如果阻塞队列中还有进程,就用wakeup原语(唤醒)队列中的进程,使其进入就绪态
if(S.value <=0){
wakeup(S.L);
}
}
对于整型信号量,当 s<=0 的时候,请求进程不会阻塞,而是进入盲等状态。
对于记录型信号量,当 s<0 的时候,请求进程会阻塞。
【考点】所以,在对记录型信号量的P操作的定义中,当信号量的值 (B) 时,执行P操作的进程变为阻塞态。
A. s<=0 B. s<0
可以避免“死锁”。
为了解决一次分配多种资源,每种资源每次分配一个,一次获得进程所需要的所有资源(每种个1个),否则进程阻塞,是记录型信号量上的进一步延伸。
AND型信号量的阻塞队列机制,为每种资源设置一个阻塞队列,当最先出现资源不足的资源种类为Ri时,那么进程就被阻塞在Ri资源对应的阻塞队列中。
void wait(S1, S2, …, Sn){
if (S1 >= 1 && … && Sn >= 1 ){ //所有资源足够
for(i=1; i<=n; i++)
Si--;
}
else{将该进程放入与发现的第一个si < 1相关联的等待队列中,并将该进程的进度计数设置为等待操作的开始。}
}
void signal(S1, S2, …, Sn){
for (i=1; i<=n; i++) {
Si++;
将与si关联的队列中等待的所有进程移到就绪队列中。
}
}
S1到Sn都表示所需资源,资源数都大于1,对每个资源进行——表示资源被占用,分配好资源之后跳出循环,wait操作结束。如果其中某个资源Si得不到满足,会执行else中的内容:把进程放进Si关联的阻塞队列中,然后程序计数器把指针移向wait操作开始。(wait操作是原语,遵循要执行都执行,执行不了就从头重新执行)
AND型信号量满足了“多种资源,数量为1”的使用情景,但是实际上还会有多种资源数量不固定的情景,AND型信号量显然处理不了这种情况的进程调度。
为了解决多资源多数量的情况,出现了信号量集。
AND的进一步延伸,设置一个最低资源数目>=1,和进程需要的资源数目>=0。
现在的使用情景是多资源多数量, 就是一个进程需要申请多个资源,每个资源数量又要求多个。描述资源的结构体做出了改动:
申请n类资源,每类资源最低t个,每类申请d个资源。
typedef struct{
int value;
int d;
int t;
struct process_control_block * list;
} semaphore;
void wait(S1, t1, d1; …; Sn, tn, dn){
if (S1>= t1 && … && Sn>=tn){
for (i=1; i<=n; i++) {
Si = Si - di;
}
}
else{
将正在执行的进程放在第一个具有si <的等待队列中,并将其程序计数器设置为等待操作的开始.
}
}
void signal(S1, d1, …, Sn, dn){
for (i=1; i<=n; i++) {
Si = Si - di;
将与si关联的队列中等待(process waiting)的所有进程移到ready queue中.
}
}
原有的value和list阻塞队列保留,新增属性t和d。
d表示进程需要的某类资源的数量,t表示进程能执行需要某类资源数量的最小值,value表示当前某类资源个数。
这里的d、t必须满足关系t>=d才能保证进程可以执行。解释一下:假设d=5,也就是进程本身需要5个A资源;t=7,也就是进程最小需要7个A类资源才能执行,多出来的两个是分给操作系统使用的,因为控制进程执行的指令也需要操作系统分配资源。当然当前i资源数S也必须大于7才能保证进程整体可以执行。
信号量集是由整形信号量一步步演变而来,每次演变都继承了上次的工作机制并且进行了缺点的改造。信号量集的已经可以适用较多的情景了。
如果wait(S,1,1)那么就是需要1种资源,需要的资源数量为1,如果S>=1这就退化成了记录型信号量;如果S=1就退化成了互斥信号量(整型信号量)。
设置互斥信号量mutex,初始值为 1,取值范围为(-1,0,1)
- mutex= 1:两个进程都没有进入互斥访问的临界区
- mutex= 0:有一个进程在临界区运行
- mutex= -1:有一个进程在临界区运行,另一个因等待而阻塞在信号量队列中
在记录型信号量的基础之上,进程访问临界区就可以直接写:
semaphore mutex = 1; //初始化信号量(记录型信号量)
P1(){
...
P(mutex);
critical section;
V(mutex);
...
}
【注意】对不同的临界资源需要设置不同的互斥信号量。
进程同步:要让各并发进程按要求有序地推进。信号量初值由用户确定。
比如:代码4需要在代码1和代码2完成之后才能开始,那么就需要调度到1->2->4实现同步关系。保证一前一后地执行。
semaphore s=0;
P1(){ P2(){
code1; P(s);
code2; code4;
V(s); code5;
code3; code6;
} }
例1:不需要信号量机制就可以实现的功能是(D)。
A.进程同步 B.进程互斥 C.执行的前驱关系 D.进程的并发执行
例2:使用互斥锁进行同步互斥时,(C)情况会导致死锁。
A.一个线程对同一个互斥锁连续加锁2次。
B.一个线程尝试对一个已经加锁的互斥锁再次加锁。
C.两个线程分别对2个不同的互斥琐先后加锁,但是顺序相反。
D.一个线程对一个互斥锁连续加锁,然后忘记解锁。
引入管程的原因:信号量机制在编写的时候,编写程序困难、易出错,P(S)和V(S)操作大量分散在各个进程中,不易管理,所以引入**管程(monitor)**的概念。
1973年,Brinch Hansen首次在程序设计语言(Pascal)中引入了“管程”成分——一种高级同步机制。
定义:管程是一种高级的同步机制(同步工具),本质上也是用于实现进程的同步、互斥。OS资源管理模块,解决了信号量机制中大量同步操作分散的问题。
由编译器负责实现各进程互斥地进入管程中的过程,程序员不需要再手动实现”互斥“,直接调用方法,就已经互斥的进行的。
管程是一种特殊的软件模块(有点像类)
- 局部于管程的共享数据结构(结构体)
- 对该数据结构进行操作的一组过程(函数)
- 对局部于管程的共享数据设置初始值的语句
- 管程有一个名字
管程中包含条件变量,用于管理进程的阻塞和唤醒。这个条件变量是管程内部一种特殊变量,类似信号量机制中的信号量,用于实现进程同步。
其形式为 condition x
,对它的操作仅有wait
和signal
。
【注意】管程中signal
操作与信号量中的V
操作不同。V操作一定会更改信号量的值 S:=S+1,但是管程signal操作是针对某个条件变量的,如果不存在因为该条件变量而阻塞的进程,那么该signal操作也就不会产生任何影响。
-
x.wait
:正在调用管程的进程因 x 条件需要被阻塞或挂起,则调用x.wait
将自己插入到 x 条件的等待队列上,并释放管程,直到 x 条件变化。此时其它进程可以使用该管程。
-
x.signal
:正在调用管程的进程发现 x 条件发生了变化,则调用x.signal
,重新启动一个因 x 条件而阻塞或挂起的进程。(与信号量的signal不同,没有 S:=S+1 的操作)
-
局部于管程的数据只能被局部与管程的过程(函数、方法)所访问;
-
一个进程只能通过调用管程内的过程才能进入管程访问共享数据;
- 管程是被进程调用的,管程是语法范围,无法创建和撤销。
-
每次仅允许一个进程在管程内执行某个内部过程。
1、2就是管程里面的数据结构,只能被管程里面的函数修改,调用这个函数来修改。
Java中用synchronized
来描述一个函数,那么这个函数同一时间内仅能被一个线程调用。
public synchronized void insert(bool lock){
...
}
//每次只能有一个线程进入insert 函数,如果多个线程同时调用 insert 函数,则后来者需要排队等待。
static class monitor {
private Item buffer[] = new Item[N];
private int count = 0;
public synchronized void insert (Item item) {
...
}
}
- 关系分析。找出各进程、进程之间的互斥、同步关系。
- 整理思路。根据各进程的操作流程,确定P、V操作的大致顺序。
- 设置信号量。根据题目条件确定信号量的初值。(互斥信号量一般初值为1,同步信号量要看对应的资源的数量)
设置信号量,要考虑的是临界区事件的前后关系,而不是进程的关系,后者联系会变多,信号量多。
producer-consumer
生产者-消费者用于解决:多个进程之间的同步、互斥问题。
生产者-消费者问题,在两者之间设立n个缓冲池,生产者进程将它的所有产品放入一个缓冲区(就是临界资源),消费者从缓冲池中取出产品。这过程中,两者进程是以异步的方式(取时不能放,放时不能取)运行的,但是它们之间需要保持同步(一前一后),即:
不允许==消费者==去==空缓冲区==去产品;(空 -> 阻塞)
不允许==生产者==去已经装==满==了产品的==缓冲区==放产品。
- 多生产者-消费者问题:
【技巧】可以删去互斥变量mutex的原因在于:本题中的缓冲区大小为1,在任何时刻,apple、 orange、 plate三个同步信号量中最多只有一个是1。因此在任何时刻,最多只有一个进程的P操作不会被阻塞,并顺利地进入临界区。
reader-writer problem
有一个写者很多读者。多个读者可以同时读文件,但写者在写文件时不允许有读者在读文件,同样有读者在读文件时写者也不去能写文件。
【总结】同类进程不互斥,异类进程互斥。
要求:
- 允许多个读者可以同时对文件执行读操作;
- 只允许一个写者往文件中写信息;
- 任一写者在完成写操作之前不允许其他读者或写者工作;
- 写者执行写操作前,应让已有的读者和写者全部退出。
潜在的问题:只要有读进程还在读,写进程就要一直阻塞等待,可能“饿死”。因此,这种算法中,读进程是优先的。
读者想要执行count++(读者数量+1),需要在没有写者准备的情况下才能进行。
当有写者P(w),想要写,那么这样写者会阻塞在P(rw),那么新的读者就不能再进入临界区,当所有读者读完之后,执行V(rw),那么写者就可以直接开始写了。直到写完V(w),才可以有新的读者进入。
【重点】读者-写者问题为我们解决复杂的互斥问题提供了一个参考思路。
其核心思想在于设置了一个计数器count用来记录当前正在访问共享文件的读进程数。我们可以用count的值来判断当前进入的进程是否是第一个/最后一个读进程,从而做出不同的处理。
另外,对count变量的检查和赋值需要实现“一气呵成”,自然应该想到用互斥信号量。
- 关系分析。系统中有5个哲学家进程,5位哲学家与左右邻居对其中间筷子的访问是互斥关系。
- 整理思路。这个问题中只有互斥关系,但与之前遇到的问题不同的事,每个哲学家进程需要同时持有两个临界资源才能开始吃饭。如何避免临界资源分配不当造成的死锁现象,是哲学家问题的精髓。
- 信号量设置。定义互斥信号量数组chopstick[5]={1,1,1,1,1}用于实现对5个筷子的互斥访问。并对哲学家按0~4编号,哲学家i左边的筷子编号为i,右边的筷子编号为(i+1)%5。
当5个哲学家进程并发执行时,某个时刻恰好每个哲学家进程都执行申请筷子,并且成功申请到第i支筷子(相当于5个哲学家同时拿起他左边的筷子), 接着他们又都执行申请右边筷子, 申请第i+1支筷子。此时每个哲学家仅拿到一支筷子, 另外一支只得无限等待下去, 引起死锁。在给出几种有效阻止死锁的方案之前,首先给出两个断言:
(1)系统中有N个并发进程。 若规定每个进程需要申请2个某类资源, 则当系统提供N+1个同类资源时,无论采用何种方式申请资源, 一定不会发生死锁。分析:N+1个资源被N 个进程竞争, 由抽屉原理可知, 则至少存在一个进程获2个以上的同类资源。这就是前面提到的哲学家就餐问题中5个哲学家提供6支筷子时一定不会发生死锁的原因。
(2)系统中有N个并发进程。 若规定每个进程需要申请R个某类资源, 则当系统提供K=N*(R-1)+1个同类资源时,无论采用何种方式申请使用,一定不会发生死锁。
分析:在最坏的情况下,每个进程都申请到R-1个同类资源, 此时它们均阻塞。 试想若系统再追加一个同类资源, 则 N 个进程中必有一个进程获得R个资源,死锁解除。
结合以上分析,哲学家就餐问题可以被抽象描述为:系统中有5个并发进程, 规定每个进程需要申请2个某类资源。 若系统提供5个该类资源, 在保证一定不会产生死锁的前提下,最多允许多少个进程并发执行?假设允许N个进程, 将R=2,K=5带入上述公式, 有N*(2-1)+1=5所以 N=4。也就意味着,如果在任何时刻系统最多允许4个进程并发执行, 则一定不会发生死锁。 大多数哲学家就餐问题死锁阻止算法都是基于这个结论。
解法:
可以对哲学家进程施加一些限制条件,比如最多允许四个哲学家同时进餐。这样可以保证至少有一个哲学家是可以拿到左右两只筷子的。
规定奇数号哲学家先拿他左边的筷子,然后在去拿右边的筷子;而偶数号哲学家则相反。按此规定,将是1、2号哲学家竞争1号筷子;3、4号哲学家竞争3号筷子。
即5位哲学家都先竞争奇数号筷子,获得后,再去竞争偶数号筷子,最后总会有一位哲学家能够获得两只筷子而进餐。
采用**AND型信号量(互斥访问)**机制来解决,当一个哲学家左右两边的筷子都可用时,才允许他抓起筷子,有一个筷子时候不能抓。即要求每个哲学家先获得两个临界资源(筷子)后方能进餐。
【2019年408真题】有n(n ≥ 3)名哲学家围坐在一张圆桌边,每名哲学家交替地就餐和思考。再圆桌中心有m(m ≥ 1)个碗,每两名哲学家之间有一根筷子。每名哲学家必须取到一个碗和两侧的筷子后,才能就餐,进餐完毕,将碗和筷子放回原位,并继续思考。为使尽可能多的哲学家同时就餐,且防止出现死锁现象,请使用信号量的P、V操作[wait(),signal()操作]描述上述过程中的互斥与同步,并说明所用信号量及初始值的含义。
思考:
可以使用碗的数量 m 来限制访问的人数。也就是**bowl
这个信号量既充当资源,又起到了限制访问人数的作用(mutex)**。需要注意的是bowl
的数量一定要小于 n。
也就得到碗资源的数量:bowl: min{n-1, m}。
伪代码:
semaphore bowl; //碗
semaphore chopsticks[n]; //n个筷子
//赋值
for(int i=0; i<n; i++)
chopsticks[i] = 1; //每个哲学家一侧筷子个数为1
bowl = min(m,n-1); //限制访问资源的人数最多为n-1
Process Philosopher()
{
while(true)
{
思考;
P(bowl); //取碗
P(chopsticks[i]); //左手筷子
P(chopsticks[(i+1)%n] //右手筷子
干饭;
V(chopsticks[i]);
V(chopsticks[(i+1)%n];
V(bowl);
}
}
优化:
根据解法方案三:采用AND型信号量(互斥访问)机制来解决,当一个哲学家左右两边的筷子都可用时,才允许他同时抓起筷子,有一个筷子时候不能抓。即要求每个哲学家先获得两个临界资源(筷子)后方能进餐。
那么把抓筷子的动一气呵成,即上锁。
semaphore lock = 1; //用以互斥申请资源的信号量
semaphore bowl = m; //碗
semaphore chopsticks[n]; //n个筷子
//赋值
for(int i=0; i<n; i++)
chopsticks[i] = 1; //每个哲学家一侧筷子个数为1
bowl = min(m,n-1); //限制访问资源的人数最多为n-1
Process Philosopher()
{
while(true)
{
思考
P(lock); //"上锁"
P(chopsticks[i]); //左手筷子
P(chopsticks[(i+1)%n] //右手筷子
P(bowl); //取碗
V(lock);
干饭
V(chopsticks[i]);
V(chopsticks[(i+1)%n];
V(bowl);
}
}
死锁:资源在对方手中,它要的资源在我手中,谁也不给谁。至少有两个或以上的进程同时发“死锁”。
【规范】在并发环境下(多道程序环境中),各进程因竞争有限的资源而造成的一种互相等待对方手里的资源,导致各进程都阻塞,都无法向前推进的现象,就是“死锁”。发生死锁后若无外力干涉,这些进程都将无法向前推进。
饥饿:长期得不到想要的资源,这个资源不一定在哪里。可能是只有一个进程“饥饿”。
【规范】由于长期得不到想要的资源,某进程无法向前推进的现象。比如:在短进程优先(SPF)算法中,若有源源不断的短进程到来,则长进程将一直得不到处理机,从而发生长进程“饥饿”。
死循环:某进程执行过程中一直跳不出某个循环的现象。有时是因为程序逻辑bug导致的,有时是程序员故意设计的。
死锁、饥饿是操作系统分配资源不合理的问题,死循环是程序员代码逻辑错误的问题。
对不可剥夺资源的不合理分配就可能导致死锁。
-
独占资源分配不当
-
竞争不可抢占资源。
各进程对不可剥夺的资源(如打印机)的竞争可能引起死锁,对可剥夺的资源(CPU)的竞争是不会引起死锁的。
-
竞争可消耗资源。
-
进程推进顺序不当。
请求和释放资源的顺序不当,也同样会导致死锁。
例如:并发执行的进程P1、P2分别申请并占有了资源R1、R2,之后进程P1又紧接着申请资源R2,而进程P2又申请资源R1,两者会因为申请的资源被对方占有而阻塞,从而发生死锁。
- 信号量使用不当。
例如:生产者-消费者问题中,如果实现互斥的P操作在实现同步的P操作之前,就有可能导致死锁。(可以把互斥信号量、同步信号量也看做是一种抽象的系统资源)。
产生死锁必须同时满足一下四个条件,只要其中任一条件不成立,死锁就不会发生。
- 互斥条件:只有对必须互斥使用的资源的争抢才会导致死锁(如哲学家的筷子、打印机设备)。像内存、扬声器这样可以同时让多个进程使用的资源是不会导致死锁的(因为进程不用阻塞等待这种资源)。
- 请求和保持条件:进程已经保持了至少一个资源,但又提出了新的资源请求,而该资源又被其他进程占有,此时请求进程被阻塞,但又对自己己有的资源保持不放。
- 不可抢占条件(不可剥夺):进程所获得的资源在未使用完之前,不能由其他进程强行夺走,只能主动释放。
- 循环等待条件:存在一种进程资源的循环等待链,链中的每一个进程已获得的资源同时被下一个进程所请求。
【注意】发生死锁时一定有循环等待,但是发生循环等待时未必死锁(循环等待是死锁的必要不充分条件)。
- 不允许发生死锁
- 静态策略:预防死锁:破坏死锁产生的四个必要条件中的一个或几个。
- 动态策略:避免死锁:用某种方法防止系统进入不安全状态,从而避免死锁(银行家算法)。
- 允许发生死锁
- 检测死锁
- 解除死锁
这4种处理方式中,从严到宽,即并发性从小到大排序:
预防 < 避免(银行家)< 检测(死锁定理、资源分配图)< 解除
破坏四个条件就可以预防死锁。所以有4个策略:
SPOOLing技术
操作系统可以采用SPOOLing技术把独占设备在逻辑上改造成共享设备。
例如:用SPOOLing技术将打印机改造为共享设备。使用了SPOOLing技术,在进程和设备之间,添加一个中转站可以直接接受请求,然后自己再后续操作打印机。那么在各进程看来,自己对打印机资源的使用请求立即就被接收处理了,不需要再阻塞等待。
缺点:并不是所有的资源都可以改造成可共享使用的资源。并且为了系统安全,很多地方还必须保护这种互斥性。因此,很多时候都无法破坏互斥条件。
一次性分配策略(静态分配策略):一次性申请其在运行过程中的所需的所有资源,在它的资源未满足前,不让它投入运行。
该策略实现起来简单,但也有明显的缺点:
- 有些资源可能只需要用很短的时间,因此如果进程的整个运行期间都一直保持着所有资源,就会造成严重的资源浪费,资源利用率极低。
- 资源被释放就会马上被分配,那么某一个进程需要的两种资源不能同时获得,就需要一直等待,可能导致饥饿。
可剥夺资源:当它请求不到新资源的时候,就要放弃所有的资源。
方案一:当某个进程请求新的资源得不到满足时,它必须立即释放保持的所有资源,待以后需要时再重新申请。也就是说,即使某些资源尚未使用完,也需要主动释放,从而破坏了不可剥夺条件。
方案二:当某个进程需要的资源被其他进程所占有的时候,可以由操作系统协助,将想要的资源强行剥夺。这种方式一般需要考虑各进程的优先级(比如:剥夺调度方式,就是将处理机资源强行剥夺给优先级更高的进程使用)
缺点:
- 实现起来比较复杂;
- 释放已获得的资源可能造成前一阶段工作的失效。因此这种方法一般只适用于易保存和恢复状态的资源,如CPU;
- 反复地申请和释放资源会增加系统开销,降低系统吞吐量;
- 若采用方案一,意味着只要暂时得不到某个资源,之前获得的那些资源就都需要放弃,以后再重新申请。如果一直发生这样的情况,就会导致进程饥饿。
资源有序分配法(顺序资源分配法):限制用户申请资源的顺序。系统给每类资源一个编号,每一个进程按编号递增的顺序请求资源,同类资源(即编号相同的资源)一次申请完,释放则相反。
原理分析:一个进程只有已占有小编号的资源时,才有资格申请更大编号的资源。按此规则,已持有大编号资源的进程不可能逆向地回来申请小编号的资源,从而就不会产生“循环等待链”,循环等待的现象。所以在任何一个时刻,总有一个进程拥有的资源编号是最大的,那这个进程申请之后的资源必然畅通无阻。因此,不可能出现所有进程都阻塞的死锁现象。
缺点:
- 不方便增加新的设备,因为可能需要重新分配所有的编号;
- 进程实际使用资源的顺序可能和编号递增顺序不一致,会导致资源浪费;
- 必须按规定次序申请资源,用户编程麻烦。
所谓安全序列,就是指如果系统按照这种序列分配资源,则每个进程都能顺利完成。只要能找出一个安全序列,系统就是安全状态。当然,安全序列可能有多个。
如果分配了资源之后,系统中找不出任何一个安全序列,系统就进入了不安全状态。这就意味着之后可能所有进程都无法顺利的执行下去。当然,如果有进程提前归还了一些资源,那系统也有可能重新回到安全状态,不过我们在分配资源之前总是要考虑到最坏的情况。
如果系统处于安全状态,就一定不会发生死锁。如果系统进入不安全状态,就可能发生死锁(处于不安全状态未必就是发生了死锁,但发生死锁时一定是在不安全状态)。
因此可以在资源分配之前预先判断这次分配是否会导致系统进入不安全状态,以此决定是否答应资源分配请求。这也是“银行家算法”的核心思想。
核心思想:在进程提出资源申请时,先预判此次分配是否会导致系统进入不安全状态。如果会进入不安全状态,就暂时不答应这次请求,让该进程先阻塞等待。
【2019年408真题】银行家算法是一种死锁避免算法,不能判断系统是否处于死锁。
死锁定理:如果某时刻系统的资源分配图是不可完全简化的,那么此时系统死锁。
- 用某种数据结构来保存资源的请求和分配信息;
- 提供一种算法,利用上述信息来检测系统是否已进入死锁状态。
如图所示,R1有3个分配边,意味着R1的资源3个已经全部分配出去了,那么此时的P2的请求资源就不能被满足了;但是P1的请求资源可以被满足,因为R2还剩1个资源,P1进程可以执行。P1完成后就会把分配的资源还回去,那么就消除分配边,那么P2的请求此时就可以被满足了。所以不死锁。
- 如果系统中剩余的可用资源数足够满足进程的需求,那么这个进程暂时是不会阻塞的,可以顺利地执行下去。
- 如果这个进程执行结束了把资源归还系统,就可能使某些正在等待资源的进程被激活,并顺利地执行下去。
按上述过程分析,最终能消除所有边,就称这个图是可完全简化的。此时一定没有发生死锁(相当于能找到一个安全序列)。
如果最终不能消除所有边,那么此时就是发生了死锁。最终还连着边的那些进程就是处于死锁状态的进程。
三种方法
-
终止所有死锁进程。
-
逐个终止死锁进程。
又分为三类。
-
付出代价最小的死锁解除算法。
- 资源剥夺法。挂起(暂时放到外存上)某些死锁进程,并抢占它的资源,将这些资源分配给其他的死锁进程。但是应防止被挂起的进程长时间得不到资源而饥饿。
- 撤销进程法(或称终止进程法)。强制撤销部分、甚至全部死锁进程,并剥夺这些进程的资源。这种方式的优点是实现简单,但所付出的代价可能会很大。因为有些进程可能已经运行了很长时间,已经接近结束了,一旦被终止可谓功亏一篑,以后还得从头再来。
- 进程回退法。让一个或多个死锁进程回退到足以避免死锁的地步。这就要求系统要记录进程的历史信息,设置还原点,所以也难以实现。
根据下面,选择要解除的进程:
- 进程优先级。选择优先级低的进行解除。
- 进程执行了多少时间,还需要多少时间。选择使用(执行)时间少的解除。(因为执行时间长的都已经执行了好久了,现在解除可能要重头再来)
- 进程使用了多少资源,还需要多少资源。选择使用资源多的。(解除这种占有的资源多的进程,死锁可能会更快的解除)
- 进程是交互式的,还是批处理式的。选择批处理式的。(用户使用的是交互式的,需要及时反馈,不适合解除)