进程的
概念
理解“进程”和“程序”的区别
组成
一个进程由哪些部分组成
特征
进程有哪些重要的特征
首先认识一个新的概念:进程。
第一章中,我们经常会说,程序……,偶尔才会提到进程。不要把程序和进程这两个概念混淆了,一会儿会介绍。
打开Windows系统的任务管理器,可以看到,系统中现在正在运行的进程有这么多。
如果打开了QQ程序,QQ会启动,同时在进程里,可以看到有一条和QQ相关的条目。
而此时,如果我想要登录两个、三个QQ。
这时会发现,进程栏里,QQ相关的条目,出现了3条,表示系统中有3个QQ进程正在运行。
虽然说,这三次我打开的都是QQ.exe
这个程序,但是这一个程序的三次执行,会对应三个不同的进程。
程序:是静态的,就是个存放在磁盘里的可执行文件,就是一系列的指令集合。
进程(Process):是动态的,是程序的一次执行过程。
同一个程序多次执行会对应多个进程。
现在问题来了,既然这3个是不同的进程,但是它们执行的是同一个相同的程序。操作系统作为这些进程的管理者,怎么区分各个进程?不能都称之为“QQ进程”吧。
问题:操作系统是这些进程的管理者,它要怎么区分各个进程?
解决方法:当进程被创建时,操作系统会为该进程分配一个唯一的、不重复的“身份证号”——PID(Process ID,进程ID)
打开任务管理器,可以看到各个进程的PID,按照递减次序排列一下,很容易可以看出,每个进程的PID都是不重复的。
此时,我再打开一个Google Chrome,再打开一个网易云音乐。
可以发现,进程列表中多了两者的进程信息,其PID分别为17184、17872。
此时,我把这两个程序退出,然后重新再次打开。
可以看到,它们的PID又发生了变化,分别为18464、18224。
可见,我们每新建一个进程,都会给它分配一个不重复的、唯一的ID。
总之,再回到我们刚才的问题。我们运行的三个QQ进程,它们虽然都叫“腾讯QQ(32位)(2)”,但是它们背后各自有不同的PID
此外,刚刚的图中,每个进程的详细信息除了PID还有创建者(UID)、分配了多少内存、对I/O设备的使用情况等,操作系统会根据这些进程的各个信息,为其执行相应的管理策略。
操作系统要记录PID、进程所属用户ID(UID)——基本的描述信息,用来区分各个进程。
还要记录给进程分配了哪些资源(如:分配了多少内存、正在使用哪些I/O设备、正在使用哪些文件)——用于实现操作系统对资源的管理。
还要记录进程的运行情况(如:CPU使用时间、磁盘使用情况、网络流量使用情况等)——用于实现操作系统对进程的控制、调度。
既然操作系统要在背后为每一个进程记录这么多信息。那么这些信息,都会被统一地放在一个叫做PCB(Process Control Block)的数据结构当中,即进程控制块。
操作系统需要对各个并发运行的进程进行管理,但凡管理时所需要的信息,都会被放在PCB中。
所以,PCB是一个很重要的结构,它是进程存在的唯一标志,当一个进程被创建时,操作系统也会为它创建相应的PCB;而当一个进程结束的时候,操作系统就会回收它的PCB。
操作系统对进程进行管理工作所需的信息都存放在PCB中,PCB中的信息大致可按如下分类。(我们想要认识PCB当中每一个属性,是不可能的,也没必要)
除了PCB之外,进程还有两个很重要的组成部分:程序段、数据段。
刚才说过,PCB是给操作系统用的一个数据结构。而程序段、数据段其实是给进程自己用的。
第一章当中就讲过这个问题,此处再进行扩充。
程序要想运行,需要编译成二进制的指令。然后对这一条条的指令,再依次上CPU运行。
此处,我们把程序运行的过程,再进一步细致讨论一下。
我们的高级语言程序,经过一系列的编译、链接的步骤,最终会形成一个可执行文件(如Windows电脑里就是.exe
文件),这个可执行文件平时是保存在硬盘当中的。这个可执行文件里面保存的其实就是我们刚才说的那一系列的指令序列。
而当程序要运行之前,需要把它从硬盘读入到内存当中,并且操作系统会建立一个与它相对应的进程。
经过刚才的学习,我们知道,建立一个对应的进程,那么它会建立相对应的PCB。
除了PCB之外,这个程序的那一系列指令序列也需要读入内存当中。这一系列指令序列,我们把它称为程序段。那么,这个程序执行的过程,或者说这个进程执行的过程,其实就是CPU从内存当中读入这样一条条的指令然后来执行这些指令。
除了执行这些指令之外,其实在执行指令的过程当中,会有一些“中间的数据”,比如int x; x++;
,我们就定义了一个变量x。那么这些变量的内容,肯定也需要放在内存当中。所以还会有另外一个叫作数据段的区域,用来存放这个程序运行过程当中所产生的、所需要使用的各种数据。
所以,一个进程实体(进程映像)由PCB、程序段、数据段组成。
我们之前一直在说:进程由哪些部分组成。但是更严格的来说,应该是:一个进程实体(进程映像)由哪些部分组成。进程是动态的,进程实体是静态的。如何理解?我们可以把进程实体理解为这个进程在动态执行过程中某一时刻的一个快照,进程实体可以反映这个进程在某一时刻的状态(如:x++;
后,x=2)。
进程是动态的,进程实体(进程映像)是静态的。
进程实体反映了进程在某一时刻的状态。
程序段、数据段、PCB三部分组成了进程实体(进程映像)。
更确切的说,应该是“进程实体(进程映像)的组成”。不过,除非专门考察进程与进程实体的区别,不然也没必要去抠这个字眼。
PCB是给管理者,也就是给操作系统用的。
而程序段、数据段里面的内容,是给进程自己用的,与进程自身的运行逻辑有关。
所以在引入了进程实体的概念之后,我们可以把进程定义为:
进程是进程实体的运行过程,是系统进行资源分配和调度的一个独立单位。
进程是一个资源分配的独立单位,也很好理解,从刚才我们看的任务管理器的进程列表里面,很清楚的可以看到,操作系统就是以进程为单位,给各个进程分配资源的,如内存。
还涉及到一个进程的“调度”。其实所谓的调度,就是指,操作系统决定让哪个进程上CPU运行。进程调度相关的内容会在之后的小节中进一步学习。
一个程序运行多次,会对应多个进程。如果我们同时挂三个QQ号,那么这三个QQ进程,它们的PCB、数据段是各不相同的。PCB不相同很好理解;而数据段不相同,是因为,我们这三个QQ号,它涉及的一些信息、数据都是存放在数据段当中的,肯定是不相同的。但是,这三个QQ进程背后,它们的程序段的内容是相同的,因为它们执行的是相同的QQ程序。
PCB是进程存在的唯一标志。
程序是静态的,进程是动态的,相比于程序,进程有以下特征:
动态性是进程最基本的特征。
异步性会导致并发程序执行结果的不确定性。各进程独立、不可预知,但有时我们又必须让几个进程之间相互配合、协调着进行。具体会在后面“进程同步”相关部分进一步学习。
所有的这些特性,都不要死记硬背,而是理解。
状态
运行状态
就绪状态
阻塞状态
运行、就绪、阻塞,是三种基本状态
创建状态
终止状态
状态间的转换
- 就绪态 → 运行态
- 运行态 → 就绪态
- 运行态 → 阻塞态
- 阻塞态 → 就绪态
进程的组织方式(各个进程PCB的组织方式)
之前我们提到过,我们的程序(即可执行文件*.exe
),平时是存放在硬盘里的,而当它想要执行的时候,需要把它调入内存,同时操作系统会为它建立相应的PCB,也就是建立一个相应的进程。那当一个进程正在被创建的期间,这个进程的状态就是处于“创建态”。在这个阶段,操作系统会给这个进程分配相应的系统资源(比如给它分配一些内存空间)、初始化PCB。
而当一个进程创建完成之后,它就会进入一个新的状态,叫作“就绪态”。处于就绪态的进程,其实是已经具备了运行的条件,只不过此时CPU比较忙、还没有空闲,所以CPU暂时还不能为这个进程服务。
一个系统当中可能有很多个处于就绪态的进程。当CPU空闲的时候,操作系统就会从这些处于就绪态的进程当中选择其中的一个,然后让它上CPU运行。而如果一个进程此时正在CPU上运行的话,那么这个进程就处于“运行态”。
同时,我们知道,一个进程,它正在运行,意味着此时CPU正在处理这个进程背后的那个程序,也就是执行这个进程相应的指令序列。
比如说,CPU执行了进程2
,它的指令序列包含:指令1、指令2、指令3、指令4。我们假设此处的指令3,是发出了一个系统调用,而这个系统调用是请求操作系统给它分配打印机资源。
而此时,打印机设备很忙,它正在为别的进程服务,所以这个打印机资源暂时不能分配给进程2
,所以接下来的指令4,即往打印机输出数据的指令,就没办法得到执行。那既然这个进程接下来的指令,暂时不能往下执行的话,那么显然,我们就不应该让这个进程一直占用着CPU资源。
所以,类似于上述这种情况:很多时候,进程在运行的过程中,有可能会请求等待某个事件的发生(比如等待系统给它分配某一种资源,或者等待其他进程的响应,等等)。而在这个事件发生之前,这个进程是没有办法继续往下执行的,所以在这个时候,操作系统就会剥夺这个进程对CPU的使用权。让这个进程2
下CPU,并使它进入一个新的状态,叫“阻塞态”。
在进程运行的过程中,可能会请求等待某个事件的发生(如等待某种系统资源的分配,或者等待其他进程的响应)。
在这个事件发生之前,进程无法继续往下执行,此时操作系统会让这个进程下CPU,并让它进入“阻塞态”。
进程2
由于需要等待打印机设备而暂时无法继续执行,因此下CPU,并变为阻塞态。但不要混淆,此时,是打印机没有空闲,CPU是处于空闲状态的。
所以,此时CPU是再次处于空闲状态的,操作系统便又会选择一个处于就绪态的进程,让它上CPU运行。
当CPU空闲时,又会选择另一个“就绪态”进程上CPU运行。
接下来的故事是这样的:
这个打印机设备,之前是在为别的进程服务,那如果说这个打印机的服务结束,这个打印机空闲了下来。它空闲下来的时候,它就可以分配给刚才请求打印机的那个进程,也就是进程2
。所以,当操作系统把这个打印机资源分配给进程2
的时候,这个进程2
等待的事件其实就已经发生了。此时,操作系统会让这个进程2
从阻塞态,再次回到就绪态。
也就是,当进程等待的事件发生后,那么这个进程就再次拥有了上处理机运行的条件。
接着刚才的例子:
此时在CPU上运行的进程1
,它已经运行结束了。那么,在它运行结束的时候,它会发出一个叫exit
的系统调用,这个系统调用就是要请求操作系统终止这个进程,此时这个进程的状态就会变成“终止态”。然后,操作系统会让这个进程下CPU,并且做一系列善后的工作,会回收这个进程所占有的各种资源,包括内存空间、打印机设备等等,总之所有的资源都要回收。并且最后还会回收这个进程的PCB。
而当终止进程的工作完成后,这个进程就从系统当中彻底消失了。
再把刚才进程的各个状态,以及之间的转换,再串一下。
①一个进程在运行之前,需要被创建。在创建的过程当中,系统需要完成一系列创建相关的工作,包括新建PCB,还有给这个进程分配一系列的资源等等。如果一个进程正在处于被创建的过程中,那么这个进程就是处于“创建态”的。
当一个进程被创建完毕之后,它就拥有了可以上CPU运行的条件。那么,此时进程就进入了“就绪态”。也就是说,处于就绪态的进程,它其实只差处理机这种资源了,其他所有需要的资源、条件它都已经具备了。
②如果处于就绪态的一个进程,被操作系统调度,那这个进程就可以上处理机运行。当它在处理机上运行的时候,它就处于“运行态”。也就是说,正在处理机上运行的进程,其实是既拥有了它所需要的其他所有的那些资源、条件,同时它也拥有了处理机这种资源。
③而有的时候,正在运行的进程,可能会请求等待某个事件的发生,在这个事件发生之前,这个进程是没有办法往下继续运行的。所以在这种情况下,进程不应该一直占用处理机资源,所以此时这个进程应该被剥夺处理机资源。同时,除了被剥夺处理机资源之外,它还在等待其他的某种资源,或者说等待某一种事件的发生。此时该进程处于“阻塞态”。
④如果说,处于阻塞态的进程等待的事件发生了,或者申请的资源被分配了,这个进程就可以从阻塞态,又回到就绪态。那么,当它处于就绪态,就又说明,这个进程已经拥有了除了处理机以外的所有需要的资源、条件。
⑤处于运行态的进程,它可以主动地请求运行结束;或者说它在运行的过程中遇到了一些不可修复的错误(比如除以0),那么这种情况下这个进程也应该被终止。操作系统在对这个进程做相应的终止工作的时候,这个进程就处于“终止态”,此时操作系统会回收这个进程拥有的各种资源,并且最后会撤销该进程PCB。
从上述过程当中可以知道,运行态到阻塞态的这个转换,其实是进程主动的一种选择,是一种主动行为。一般来说都是进程通过主动发出系统调用的方式来申请某一资源或者请求某个事件的发生。所以这个转换的过程,是进程主动选择的。
③ 运行态 → 阻塞态 是一种进程自身做出的主动行为。
而阻塞态到就绪态的转变,它并不是由进程自身控制的。比如说一个进程正在等待打印机资源,那么这个打印机资源什么时候能分配给它,并不是这个进程说了算的。所以,从阻塞态到就绪态的转换,是一种被动的行为,并不是进程自己可以控制的。
④ 阻塞态 → 就绪态 不是进程自身能控制的,是一种被动行为。
需要注意的是,一个进程不可能由阻塞态直接转换为运行态,也不可能由就绪态直接转换为阻塞态。
因为进程要转变为阻塞态,肯定是进程在CPU上运行的过程中发出了主动请求,就必然是处于运行态的。所以,只可能从运行态→阻塞态,而不可能从就绪态→阻塞态。
再补充一些刚才没有提到的状态转换。
⑥进程可以直接从运行态→就绪态。比如说,操作系统给进程分配的时间片用完了的时候(时钟中断),进程就会从运行态转换为就绪态。这种情况下,其实进程并不缺乏任何使得其继续往下运行的条件,它只是被剥夺了处理机而已。它并不需要等待除了处理机之外的其他某个事件的发生。因此,进程是从运行态,回到了就绪态。
以上就是完整的进程五状态模型,以及之间的相互转化过程。
在这五种状态当中,运行态、就绪态、阻塞态的基本状态。因为在进程的整个生命周期当中,大部分的时间都是处于这三种状态的,所以它们是基本状态。
另外,需要强调一点,在单CPU的情况下,处于运行态的进程,同一时刻最多只会有一个;而如果说的是多核CPU的话,就意味着多个进程可以并行的运行,那在这种情况下,就会有多个进程都处于运行态。
另外需要强调的是,阻塞态又可以称为等待态;创建态又可以称为新建态;终止态又可以称为结束态。这些别名也稍微注意一下。
操作系统是怎么记录这些进程的状态的呢?
在之前我们提到过的进程PCB当中,会有一个成员变量state来表示进程的当前状态。比如1表示创建态、2表示就绪态、3表示……。
另外,操作系统会把处于同一个状态的进程,把它们的PCB组织起来,这样可以方便统一的管理。
所以,怎么把这些进程PCB组织起来,这个就是进程的组织要探讨的问题。
进程的组织有两种方式:链接方式、索引方式。
链接方式,就是操作系统会管理一系列的队列,每个队列都会指向相应状态的PCB。
比如“执行指针”,它会指向正处于“运行态”的PCB。而“就绪队列指针”它所指向的这个队列,就是此时系统中正处于就绪态的这些进程PCB。
那么,为了方便对这些进程的调度,操作系统经常会把优先级更高的那些进程PCB放在这个队列的队头。
“阻塞队列指针”也一样,它会指向当前处于阻塞态的进程PCB。
而在很多操作系统中,还会根据阻塞原因的不同,再把阻塞队列分成多个,如下图。
操作系统会给各个状态的进程,建立索引表,每个索引表中的每个表项,又会相应的指向PCB,如图所示。
(大多数的操作系统使用的都是链式方式)
进程的组织方式
- 链接方式
- 按照进程状态将PCB分为多个队列
- 操作系统持有指向各个队列的指针
- 索引方式
- 根据进程状态的不同,建立几张索引表
- 操作系统持有指向各个索引表的指针
总之,它回答的就是,操作系统该怎么把各个进程的PCB给组织起来,这样的一个问题。总之了解即可。
这个小节中,更值得注意的还是进程的状态、进程状态之间的转换问题。上图被框起来的部分,是考研当中最高频考察的部分。
进程控制
基本概念
什么是进程控制?
如何实现进程控制?
用“原语”实现。
进程控制相关的原语
- 进程的创建
- 进程的终止
- 进程的阻塞
- 进程的唤醒
- 进程的切换
进程控制的主要功能是对系统中的所有进程实施有效的管理,它具有创建新进程、撤销已有进程、实现进程状态转换等功能。
简单来说,进程控制,就是要实现进程的状态转换。
比如“创建新进程”,不就是让进程由“创建态”转换为“就绪态”吗。“撤销已有进程”,就是让进程由“运行态”转换为“终止态”。
那么,在进程状态转换的过程中,操作系统需要做一些什么事情呢?——这就是这个小节我们要讨论的内容。
进程控制是要用“原语”实现的。
而“原语”的概念,我们在第一章当中提到过。——操作系统的内核中,有一种特殊的程序叫原语,它的执行具有原子性。也就是说,这段程序的运行必须一气呵成,中间不允许被中断。
也就是说,进程控制(实现进程的状态转换)的操作一定是一气呵成的。
思考:为什么进程控制(状态转换)的过程要“一气呵成”?
例如:假设PCB中的变量state表示进程当前所处状态,1表示就绪态,2表示阻塞态……
那么,如果一个进程是处于就绪态的(state=1),这个进程的PCB肯定是要挂在就绪队列里的。而如果state=2的话,这个进程的PCB就一定挂在阻塞队列里。
接下来,考虑这样一种情况:
处于阻塞队列里的进程,它肯定是在等待某一事件的发生。那假设PCB2
这个进程,它所等待的事件已经发生了,那么,此时这个进程就要从阻塞态→就绪态了。
所以,操作系统内核程序就需要把这个进程的状态从阻塞态变为就绪态,而进行状态转换的过程,至少需要做两件事情:①把PCB2
的state
设为1;②把PCB2
从阻塞队列放到就绪队列。
接下来,考虑这样一种情况:
假设我把PCB2
的state已经设为1了(如上述的步骤①),而此时,(在执行步骤②,也就是将其从阻塞队列放到就绪队列之前)收到了中断信号,系统就会转而去处理中断。就会导致这样一个结果,PCB2
的state=1,但是它却处在阻塞队列里。就会导致,该进程的state所表示的状态,和该进程所处的队列不统一。
因此,如果操作系统对进程的状态转换如果不能“一气呵成”。就有可能导致操作系统中的某些关键数据结构信息不统一的情况,这会影响操作系统进行后续的别的一些工作,会让系统出错。
那刚好,“原语”这种程序,它具有一气呵成、不可中断的性质,所以我们可以用“原语”这种特殊的程序来实现“一气呵成”这样的事情,从而来实现进程控制。
原语的执行具有原子性,即执行过程只能一气呵成,期间不允许被中断。
现在会有个疑问,为什么“原语”就“一气呵成、不可中断”了呢?
其实它的原子性,是由两个特权指令:“关中断指令”和“开中断指令”实现的。
我们来看一下关中断、开中断,这两个指令的作用。
假设这是一个正在运行的内核程序,那CPU会依次执行这些指令,而且根据第一章的讲解,我们知道,CPU每执行完一条指令,之后,都会例行检查是否有中断信号需要处理。
如果它在执行完指令2
之后,CPU发现有一个中断信号,那么在这种情况下,CPU就会暂停当前执行的程序,转而执行处理中断的程序。等这个中断处理程序完成之后,再回到原来的程序中继续往下执行。
正常情况:CPU每执行完一条指令都会例行检查是否有中断信号需要处理,如果有,则暂停运行当前这段程序,转而执行相应的中断处理程序。
接下来看一下,如果执行到了“关中断指令”,会出现什么情况。
CPU在依次执行这些指令,当它执行了“关中断指令”这条特权指令之后,CPU就不再例行检查中断信号了。
CPU会接着往下执行。那如果在执行指令a
后,有一个外部中断信号到来了,它就并不会像之前一样例行检查是否有中断信号,而是会继续往下执行,一直到CPU执行了“开中断指令”之后,它才会恢复以前的那种习惯,即“每执行完一条指令,检查一下此时是否有中断信号”。
CPU执行了关中断指令之后,就不再例行检查中断信号,直到执行开中断指令之后才会恢复检查。
开中断指令执行之后,它这时才会检查是否有中断信号,于是便发现:来了一个外部中断信号我还没有处理。然后转而执行这个中断处理程序。
从这个例子我们就可以看到,在关中断和开中断这两个指令之间的这一系列的指令序列,它们的执行肯定是不可被中断的,这样就实现了所谓的“原子性”。
这样,开中断、关中断 之间的这些指令序列就是不可被中断的,这就实现了“原子性”。
这两个指令是特权指令。
但是,我们思考一下:如果这两个特权指令允许普通的用户程序使用的话,会发生什么情况?
那这是不是就意味着:我可以直接在我的程序最开头,植入一个“关中断指令”,一直到我的程序末尾,再执行“开中断指令”,这样的话,只要我的程序上CPU运行了,那我的程序就会一直霸占着CPU,而不会被中断。那显然,这种情形是不允许发生的。
所以,开中断、关中断指令是特权指令,只能让内核使用,而不能让普通用户使用。
至此,再次总结一下:
1、进程控制(进程的状态转换)这个事情,必须一气呵成。而想要做到一气呵成,我们可以用“原语”这种特殊的程序来实现。
2、而“原语”的实现,需要由开中断指令和关中断指令来配合着完成。
接下来需要探讨的是,实现进程控制的这些“原语”,到底做了哪些事情?
这些原语,每个原语所做的事情具体有哪些,的确很多,但不需要死记硬背,理解其前因后果即可。以理解为主,而不是想着把每一个步骤刻意的默写到位。
首先来看用于实现“进程的创建”的原语。
如果操作系统要创建一个进程,那么它就必须使用创建原语。
而创建原语,会干这样几件事情:
首先,申请一个空白的PCB。因为PCB是进程存在的唯一标志,所以,要想创建一个进程肯定要创建一个和它相对应的PCB。
另外,还会给这个进程分配它所需要的资源。比如内存空间,等等。
然后还会把这个PCB的内容进行一番初始化。比如分配PID、设置UID,等等。
最后,它还会把这个PCB插入到就绪队列。
所以,创建原语让一个进程从创建态→就绪态。
那有一些典型的事件,会引起操作系统使用创建原语创建一个进程。
比如,当一个用户登录的时候。操作系统会给这个用户建立一个与之对应的用户管理进程,或者用户通信进程,等等。
或者,发生作业调度的时候,也会创建一个进程。
作业,就是此时还放在外存里的、还没有投入运行的程序。所谓的作业调度就是指,从外存当中挑选一个程序,把它放入内存,让它开始运行。
那我们知道,当一个程序要运行的时候,肯定是要创建一个进程的。所以,当发生作业调度的时候,就需要使用到创建原语了。
有时,一个进程可能向操作系统提出某些请求,操作系统会专门建立一个进程来处理这些请求。
还有的时候,一个进程可以主动请求创建一个子进程。
总之,发生这些事件的时候,都会引起系统创建一个新的进程,也就是说,它会使用到创建原语。
撤销原语是终止一个进程所使用的。
使用撤销原语之后,就能使进程由某种状态转向终止态,最终这个进程从系统中彻底消失。
撤销原语需要做这样的一些事情:
很多事件会引起一个进程的终止:
Ctrl+Alt+delete
打开任务管理器来强行结束某一进程。有时一个进程要从运行态→阻塞态,这时操作系统就会在背后执行一个阻塞原语来实现。
阻塞一个进程比较简单:
首先,找到要阻塞的进程,它对应的PCB。
之后,需要保护进程运行的现场,系统还需要将PCB的状态信息设置为“阻塞态”,还要让进程下处理机、暂停其运行。
什么叫“保护进程运行现场”,这个一会儿再解释,因为这又是一个比较庞大的话题。
最后,将PCB插入相应事件的等待队列。
经过之前的学习我们知道,一个进程需要被阻塞,那肯定是因为它主动请求要等待某一事件的发生。
而如果这个进程所等待的事件发生了之后,这个进程就会被唤醒,也就是说,操作系统会让这个进程的状态从阻塞态→就绪态。
那么这个时候就要用到唤醒原语。
唤醒原语需要做这样几个事情:
无论是阻塞原语、唤醒原语,它们做的这个流程其实很容易理解。
但是需要注意的是:一个进程因为什么事情被阻塞,就应该被什么事情给唤醒。所以,唤醒原语和阻塞原语,必须是成对使用的。
切换原语,会让此时正处于运行态的进程下处理机,让它回到就绪队列;并且从就绪队列当中,选择一个处于就绪态的进程,让它上处理机运行。
所以,切换原语会让两个进程的状态发生改变:一个是运行态→就绪态;另一个是就绪态→运行态。
切换原语需要做如下一些事情:
首先,要把进程的运行环境信息存到PCB当中
什么叫“进程的运行环境信息”呢?这点涉及到一些硬件的知识,我们一会儿再展开细聊。
另外,它还会把进程的PCB移到相应的队列。
它还会挑选一个进程,让它上处理机运行,并且更新其PCB。
同时,它还会根据这个新进程的PCB,恢复出它所需要的运行环境。
什么叫“保存运行环境”,什么叫“恢复运行环境”,这是比较难理解的地方,接下来我们深入探讨一下这个问题。
再次拓展:程序是如何运行的?
通过之前的学习,我们已经了解到了,一个程序的运行需要经历这样一个流程。
程序运行之前,需要把它的指令放到内存当中,然后CPU从内存中读取这些一条一条的指令并且执行。
但是接下来我们要拓展一个更深层次的细节:
CPU在执行这些指令的过程中,需要进行一系列的运算。那么,CPU当中会设置很多的“寄存器”来存放这些指令在运行过程当中所需要的某些数据。总之,寄存器,就是CPU里面用于存放数据的一些地方。
那CPU里面会有各种各样的寄存器。
比如说,我们之前提到过的PSW
,就是程序状态字寄存器
,CPU的状态,内核态/用户态,这个状态信息,就是保存在PSW这个寄存器里面的(当然,PSW里面还保存了一些其他的信息,这个我们就不说了,这是计组需要探讨的内容)。
另外,CPU中还会有一个比较关键的寄存器,叫做PC
,也就是程序计数器
寄存器,这个寄存器里面存放的是接下来需要执行的指令它的地址是多少。
另外,CPU中还会有一个指令寄存器
,IR
。这个寄存器中存放的是,当前CPU正在执行的那条指令。
此外,CPU还会有其他的一些通用寄存器,用来存放一些别的重要信息。
总之,CPU中有很多寄存器,我们这只列举了操作系统这门课需要了解一下的寄存器。
接下来我们分析一下,对于如下这样一系列指令的执行,在背后发生了什么样的事情?
指令1
,那么它会把指令1的内容
读到IR
当中,并且,PC
中会存放接下来它应该执行的那条指令,也就是指令2
的地址。
指令1
时,它发现,它要做的是在内存中的某个地方,写入变量x的值。那么CPU在执行指令1的时候,就会往内存中的某个地方写入变量x的值,为1。指令1
后,CPU就会执行下一条指令,那么通过PC
这个寄存器,它就知道下一条要执行的指令,应该是指令2
,所以接下来它会取出指令2
,把指令2
的内容放在IR
中。同时,PC
的内容也更新为再下一条指令。
指令2
的内容是,把变量x的值放到某一个通用寄存器当中。所以,CPU会从内存中取出这个x变量的值,把它放到通用寄存器当中。于是这个通用寄存器的内容就变成了1。指令3
,然后PC
、IR
的内容同样也会更新。
指令3
的内容,它会把通用寄存器中的数据进行+1
的操作。所以,通用寄存器中的值就会从1变成2。指令4
,是让它把这个通用寄存器当中的内容,把它重新写回到变量x所存放的位置当中。所以执行了指令4
之后,就会把内存中x的值,从1改为2。 可以看到,我们的int x=1;
和x++;
两个操作,其实CPU是在背后通过执行更基本的指令,来完成的。
并且从刚才讲的这些过程当中我们可以发现,这些指令按顺序执行的过程当中,有很多中间结果是放在这些寄存器当中的。比如x++;
这个操作,刚开始得出的2
这个值并没有赋给x
,而是先放在了通用寄存器
当中。
更值得注意的是,这些寄存器,并不是当前正在执行的这个进程所独属的。如果其他进程再上CPU运行的话,那么这些寄存器也会被其他进程所使用。
那么,这就会发生一种情况:
还是刚才的例子,假设CPU执行完指令3
之后,此时CPU需要转而执行另一个程序的话。
我们知道,另一个进程如果上CPU运行的话,另一个进程也会同样地使用到这些寄存器。所以,另一个进程上CPU运行的时候,有可能把前一个进程在寄存器当中保留的这些中间结果给覆盖掉。
而我们刚才的那个进程不是执行到了指令3
吗,而因为刚才执行到指令3时,寄存器当中得到的中间结果,此时都已经被覆盖掉了,所以这个进程就已经没有办法再往下运行了。
所以,为了解决这个问题,可以采取这样的策略:
当一个进程,它要下处理机的时候,可以把它之前运行的这个运行环境的信息,把它保存在自己的PCB当中。当然,这个PCB当中并不需要把所有的寄存器信息都保存下来,只需要保存一些必要的信息就可以了(比如PSW、PC、通用寄存器)。
比如,这个进程在执行指令3
后,要下处理机了,那么我们将此时必要的寄存器信息保存在其PCB当中,接下来才去切换成别的进程。
接下来,别的进程在使用CPU的时候,可能会往这些寄存器里面写各种各样的数据。总之,会覆盖之前那个进程的数据。
但是,当之前的那个进程需要重新回到CPU运行的时候,操作系统就可以根据之前保存下来的这些信息,来恢复它的运行环境了。那把它的运行环境恢复之后,CPU就知道,接下来它要执行的是指令4
,并且此时通用寄存器当中存放的数据是2。
所以,既然接下来要执行的是指令4
。那CPU就会根据PC
的指向,把指令4
的指令内容取到IR
当中,同时再让PC
去指向下一条指令。然后执行当前要执行的指令4
,看看该指令具体要干什么,它发现,它需要把当前通用寄存器当中的内容,写回到x存放的位置。
所以,接下来,它就会把2
这个数据,写回到x
的这个位置。
至此,这两个C语言代码就真正完成了。
上述讲了这么多的内容,主要就是为了让大家理解,什么叫“进程的运行环境信息”。其实所谓的“进程运行环境”,或者叫“进程上下文(Context)”,它就是运行过程当中,寄存器中存储的那些中间结果。当一个进程需要下处理机的时候,需要把它的运行环境存储到自己的PCB当中;而当一个进程需要重新回到CPU运行的时候,就可以重新从PCB当中恢复它之前运行的那个环境,让它继续往下执行了。
所以,保存进程的运行环境、恢复进程的运行环境,这是实现进程并发运行的一个很关键的技术。
(这些硬件的知识,在学过计组后会更好理解了)
对于相关原语具体做了哪些步骤,不需要死记硬背,理解即可。其实,无论是哪种原语,它所做的工作,无非就是三类事情:
1、更新PCB当中的一些信息(主要是:①修改进程状态字state;②保存/恢复运行环境)
2、把PCB插入到某合适的队列当中
3、在进程创建、终止的过程中,还应考虑分配/回收资源的问题
这个小节我们会学习进程通信的几种方式,分别是
- 共享存储
- 基于数据结构的共享
- 基于存储区的共享
- 消息传递
- 直接通信方式
- 间接通信方式
- 管道通信
进程之间的通信(Inter-Process Communication, IPC)是指两个进程之间产生数据交互。
在一个系统当中,同时会有多个进程正在运行,那么这些进程之间难免需要相互配合着工作,在这种情况下,进程和进程之间的数据通信就显得很有必要了。
比如正在浏览微博的时候,可以通过分享功能,把一条微博分享给微信好友。这时就发生进程间的通信了。本来那条微博的链接是在微博里面的,直接就分享到微信里面了,这个过程显然是进程和进程之间发生了数据交互、发生了通信 。
既然进程之间的通信是很有必要的,那么该怎样实现进程之间的通信呢?这个需要操作系统的支持。
为什么进程之间的通信一定要有操作系统内核的支持,原因是这样的。
我们系统中给各个进程分配内存地址空间的时候,各个进程的内存地址空间是相互独立的,比如进程P它可以访问自己的空间,进程Q可以访问自己的空间。但是进程P不可以访问进程Q的地址空间。
这么规定,是出于安全的考虑。因为,如果一个进程可以随意地访问其他进程的内存地址空间,那么一个进程就可以随意修改其他进程的数据了,或者随意读取其他进程的数据。那这样的话,试想一下,比如你的手机里面不知什么时候安装了一个垃圾软件,这个垃圾软件,如果它可以随意地访问其他进程的地址空间,那有可能它直接把你微信里的私密的一些聊天数据,或者照片之类的,直接读取走了。这显然是不安全的。
因此,出于安全考虑,各个进程只能访问自己的这片内存地址空间,而不能访问其他进程的内存地址空间,无论是读、写,都不行。
因此,如果P和Q,它们之间想要进行数据交互,想要进行进程之间的通信,那么显然,进程P是不可能直接把这个数据写到Q的这片空间里面的。
所以,由于进程不可直接访问其他进程的内存地址空间,因此就必须要有操作系统的支持才可以完成进程之间的通信。
接下来会介绍三种进程之间的通信方式:共享存储、消息传递、管道通信。
各个进程只能访问自己的这片空间,但是如果操作系统支持共享存储的功能,那么一个进程,它可以申请一片共享存储区。而这片共享存储区,也可以被其他进程所共享。
这样的话,一个进程P,如果它要给Q传送数据的话,那么P就可以先把数据写到这片共享存储区里面,因为P对共享存储区是有访问权限的。接下来进程Q再从共享存储区里面读出数据。
由于共享存储区可以被多个进程所共享,因此这些数据之间的数据交换,就可以通过这一片被共享的区域来进行。这就是共享存储的进程间通信方式。
比如Linux中,如何实现共享内存:
int shm_open(...); //通过shm_open系统调用,申请一片共享内存区
void * mmap(...); //通过mmap系统调用,将共享内存区映射到进程自己的地址空间
(注:什么叫内存区映射,这是第三章内容。通过“增加页表项/段表项”,即可将同一片共享内存区映射到各个进程的地址空间中。)
另外,还需要注意一个问题,如果多个进程都往这片区域写数据的话,有可能会导致写冲突,会导致数据覆盖的问题。所以,各个进程之间如果使用共享存储的方式来进行通信的话,那么需要保证各个进程对这个共享存储区的访问是互斥的。也就是当进程P正在访问这片区域的时候,那其他进程就不能访问这片区域。
怎么实现这个互斥的功能呢?
操作系统内核会提供一些同步互斥工具(比如在后面会学习的P、V操作),各个进程从而能够对共享存储区实现一种互斥的访问。
刚才我们说的这种共享存储的方案,是基于存储区的共享。操作系统给你划定了这么大的一片区域,之后,若干个进程,到底是想往这片区域里的哪个位置写,或者从哪个位置读,这些都是很自由的。操作系统只负责把这片区域划给你,但是并不管你怎么使用这片区域。
基于存储区的共享:操作系统在内存中划出一块共享存储区,数据的形式、存放位置都由参与通信的进程自己控制,而不是操作系统。这种共享方式速度更快,是一种高级通信方式。
相比之下,还有一种基于数据结构的共享。
操作系统给你们两个进程,划定的共享区域,它就规定,只能存放一个长度为10的数组。这样的话,各个进程之间的通信自由度就没那么高,并且传送数据的速度也会比较慢。
基于数据结构的共享:比如共享空间里只能放一个长度为10的数组。这种共享方式速度慢、限制多,是一种低级通信方式。
如果采用这种通信方式,那么进程之间的数据交换会以格式化的消息(Message)为单位。通过操作系统提供的“发送消息/接收消息”两个原语来进行数据交换。
所谓格式化的消息,由两个部分组成:消息头、消息体。
消息头,要写明注明,这个消息是由谁发送的,到底要发送给谁,整个消息的长度是多少,等等这些概要性的信息。
消息体,就是具体的,一个进程想要传送给另一个进程的数据。
这种消息传递的通信方式,又可以进一步划分为:直接通信方式、间接通信方式。
其中,直接通信方式就是,发送进程要指明接收进程的ID。(系统里每一个进程都会有一个ID,叫PID)直接通信方式的意思就是,我发送的时候,直接点明,就是要它接收。
而间接通信方式,会通过一个叫作“信箱”的中间实体来进行通信。所以间接通信方式又称为“信箱通信方式”。
进程P现在要给进程Q发送一个消息,而在操作系统的内核区域是管理着各个进程的PCB的。
同时,会有各个进程PCB对应的消息队列。比如进程Q就有一个进程Q的消息队列
,也就是其他进程要发送给进程Q、应该被进程Q接收的这些消息,都挂在这个队列里面。
现在,进程P要给进程Q发送一个消息。首先,每个进程自己是有自己的地盘、自己的内存空间的,它会在此先完善这个消息的信息(如图msg),包括消息头、消息体。接下来,进程P会使用到发送原语send(Q, msg)
(操作系统提供的发送原语),用它来指明,我的这个消息msg
,是要发送给Q
这个进程。
这个发送原语,会导致操作系统内核接收到这个消息,并且会把它 挂到进程Q的消息队列里面。
此时,这个消息msg
由进程P的内存空间,复制到了内核空间当中。
接下来,进程Q通过接收原语receive(P, &msg)
,来指明现在要接收一个消息,是P
发来的。此时,操作系统会检查进程Q的消息队列,看一下这几个消息到底哪一个是由P发送过来的。
找到了由P发送过来的消息,那么操作系统内核会把这个消息体的数据,又从操作系统的内核区复制到进程Q的用户区、地址空间。
消息传递——直接通信方式:点名道姓的消息传递。我在发送的时候,指明要发送给谁;我在接收的时候,指明要接收谁发来的消息。
刚才我们说过,间接通信方式,它需要一个中间实体(所谓的“信箱”)来进行消息的传递(所以又称之为信箱通信方式)。
这种通信方式是这样来实现的:
进程P和进程Q想要进行通信。那么进程P可以通过系统调用,申请一个信箱,当然也可以申请多个邮箱。比如此处,进程P申请了信箱A、信箱B。
现在,这两个进程怎么进行通信呢?
首先,进程P在自己的地址空间里完善消息msg
的内容,然后进程P可以使用发送原语send(A, msg)
,往信箱A
发送消息msg
。
间接通信方式,是指明了我要发送到哪个信箱,并没有指明我要发送给哪个进程。
那,进程Q在使用接收原语的时候,它可以指明,我要从信箱A中接收一个消息体。这样,信箱A中的这一个msg,就会被操作系统复制到进程Q的空间中了。
这就是使用信箱来完成消息传递的过程。
通常来说,操作系统是可以允许多个进程往同一个信箱里send消息,也可以多个进程从同一个邮箱里receive消息。
“管道”这个词还是很形象的,它就像一个水管、管道一样。就是,写进程可以从管道的一边写入数据,读进程从管道的另一边取走数据。
这个数据的流动只能是单向的。从左到右,或者从右到左,就像一根水管里的水流一样,不可以是双向同时进行的。
这里的管道,其实是一种特殊的共享文件,又名pipe文件。也就是,如果两个进程要用管道的方式进行进程通信,那么首先我们需要系统调用的方式,来申请一个管道文件,操作系统会新建这个管道文件。这个文件的本质就是在内存当中开辟了一个大小固定的内存缓冲区。然后,两个进程可以往这个内存缓冲区里面写数据和读数据,但是这个数据的读写是先进先出的(FIFO)。
问题:说到这里,管道通信是为两个进程开辟了一块内存缓冲区,而刚才所说的共享存储,也是开辟了一块共享存储区,也是可以被进程P、Q共享访问的。那么它们有什么区别?
区别是这样的:
刚才我们讲的基于存储区的共享,进程P、Q对于共享存储区中,具体的存储、读取位置,没有任何限制,很自由。但是,管道通信的方式,其中是一个数据流的形式,如果前边有空位,则写数据的时候要先往前边写,前边占满了再接着往后边区域写;读数据的时候也一样,只能先把前边的数据读空了,才可以读后边的这些数据,先进先出。
所以,管道通信和共享存储通信,区别还是很大的,管道通信的读写一定是先进先出的,可以把它理解为一个循环队列;而共享存储的读写是没有任何限制的。
1、管道只能采用半双工通信,某一时间段内只能实现单向的传输。如果要实现双向同时通信(全双工通信),则需要设置两个管道。
2、各进程要互斥地访问管道。(由操作系统实现)
3、刚才已经介绍到,这个管道是一个大小固定的内存缓冲区,因此会被写满。当管道写满时,写进程将阻塞,直到读进程将管道中的数据取走,即可唤醒写进程。
4、另一个方面,当管道读空时,读进程将阻塞,直到写进程往管道中写入数据,即可唤醒读进程。
5、(这一点,是很多教材最有争议的一个地方)管道通信的方式就决定了,一旦管道中的数据被读出,那就彻底消失了。所以,如果有多个读进程在读同一个管道的时候,就有可能导致错乱:因为我们管道里面的数据,它并没有指明我到底是要给进程Q的、还是要给进程R的。所以,如果多个进程都从同一个管道这读数据的话,那么就有可能这个数据的读取动作是比较乱的:第一块数据被Q读走了、第二块数据被R读走了。
针对这一问题,不同的操作系统会有不同的解决方案:①一个管道允许多个写进程,一个读进程(2014年408真题高教社官方答案);②允许有多个写进程,多个读进程,但系统会让各个读进程轮流从管道中读数据(Linux的方案)。
有的操作系统是①的方案,有的操作系统是②的方案。因此,有的教材按照①来说,管道允许多个写进程、但只允许一个读进程;而有的教材按照②来说,一个管道允许多个写进程、多个读进程。
对于408考试来说,按照①来说即可。但是从现实应用的角度来看,①②都是存在的。
共享存储、消息传递、管道通信这三种常见的进程间通信方式。这三种通信的功能都是需要操作系统的底层来支持的。
共享存储,会设置一块共享内存区,并映射到进程的虚拟地址空间(这个问题在学完第三章,段表、页表之后再回来看就懂了,这个很简单)。
另外,各个进程对共享空间的访问要互斥地进行,这个互斥的效果是由进程自己进行控制的,如使用P、V操作来实现。实际上,对于共享存储区的互斥访问,是一个经典的同步互斥问题:读者、写者问题(在后面会讲)。
消息传递分为两种通信方式。直接通信方式就是要指名道姓地指明我要把消息发给哪个进程,然后操作系统会把这个消息直接挂到接收进程的消息队列里面。
间接通信方式又叫信箱通信,操作系统会把消息放到指定的信箱当中,而消息的接收者也需要指明自己从哪个信箱当中取走消息。
管道通信。对于操作系统而言,管道通信的这个管道,是一个特殊的共享文件(比如你在Linux系统上是的确能够找到这一文件的),但本质上这个文件就是一个内存缓冲区,如果结合数据结构的知识来看的话,这个内存缓冲区其实就是一个循环队列。那么,如果管道写满了,写进程就会被阻塞;如果管道读空了,读进程就会被阻塞。
此外,一个管道文件只能实现半双工通信。这类似于现实生活中的一根水管,同一时刻,水不可能既从左往右流、又从右往左流。
所以,如果想要从左往右、从右往左的水流同时存在的话,我可以建立两个管道,实现双向同时通信。
读进程想要从管道读数据,只需保证一点:管道不是空的,即可。至于管道是否写满、还是只写了一部分,都无所谓,只要不是空的就能读。
写进程同理,只要管道没满,还有空间让我可以写,就可以往管道中写数据。至于管道是完全空了、还是有一部分数据,都无所谓,只要不满就能写。
线程
- 什么是线程,为什么要引入线程?
- 引入线程机制后,有什么变化?
- 线程有哪些重要的属性
在很久很久以前,在没有引入进程之前,系统中的各个程序只能串行执行。所以,在那个时候,想要一边运行QQ、一边运行音乐播放器,是无法实现的,我们不能边聊QQ、边听音乐。
但是在后来,我们引入了进程之后,就可以实现边聊QQ、边听音乐,这样的事情。
但是,再来深入思考一下,QQ具体可以做什么事情呢?
我们可以用QQ进行视频、文字聊天、传送文件。这些事情,在进程当中是怎么完成的?
很显然,在传统的进程定义当中,进程是程序的一次执行。但是这些功能,不可能是由一个程序顺序执行来处理的。如果只能顺序执行,就不可能在用户看来这几件事情是可以同时发生的。
所以,有的进程,它是需要“同时”处理很多事情的。但是传统的进程,只能串行地执行一系列的程序代码。
在传统的进程中,CPU只能轮流地为每个进程服务。这些进程就可以并发地执行,并且每一个进程会有它自己相应的一系列代码。被CPU服务的时候,这些代码就可以一句一句地往下运行。
传统的进程是程序执行流的最小单位。
为了满足像刚才我们说的,一个进程当中同时做很多事情,人们又引入了线程机制,增加系统的并发度。
引入线程之后,CPU的调度、服务对象,就不再是进程,而是进程当中的线程。每一个进程当中可能包含多个线程,CPU会用一定的算法、轮流地为这些线程进行服务。
那么,对于QQ进程里面的两个功能:视频和文字聊天,如果想让它俩并发运行的话,就可以让它们分别作为QQ进程当中的两个线程。
在引入线程后,线程就成为了程序执行流的最小单位。
可以把线程理解为“轻量级进程”。
线程是一个基本的CPU执行单元,也是程序执行流的最小单位。
引入线程之后,不仅是进程之间可以并发,进程内的各线程之间也可以并发,从而进一步提升了系统的并发度,可以使得一个进程内也可以并发处理各种任务(如QQ视频、文字聊天、传文件)。
引入线程后,进程不再是CPU调度的基本单位,进程只作为除CPU之外的系统资源的分配单元。意思就是说,假如计算机当中有各种各样的系统资源,那么这些资源是分配给进程的,而不是分配给线程。
对于资源分配调度、并发性,方面的变化,上面已经讲清楚了。
系统开销方面。传统的进程并发,需要切换进程的运行环境,切换进程的运行环境,系统开销比较大。引入了线程之后,如果我们是在同一个进程之中对于不同线程进行切换的,我们就不需要切换进程的运行环境,则并发带来的系统开销就会降低。
类比:去图书馆看书。
切换进程运行环境,相当于,有一个不认识的人要用桌子,你需要把你的书收走,他把自己的书放到桌上。
同一进程内的进程切换,相当于,你的室友要用这张书桌,既然你俩认识,可以不把桌子上的书收走。
线程
- 线程的实现方式
- 用户级线程
- 内核级线程
- 多线程模型
- 一对一模型
- 多对一模型
- 多对多模型
用户级线程(User-Level Thread, ULT)
历史背景:早期的操作系统(如:早期Unix)只支持进程,不支持线程。当时的“线程”是由线程库实现的。
在这个时代,操作系统看到的依然的只有进程。但是程序员们写的程序当中,可以使用线程库来实现多个线程并发地运行这样的事情。
QQ的视频聊天、文字聊天、传送文件,如果要让这三个事情并发地运行的话,那么在不支持线程的操作系统当中,我们可以分别建立三个进程,这三个进程,分别是处理其中的某一个事情的。
进程1的代码是不断地来处理视频聊天这件事情的,进程2是不断地来处理文字聊天,进程3是不断地处理传送文件的。用一个while(true)
控制其不停地循环。
那么,其实我们可以用这样的方式来实现,让这三段代码并发地运行,如下图。
由于我们while循环执行速度是非常快的,所以这三个事情可以看作并发执行。
从代码的角度看,线程其实就是一段代码逻辑。上述三段代码逻辑上可以看作三个“线程”。while循环就是一个最弱智的“线程库”,线程库完成了对线程的管理工作(如调度)。
我们上面通过一个while循环和若干个if语句,就实现了一个最简单的线程库。线程库本质上就是这么个意思。
很多编程语言提供了用于管理线程的强大的线程库,可以实现线程的创建、销毁、调度等功能。
操作系统仍然只能看到进程,只不过程序员用代码逻辑实现了内部各个事件的并发,通过线程库的方式创建了“逻辑上的线程”,也就是所谓的用户级线程。
思考:
1、线程的管理工作由谁来完成?
是由应用程序通过线程库来完成的(比如我们上面用while循环,里面三个if语句管理线程的并发执行),并不是操作系统负责的。
用户级线程由应用程序通过线程库实现,所有的线程管理工作都由应用程序负责(包括线程切换)。
2、线程切换是否需要CPU变态?
很显然,线程之间的交替执行,只是代码语句中的条件,和请求操作系统服务没有半毛钱关系。是应用程序自己内部执行的,不需要操作系统的干涉,不需要CPU切换状态。
用户级线程中,线程切换在用户态下即可完成,无需操作系统干预。
3、操作系统能否意识到用户级线程的存在?
显然,操作系统只能看到这个进程的存在,它只知道有这样一个进程,有一整块代码。这一整块代码里面又分别被分为了几个线程,操作系统是意识不到这些线程的存在的。这也是这种线程的实现方式叫做用户级线程的原因,只有用户才能感知到这个用户级线程的存在,而操作系统感知不到。
在用户看来,是有多个线程。但是在操作系统内核看来,并意识不到线程的存在。“用户级线程”就是“从用户视角看能看到的线程”。
4、这种线程的实现方式有什么优点和缺点?
优点:用户级线程的管理工作,包括切换、创建等,都不需要请求操作系统的服务,只在用户态下就能完成,也就是对用户级线程的管理,并不需要涉及到CPU变态这件事情。而之前说过,CPU变态是有开销、有成本的,所以对于用户级线程的管理工作肯定开销小、效率高。
缺点:还看我们刚才写的最简单的这个“线程库”,看一下这三个用户级线程的执行,如下图
假设QQ进程上处理机运行。而这次运行的时候,是i==0
的,也就是视频聊天的这段代码会上处理机运行。但是,假设视频聊天的这段代码,在运行的过程中发生了阻塞,比如说它想要申请摄像头资源但是申请失败,那么由于它想要的那个系统资源得不到满足,因此这段代码的执行就会被阻塞。那么,代码的执行被阻塞在{处理视频聊天的代码;}
这里了,那么这整个while
循环,就不能进行下去了。只有这个阻塞被解除之后,这个循环才能继续进行下去。
所以,这种用户级线程有一个很明显的缺点,就是,如果其中的某一个线程被阻塞,那么其他的线程也会被阻塞、也没办法运行下去。
此外,这种实现方式,CPU的调度单位依然是进程,操作系统是给进程分配CPU时间的,因此即使电脑是多核处理机,但是由于进程才是CPU调度的基本单位,因此这个进程只能被分配一个核心,其中的各个线程不能并行地运行。
优点:用户级线程的切换在用户空间即可完成,不需要切换到核心态,线程管理的系统开销小,效率高。
缺点:当一个用户级线程被阻塞后,整个进程都会被阻塞,并发度不高(最大的缺点)。多个线程不可在多核处理机上并行运行。
这就是在早期的时候,人们实现线程的方式。在这个时候,操作系统还只支持进程,不支持线程。
之后,随着操作系统的发展,越来越多的系统开始支持线程。
内核级线程(Kernel-Level Thread, KLT),又称“内核支持的线程”。是由操作系统支持的线程。
这种内核级线程,就是操作系统视角也可以看得到的线程。大多数现代操作系统都实现了内核级线程,如Windows、Linux。
接下来,同样思考几个问题:
1、线程的管理工作由谁来完成?
由于这个线程是在操作系统层面实现的线程,因此这个线程的管理工作当然是需要由操作系统来完成。
内核级线程的管理工作由操作系统内核完成。
2、线程切换是否需要CPU变态?
既然这些线程由操作系统管理,那么它们的管理工作肯定是需要操作系统介入的。因此,在进行线程切换的时候,当然是需要从用户态转变为内核态的。
线程调度、切换等工作都由内核负责,因此内核级线程的切换必然需要在核心态下才能完成。
3、操作系统是否能意识到内核级线程的存在?
能。
操作系统会为每个内核级线程建立相应的TCB(Thread Control Block,线程控制块),通过TCB对线程进行管理。“内核级线程”就是“从操作系统内核视角看能看到的线程”。
4、这种线程的实现方式有什么优点和缺点?
优点:如果一个操作系统支持内核级线程的话,那么在这种操作系统当中,内核级线程是处理机调度的基本单位,而进程只作为分配资源的基本单位。因此在多核CPU的环境下,这几个线程可以分别分派到不同的核心下、并行地执行。
另外,不同的内核级线程中,可以跑不同的代码逻辑,如上图。那么,由于内核级线程是处理机分配的基本单位,那在这种情况下,即使其中的某一个线程被阻塞,那其他的线程依然可以继续执行下去。
缺点:当引入了内核级线程之后,一个进程有可能会对应多个内核级线程,操作系统需要对这些线程进行管理,所以内核级线程之间的切换,需要CPU从用户态变为内核态,当切换完成之后还需要从内核态变为用户态。而之前我们提到过很多次,CPU变态是有成本、有开销的,所以这种实现方式会导致线程的管理成本更高、开销更大。
优点:当一个线程被阻塞后,别的线程还可以继续执行,并发能力强。多线程可在多核处理机上并行执行。
缺点:一个用户进程会占用多个内核级线程,线程切换由操作系统内核完成,需要切换到核心态,因此线程管理的成本高,开销大。
我们刚才介绍了用户级线程、内核级线程,这两种线程的实现方式都各有优缺点。
那么有没有可能,将这两种线程的实现方式,结合起来呢?
在支持内核级线程的系统当中,如果再引入线程库的话。那么,我们就可以实现,把若干个用户级线程映射到某一个内核级线程,这样的事情。
根据用户级线程和内核级线程的这种映射关系,就引出了三种多线程模型。
在支持内核级线程的系统中,根据用户级线程和内核级线程的映射关系,可以划分为几种多线程模型。
像刚才我们一直在讲的这种模型,一个用户级线程,对应一个内核级线程,这个是一对一模型。
一对一模型:一个用户级线程映射到一个内核级线程。每个进程有多少个内核级线程,就有多少个用户级线程,即每个用户进程有与用户级线程同数量的内核级线程。
优点:和刚才所说的一样。一个线程被阻塞后,别的进程还能正常执行,因为内核级线程是处理机分配的基本单位。另外,这些用户级线程可以在多核处理机上并行地执行。
缺点:和刚才所说的一样。一个用户进程会占用多个内核级线程,线程切换由操作系统内核完成,需要切换到核心态,因此线程管理的成本高,开销大。
多对一模型:多个用户级线程映射到一个内核级线程。且一个进程只被分配到一个内核级线程。
如果是这种映射关系,其实它就退化到了我们之前提到的纯粹的用户级线程那种实现方式了。
优点:用户级线程的切换在用户空间即可完成,不需要切换到核心态,线程管理的系统开销小,效率高。
缺点:当一个用户级线程被阻塞后,整个进程都会被阻塞,并发度不高。多个线程不可在多核处理机上并行运行。
注意:对于多对一模型,我们默认“一个进程只对应一个内核级线程”。
操作系统只“看得见”内核级线程,因此只有内核级线程才是处理机分配的单位。
多对多模型:n个用户级线程映射到m个内核级线程(n ≥ m)。每个用户进程对应m个内核级线程。
上图中,由于一个进程有两个内核级线程,因此,其中一个内核级线程被阻塞的话,另一个内核级线程是可以继续运行下去的。因此,它克服了多对一模型并发度不高的缺点(一个阻塞全体阻塞)。另外,这种多对多的模型,n是大于等于m的,也就是内核级线程的数量,是少于用户级线程的,因此操作系统对这些线程的管理开销也相应的会更小,因此又克服了一对一模型中一个用户进程占用太多内核级线程,开销太大的缺点。
至此,我们再来理解一下用户级线程和内核级线程之间的区别和联系。
可以这么理解:
所谓的用户级线程,其实就是一个“代码逻辑”的载体。
而内核级线程,可以理解为是一个“运行机会”的载体。因为操作系统在分配CPU资源的时候,是以内核级线程为单位进行分配的。内核级线程才是处理机分配的单位。(因此,上图中,这个进程最多能被分配两个核心)
一段“代码逻辑”只有获得了“运行机会”才能被CPU执行。
这可以让我们的线程管理有更多的灵活性,比如上图中,假设“视频聊天”需要耗费比较多的CPU资源的话,那么我们可以让左边这个内核级线程专门执行“视频聊天”的代码逻辑;而右边这个内核级线程,可以让它并发地执行“文字聊天”和“文件传输”的代码逻辑。
那么,假设在某一时刻,文件传输需要耗费很多的CPU资源,那么此时我们可以让“文字聊天”去左边的内核级线程上处理。
需要注意的是,在引入了内核级线程之后,一个进程可能会对应多个内核级线程,而只有所有的内核级线程都被阻塞的时候,我们才说这个进程进入了阻塞状态。
内核级线程中可以运行任意一个有映射关系的用户级线程代码,只有两个内核级线程中正在运行的代码逻辑都阻塞时,这个进程才会阻塞。
处理机调度
- 基本概念
- 三个层次
- 高级调度(作业调度)
- 中级调度(内存调度)
- 低级调度(进程调度)
- 三层调度的联系、对比
- 补充知识
- 进程的“挂起态”
- 七状态模型
其实调度的概念离我们的生活并不遥远。
比如在我们去银行的时候,这个银行,它可能有几个窗口为客户服务。那么,这些客户,到底应该先为谁服务呢?银行一般采用的是先到先服务的这种原则。
如果说此时有一个vip客户,这个客户在银行里可能就会优先被服务、优先级更高。
再看另外一个例子。一个宿舍,早上大家都想上卫生间。每个人都想使用,但是有的人想使用3分钟,有的人要10分钟,有的人要1分钟……那么,大家在商量之后,就决定了一种使用这个资源的一个原则:使用时间短的就先使用,使用时间长的就后使用;如果时间长度相同的,先排队的先使用。
其实所谓的调度,它指的就是,当我们有一堆任务要处理的时候,由于当前的资源有限,那么这些事情没办法同时地被处理,那这个时候,我们就需要按照某种规则(比如先到先服务、时间短的优先),来决定我们要用什么样的顺序来处理这些任务。这就是所谓的“调度”。
当有一堆任务要处理,但由于资源有限,这些事情没法同时处理。这就需要确定某种规则来决定处理这些任务的顺序,这就是“调度”研究的问题。
我们来看一下,在一个程序整个生命周期内,什么时候会发生调度的情况。
第一种,叫做高级调度,又叫作业调度。
此处补充一下作业的概念。
所谓的作业,指的就是某一个具体的任务。
可能有时会看到这种描述:用户向操作系统提交了一个作业。
这句话,其实可以理解为:用户让操作系统帮他启动某一个程序,这个程序是来处理某一个具体的任务的。
我们知道,我们要启动一个程序,那么这个程序相关的数据肯定需要从外存放到内存里面。
但是我们的内存资源又是有限的。
所以,如果内存已经满了,那么我们给操作系统提交的作业(或者我们想让系统帮我们启动的这个程序)就有可能没办法马上把它们放入内存(没办法马上启动)。
在这个时候,操作系统就会做高级调度(作业调度)。
操作系统会按照作业调度的相关规则,从作业后备队列里面选择一个先把它调入内存,并且会为这个作业建立与它相对应的进程,也就是建立一个PCB。这就是高级调度(作业调度)要做的事情。如果当前用户提交了很多作业,操作系统就要决定接下来到底要执行哪一个作业。
高级调度(作业调度):按一定的原则从外存的作业后备队列中挑选一个作业调入内存,并创建进程。简化理解:好几个程序需要启动,到底先启动哪个。
每个作业只调入一次,调出一次。作业调入时会建立PCB,调出时才撤销PCB。
低级调度(进程调度/处理机调度):按照某种策略从就绪队列中选取一个进程,将处理机分配给它。
因为我们内存中同一时刻是存在很多进程的,而我们CPU的资源也是有限的。所以,操作系统也需要按某种策略,从我们的进程就绪队列中挑选出一个进程,把处理机资源分配给它。
多道程序并发,这件事情,肯定要用到进程调度。
所以,进程调度是操作系统中最基本的一种调度,在一般的操作系统中都必须配置进程调度。
并且,进程调度的频率是很高的,一般几十毫秒一次。因为高频率的进程调度,才可以让各个进程很快速地、轮流地上CPU执行。这样才可以让用户在宏观上看,好像是各个进程在同时运行。
刚才我们说过,计算机当中有可能出现内存资源不足的情况。内存里面此时存在多个进程的数据。
而如果内存不足的话,其实我们是可以让一些不太紧急、不太重要的进程,先把这些进程的数据从内存调出到外存。
如果一个进程的数据,把它从内存放入到了外存里面,那这个进程此时就处于挂起状态。操作系统会把这些进程的PCB组织起来成为一个队列,叫挂起队列。(类似于之前说过的就绪队列、阻塞队列)
暂时调到外存等待的进程状态为挂起状态。被挂起的进程PCB会被组织成挂起队列。
那此时已经有空闲的内存资源了,操作系统又可以通过某一种调度策略,来决定,到底要把哪个进程的数据先调回内存。这个就是中级调度管的事情,又叫内存调度。
中级调度(内存调度):按照某种策略决定将哪个处于挂起状态的进程重新调入内存。
我们平时用手机的时候会有这样的体验:有时在切换程序(切换进程)的时候,有的时候会发现切换的很快,而有的时候切换的又很慢。有种可能的原因就是,当切换很快的时候,这个进程的数据有可能是位于内存里面的;而切换进程很慢的时候,有可能是因为那个进程的数据已经不在手机内存里,而是被系统调到了外存当中,所以当你切换到这个进程的时候,系统会发现,这个进程此时非运行不可了,它再把相关的数据从外存再读回内存。
那么显然,在进程运行的生命周期内,有可能会多次调出、调入内存,所以中级调度发生的频率肯定要比高级调度的频率更高。
刚刚提到了挂起状态。此处再补充一个与挂起状态相关的,七状态模型。
暂时调到外存等待的进程状态为挂起状态(挂起态,suspend)。
挂起态又可以进一步细分为就绪挂起、阻塞挂起两种状态。
之前已经学习了进程的五状态模型。这是考研408里要求掌握的一种模型。
但对于一些自主命题的学校,也有可能要掌握七状态模型。总之了解一下。
那么,在引入了就绪挂起、阻塞挂起两种状态之后,一个处于就绪态的进程,如果此时这个系统的负载比较高、内存空间已经不够用了,那么它有可能会把一个处于就绪态的进程,把它暂时调到外存当中,这个进程就进入了一个就绪挂起的状态。一直到内存空闲,或者这个进程急需进行,那么这个进程又会被激活,并把它相应数据挪回内存中。
同样地,一个阻塞态的进程也可以被挂起,相应的也可以被激活、重新调入内存。
而有的操作系统,对于处于阻塞挂起的进程,当它等待的阻塞事件发生的时候,这个进程会从阻塞挂起变为就绪挂起。之后,当它再被重新调回内存的时候,直接就是就绪态,而不是回到阻塞态。
还有的时候,一个进程处于运行态,当运行结束之后,可能这个进程下处理机的时候就会被直接放到外存当中,让它进入就绪挂起的状态。
而有的时候,一个处于创建态的进程,当它创建结束之后、创建完PCB之后,有可能出现内存空间不够的情况,那这种情况下有可能,处于创建态的进程,创建之后处于就绪挂起的状态。
需要注意的是,“挂起”和“阻塞”的区别。
这两种状态都是暂时不能获得CPU的服务,但区别在于,处于挂起态的进程,进程映像是放在外存里的;而处于阻塞态的进程,它的进程映像其实还在内存当中。
有的操作系统,会把就绪挂起、阻塞挂起分为两个不同的挂起队列。还有的操作系统会把阻塞挂起的进程,根据阻塞原因不同,再把阻塞挂起队列细分为多个队列。
以上就是七状态模型。
- 时机
- 什么时候需要进程调度?
- 什么时候不能进行进程调度?
- 切换与过程
- “狭义的调度”与“切换”的区别
- 进程切换的过程需要做什么?
- 方式
- 非剥夺调度方式(非抢占式)
- 剥夺调度方式(抢占式)
进程调度(低级调度),就是按照某种算法从就绪队列中选择一个进程为其分配处理机。
什么时候需要进行进程调度与切换?
(1)当前运行的进程主动放弃处理机
①进程正常终止;
②运行过程中发生异常而终止;
③进程主动请求阻塞(如 等待I/O)。
(2)当前运行的进程被动放弃处理机
①分给进程的时间片用完;
②有更紧急的事需要处理(如 I/O中断);
③有更高优先级的进程进入就绪队列。
而进程调度并不是什么时候都能进行的,有些时候不能进行进程调度与切换。
(1)在处理中断的过程中。由于中断处理过程很复杂,与硬件密切相关,因此很难做到在进行中断处理过程、处理到一半的时候,去进行进程切换的。
(2)进程在操作系统内核程序临界区中。(下文会具体讲)
(3)在原子操作过程中(原语)。原子操作不可中断,要一气呵成(如之前讲过的修改PCB中进程状态标志,并把PCB放到相应队列。而如果在执行到一半去进行进程调度与切换了,就有可能导致数据不匹配、安全隐患)
进程在操作系统内核程序临界区中不能进行调度与切换。(正确表述)
(2012年联考真题)进程处于临界区时不能进行处理机调度。(错误!)
临界资源:一个时间段内只允许一个进程使用的资源。各个进程需要互斥地访问临界资源。(你访问临界资源的时候我不能访问,我访问临界资源的时候你不能访问,这就是互斥的意思)
临界区:访问临界资源的那段代码。(因此,各个进程肯定也要互斥地进入临界区。因为“进入临界区” = 运行访问临界资源的代码 = 访问临界资源)
内核程序临界区一般是用来访问某种内核数据结构的,比如进程的就绪队列(由各就绪进程的PCB组成)。
当一个进程正处于内核程序临界区,而这个临界区是访问就绪队列的话。那么,在访问之前,它会把这个就绪队列上锁。
而如果说这个进程当前还没有退出内核程序临界区的话,也就意味着,这个临界资源并没有被解锁。那么,在没有解锁的情况下,如果我们要发生进程调度的话,那么进程调度是肯定要访问就绪队列这一临界资源的。而由于就绪队列这一临界资源此时还处于上锁的状态,所以,如果在这种情况下去进行进程调度的话,那么此时肯定是没办法顺利进行进程调度的。
所以,可以看到,对于内核程序临界区访问的这些临界资源,也就是这些内核数据结构而言。如果这些内核数据结构、这些临界资源被上锁了,并且没有被尽快释放的话,那么就有可能影响到操作系统内核其他的管理工作。
所以,我们在访问内核程序临界区的期间内,我们不能进行进程的调度和切换。我们必须让进程尽快执行完那些内核程序临界区的代码,之后尽快地把对临界资源上的锁给解除,只有这样其他的操作系统内核才能有序进行管理工作。
而另外一种情况。
假如这个进程访问的是一种普通的临界资源,比如是一个打印机。那么,它在访问打印机的时候,会先对打印机上锁。
打印机在打印完成之前,这个进程一直是在临界区内的,一直保持着对打印机的访问,由于这个进程没有退出临界区,所以打印机一直是上锁的状态。但是,又由于打印机是一种慢速的设备,如果这个情况下,不允许进程调度、切换的话,那么就需要这个进程一直空等着这个打印机的打印结束。同时在这个进程空等的时候,它还霸占着CPU,所以CPU一直是在空闲的状态,什么有用的也没有做。
所以,如果进程在访问普通的临界资源,在普通的临界区当中的话,这个情况下其实是应该进行进程调度的。因为普通的临界区访问的这些普通的临界资源,并不会直接地影响到操作系统内核的管理工作。所以,为了增加操作系统的并发度、增加CPU的利用率,那么在访问这些普通的临界区的时候,是可以进行进程调度和切换的。
至此,对于开头的两个表达,为什么一个对一个错,已经可以理解了。
接下来看下一个问题。
在有的操作系统里,它只允许进程主动地放弃处理机,而不允许这个进程在运行过程中被迫地被剥夺处理机资源。
但是还有的操作系统,是允许当有更紧急的任务需要处理时,能强行地剥夺当前运行进程的处理机资源的。
所以由“当前进程是否可以被强行剥夺处理机资源”这个问题,我们引出了下一个知识点——进程调度的方式。
分为非剥夺调度方式(非抢占式)和剥夺调度方式(抢占式)。
非剥夺调度方式,又称非抢占方式。即,只允许进程主动放弃处理机。在运行过程中即使有更紧迫的任务到达,当前进程依然会继续使用处理机,直到该进程终止或主动要求进入阻塞态。
实现简单,系统开销小但是无法及时处理紧急任务,适合于早期的批处理系统。
剥夺调度方式,又称抢占方式。当一个进程正在处理机上执行时,如果有一个更重要或更紧迫的进程需要使用处理机,则立即暂停正在执行的过程,将处理机分配给更重要紧迫的那个进程。
可以优先处理更紧急的进程,也可实现让各进程按时间片轮流执行的功能(通过时钟中断)。适合于分时操作系统、实时操作系统。
“狭义的进程调度”与“进程切换”的区别:
狭义的进程调度指的是从就绪队列中选中一个要运行的进程。(这个进程可以是刚刚被暂停执行的进程,也可能是另一个进程,后一种情况就需要进程切换)
进程切换是指一个进程让出处理机,由另一个进程占用处理机的过程。
广义的进程调度包含了选择一个进程和进程切换两个步骤。
进程切换的过程主要完成了:
1、对原来运行进程各种数据的保存
2、对新的进程各种数据的恢复
(如:程序计数器、程序状态字、各种数据寄存器等处理机现场信息,这些信息一般保存在进程控制块)
因此,我们可知,进程的切换是有代价的,是需要付出一定的时间代价的。所以不能简单的认为,进程切换越频繁、进程的并发度就越高。如果过于频繁地进行进程调度、切换,必然会使整个系统的效率降低,使系统大部分时间花在了进程切换上,而真正用于执行进程的时间减少。
- CPU利用率
- 系统吞吐量
- 周转时间
- 周转时间、平均周转时间
- 带权周转时间、平均带权周转时间
- 等待时间
- 响应时间
注意理解各个指标为什么这样设计;并且会计算。
早期的计算机,其造价是非常昂贵的,特别是CPU占了造价的很大一部分。现代的计算机CPU其实也不便宜。
因此人们会希望让CPU尽可能多地工作。
CPU利用率:指CPU“忙碌”的时间占总时间的比例。
利用率 = 忙碌的时间 总时间 利用率 = \frac{忙碌的时间}{总时间} 利用率=总时间忙碌的时间
有些题还会让计算某种设备的利用率(不一定是CPU利用率)。
例如:某计算机只支持单道程序,某个作业刚开始需要在CPU上运行5秒,再用打印机打印输出5秒,之后再执行5秒,才能结束。在此过程中,CPU利用率、打印机利用率分别是多少?
答:CPU利用率 = (5+5) / (5+5+5) = 66.66%;打印机利用率 = 5 / 15 = 33.33%。
通常会考察多道程序并发执行的情况,可以用“甘特图”来辅助计算。
对于计算机来说,它肯定希望用尽可能少的时间处理完尽可能多的作业。就设计了一个系统吞吐量的指标。
系统吞吐量:单位时间内完成作业的数量。
系统吞吐量 = 总共完成了多少道作业 总共花了多少时间 系统吞吐量 = \frac{总共完成了多少道作业}{总共花了多少时间} 系统吞吐量=总共花了多少时间总共完成了多少道作业
例如:某计算机系统处理完10道作业,共花费100秒,则系统吞吐量为?
答:10 / 100 = 0.1 道/秒
对于计算机的用户来说,他很关心自己的作业从提交到完成花了多少时间。
周转时间,是指从作业被提交给系统开始,到作业完成为止的这段时间间隔。
它包括四个部分:
作业在外存后备队列上等待作业调度(高级调度)的时间、进程在就绪队列上等待进程调度(低级调度)的时间(即处于就绪态)、进程在CPU上执行的时间(即处于运行态)、进程等待I/O操作完成的时间(即阻塞态)。后三项在一个作业的整个处理过程中,可能发生多次。
(作业)周转时间 = 作业完成时间 − 作业提交时间 (作业)周转时间 = 作业完成时间 - 作业提交时间 (作业)周转时间=作业完成时间−作业提交时间
对于用户来说,更关心自己的单个作业的周转时间。
平均周转时间 = 各作业周转时间之和 作业数 平均周转时间 = \frac{各作业周转时间之和}{作业数} 平均周转时间=作业数各作业周转时间之和
对于操作系统来说,更关心系统的整体表现,因此更关心所有作业周转时间的平均值。
思考:有的作业运行时间短,有的作业运行时间长,因此在周转时间相同的情况下,运行时间不同的作业,给用户的感觉肯定是不一样的。
周转时间相同,若运行时间越长,则用户体验越好。例如A:等待10分钟,运行1分钟,周转时间为11分钟;B:等待1分钟,运行10分钟,周转时间为11分钟。
因此,人们又提出另外一个指标:带权周转时间。
带权周转时间 = 作业周转时间 作业实际运行的时间 = 作业完成时间 − 作业提交时间 作业实际运行的时间 带权周转时间 = \frac{作业周转时间}{作业实际运行的时间} = \frac{作业完成时间-作业提交时间}{作业实际运行的时间} 带权周转时间=作业实际运行的时间作业周转时间=作业实际运行的时间作业完成时间−作业提交时间
对于周转时间相同的两个作业,实际运行时间长的作业在相同时间内被服务的时间更多,带权周转时间更小,用户满意度更高。
对于实际运行时间相同的两个作业,周转时间短的带权周转时间更小,用户满意度更高。
容易发现:
带权周转时间 必然 ≥ 1
带权周转时间与周转时间 都是 越小越好
相应地,和平均周转时间一样,也会有一个平均带权周转时间。这个就是操作系统比较关心的一个指标。
平均带权周转时间 = 各作业带权周转时间之和 作业数 平均带权周转时间 = \frac{各作业带权周转时间之和}{作业数} 平均带权周转时间=作业数各作业带权周转时间之和
计算机的用户希望自己的作业尽可能少的等待处理机。
等待时间,就是进程/作业处于等待处理机状态时间之和,等待时间越长,用户满意度越低。
对于进程来说,等待时间就是指进程建立后等待被服务的时间之和,在等待I/O完成的期间其实进程也是在被服务的,所以不计入等待时间。
对于作业来说,不仅要考虑建立进程后的等待时间,还要加上作业在外存后备队列中等待的时间。
一个作业总共需要被CPU服务多久、被I/O设备服务多久一般是确定不变的,因此调度算法其实只会影响作业/进程的等待时间。当然,同样地,也有“平均等待时间”来评价整体性能。
对于计算机用户来说,会希望自己提交的请求(比如通过键盘输入了一个调试命令)尽早地开始被系统服务、回应。
响应时间,指从用户提交请求到首次产生响应所用的时间。
调度算法
- 先来先服务(FCFS)
- 短作业优先(SJF)
- 高响应比优先(HRRN)
Tips:各种调度算法的学习思路
1、算法思想。每一种算法的提出,它是想解决一个什么问题、出于什么目的。
2、算法规则。为了解决问题,它采用了怎样的算法规则。
3、这种调度算法是用于 作业调度 还是 进程调度?用于作业调度和用于进程调度的时候有没有什么区别。
4、抢占式?非抢占式?各种算法,可能有抢占式的版本、非抢占式的版本。
5、优点和缺点。
6、是否会导致饥饿。
饥饿:某进程/作业长期得不到服务,它就处于饥饿状态。
1、算法思想
主要从“公平”的角度考虑。(类似于我们生活中排队买东西的例子)
2、算法规则
按照作业/进程到达的先后顺序进行服务。
3、用于作业/进程调度
用于作业调度时,考虑的是哪个作业先到达后备队列;(后备队列之前讲过,是在外存中的)
用于进程调度时,考虑的是哪个进程先到达就绪队列。(就绪队列,是在内存中的)
4、是否可抢占
非抢占式的算法。
也就是对于当前正在占用处理机的那个进程或作业,只有它主动放弃处理机的时候,才会进行调度,才会根据这个算法规则再去选择下一个进行调度。
【例题】
各进程到达就绪队列的时间、需要的运行时间如下表所示。使用先来先服务调度算法,计算各进程的等待时间、平均等待时间、周转时间、平均周转时间、带权周转时间、平均带权周转时间。
进程 | 到达时间 | 运行时间 |
---|---|---|
P1 | 0 | 7 |
P2 | 2 | 4 |
P3 | 4 | 1 |
P4 | 5 | 4 |
等待时间 = 等待被服务的时间 = 周转时间 - 运行时间 - I/O操作的时间
(本例是纯计算型的进程,不涉及I/O操作,因此,其要么在等待被调度、要么在运行。但如果有I/O操作的进程,也要考虑到)
周转时间 = 完成时间 - 提交时间
带权周转时间 = 周转时间 / 运行时间
先来先服务调度算法:按照到达的先后顺序调度,事实上就是等待时间越久的越优先得到服务。
因此,调度顺序为:P1 → P2 → P3 → P4
周转时间: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; P4 = 11/4 = 2.75
等待时间:P1 = 7-7 = 0; P2 = 9-4 = 5; P3 = 8-1 = 7; P4 = 11-4 = 7
平均周转时间 = 8.75; 平均带权周转时间 = 3.5; 平均等待时间 = 4.75
对于上例,可以注意到,对于P3来说,它的带权周转时间为8,是非常大的。意味着,这个进程本来需要运行很短的时间,但是它需要等很长的时间才能得到处理。对于P3的用户来说,他的体验就是特别糟糕的。
5、优缺点
优点:公平、算法实现简单
缺点:排在长作业(进程)后面的短作业需要等待很长时间,带权周转时间很大,对短作业来说用户体验不好。即,FCFS算法对长作业有利,对短作业不利。
6、是否会导致饥饿
不会。
不管是哪个作业,只要它一直等着,它前面的那些作业总会被进行完,它总会得到服务的。
对于先来先服务算法,其各项指标其实都是不太优秀的。因此又提出了短作业优先算法。
1、算法思想
追求最少的平均等待时间,最少的平均周转时间,最少的平均带权周转时间。
2、算法规则
最短的作业/进程优先得到服务(所谓“最短”,是指要求服务时间最短)
3、用于作业/进程调度
既可用于作业调度,也可用于进程调度。用于进程调度时称为“短进程优先(SPF, Shortest Process First)算法”。
4、是否可抢占
SJF和SPF是非抢占式的算法。但是也有抢占式的版本——最短剩余时间优先算法(SRTN, Shortest Remaining Time Next)。
【例题】
各进程到达就绪队列的时间、需要的运行时间如下表所示。使用非抢占式的短作业优先调度算法,计算各进程的等待时间、平均等待时间、周转时间、平均周转时间、带权周转时间、平均带权周转时间。
(本例为进程调度的场景,所以严格来说,用于进程调度应该称为:短进程优先调度算法(SPF))
进程 | 到达时间 | 运行时间 |
---|---|---|
P1 | 0 | 7 |
P2 | 2 | 4 |
P3 | 4 | 1 |
P4 | 5 | 4 |
短作业/进程优先调度算法:每次调度时选择当前已到达且运行时间最短的作业/进程。
因此,调度顺序为:P1 → P3 → P2 → P4
周转时间 = 完成时间 - 到达时间
P1 = 7-0 = 7; P3 = 8-4 = 4; P2 = 12-2 = 10; P4 = 16-5 = 11
带权周转时间 = 周转时间 / 运行时间
P1 = 7/7 = 1; P3 = 4/1 = 4; P2 = 10/4 = 2.5; P4 = 11/4 = 2.75
等待时间 = 周转时间 - 运行时间
P1 = 7-7 = 0; P3 = 4-1 = 3; P2 = 10-4 = 6; P4 = 11-4 = 7
平均周转时间 = 8; 平均带权周转时间 = 2.56; 平均等待时间 = 4
对比FCFS算法的结果,显然SPF算法的平均等待/周转/带权周转时间都要小。
【例题】
各进程到达就绪队列的时间、需要的运行时间如下表所示。使用抢占式的短作业优先调度算法,计算各进程的等待时间、平均等待时间、周转时间、平均周转时间、带权周转时间、平均带权周转时间。
(抢占式的短作业优先算法又称“最短剩余时间优先算法(SRTN)”)
进程 | 到达时间 | 运行时间 |
---|---|---|
P1 | 0 | 7 |
P2 | 2 | 4 |
P3 | 4 | 1 |
P4 | 5 | 4 |
最短剩余时间优先算法:每当有进程加入就绪队列改变时就需要调度,如果新到达的进程剩余时间比当前运行的进程剩余时间更短,则由新进程抢占处理机,当前运行进程重新回到就绪队列。另外,当一个进程完成时也需要调度。
需要注意的是,当有新进程到达的时候就绪队列就会改变,就要按照上述规则进行检查。以下Pn(m)表示当前Pn进程剩余时间为m。各个时刻的情况如下:
0时刻(P1到达):P1(7)
2时刻(P2到达):P1(5)、P2(4)
4时刻(P3到达):P1(5)、P2(2)、P3(1)
5时刻(P3完成、P4到达):P1(5)、P2(2)、P4(4)
7时刻(P2完成):P1(5)、P4(4)
11时刻(P4完成):P1(5)
周转时间 = 完成时间 - 到达时间
P1=16-0=16;P2=7-2=5;P3=5-4=1;P4=11-5=6
带权周转时间 = 周转时间 / 运行时间
P1=16/7=2.28;P2=5/4=1.25;P3=1/1=1;P4=6/4=1.5
等待时间 = 周转时间 - 运行时间
P1=16-7=9;P2=5-4=1;P3=1-1=0;P4=6-4=2
平均周转时间=7;平均带权周转时间=1.5;平均等待时间=3
在抢占式的短作业优先算法中,这些进程的执行可能是断断续续的(如上例中P1、P2),这和之前我们的例子是不一样的,因此对于其各个指标的计算要注意一下。
对比非抢占式的短作业优先算法,显然抢占式的这几个指标又要更低。
注意几个小细节:
1.如果题目中未特别说明,所提到的“短作业/进程优先算法”默认是非抢占式的。
2.很多书上都会说:“SJF调度算法的平均等待时间、平均周转时间最少”。但是,根据刚才的例子可见,最短剩余时间优先算法(即抢占式的SJF)还要更少。
因此,严格来说,这个表述是不严谨的。应该这样说:
在所有进程同时可运行时,采用SJF调度算法的平均等待时间、平均周转时间最少。
或者说:在所有进程都几乎同时到达时,采用SJF调度算法的平均等待时间、平均周转时间最少。
如果不加上这些前提条件,则应该说:抢占式的短作业/进程优先调度算法(最短剩余时间优先,SRTN算法)的平均等待时间、平均周转时间最少。
3.虽然严格来说,SJF的平均等待时间、平均周转时间不一定最少(与SRTN相比)。但是它相比于其它算法(如FCFS),SJF依然可以获得较少的平均等待时间、平均周转时间。
4.如果选择题中遇到“SJF算法的平均等待时间、平均周转时间最少”的选项,那最好判断其他选项是不是有明显的错误,如果没有更合适的选项,那也应该选择此选项。
5、优缺点
优点:“最短的”平均等待时间、平均周转时间
缺点:不公平。对短作业有利,对长作业不利。如果有源源不断的短作业进来的话,可能长作业会产生饥饿现象。另外,作业/进程的运行时间的由用户提供的,并不一定真实,不一定能做到真正的短作业优先。
6、是否会导致饥饿
会。如果源源不断地有短作业/进程到来,可能使长作业/进程长时间得不到服务,产生“饥饿”现象。如果一直得不到服务,则称为“饿死”。
FCFS算法是在每次调度的时候选择一个等待时间最长的作业(进程)为其服务。但是没有考虑到作业的运行时间,因此导致了对短作业不友好的问题。
SJF算法是选择一个执行时间最短的作业为其服务。但是又完全不考虑各个作业的等待时间,因此导致了对长作业不友好的问题,甚至还会造成饥饿问题。
能不能设计一个算法,既考虑到各个作业的等待时间,也能兼顾运行时间呢?
基于这种想法,人们就提出了高响应比优先算法。
1、算法思想
要综合考虑作业/进程的等待时间和要求服务的时间。
2、算法规则
在每次调度时先计算各个作业/进程的响应比,选择响应比最高的作业/进程为其服务。
响应比 = 等待时间 + 要求服务时间 要求服务时间 响应比 = \frac{等待时间+要求服务时间}{要求服务时间} 响应比=要求服务时间等待时间+要求服务时间
(不难看出,响应比
这一参数一定是≥1
的)
3、用于作业/进程调度
既可以用于作业调度,也可以用于进程调度。
4、是否可抢占
非抢占式的算法。因此只有当前运行的作业/进程主动放弃处理机时,才需要调度,才需要计算响应比。
【例题】
各进程到达就绪队列的时间、需要的运行时间如下表所示。使用高响应比优先调度算法,计算各进程的等待时间、平均等待时间、周转时间、平均周转时间、带权周转时间、平均带权周转时间。
进程 | 到达时间 | 运行时间 |
---|---|---|
P1 | 0 | 7 |
P2 | 2 | 4 |
P3 | 4 | 1 |
P4 | 5 | 4 |
高响应比优先算法:非抢占式的调度算法,只有当前运行的进程主动放弃CPU时(正常/异常完成,或主动阻塞),才需要进行调度,调度时计算所有就绪进程的响应比,选响应比最高的进程上处理机。
响应比 = (等待时间+要求服务时间) / 要求服务时间
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)
小技巧:P2和P4要求服务时间一样,P2到达时间更早(等待时间更长),所以计算出来必然是P2响应比更大。
12时刻(P2完成):就绪队列中只剩下P4
5、优缺点
综合考虑了等待时间和运行时间(要求服务时间)
等待时间相同时,要求服务时间短的优先(SJF的优点)
要求服务时间相同时,等待时间长的优先(FCFS的优点)
对于长作业来说,随着等待时间越来越久,其响应比也会越来越大,从而避免了长作业饥饿的问题。
6、是否会导致饥饿
不会。
注:
这几种算法主要关心对用户的公平性、平均周转时间、平均等待时间等评价系统整体性能的指标,但是不关心“响应时间”,也并不区分任务的紧急程度,因此对于用户来说,交互性很糟糕。因此这三种算法一般适合用于早期的批处理系统,当然,FCFS算法也常结合其他的算法使用,在现在也扮演着很重要的角色。而适合用于交互式系统的调度算法将在下个小节介绍。
调度算法
- 时间片轮转调度算法(RR)
- 优先级调度算法
- 多级反馈队列调度算法
Tips:各种调度算法的学习思路
1、算法思想
2、算法规则
3、这种调度算法是用于 作业调度 还是 进程调度?
4、抢占式?非抢占式?
5、优点和缺点
6、是否会导致饥饿
1、算法思想
公平地、轮流地为各个进程服务,让每个进程在一定时间间隔内都可以得到响应。
(时间片轮转算法是伴随着分时操作系统的诞生而诞生的。)
2、算法规则
按照各进程到达就绪队列的顺序,轮流让各个进程执行一个时间片(如100ms)。若进程未在一个时间片内执行完,则剥夺处理机,将进程重新放到就绪队列队尾重新排队。
3、用于作业/进程调度
用于进程调度(只有作业放入内存建立了相应的进程后,才能被分配处理机时间片)
因为所谓的“时间片”,其实指的是处理机的时间片,而一个作业只有放入了内存建立了进程后,作为一个进程,它才有可能被分配处理机的时间片,因为进程是执行的基本单位。
4、是否可抢占
若进程未能在时间片内运行完,将被强行剥夺处理机使用权,因此时间片轮转调度算法属于抢占式的算法。由时钟装置发出时钟中断来通知CPU时间片已到。
【例题】
各进程到达就绪队列的时间、需要的运行时间如下表所示。使用时间片轮转调度算法,分析时间片大小分别是2、5时的进程运行情况。
进程 | 到达时间 | 运行时间 |
---|---|---|
P1 | 0 | 5 |
P2 | 2 | 4 |
P3 | 4 | 1 |
P4 | 5 | 6 |
注意。在这一小节中,我们不再像上一小节那样,计算平均周转时间、平均等待时间这些指标。
原因在于,时间片轮转算法,它一般是用于分时操作系统的,比起之前的那些平均周转时间等指标,这种操作系统会更关心进程的响应时间。(当然,题目中要求计算,还是都要会计算)
时间片轮转调度算法:轮流让就绪队列中的进程依次执行一个时间片(每次选择的都是排在就绪队列队头的过程)
时间片大小为2时:(注,以下括号内表示当前时刻就绪队列中的进程、进程的剩余运行时间)
刚开始就绪队列是空的。
0时刻(P1(5)):0时刻只有P1到达就绪队列,让P1上处理机运行一个时间片。
之后,一个时间片用完了,会进行下一次调度。
2时刻(**P2(4)**→P1(3)):2时刻P2到达就绪队列,P1运行完一个时间片,被剥夺处理机,重新放到队尾。原因是,P1运行一个时间片(运行态),之后重新回到就绪态,所以自然要插入到就绪队列的队尾了。
此时P2排在队头,因此让P2上处理机,运行一个时间片。(注意:2时刻,P1下处理机,同一时刻新进程P2到达,如果在题目中遇到这种情况,默认新到达的进程先进入就绪队列)
4时刻(**P1(3)**→P3(1)→P2(2)):4时刻P3到达,先插到就绪队列队尾(即入队),紧接着,P2下处理机也入队。选择队头的一个元素,让它上处理机执行一个时间片,即2个时间单位。
5时刻(P3(1)→P2(2)→P4(6)):5时刻P4到达,入队。(注意:由于P1的时间片还没用完,因此暂时不调度。另外,此时P1处于运行态,并不在就绪队列中)
接下来,P1又会再执行1个时间单位,把整个时间片用完。
6时刻(**P3(1)**→P2(2)→P4(6)→P1(1)):在6这个时刻,P1的时间片用完了,此时会发生一次调度。P1下处理机,重新放回就绪队尾。
队头元素,P3,上处理机运行。
需要注意的是,P3本身的运行时间只需要1个时间单位的长度。所以,虽然时间片的大小为2,但是由于P3只需要运行1个单位的时间,所以在时刻7的时候,P3会主动放弃处理机。
7时刻(P2(2)→P4(6)→P1(1)):虽然P3的时间片没用完,但是由于P3只需运行1个单位的时间,运行完了会主动放弃处理机,因此也会发生调度。队头进程P2上处理机。
P2在运行2个单位时间,即1个时间片之后,又要发生调度。
9时刻(**P4(6)**→P1(1)):进程P2时间片用完,并刚好运行完,发生调度,P4上处理机。
11时刻(**P1(1)**→P4(4)):P4时间片用完,重新回到就绪队列。P1上处理机。
P1此时只剩下1个单位时间,因此在运行完成后会主动放弃处理机。
12时刻(P4(4)):P1运行完,主动放弃处理机,此时就绪队列中只剩P4,P4上处理机。
P4执行完1个时间片后,操作系统会再次发生一次调度。但由于此时就绪队列为空,所以会让P4接着运行1个时间片。
14时刻(null):就绪队列为空,因此让P4接着运行一个时间片。
16时刻:所有进程运行结束。
时间片大小为5时:
0时刻(P1(5)):只有P1到达,P1上处理机。
2时刻(P2(4)):P2到达,但P1时间片尚未结束,因此暂不调度。
4时刻(P2(4)→P3(1)):P3到达,但P1时间片尚未结束,因此暂不调度。
5时刻(**P2(4)**→P3(1)→P4(6)):P4到达,同时,P1运行结束。发生调度,P2上处理机。
9时刻(**P3(1)**→P4(6)):P2运行结束,虽然时间片没用完,但是会主动放弃处理机。发生调度。
10时刻(P4(6)):P3运行结束,虽然时间片没用完,但是会主动放弃处理机。发生调度。
15时刻():P4时间片用完,但就绪队列为空,因此会让P4继续执行一个时间片。
若按照先来先服务调度算法:
不难看出,它和刚才时间片为5的情况是很类似的。区别只在于15时刻是否需要进行一下检查。
如果时间片太大,使得每个进程都可以在一个时间片内就完成,则时间片轮转调度算法退化为先来先服务调度算法,并且会增大进程响应时间。因此时间片不能太大。
另一方面,进程调度、切换是有时间代价的(保存、恢复运行环境),因此如果时间片太小,会导致进程切换过于频繁,系统会花大量的时间来处理进程切换,从而导致实际用于进程执行的时间比例减少。可见时间片也不能太小。
因此,我们的时间片既不能太大、也不能太小,要选择适中。一般来说,设计时间片时要让切换进程的开销占比不超过1%。
5、优缺点
优点:公平;响应快,适用于分时操作系统;
缺点:由于高频率的进程切换,因此有一定开销;不区分任务的紧急程度。
6、是否会导致饥饿
不会。
7、补充
时间片太大或太小分别会造成的影响。
1、算法思想
随着计算机的发展,特别是实时操作系统的出现,越来越多的应用场景需要根据任务的紧急程度来决定处理顺序。
2、算法规则
每个作业/进程有各自的优先级,调度时选择优先级最高的作业/进程。
3、用于作业/进程调度
既可用于作业调度,也可用于进程调度。甚至,还会用于在之后会学习的I/O调度中。
4、是否可抢占
抢占式、非抢占式都有。做题时的区别在于:非抢占式只需在进程主动放弃处理机时进行调度即可,而抢占式还需在就绪队列变化时,检查是否会发生抢占。
【例题】
各进程到达就绪队列的时间、需要的运行时间、进程优先数如下表所示。使用非抢占式的优先级调度算法,分析进程运行情况。(注:优先数越大,优先级越高)
注:也有的题是优先数越小、优先级越高。总之看清题目给的条件。
进程 | 到达时间 | 运行时间 | 优先数 |
---|---|---|---|
P1 | 0 | 7 | 1 |
P2 | 2 | 4 | 2 |
P3 | 4 | 1 | 3 |
P4 | 5 | 4 | 2 |
非抢占式的优先级调度算法:每次调度时选择当前已到达且优先级最高的进程。当前进程主动放弃处理机时发生调度。
注:以下括号内表示当前处于就绪队列的进程。
0时刻(P1):只有P1到达,P1上处理机。
7时刻(P2、P3、P4):P1运行完成主动放弃处理机,其余进程都已到达,P3优先级最高,P3上处理机。
8时刻(P2、P4):P3完成,P2、P4优先级相同,由于P2先到达,因此P2优先上处理机。
12时刻(P4):P2完成,就绪队列只剩P4,P4上处理机。
若上题改为,使用抢占式的优先级调度算法。其余条件均不变。
抢占式的优先级调度算法:每次调度时选择当前已到达且优先级最高的进程。当前进程主动放弃处理机时发生调度。另外,当就绪队列发生改变时也需要检查是否会发生抢占。
注:以下括号内表示当前处于就绪队列的进程
0时刻(P1):只有P1到达,P1上处理机。
2时刻(P2):P2到达就绪队列,优先级比P1更高,发生抢占。P1回到就绪队列,P2上处理机。
4时刻(P1、P3):P3到达,优先级比P2更高,P2回到就绪队列,P3抢占处理机。
5时刻(P1、P2、P4):P3完成,主动释放处理机,同时,P4也到达,由于P2比P4更先进入就绪队列,因此选择P2上处理机。
7时刻(P1、P4):P2完成,就绪队列只剩P1、P4,P4上处理机。
11时刻(P1):P4完成,P1上处理机。
16时刻():P1完成,所有进程均完成。
补充:
就绪队列未必只有一个,可以按照不同优先级来组织。另外,也可以把优先级高的进程排在更靠近队头的位置。
根据优先级是否可以动态改变,可将优先级分为静态优先级和动态优先级两种。
静态优先级:创建进程时确定,之后一直不变。
动态优先级:创建进程时有一个初始值,之后会根据情况动态地调整优先级。
思考:如何合理地设置各类进程的优先级?
通常,系统进程优先级高于用户进程。
前台进程优先级高于后台进程。(前台进程毕竟是此时此刻用户能看到的进程,这些进程的指标肯定更重要)
操作系统更偏好I/O型进程(或称I/O繁忙型进程)。(注:与之相对应的是计算型进程,或称CPU繁忙型进程)。原因是,I/O设备和CPU可以并行工作,如果优先让I/O繁忙型进程优先运行的话,则越有可能让I/O设备尽早地投入工作,则资源利用率、系统吞吐量都会得到提升。
思考:如果采用的是动态优先级,什么时候应该调整?
可以从追求公平、提升资源利用率等角度考虑。
如果某进程在就绪队列中等待了很长时间,则可以适当提升其优先级。
如果某进程占用处理机运行了很长时间,则可适当降低其优先级。(有点像高响应比优先算法)
如果发现一个进程频繁地进行I/O操作,则可适当提升其优先级。
5、优缺点
优点:用优先级区分紧急程度、重要程度,适用于实时操作系统。可灵活地调整对各种作业/进程的偏好程度。
缺点:若源源不断地有高优先级进程到来,则可能导致饥饿。
6、是否会导致饥饿
会。
至此,在学习各种算法后,我们思考:
FCFS算法的优点是公平;
SJF算法的优点是能尽快处理完短作业,平均等待/周转时间等参数很优秀。
时间片轮转调度算法可以让各个进程得到及时的响应。
优先级调度算法可以灵活地调整各种进程被服务的机会。
那么,我们会想:能否对各种算法做一个折中权衡?得到一个综合表现优秀平衡的算法呢?
基于这个想法,人们就提出了多级反馈队列调度算法。
1、算法思想
对其他调度算法的折中权衡。
2、算法规则
①设置多级就绪队列,各级队列优先级从高到低,时间片从小到大;
②新进程到达时先进入第1级队列,按FCFS原则排队等待被分配时间片,若用完时间片进程还未结束,则进程进入下一级队列队尾。如果此时已经是在最下级的队列,则重新放回该队列队尾。
③只有第k级队列为空时,才会为k+1级队头的进程分配时间片。
3、用于作业/进程调度
用于进程调度。
4、是否可抢占
抢占式的算法。在k级队列的进程运行过程中,若更上级的队列(1~k-1级)中进入了一个新进程,则由于新进程处于优先级更高的队列中,因此新进程会抢占处理机,原来运行的进程放回k级队列队尾。
【例题】
各进程到达就绪队列的时间、需要的运行时间如下表所示。使用多级反馈队列调度算法,分析进程运行的过程。
进程 | 到达时间 | 运行时间 |
---|---|---|
P1 | 0 | 8 |
P2 | 1 | 4 |
P3 | 5 | 1 |
(根据队列优先级的不同,对应的时间片长度也不同,如图)
设置多级就绪队列,各级队列优先级从高到低,时间片从小到大。
新进程到达时先进入第1级队列,按FCFS原则排队等待被分配时间片。若用完时间片时进程还未结束,则进程进入下一级队列队尾。如果此时已经在最下级的队列,则重新放回最下级队列队尾。
只有第k级队列为空时,才会为k+1级队头的进程分配时间片。
被抢占处理机的进程重新放回原队列队尾。
0时刻,P1到达,并进入1级队列。此时没有其他进程,那自然P1上处理机运行。
由于第1级队列的时间片为1,所以当P1执行了1个单位的时间后,它的时间片就用完了。它在用完时间片之后还没有结束,所以它会进入下一级队列(即第2级队列)的队尾。
1时刻,P2到达。此时这种情形,由于更高级别的队列中还有待处理的作业,因此暂时不会处理较低级别的队列当中的进程。因此,在此时,会选择P2上处理机运行。
同样,P2上处理机运行1个单位的时间之后,同理也会被放到下一级队列队尾。
2时刻,由于更高级别队列(第1级队列)已经全部为空了,所以,便轮到了为第2级队列进行调度,会为该队列队头的进程(P1)分配一次时间片。因此,P1会上处理机运行2个单位的时间。
当P1用完它的时间片之后,由于它还没有全部执行结束,因此还会被放到下一级队列(第3级队列)。
4时刻。优先执行最高级别队列中的进程,因此P2即将上处理机执行一个长度为2的时间片。需要注意的是,虽然该级队列的时间片为2,但当进程P2上处理机运行了1个单位时间后,来到了时刻5,此时进程P3到达。
这种情形,由于更高级别的队列中有进程到达,因此会发生抢占处理机的情况。此时,P2进程会被剥夺处理机。但是当它下处理机后,并不是放到下一级队列,而是把它放回原来这个队列的队尾。
之后,让P3上处理机运行。在该队列运行了一个时间片(1个单位时间)后,刚好P3运行完毕,直接调出内存。
6时刻。P2上处理机运行。由于P2此时已经运行过2个单位的时间,因此在本次时间片结束时,也是P2运行完毕的时候,P2完成、调出内存。
8时刻。P1上处理机运行一个时间片(4个单位时间)。需要注意,在本次时间片使用完后,P1共计运行了7个单位时间。此时也属于“用完时间片但进程还未结束”的情况,和上面是一样的道理,但问题是已经没有更下一级的队列了。所以,它只能还放到这一队列的队尾了。
12时刻。P1上处理机运行,运行1个时间单位后,P1全部运行完毕,调出内存。
**整个流程:**P1(1) → P2(1) → P1(2) → P2(1) → P3(1) → P2(2) → P1(4) → P1(1)
5、优缺点
多级反馈队列有很多优点。
对各类型进程相对公平(FCFS的优点);
每个新到达的进程都可以很快就得到响应(RR的优点);
短进程只用较少的时间就可完成(SPF的优点);
不必实现估计进程的运行时间(避免用户作假);
可灵活地调整对各类进程的偏好程度,比如CPU密集型进程、I/O密集型进程(拓展:可以将因I/O而阻塞的进程重新放回原队列,这样I/O型进程就可以保持较高优先级)
6、是否会导致饥饿
会。
对于非抢占式调度算法,我们只需关注各个进程主动放弃处理机的时刻、检查是否需要调度就可以了;而对于抢占式调度算法,除了刚才所说的情况之外,还需要注意就绪队列发生改变的时刻(要关注是否会发生抢占,不然怎么叫抢占式呢),也要进行检查。
与上一小节讲的那些调度算法相比,这三个调度算法的优缺点是比较不容易出考题的。
另外,最右一列的补充内容,要了解。
注:比起早期的批处理操作系统来说,由于计算机造价大幅降低,因此之后出现的交互式操作系统(包括分时操作系统、实时操作系统等)更注重系统的响应时间、公平性、平衡性等指标(而不是一味地追求之前批处理系统中的平均周转时间等指标)。而这几种算法恰好也能较好地满足交互式系统的需求。因此这三种算法适合用于交互式系统。(比如UNIX使用的就是多级反馈队列调度算法)
在此之前,我们其实已经了解过“进程的异步性”相关知识。
回顾:进程具有异步性的特征。异步性是指,各并发执行的进程以各自独立的、不可预知的速度向前推进。
比如有两个进程,各自有若干指令。若要保证“一号进程的指令2”一定要在“二号进程的指令1”之前执行,则操作系统要提供“进程同步机制”来实现上述需求。
另一个例子:比如进程通信当中的管道通信。读进程和写进程并发地运行,由于并发必然导致异步性,因此“写数据”和“读数据”两个操作执行的先后顺序是不确定的。而实际应用中,又必须按照“写数据→读数据”的顺序来执行。即,由于异步性,写数据、读数据的发生顺序是不可预知的;但是我们又必须要保证写数据要在读数据之前发生。
如何解决这种异步问题,就是“进程同步”所讨论的内容。
同步亦称直接制约关系,它是指为完成某种任务而建立的两个或多个进程,这些进程因为需要在某些位置上协调它们的工作次序而产生的制约关系。进程间的直接制约关系就是源于它们之间的相互合作。
进程的“并发”需要“共享”的支持。各个并发执行的进程不可避免的需要共享一些系统资源(比如内存,又比如打印机、摄像头这样的I/O设备)
两种资源共享方式:
互斥共享方式
系统中的某些资源,虽然可以提供给多个进程使用,但一个时间段内只允许一个进程访问该资源。
同时共享方式
系统中的某些资源,允许一个时间段内由多个进程“同时”对它们进行访问。
我们把一个时间段内只允许一个进程使用的资源称为临界资源。许多物理设备(比如摄像头、打印机)都属于临界资源。此外还有许多变量、数据、内存缓冲区等都属于临界资源。
对临界资源的访问,必须互斥地进行。互斥,亦称间接制约关系。进程互斥指当一个进程访问某临界资源时,另一个想要访问该临界资源的进程必须等待。当前访问临界资源的进程访问结束,释放该资源之后,另一个进程才能去访问临界资源。
对临界资源的互斥访问,可以在逻辑上分为如下四个部分:
do {
entry section; //进入区
critical section; //临界区
exit section; //退出区
remainder section; //剩余区
} while(true)
注意:
临界区是进程中访问临界资源的代码段。
进入区和退出区是负责实现互斥的代码段。
临界区也可称为“临界段”。
如果一个进程暂时不能进入临界区,那么该进程是否应该一直占着处理机?该进程有没有可能一直进不了临界区?这些都是实现进程互斥时要考虑的问题。
为了实现对临界资源的互斥访问,同时保证系统整体性能,需要遵循以下原则:
1、空闲让进。
临界区空闲时,可以允许一个请求进入临界区的进程立即进入临界区。
2、忙则等待。
当已有进程进入临界区时,其他试图进入临界区的进程必须等待。
3、有限等待。
对请求访问的进程,应保证能在有限时间内进入临界区(保证不会饥饿)。
4、让权等待。
当进程不能进入临界区时,应立即释放处理机,防止进程忙等待。
进程互斥的软件实现方法
- 单标志法
- 双标志先检查
- 双标志后检查
- Peterson算法
学习提示:
1、理解各个算法的思想、原理
2、结合上小节学习的“实现互斥的四个逻辑部分”,重点理解各算法在进入区、退出区都做了什么
3、分析各算法存在的缺陷(结合“实现互斥要遵循的四个原则”进行分析)
进程A、进程B在系统中并发地运行,如下
进程A:
{
其他代码;
使用打印机;
其他代码;
}
进程B:
{
其他代码;
使用打印机;
其他代码;
}
先调度A上处理机运行。
当A在使用打印机的过程中,分配给它的时间片用完了,接下来操作系统调度B让它上处理机运行。进程B也在使用打印机。
结果:A、B的打印内容混在一起了。
如果让A、B进程互斥地访问打印机,就可以解决问题了。接下来讨论如何从软件的角度实现A、B互斥。
算法思想:两个进程在访问完临界区后会把使用临界区的权限转交给另一个进程。也就是说每个进程进入临界区的权限只能被另一个进程赋予。
int turn = 0; //设置一个标志位turn,它表示当前允许进入临界区的进程号
P0进程:
{
while(turn != 0); //①进入区
critical section; //②临界区
turn = 1; //③退出区
remainder section; //④剩余区
}
P1进程:
{
while(turn != 1); //⑤进入区
critical section; //⑥临界区
turn = 0; //⑦退出区
remainder section; //⑧剩余区
}
turn的初值为0,即刚开始只允许0号进程进入临界区。
若P1先上处理机运行,则会一直卡在⑤。直到P1的时间片用完,发生调度,切换P0上处理机运行。
代码①不会卡住P0,P0可以正常访问临界区,在P0访问临界区期间即使切换回P1,P1依然会卡在⑤。
只有P0在退出区将turn改为1后,P1才能进入临界区。
因此,该算法可以实现“同一时刻最多允许一个进程访问临界区”。
turn变量背后的逻辑:表达“谦让”。
①是否轮到自己用?(检查)
②访问临界资源~~~
③下次让对方用。(表达谦让)
④做其他事情。
⑤⑥⑦⑧同理。
只能按P0→P1→P0→P1→……这样轮流访问。这种必须“轮流访问”带来的问题是,如果此时允许进入临界区的进程是P0,而P0一直不访问临界区,那么虽然此时临界区空闲,但是并不允许P1访问。
因此,单标志法存在的主要问题是:违背“空闲让进”原则。
算法思想:设置一个布尔型数组flag[]
,数组中各个元素用来标记各进程想进入临界区的意愿,比如flag[0] = true
意味着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将都无法进入临界区。
因此,双标志后检查法虽然解决了“忙则等待”的问题,但是又违背了“空闲让进”和“有限等待”原则,会因各进程都长期无法访问临界资源而产生“饥饿”现象。
算法思想:结合双标志法、单标志法的思想。如果双方都争着想进入临界区,那可以让进程尝试“孔融让梨”(谦让)。做一个有礼貌的进程。
bool flag[2]; //表示进入临界区意愿的数组,初始值都是false
int turn = 0; //turn表示优先让哪个进程进入临界区
P0进程:
{
flag[0] = true; //①表达自己想用的意愿
turn = 1; //②把使用权让给对方
while(flag[1] && turn==1); //③对方想用,且最后一次是自己“让梨”,则自己循环等待
critical section; //④访问临界资源
flag[0] = false; //⑤设置自己为不想用了
remainder section;
}
P1进程:
{
flag[1] = true; //⑥
turn = 0; //⑦
while(flag[0] && turn==0); //⑧
critical section; //⑨
flag[1] = false; //⑩
remainder section;
}
①②③整体为进入区。(或者看临界区,临界区之前的都是进入区)
进入区:①主动争取;②主动谦让;③检查对方是否也想使用,并且,最后一次是不是自己表示了谦让。
自己可以推导一下,按照不同的执行顺序穿插执行会发生什么:
①②③⑥⑦⑧…
①⑥②③…
①③⑥⑦⑧…
①⑥②⑦⑧…
自己动手体会,此处抛出结论:Peterson算法用软件方法解决了进程互斥问题,遵循了空闲让进、忙则等待、有限等待三个原则,但是依然未遵循让权等待的原则。
进程互斥的硬件实现方法
- 中断屏蔽方法
- TestAndSet(TS指令 / TSL指令)
- Swap指令(XCHG指令)
学习提示:1、理解各方法的原理;2、了解各方法的优缺点。
利用“开/关中断指令”实现(与原语的实现思想相同,即在某进程开始访问临界区到结束访问为止都不允许被中断,也就不能发生进程切换,因此也不可能发生两个同时访问临界区的情况)
......
关中断; //关中断后即不允许当前进程被中断,也必然不会发生进程切换
临界区;
开中断; //直到当前进程访问完临界区,再执行开中断指令,才有可能有别的进程上处理机并访问临界区
......
优点:简单、高效
缺点:不适用于多处理机。因为,关中断只对执行了关中断指令的处理机有用,所以,如果此时处理机A执行了关中断,就意味着这上面的进程不会被切换,就可以顺利的互斥访问临界区,但是对于处理机B来说,它还是会正常的切换进程,如果说处理机B上的进程也要访问同一个临界区,就会出现两个处理机上的两个进程同时对临界区进行访问的情况。
只适用于操作系统内核进程,不适用于用户进程。因为开中断、关中断指令需要的特权比较大,只能运行在内核态,这组指令如果能让用户随意使用会很危险。
简称TS指令,也有地方称为TestAndSetLock指令,或TSL指令。
TSL指令是用硬件实现的,执行的过程不允许被中断,只能一气呵成。以下是用C语言描述的逻辑。(这只是用C语言代码表示的一段逻辑流程,但是这些事情实际是硬件用一些它的方法来完成的,不要混淆。)
//布尔型共享变量lock表示当前临界区是否被加锁,true表示已加锁,false表示未加锁
/*返回当前上锁情况,并设为上锁*/
bool TestAndSet(bool *lock) {
bool old;
old = *lock;
*lock = true;
return old;
}
进程P {
while(TestAndSet(&lock));
critical section;
lock = false;
remainder section;
}
若刚开始lock是false,则TSL的返回值为false,while循环条件不满足,直接跳过循环,进入临界区。
若刚开始lock是true,则执行TSL后返回值为true,while循环条件满足,会一直循环,直到当前访问临界区的进程在退出区进行“解锁”。
相比软件实现方法,TSL指令把“上锁”和“检查”操作用硬件的方式变成了一气呵成的原子操作。
优点:实现简单,无需像软件实现方法那样严格检查是否会有异步带来的逻辑漏洞;适用于多处理机环境。
缺点:不满足“让权等待”原则,暂时无法进入临界区的进程会占用CPU并循环执行TSL指令,从而导致“忙等”。
有的地方也叫Exchange指令,或简称XCHG指令。
Swap指令是用硬件实现的,执行的过程不允许被中断,只能一气呵成。以下是用C语言描述的逻辑。
/* 交换a与b的值 */
Swap(bool *a, bool *b) {
bool temp;
temp = *a;
*a = *b;
*b = temp;
}
//通过Swap指令实现互斥的算法逻辑
//lock表示当前临界区是否被加锁
进程P {
bool old = true;
while(old == true)
Swap(&lock, &old);
critical section;
lock = false;
remainder section;
}
逻辑上来看Swap和TSL并无太大区别,都是先记录下此时临界区是否已经被上锁(记录在old变量上),再将上锁标记lock设置为true,最后检查old,如果old为false则说明之前没有别的进程对临界区上锁,则可跳出循环,进入临界区。
优点:实现简单,无需像软件实现方法那样严格检查是否会有逻辑漏洞;适用于多处理机环境。
缺点:不满足“让权等待”原则,暂时无法进入临界区的进程会占用CPU并循环执行Swap指令,从而导致“忙等”。
信号量机制
- 整型信号量
- 记录型信号量
复习回顾+思考:之前学习的这些进程互斥的解决方案分别存在哪些问题?
四种软件实现方式:单标志法、双标志先检查、双标志后检查、Peterson算法;
三种硬件实现方式:中断屏蔽方法、TS/TSL指令、Swap/XCHG指令。
1、在双标志先检查法中,进入区的“检查”、“上锁”操作无法一气呵成,从而导致了两个进程有可能同时进入临界区的问题;
2、所有的解决方案都无法实现“让权等待”。
1965年,荷兰学者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操作。
【例】某计算机系统中有一台打印机……
int S = 1; //初始化整型信号量S,表示当前系统中可用的打印机资源数
/* wait 原语,相当于 进入区 */
void wait(int S) {
while(S <= 0); //如果资源数不够,就一直循环等待
S = S-1; //如果资源数够,则占用一个资源
}
//“检查”和“上锁”一气呵成,避免了并发、异步导致的问题
//存在的问题:不满足“让权等待”原则,会发生”忙等“
/* signal 原语,相当于 退出区 */
void signal(int S) {
S = S+1; //使用完资源后,在退出区释放资源
}
wait原语和双标志先检查法在逻辑上做的事情其实是一样的,可以自行对比一下。只不过wait原语是一气呵成的。
这里好像有一个bug:
由于原语不会被中断,那么在wait的过程中,如果
S<=0
一直满足,而此时又不能进行进程切换,S<=0
这一情形也得不到处理,那么就会一直占用处理机?其实这个,很多教材都是这样写的,我们姑且不管它、认为它没有问题。
整型信号量的缺陷是存在“忙等”问题,因此人们又提出了“记录型信号量”,即用记录型数据结构表示的信号量。
/* 记录型信号量的定义 */
typedef struct {
int value; //剩余资源数
struct process *L; //等待队列
} semaphore;
/* 某进程需要使用资源时,通过wait原语申请 */
void wait(semaphore S) {
S.value--;
if(S.value < 0) {
block(S.L);
}
}
在wait原语中,执行S.value--
,紧接着判断S.value < 0
,这是因为,如果资源数减1之后小于0,则说明这是一个剩余资源数不够的情况,就要让该进程从运行态进入阻塞态。
如果剩余资源数不够,使用block原语使进程从运行态进入阻塞态,并把它挂到信号量S的等待队列(即阻塞队列)中。
/* 进程使用完资源后,通过 signal 原语释放 */
void signal(semaphore S) {
S.value++;
if(S.value <= 0) {
wakeup(S.L);
}
}
在signal原语中,当某一个进程释放出一个资源S时,S.value++
资源数会加1,但是,若释放1个资源之后,剩余资源数仍然是<=0
的,则说明等待队列(阻塞队列)中有等待该资源的进程正在等待执行,于是从等待队列S.L
中唤醒一个进程。
释放资源后,若还有别的进程在等待这种资源,则使用wakeup原语唤醒等待队列中的一个进程,该进程从阻塞态变为就绪态。
【例】假设系统中有2个打印机资源,有4个进程要用打印机。
由于当前有2台打印机,因此value=2
。有4个进程,P0、P1、P2、P3。
4个进程分别执行:
wait(S)
,value=1
,之后开始使用打印机。wait(S)
,value=0
,之后开始使用打印机。wait(S)
,value=-1
,于是被block
,P2插入等待队列。wait(S)
,value=-2
,于是被block
,P3插入等待队列。由于P2、P3都处于阻塞态。所以CPU接下来只能为P0、P1服务了。
CPU为P0服务:P0进程执行signal(S)
,value=-1
,由于此时value<=0
,说明此时等待队列里还是有进程的,因此会执行wakeup
,用来唤醒该信号量等待队列的队头进程。
并且会把P0进程刚刚释放的打印机资源分配给被唤醒的队头进程,即P2进程。
CPU为P2服务:P2进程执行signal(S)
,value=0
,同理,也会wakeup
来唤醒此时等待队列队头的进程。
此时,P3由阻塞态变为就绪态,并且P2进程刚刚释放的打印机资源分配给P3进程。
接下来,假设CPU为P1进程服务了。(因为P3此时可能正在打印当中)
signal(S)
,value=1
,由于不符合value<=0,因此不涉及wakeup,执行完毕剩下的代码就结束了。value=2
。/* 记录型信号量的定义 */
typedef struct {
int value; //剩余资源数
struct process *L; //等待队列
} semaphore;
/* 某进程需要使用资源时,通过 wait 原语申请 */
void wait(semaphore S) {
S.value--;
if(S.value < 0){
block(S.L);
}
}
/* 进程使用完资源后,通过 signal 原语释放 */
void signal(semaphore S) {
S.value++;
if(S.value <= 0){
wakeup(S.L);
}
}
在考研题目中wait(S)、signal(S)也可以记为P(S)、V(S),这对原语可用于实现系统资源的“申请”和“释放”。
S.value的初值表示系统中某种资源的数目。
对信号量S的一次P操作意味着进程请求一个单位的该类资源,因此需要执行S.value–,表示资源数减1,当减1之后S.value<0
时表示该类资源在减之前就已经没有了,因此进程应调用block原语进行自我阻塞(当前运行的进程从运行态→阻塞态),主动放弃处理机,并插入该类资源的等待队列S.L
中。可见,该机制遵循了“让权等待”原则,不会出现“忙等”现象。(因为它申请资源之后一旦发现不满足运行条件,就会随即进行自我阻塞)
对信号量S的一次V操作意味着进程释放一个单位的该类资源,因此需要执行S.value++,表示资源数加1,若加1后仍是S.value<=0
,表示依然有进程在等待该类资源,因此应调用wakeup原语唤醒等待队列中的第一个进程(被唤醒进程从阻塞态→就绪态)。
注:若考试中出现P(S)、V(S)的操作,除非特别说明,否则默认S为记录型信号量。
信号量机制
- 实现进程互斥
- 实现进程同步
- 实现进程的前驱关系
一个信号量对应一种资源。
信号量的值 = 这种资源的剩余数量(信号量的值如果小于0,说明此时有进程在等待这种资源)
P(S) —— 申请一个资源S,如果资源不够就阻塞等待
V(S) —— 释放一个资源S,如果有进程在等待该资源,则唤醒一个进程
1、分析并发进程的关键活动,划定临界区(如:对临界资源打印机的访问就应放在临界区)
2、设置互斥信号量mutex,初值为1(理解:mutex表示“进入临界区的名额”)
3、在进入区P(mutex) —— 申请资源
4、在退出区V(mutex) —— 释放资源
========================
/* 记录型信号量的定义 */
typedef struct {
int value; //剩余资源数
struct process *L; //等待队列
} semaphore;
=========================
/* 信号量机制实现互斥 */
semaphore mutex = 1; //初始化信号量
P1(){
...
P(mutex); //使用临界资源前需要加锁
临界区代码段...
V(mutex); //使用临界资源后需要解锁
...
}
P2(){
...
P(mutex);
临界区代码段...
V(mutex);
...
}
【注意】
写题的时候不需要写semaphore的结构体,直接简要的做信号量声明
semaphore a = 1;
即可。当然,如果题目中特别说明了,也是要知道semaphore结构体怎么定义的。当使用semaphore定义信号量变量时,就已经意味着它并不是整型信号量,而是一个记录型信号量,是自带排队阻塞的功能的,并不会造成忙等!
对不同的临界资源需要设置不同的互斥信号量。(例如打印机资源是mutex1,摄像头资源是mutex2)
P、V操作必须成对出现。缺少P(mutex)就不能保证临界资源的互斥访问;缺少V(mutex)会导致资源永不被释放,等待进程永不被唤醒。
进程同步:要让各并发进程按要求有序地推进。
P1(){
代码1;
代码2;
代码3;
}
P2(){
代码4;
代码5;
代码6;
}
比如,P1、P2并发执行,由于存在异步性,因此二者交替推进的次序是不确定的。(比如:P2先执行了代码4、代码5,此时它的时间片用完,之后P1执行代码1,再之后P2执行代码6…这都是不可预知的)
若P2的“代码4”要基于P1的“代码1”和“代码2”的运行结果才能执行,那么我们就必须保证“代码4”一定是在“代码2”之后才会执行。
这就是进程同步的问题,让本来异步并发的进程互相配合,有序推进。
用信号量实现进程同步:
1、分析什么地方需要实现“同步关系”,即必须保证“一前一后”执行的两个操作(或两句代码)
2、设置同步信号量S,初始为0
3、在“前操作”之后执行V(S)
4、在“后操作”之前执行P(S)
技巧口诀:前V后P。
/* 信号量机制实现同步 */
semaphore S = 0; //初始化同步信号量,初始值为0
P1(){
代码1;
代码2;
V(S);
代码3;
}
P2(){
P(S);
代码4;
代码5;
代码6;
}
若先执行到V(S)操作,则S++后S=1。之后当执行到P(S)操作时,由于S=1,表示有可用资源,会执行S–,S的值变回0,P2进程不会执行block原语(即不会阻塞),而是继续往下执行代码4。
若先执行到P(S)操作,由于S=0,S–后S=-1,表示此时没有可用资源,因此P操作会执行block原语,主动请求阻塞。之后当执行完代码2,继而执行V(S)操作,S++,使S变回0,由于此时有进程在该信号量对应的阻塞队列中,因此会在V操作中执行wakeup原语,唤醒P2进程。这样P2就可以继续执行代码4了。
接下来看一个比较复杂的。
进程P1中有句代码S1,P2中有句代码S2,P3中有句代码S3,…,P6中有句代码S6。这些代码要求按如下前驱图所示的顺序来执行:
其实每一对前驱关系都是一个进程同步问题(需要保证一前一后的操作),因此
1、要为每一对前驱关系各设置一个同步信号量
2、在“前操作”之后对相应的同步信号量执行V操作
3、在“后操作”之前对相应的同步信号量执行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;
...;
}
(每年几乎都有一个大题是让用信号量实现互斥、同步的)
系统中有一组生产者进程和一组消费者进程,生产者进程每次生产一个产品放入缓冲区,消费者进程每次从缓冲区中取出一个产品并使用。(注:这里的“产品”理解为某种数据)
生产者、消费者共享一个初始为空、大小为n的缓冲区。
只有缓冲区没满时,生产者才能把产品放入缓冲区,否则必须等待。缓冲区没满→生产者生产
只有缓冲区不空时,消费者才能从中取出产品,否则必须等待。缓冲区没空→消费者消费
缓冲区是临界资源,各进程必须互斥地访问。互斥关系
假设两个生产者进程同时访问缓冲区,那么它们可能往同一块区域放入数据,就会产生问题。
PV操作题目分析步骤:
1、关系分析。找出题目中描述的各个进程,分析它们之间的同步、互斥关系。
2、整理思路。根据各进程的操作流程确定P、V操作的大致顺序。(前V后P)
3、设置信号量。并根据题目条件确定信号量初值。(互斥信号量初值一般为1,同步信号量初值要看对应资源的初始值是多少)
同步的实现:
分析出两对同步关系。为这两对同步关系分别设置一个信号量,然后“前V后P”。
互斥的实现:
我们只需设置互斥信号量mutex,初值为1。
然后在临界资源的前面和后面分别对互斥信号量执行P、V操作就可以了。
对于上例中,full、empty、mutex的具体含义及需求,可分析得出其初始值如下。
semaphore mutex = 1; //互斥信号量,实现对缓冲区的互斥访问
semaphore empty = n; //同步信号量,表示空闲缓冲区的数量
semaphore full = 0; //同步信号量,表示产品的数量,也即非空缓冲区的数量
producer() {
while(1){
生产一个产品;
P(empty); //消耗一个空闲缓冲区
P(mutex); //实现对缓冲区的互斥访问
把产品放入缓冲区;
V(mutex);
V(full); //增加一个产品
}
}
consumer() {
while(1){
P(full); //消耗一个产品(非空缓冲区)
P(mutex);
从缓冲区取出一个产品;
V(mutex);
V(empty); //增加一个空闲缓冲区
使用产品;
}
}
可总结得出,实现互斥是在同一进程中进行一对PV操作;实现两进程的同步关系,是在其中一个进程中执行P,另一个进程中执行V。
问题1:相邻P操作能否调换顺序
我们把两个P的顺序颠倒一下,如下所示
producer() {
while(1){
生产一个产品;
P(mutex); //①
P(empty); //②
把产品放入缓冲区;
V(mutex);
V(full);
}
}
consumer() {
while(1){
P(mutex); //③
P(full); //④
从缓冲区取出一个产品;
V(mutex);
V(empty);
使用产品;
}
}
我们刚才是先对同步信号量执行了P、再对互斥信号量执行了P。现在我们颠倒一下,让P(mutex)在前。
若此时缓冲区内已经放满产品,则empty=0,full=n;
则生产者进程执行①使mutex变为0,再执行②,由于已经没有空闲缓冲区,因此生产者被阻塞;
由于生产者阻塞,因此切换回消费者进程。消费者进程执行③,由于mutex=0,即生产者还没释放对临界资源的“锁”,因此消费者也被阻塞。
这就造成了生产者等待消费者释放空闲缓冲区,而消费者又等待生产者释放临界区的情况,生产者和消费者循环等待被对方唤醒,出现“死锁”。
同样的,若缓冲区中没有产品,即full=0,empty=n,按③④①的顺序执行就会发生死锁。
因此,实现互斥的P操作一定要在实现同步的P操作之后。(个人理解:先确定资源数量是否足够、是否阻塞,若确定资源足够了,之后再去占用临界区处理资源。而不要先占着临界区,然后发现资源不足,这时候由于资源不足导致被阻塞,从而也无法进行对临界区的解锁了)
问题2:相邻V操作能否调换顺序
接着,再思考一下,若两个V操作的顺序颠倒一下,会不会有问题?
V操作不会导致进程阻塞,因此==两个V操作顺序可以交换==。
问题3:上例中“生产一个产品”、“使用产品”为什么不放在P、V操作之间,如果想放能不能放?
从逻辑上来讲,“生产一个产品”、“使用产品”是可以放在临界区代码中的。
但是,如果这样做,会使临界区代码变得更长,也就是一个进程对临界区的上锁、解锁周期更长。这样不利于各个进程交替地使用临界区。所以,我们要让临界区代码尽可能的短。
所以,这样做,逻辑上没问题,但是会对效能造成影响,因此并不建议这样做。
PV操作题目的解题思路:
1、关系分析。找出题目中描述的各个进程,分析它们之间的同步、互斥关系。
2、整理思路。根据各进程的操作流程确定P、V操作的大致顺序。
3、设置信号量。设置需要的信号量,并根据题目条件确定信号量初值。(互斥信号量初值一般为1,同步信号量的初始值要看对应资源的初始值是多少)
生产者消费者问题是一个互斥、同步的综合问题。
对于初学者来说最难的是发现题目中隐含的两对同步关系。
有时候是消费者需要等待生产者生产,有时候是生产者要等待消费者消费,这是两个不同的“一前一后问题”,因此也需要设置两个同步信号量。
易错点:实现互斥和实现同步的两个P操作的先后顺序(死锁问题)。
桌子上有一只盘子,每次只能向其中放入一个水果。爸爸专向盘子中放苹果,妈妈专向盘子中放橘子,儿子专等着吃盘子中的橘子,女儿专等着吃盘子中的苹果。只有盘子空时,爸爸或妈妈才可向盘子中放一个水果。仅当盘子中有自己需要的水果时,儿子或女儿可以从盘子中取出水果。用PV操作实现上述过程。
根据上一小节学过的,我们可以把这个盘子抽象为大小为1,初始为空的缓冲区。
把父亲和母亲看作两个生产者进程。
把女儿和儿子看作两个消费者进程。
和上一小节的问题不同的是,这里不同生产者、消费者,所生产、消费的东西是不一样的。这也就是为什么这一小节叫“多生产者—多消费者”,这里的“多”并不在于“多个”,而应该理解为“多类”,即==不同类别的生产者、不同类别的消费者,他们所需要生产、消费的产品是不一样的==。
1、关系分析。找出题目中描述的各个进程,分析它们之间的同步、互斥关系。
2、整理思路。根据各进程的操作流程确定P、V操作的大致顺序。
(互斥:在临界区前后分别PV;同步:前V后P)
3、设置信号量。设置需要的信号量,并根据题目条件确定信号量初值。
互斥关系:
对缓冲区(盘子)的访问要互斥地进行。
同步关系(一前一后):
1.父亲将苹果放入盘子后,女儿才能取苹果;
2.母亲将橘子放入盘子后,儿子才能取橘子;
3.只有盘子为空时,父亲或母亲才能放入水果。
“盘子为空”这个事件可以由儿子或女儿触发,事件发生后才允许父亲或母亲放水果。
semaphore mutex = 1; //实现互斥访问盘子(缓冲区)
semaphore apple = 0; //盘子中有几个苹果
semaphore orange = 0; //盘子中有几个橘子
semaphore plate = 1; //盘子中还可以放多少个水果
dad() {
while(1){
准备一个苹果;
P(plate);
P(mutex);
把苹果放入盘子;
V(mutex);
V(apple);
}
}
mom() {
while(1){
准备一个橘子;
P(plate);
P(mutex);
把橘子放入盘子;
V(mutex);
V(orange);
}
}
daughter() {
while(1){
P(apple);
P(mutex);
从盘中取出苹果;
V(mutex);
V(plate);
吃掉苹果;
}
}
son() {
while(1){
P(orange);
P(mutex);
从盘中取出橘子;
V(mutex);
V(plate);
吃掉橘子;
}
}
问题:可不可以不用互斥信号量mutex?
如下。
semaphore apple = 0; //盘子中有几个苹果
semaphore orange = 0; //盘子中有几个橘子
semaphore plate = 1; //盘子中还可以放多少个水果
dad() {
while(1){
准备一个苹果;
P(plate);
把苹果放入盘子;
V(apple);
}
}
mom() {
while(1){
准备一个橘子;
P(plate);
把橘子放入盘子;
V(orange);
}
}
daughter() {
while(1){
P(apple);
从盘中取出苹果;
V(plate);
吃掉苹果;
}
}
son() {
while(1){
P(orange);
从盘中取出橘子;
V(plate);
吃掉橘子;
}
}
分析:刚开始,儿子、女儿进程即使上处理机运行也会被阻塞。如果刚开始是父亲进程先上处理机运行,则:父亲P(plate),可以访问盘子 → 母亲P(plate),阻塞等待盘子 → 父亲放入苹果V(apple),女儿进程被唤醒,其他进程即使运行也都会阻塞,暂时不可能访问临界资源(盘子) → 女儿P(apple),访问盘子,V(plate),等待盘子的母亲进程被唤醒 → 母亲进程访问盘子(其他进程暂时都无法进入临界区) → …
结论:即使不设置专门的互斥变量mutex,也不会出现多个进程同时访问盘子的现象。
原因在于:本题中的缓冲区大小为1,在任何时刻,apple、orange、plate三个同步信号量中最多只有一个是1。因此在任何时刻,最多只有一个进程的P操作不会被阻塞,并顺利地进入临界区…
如果盘子容量为2的话…这样就不行了。
总结:在生产者—消费者问题中,如果缓冲区大小为1,那么有可能不需要设置互斥信号量就可以实现互斥访问缓冲区的功能。当然,这不是绝对的,要具体问题具体分析。
建议:在考试中如果来不及仔细分析,可以加上互斥信号量,保证各进程一定会互斥地访问缓冲区。但需要注意的是,实现互斥的P操作一定要在实现同步的P操作之后,否则可能引起“死锁”。
PV操作题目的解题思路:
1、关系分析。找出题目中描述的各个进程,分析它们之间的同步、互斥关系。
2、整理思路。根据各进程的操作流程确定P、V操作的大致顺序。
3、设置信号量。设置需要的信号量,并根据题目条件确定信号量初值。(互斥信号量初值一般为1,同步信号量的初始值要看对应资源的初始值是多少)
解决“多生产者—多消费者问题”的关键在于理清复杂的同步关系。
在分析同步问题(一前一后问题)的时候不能从单个进程行为的角度来分析,要把“一前一后”发生的事看做是两种“事件”的前后关系。
比如,如果从单个进程行为的角度来考虑的话,我们会有以下的结论:
如果盘子里装有苹果,那么一定要女儿取走苹果后父亲或母亲才能再放入水果
如果盘子里装有橘子,那么一定要儿子取走橘子后父亲或母亲才能再放入水果
这么看是否就意味着要设置四个同步信号量分别实现这四个“一前一后”的关系了?
正确的分析方法应该==从“事件”的角度来考虑==,我们可以把上述四对“进程行为的前后关系”抽象为一对“事件的前后关系”。
盘子变空事件 → 放入水果事件。“盘子变空事件”既可由儿子引发,也可由女儿引发;“放水果事件”既可能是父亲执行,也可能是母亲执行。这样的话,就可以用一个同步信号量解决问题了。
假设一个系统有三个抽烟者进程和一个供应者进程。每个抽烟者不停地卷烟并抽掉它,但是要卷起并抽掉一支烟,抽烟者需要有三种材料:烟草、纸和胶水。三个抽烟者中,第一个拥有烟草、第二个拥有纸、第三个拥有胶水。供应者进程无限地提供三种材料,供应者每次将两种材料放在桌子上,拥有剩下那种材料的抽烟者卷一根烟并抽掉它,并给供应者进程一个信号告诉完成了,供应者就会放另外两种材料在桌上,这个过程一直重复(让三个抽烟者轮流地抽烟)。
本质上这题也属于“生产者—消费者”问题,更详细的说应该是“可生产多钟产品的单生产者—多消费者”。
1、关系分析。找出题目中描述的各个进程,分析它们之间的同步、互斥关系。
桌子可以抽象为容量为1的缓冲区,要互斥访问。
(桌子不应该看作容量为2,而应该把两个物品看作1个组合。组合一:纸+胶水;组合二:烟草+胶水;组合三:烟草+纸)
同步关系(从事件的角度来分析):
桌上有组合一 → 第一个抽烟者取走东西;
桌上有组合二 → 第二个抽烟者取走东西;
桌上有组合三 → 第三个抽烟者取走东西;
发出完成信号 → 供应者将下一个组合放到桌上。
2、整理思路。根据各进程的操作流程确定P、V操作的大致顺序。
3、设置信号量。设置需要的信号量,并根据题目条件确定信号量初值。(互斥信号量初值一般为1,同步信号量的初始值要看对应资源的初始值是多少)
semaphore offer1 = 0; //桌上组合一的数量
semaphore offer2 = 0; //桌上组合二的数量
semaphore offer3 = 0; //桌上组合三的数量
semaphore finish = 0; //抽烟是否完成
int i = 0; //用于实现“三个抽烟者轮流抽烟”
provider() {
while(1){
if(i==0){
将组合一放桌上;
V(offer1);
} else if(i==1){
将组合二放桌上;
V(offer2);
} else if(i==2){
将组合三放桌上;
V(offer3);
}
i = (i+1)%3;
P(finish);
}
}
smoker1(){
while(1){
P(offer1);
从桌上拿走组合一;卷烟;抽掉;
V(finish);
}
}
smoker2(){
while(1){
P(offer2);
从桌上拿走组合二;卷烟;抽掉;
V(finish);
}
}
smoker3(){
while(1){
P(offer3);
从桌上拿走组合三;卷烟;抽掉;
V(finish);
}
}
吸烟者问题可以为我们解决“可以生产多个产品的单生产者”问题提供一个思路。
值得吸取的精华是:“轮流让各个吸烟者吸烟”必然需要“轮流地在桌上放上组合一、二、三”,注意体会我们是如何用一个整型变量i实现这个“轮流”过程的。
如果题目改为“每次随机地让一个吸烟者吸烟”,我们又该如何用代码写出这个逻辑呢?——用random函数控制i每次的变化即可。
若一个生产者要生产多种产品(或者说会引发多种前驱事件),那么各个V操作应该放在各自对应的“事件”发生之后的位置。
有读者和写者两组并发进程,共享一个文件,当两个或两个以上的读进程同时访问共享数据时不会产生副作用,但若某个写进程和其他进程(读进程或写进程)同时访问共享数据时则可能导致数据不一致的错误。
因此要求:
①允许多个读者可以同时对文件执行读操作;
②只允许一个写者往文件中写信息;
③任一写者在完成写操作之前不允许其他读者或写者工作;
④写者执行写操作前,应让已有的读者和写者全部退出。
解释:
文件是什么,这个后面会具体讲。多个读者进程在读一个文件的时候,由于它们是不会对文件中数据进行改变的,因此多个读者可以同时读。即解释了①。
由于写者进程会改变文件的内容,所以它是不能和其他进程同时使用这个文件的。否则,读者进程想读原本的数据,但期间被写进程更新了内容;两个写进程同时写,会引起数据覆盖的问题…
1、关系分析。
2、整理思路。
3、设置信号量。
两类进程:写进程、读进程。
互斥关系:写进程—写进程、写进程—读进程。而读进程与读进程不存在互斥问题。
semaphore rw = 1; //用于实现对共享文件的互斥访问
writer() {
while(1){
P(rw); //写之前 加锁
写文件...;
V(rw); //写完了 解锁
}
}
reader() {
while(1){
P(rw); //读之前 加锁
读文件;
V(rw); //读完了 解锁
}
}
如上所示,这样就实现了互斥访问文件。
但是这种方法,会导致,读者与读者之间不可以同时访问共享文件。
怎么解决?我们可以设置一个count的变量,记录当前有几个读者正在读这个文件,初值为0。在加锁动作之前,它要进行一个检查,看看自己是不是第一个读这个文件的进程,如果是第一个读进程的话,就要对该文件加锁,同时也要count++。
读完之后,要进行count–,表示当前读进程的数量减1。此外,如果count–之后,count==0,说明没有别的文件也在读了,自己就是最后一个离开的读进程,则此时要负责对共享文件解锁。
semaphore rw = 1; //用于实现对共享文件的互斥访问
int count = 0; //记录当前有几个读进程在访问文件
writer() {
while(1){
P(rw); //写之前 加锁
写文件...;
V(rw); //写完了 解锁
}
}
reader() {
while(1){
if(count == 0) //第一个来的读进程要负责加锁
P(rw); //读之前 加锁
count++; //访问文件的读进程数+1
读文件;
count--;
if(count == 0) //最后一个走的读进程要负责解锁
V(rw); //读完了 解锁
}
}
不难看出,第一个读进程到来时,会执行P(rw);加锁,而后来的读进程并不会执行P(rw);也就不会引起阻塞,便可以同时执行读文件操作。
这种方法似乎解决了读进程同时读文件的问题。但是这种方法也有问题:若两个读进程并发执行,则count=0时两个进程也许都能满足if条件,都会执行P(rw),从而使第二个读进程阻塞。
根本原因在于:对count变量的检查和赋值无法一气呵成。因此,我们可以设置另一个互斥信号量来保证各读进程对count的访问是互斥的。
semaphore rw = 1; //用于实现对共享文件的互斥访问
int count = 0; //记录当前有几个读进程在访问文件
semaphore mutex = 1; //用于保证对count变量的互斥访问
writer() {
while(1){
P(rw); //写之前 加锁
写文件...;
V(rw); //写完了 解锁
}
}
reader() {
while(1){
P(mutex); //各读进程互斥访问count
if(count == 0) //第一个来的读进程要负责加锁
P(rw); //读之前 加锁
count++; //访问文件的读进程数+1
V(mutex);
读文件;
P(mutex); //各读进程互斥访问count
count--;
if(count == 0) //最后一个走的读进程要负责解锁
V(rw); //读完了 解锁
V(mutex);
}
}
个人理解:P、V操作,semaphore信号量,它是自带阻塞功能的,当有一个进程申请到该资源后,其他任何进程无论有多高的并发度,再去P该资源后都会被阻塞。而if语句等普通逻辑,在高并发的情况下,势必会有多个进程同时满足条件的。
到此,该算法已经大致完成。但还有一个潜在的问题:只要有读进程还在读,写进程就要一直阻塞等待,可能“饿死”。因为,第一个进程开始读之后,就对这个文件上锁,之后只要一直有源源不断的读进程到来,即count一直恢复不到0,即文件不会被解锁,所以导致写进程饿死。因此,这种算法中,读进程是优先的。
怎么解决写进程饿死的问题?再设置一个信号量。如下。
semaphore rw = 1; //用于实现对共享文件的互斥访问
int count = 0; //记录当前有几个读进程在访问文件
semaphore mutex = 1; //用于保证对count变量的互斥访问
semaphore w = 1; //用于实现“写优先”
writer() {
while(1){
P(w);
P(rw);
写文件...;
V(rw);
V(w);
}
}
reader() {
while(1){
P(w);
P(mutex);
if(count == 0)
P(rw);
count++;
V(mutex);
V(w);
读文件...;
P(mutex);
count--;
if(count == 0)
V(rw);
V(mutex);
}
}
分析以下并发执行P(w)的情况:
读者1 → 读者2
写者1 → 写者2
写者1 → 读者1
读者1 → 写者1 → 读者2
写者1 → 读者1 → 写者2
结论:在这种算法中,连续进入的多个读者可以同时读文件;写者和其他进程不能同时访问文件;写者不会饥饿,但也并不是真正的“写优先”,而是相对公平的先来先服务原则。
有的书上把这种算法称为“读写公平法”。
读者—写者问题为我们解决复杂的互斥问题提供了一个参考思路。
其核心思想在于设置了一个计数器count用来记录当前正在访问共享文件的读进程数。我们可以用count的值来判断当前进入的进程是否是第一个/最后一个读进程,从而做出不同的处理。
另外,对count变量的检查和赋值不能一气呵成导致了一些错误,如果需要实现“一气呵成”,自然应该想到用互斥信号量。
最后,还要认真体会我们是如何解决“写进程饥饿”问题的。
绝大多数的考研PV操作大题都可以用之前介绍的几种生产者—消费者问题的思想来解决,如果遇到更复杂的问题,可以想想能否用读者写者问题的这几个思想解决。
一张圆桌上坐着5名哲学家,每两个哲学家之间的桌子上摆一根筷子,桌子的中间是一碗米饭。哲学家们倾注毕生的精力用于思考和进餐,哲学家在思考时,并不影响他人。只有当哲学家饥饿时,才试图拿起左、右两根筷子(一根一根地拿起)。如果筷子已在他人手上,则需等待。饥饿的哲学家只有同时拿起两根筷子才可以开始进餐,当进餐完毕后,放下筷子继续思考。
1、关系分析。系统中有5个哲学家进程,5位哲学家与左右邻居对其中间筷子的访问是互斥关系。
2、整理思路。这个问题中只有互斥关系,但与之前遇到的问题不同的是,每个哲学家进程需要同时持有两个临界资源才能开始吃饭。如何避免临界资源分配不当造成的死锁现象,是哲学家问题的精髓。
3、信号量设置。定义互斥信号量数组chopstick[5] = {1,1,1,1,1}
用于实现对5个筷子的互斥访问。并对哲学家按0~4编号,哲学家i
左边的筷子编号为i
,右边的筷子编号为(i+1)%5
。
每个哲学家进程,无非就两件事:吃饭、思考。只不过在吃饭之前需要做两件事:拿起左边的筷子、拿起右边的筷子。吃饭结束后,再依次对这两个资源进行释放。
于是我们初步做出了以下这种写法。
semaphore chopstick[5] = {1,1,1,1,1};
Pi() {
while(1) {
P(chopstick[i]); //拿左
P(chopstick[(i+1)%5]); //拿右
吃饭...;
V(chopstick[i]); //放左
V(chopstick[(i+1)%5]); //放右
思考...;
}
}
这样做的问题在于,假如此时5个哲学家都并发地执行吃饭操作。那么,如果5个哲学家并发地拿起了自己左手边的筷子…,如下图所示。
紧接着,每个哲学家会尝试拿右
,但会发现,右边的筷子已经被别人占用了。于是,所有哲学家都会被阻塞。每位哲学家循环等待右边的人放下筷子(阻塞)。发生“死锁”。
那么,怎么避免刚才这种问题,如何防止死锁的发生呢?
(1)可以对哲学家进程施加一些限制条件,比如最多允许四个哲学家同时进餐。这样可以保证至少有一个哲学家是可以拿到左右两只筷子的。
四个哲学家拿起4根左筷子
,此时桌子上一定还剩余1根筷子,这根筷子就可以被一个进程拿右
,顺利执行,执行完毕后释放,之后,其他哲学家会依次被唤醒。
(2)**要求奇数号哲学家先拿左边的筷子,然后再拿右边的筷子,而偶数号哲学家刚好相反。**用这种方法可以保证如果相邻的两个奇偶号哲学家都想吃饭,那么只会有其中一个可以拿起第一支筷子,另一个争抢失败就会在手里没有筷子的情况下直接阻塞。这就避免了占有一支后再等待另一支的情况。
按照此规则,0号和1号哲学家
会先争抢1号筷子
;2号和3号哲学家
会先争抢3号筷子
;4号哲学家
会拿起0号筷子
。
这两种解决方案对应的代码都不复杂,可以自己写一写。
提示:
(1)若想实现最多允许4个哲学家同时进餐,可以设置一个大小为4的同步信号量。
(2)在每个哲学家拿筷子之前,判断一下自己的序号是奇数还是偶数,接下来分别做不同的操作。
(3)仅当一个哲学家左右两支筷子都可用时才允许他抓起筷子。(该句描述不够严谨,原因见下文)
可以设置一个信号量mutex,在哲学家拿筷子之前和拿完筷子之后分别P、V。
semaphore chopstick[5] = {1,1,1,1,1};
semaphore mutex = 1; //互斥地取筷子
Pi() {
while(1) {
P(mutex);
P(chopstick[i]); //拿左
P(chopstick[(i+1)%5]); //拿右
V(mutex);
吃饭...;
V(chopstick[i]); //放左
V(chopstick[(i+1)%5]); //放右
思考...;
}
}
按照上述代码逻辑,分析会发生什么情况。
【情况1】
0号哲学家
开始拿筷子,他在执行P(mutex)
后不会被阻塞,于是他会拿左
。此时,如果发生了进程切换,切换到了2号哲学家
,此时,它会P(mutex)
,从而被阻塞。一直到0号哲学家
执行完拿右
并且V(mutex)
之后,2号哲学家
才可以被唤醒。
然后2号哲学家
同样的也可以拿左
、拿右
。
可见,一个哲学家,当他的左、右筷子都可使用时,他的拿筷子操作是可以一气呵成的。
【情况2】
0号哲学家
先运行,执行P(mutex)
,然后拿左
、拿右
,再V(mutex)
。
在这时,如果1号哲学家
也想吃饭,那么,他可以顺利的执行P(mutex)
,但是,当它执行拿左
的时候,就会被阻塞。
此时,如果2号哲学家
也想吃饭,那么他会尝试进行P(mutex)
,显然此时他会被阻塞。
这就产生一个问题:即使2号哲学家
的左右筷子都可用,但是他依然拿不起他两边的筷子,依然会被阻塞。
【情况3】
首先,0号哲学家
拿了左、右筷子。
之后,4号哲学家
也开始吃饭,可以顺利执行拿左
,但是当他尝试拿右
时,便会被阻塞。
在这种情况下,就相当于:4号哲学家在拿了一支筷子的情况下,同时在等待别的筷子。
所以,虽然说的是“只有两边的筷子都可用时,才允许哲学家拿起筷子”,但现在看来,实际上并不能保证这种条件。因此这种说法其实是不太严谨的。
比较准确的说法应该是:各哲学家拿筷子这件事必须互斥地执行。这就保证了即使一个哲学家在拿筷子拿到一半时被阻塞,也不会有别的哲学家会继续尝试拿筷子。而某个哲学家拿筷子到一半时被阻塞,一定是在等待另一个哲学家对他所等待的资源的释放,之后,那个哲学家吃完饭并释放筷子后,阻塞便可被唤醒。即,当前正在吃饭的哲学家放下筷子后,被阻塞的哲学家就可以获得等待的筷子了。之后,他吃完饭后再对两个筷子释放,之后所有哲学家都可以一一顺利吃饭。这样就可以避免循环等待发生死锁的现象。因此,这种解决方案是可行的、不会发生死锁。
哲学家进餐问题的关键在于解决进程死锁。
这些进程之间只存在互斥关系,但是与之前接触到的互斥关系不同的是,每个进程都需要同时持有两个临界资源,因此就有“死锁”问题的隐患。
如果在考试中遇到了一个进程需要同时持有多个临界资源的情况,应该参考哲学家问题的思想,分析题中给出的进程之间是否会发生循环等待,是否会发生死锁。
可以参考哲学家进餐问题解决死锁的三种思路。
管程:
为什么要引入管程
管程的定义和基本特征
拓展1:用管程解决生产者消费者问题
拓展2:Java中类似管程的机制
在引入管程之前,实现进程的同步互斥操作,使用的是信号量机制。
但是,信号量机制存在的问题:编写程序困难、易出错。这一点不用解释了,肯定都深有体会。比如生产者—消费者问题中提到过,实现互斥的P操作P(mutex)
和实现同步的P操作P(full)
的顺序写反了,就会发生死锁。
所以,人们就想到能不能设计一种机制,让程序员写程序的时候不需要再关注复杂的PV操作,让写代码更加轻松呢?
1973年,Brinch Hansen首次在程序设计语言(Pascal)中引入了“管程”成分——一种高级同步机制。
管程和之前的PV操作一样,也是用来实现进程的互斥和同步的。而进程之间之所以要实现互斥和同步,这是因为,进程之间要共享某些数据资源,比如像生产者—消费者问题中,生产者、消费者都需要共享地访问缓冲区这一资源。
所以,为了实现各个进程对一些共享资源的互斥或同步的访问的话,管程就要由这样一些结构组成:
1、局部于管程的共享数据结构说明;
比如刚才说的生产者—消费者问题中,需要被共享的那个缓冲区。这个缓冲区就可以用一种数据结构来表示说明。所以,管程中要对共享资源定义一种相对应的共享数据结构。
2、对该数据结构进行操作的一组过程;
“过程”其实就是“函数”。
3、对局部于管程的共享数据设置初始值的语句;
其实就是说,对该数据结构要进行一些初始化。
4、管程有一个名字。
不难发现,管程的定义有点类似于“类”。
管程的基本特征:
1、局部于管程的数据只能被局部于管程的过程所访问;
2、一个进程只有通过调用管程内的过程才能进入管程访问共享数据;
3、每次仅允许一个进程在管程内执行某个内部过程。
monitor ProducerConsumer
condition full, empty; //条件变量用来实现同步(排队)
int count = 0; //缓冲区中的产品数
void insert(Item item){ //把产品item放入缓冲区
if(count == N)
wait(full);
count++;
insert_item(item);
if(count == 1)
signal(empty);
}
Item remove() { //从缓冲区中取出一个产品
if(count == 0)
wait(empty);
count--;
if(count == N-1)
signal(full);
return remove_item();
}
end monitor;
//生产者进程
producer() {
while(1){
item = 生产一个产品;
ProducerConsumer.insert(item);
}
}
//消费者进程
consumer() {
while(1){
item = ProducerConsumer.remove();
消费产品item;
}
}
使用管程。生产者进程先生产一个产品item,之后很简单地调用管程中的insert(item)
就可以了,具体过程就不再需要关心了。消费者进程同理。这样就会使代码很简洁。其中,缓冲区满了怎么办、缓冲区空了怎么办,这些都是由管程所解决的问题,我们的代码不需要考虑。
在编译的时候,由编译器负责实现各进程互斥地进入管程中的过程。每次仅允许一个进程在管程内执行某个内部过程。
【例1】
两个生产者进程并发执行,依次调用了insert过程。
由于刚开始没有任何一个进程正在访问管程中的某一个函数,所以第一个进程是可以顺利执行下去insert函数的。而如果在第一个进程没有执行完该函数相应的这一系列逻辑的时候,第二个进程就尝试着也想调用insert函数的话,编译器就会暂时阻止第二个进程实现这个函数。就会把第二个进程阻塞在insert函数上。类似于一个排队队列,让它先等待,等第一个进程执行完毕insert函数后,再让它执行。
互斥地访问共享数据,这是由编译器为我们实现的。程序员在写程序的时候不需要再关心如何实现互斥,只需要直接调用管程提供的一系列函数,它本身就能保证是互斥地进行的。
除了互斥之外,管程中设置的一些条件变量和等待/唤醒操作,也解决了同步问题。
【例2】
两个消费者进程先执行,生产者进程后执行。
第一个进程先执行remove函数,(看上面管程中remove函数的具体逻辑)此时需要判断缓冲区里是否有可用的产品,由于刚开始count==0
,因此会执行wait(empty)
;第二个进程同样。
之后,如果有生产者开始执行,执行管程的insert函数。(看上面管程中insert函数的具体逻辑)那么,它会把自己生产的产品放入到缓冲区当中,并且会检查自己放入的这个产品是不是这个缓冲区当中的第一个产品,如果是第一个产品的话就意味着此时有可能有别的消费者进程正在等待我的这个产品,所以接下来,生产者进程会执行一个唤醒操作signal(empty)
用来唤醒empty变量对应的等待队列中的某一个进程,一般来说是唤醒队头的进程。
被唤醒的消费者执行remove。首先执行count–,然后,如果减1后,count==N-1,就意味着它来之前缓冲区是满的,这就意味着有可能此时有被阻塞的生产者进程需要被唤醒,于是就会执行一个唤醒操作signal(full)
。最后,remove函数返回一个消费者进程想要的产品对应一个指针(算是可以这么理解)。
可见,无论是互斥还是同步,一系列问题都由管程解决,不需要程序员操心。
在体会了上面例子之后,进一步总结一下对于管程的描述。
引入管程的目的无非就是要更方便地实现进程互斥和同步。
1、需要在管程中定义共享数据(如生产者消费者问题的缓冲区);
2、需要在管程中定义用于访问这些共享数据的“入口”——其实就是一些函数(如生产者消费者问题中,可以定义一个函数用于将产品放入缓冲区,再定义一个函数用于从缓冲区取出产品)
3、只有通过这些特定的“入口”才能访问共享数据
4、管程中有很多“入口”,但是每次只能开放其中一个“入口”,并且只能让一个进程或线程进入(如生产者消费者问题中,各进程需要互斥地访问共享缓冲区。管程的这种特性即可保证一个时间段内最多只会有一个进程在访问缓冲区。)注意:这种互斥特性是由编译器负责实现的,程序员不用关心。
5、可在管程中设置条件变量及等待/唤醒操作以解决同步问题。可以让一个进程或线程在条件变量上等待(此时,该进程应先释放管程的使用权,也就是让出“入口”);可以通过唤醒操作将等待在条件变量上的进程或线程唤醒。
程序员可以用某种特殊的语法定义一个管程(比如:monitor ProducerConsumer … end monitor;),之后其他程序员就可以使用这个管程提供的特定“入口”很方便地使用实现进程同步/互斥了。这其实就是封装思想。复杂的细节隐藏了,只需对外提供一个简单易用的接口。
Java中,如果用关键字synchronized
来描述一个函数,那么这个函数同一时间段内只能被一个线程调用。
static class monitor {
private Item buffer[] = new Item[N];
private int count = 0;
public synchronized void insert(Item item) {
......
}
}
这样一来,每次只能有一个线程进入insert函数,如果多个线程同时调用insert函数,则后来者需要排队等待。
注:不熟悉Java的同学看不懂也没关系,不会考,仅作为思维拓展。熟悉Java的同学在时间充裕的情况下可以手动尝试用synchronized实现生产者消费者问题的“管程”。想表达的意思就是,管程离我们并不遥远,我们日常写代码很多地方都能运用到类似管程的机制。