一. 操作系统概述1. 操作系统提供的服务2. 操作系统特征3. 操作系统的发展与分类4. 操作系统的运行机制5. 中断和异常5.1 内中断5.2 外中断5.3 中断分类5.4 中断机制的基本原理6. 系统调用7. 操作系统体系结构7.1 大内核与微内核7.2 其他结构体系8. 操作系统引导9. 虚拟机二. 进程管理1. 进程的状态与转换1.1 进程状态切换1.2 进程的组织1.3 进程的控制创建原语撤销原语阻塞原语唤醒原语切换原语2. 进程通信2.1 共享存储2.2 消息传递2.3 管道通信3. 线程3.1 线程的实现方式及多线程模型线程的实现方式多线程模型3.2 线程的状态与转换4. 调度4.1 进程调度的时机、切换与过程、方式进程调度时机进程调度的方式进程的切换与过程4.2 调度器和闲逛进程4.3 调度算法评价指标4.4 调度算法先来先服务(FCFS)短作业优先(SJF)高响应比优先(HRRN)时间片轮转(RR)优先级调度算法多级反馈队列5.进程同步和进程互斥5.1 进程互斥软件实现单标志法双标志先检查法双标志后检查法Peterson算法5.2 进程互斥的硬件实现中断屏蔽法TestAndSet指令Swap指令5.3 互斥锁5.4 信号量机制整型信号量记录型信号量5.5 信号量机制实现进程同步和互斥信号量机制实现进程互斥信号量机制实现进程同步5.6 经典进程同步互斥问题生产者消费者问题多生产者多消费者问题吸烟者问题读者写者问题哲学家进餐问题6. 管程6.1 管程解决生产者消费者问题6.2 Java中类似的管程机制7. 死锁7.1 死锁的处理策略:预防死锁7.2 死锁的处理策略:避免死锁7.3 死锁的处理策略:检测和解除
计算机层次结构:裸机(指纯硬件,包含CPU、内存、硬盘和主板等)、操作系统、应用程序和用户。结构依次往上。
操作系统(Operating System,OS) 是指控制和管理整个计算机系统的硬件和软件资源,并合理地组织调度计算机的工作和资源的分配;以提供给用户和其他软件方便的接口和环境;它是计算机系统中最基本的系统软件。
从上面定义可以得到操作系统以下特点:
这一门课主要学习的是操作系统是系统资源的管理者,即操作系统会提供:处理机(CPU)管理、存储器管理、文件管理和设备管理。
向上层提供方便易用的服务主要是用户和应用程序不需要直接对硬件进行繁琐复杂的操作,而是通过操作系统简化这些操作。这其实体现了封装思想,即操作系统把一些丑陋的硬件功能封装成简单易用的服务,使用户能更方便地使用计算机,用户无需关心底层硬件的原理,只需要对操作,系统发出命令即可。操作系统提供的易用服务有:
GUI:图形化用户接口(Graphical User Interface)
用户可以使用形象的图形界面进行操作,而不再需要记忆复杂的命令、参数。
例子:在Windows操作系统中,删除-一个 文件只需要把文件“拖拽”到回收站即可。
命令接口
联机命令接口实例(cmd)。联机命令接口
联机命令接口就是一个指令对应一个执行。
脱机命令接口(bat文件)。脱机命令接口
批处理是提前预输入多个指令,让系统一次性全部执行。
程序接口
可以在程序中进行系统调用来使用程序接口。普通用户不能直接使用程序接口,只能通过程序代码间接使用。
如:写C语言"Hello world"程序时,在printf
函数的底层就使用到了操作系统提供的显式相关的系统调用。
系统调用类似于函数调用,是应用程序请求操作系统服务的唯一方式。有的教材中系统调用又称为广义指令。
这里的命令接口和程序接口也可以统称为用户接口。
所以上图用户和应用程序与操作系统之间的有接口,用户通过命令接口可以直接和操作系统进行交互。应用程序可以通过程序接口与操作系统进行交互。
最后操作系统作为最接近硬件的层次需要实现对硬件机器的拓展。如果一个计算机中没有任何软件支持的计算机成为裸机。在裸机上安装的操作系统,可以提供资源管理功能和方便用户的服务功能,将裸机改造成功能更强、使用更方便的机器。通常把覆盖了软件的机器称为扩充机器,又称之为虚拟机。
操作系统对硬件机器的拓展是将CPU、 内存、磁盘、显示器、键盘等硬件合理地组织起来,让各种硬件能够相互协调配合,实现更多更复杂的功能。
操作系统有并发、共享、虚拟和异步四个特征。其中共享和并发是两个最基本的特征,二者互为存在条件。
并发
这里的并发:指两个或多个事件在同一时间间隔内发生。这些事件宏观上是同时发生的,但微观上是交替发生的。如指令流水线。
而并行:指两个或多个事件在同一时刻同时发生。如双接口访存。
操作系统的并发性指计算机系统中"同时"运行着多个程序,这些程序宏观上看是同时运行着的,而微观上看是交替运行的。
操作系统出现就是为了支持"多道程序技术"。因此操作系统和并发是一起诞生的。
注意(重要考点):单核CPU同一时刻只能执行一个程序,各个程序只能并发地执行。多核CPU同一时刻可以同时执行多个程序,多个程序可以并行地执行。比如Intel的第八代i3处理器就是
共享
共享即资源共享,是指系统中的资源可供内存中多个并发执行的进程共同使用。
两种资源共享方式:互斥共享和同时共享。
互斥共享方式
系统中的某些资源,虽然可以提供给多个进程使用,但一个时间段内只允许一个进程访问该资源。
如使用QQ和微信视频。同一时间段内摄像头只能分配给其中一个进程。
同时共享方式
系统中的某些资源,允许一个时间段内由多个进积"同时"对它们进行访问。
同时共享方式:使用QQ发送文件
所谓的"同时"往往是宏观上的,而在微观上,这些进程可能是交替地对该资源进行访问的(即分时共享)。
并发和共享之间的关系:并发性指计算机系统中同时存在着多个运行着的程序。共享性是指系统中的资源可供内存中多个并发执行的进程共同使用。
还是通过上面发送文件例子来解释并发与共享的关系。使用QQ发送文件
两个进程正在并发执行(并发性)
如果失去并发性,则系统中只有一个程序正在运行,则共享性失去存在的意义。
需要共享地访问硬盘资源(共享性)
如果失去共享性,则QQ和微信不能同时访问硬盘资源,就无法实现同时发送文件,也就无法并发。
所以并发性和共享性是互为存在的关系。
虚拟
虚拟是指把一个物理上的实体变为若千个逻辑上的对应物。物理实体(前者)是实际存在的,而逻辑上对应物(后者)是用户感受到的。
如:GTA5需要
另外如果用户打开多个软件,但实际上只有一个单核的CPU,但这多个程序仍然可以运行。这是虚拟处理器技术。实际上只有一个单核CPU,在用户看来似乎有
所以虚拟技术分为时分复用技术和空分复用技术。
显然,如果失去了并发性,则一个时间段内系统中只需运行一道程序,那么就失去了实现虚拟性的意义了。因此,没有并发性,就谈不上虚拟性。
异步
异步是指,在多道程序环境下,允许多个程序并发执行,但由于资源有限,进程的执行不是一贯到底的,而是走走停停,以不可预知的速度向前推进,这就是进程的异步性。
由于并发运行的程序会争抢着使用系统资源,而系统中的资源有限,因此进程的执行不是一贯到底的,而是走走停停的,以不可预知的速度向前推进。
如果失去了并发性,即系统只能串行地运行各个程序,那么每个程序的执行会一贯到底。只有系统拥有并发性,才有可能导致异步性。
总结:并发和共享互为存在条件。没有并发和共享,就谈不上虚拟和异步,因此并发和共享是操作系统的两个最基本的特征。
主要讲述操作系统在各个阶段的主要解决的问题及各自的优缺点。
手工操作阶段
程序员将代码写在纸带上,有孔的地方是
这种运行方式主要慢在程序员将纸带放入纸带机及从纸带机中取走云心结果这几步。
上面
单道批处理系统
为了解决上面手工阶段缺点,发明了单道批处理系统,在这个阶段引入脱机输入
各个程序员可以把自己的程序放入纸带机,之后会由外围机将多个纸带程序数据先放到磁带机上,之后计算机可以直接从这个磁带中读取数据。磁带读写速度比纸带机快很多。
此时的计算机中会运行监督程序,由这个程序控制自动从磁带中输入输出数据。
采用这种系统,计算机利用率提高很多
主要优点:缓解了一定程度的人机速度矛盾,资源利用率有所提升。
主要缺点:内存中仅能有一道程序运行,只有该程序运行结束之,后才能调入下一道程序。CPU有大量的时间是在空闲等待
多道批处理系统
为了解决上述问题,多道批处理系统诞生,在这个阶段操作系统正式出现。操作系统支持多道程序并发运行。
在多道批处理系统中,每次可以往内存中读入多道程序,然后让这些程序并发运行。
这个阶段计算机利用率大大提高
主要优点:多道程序并发执行,共享计算机资源。资源利用率大幅提升,CPU和其他资源更能保持"忙碌"状态,系统吞吐量增大。
主要缺点:用户响应时间长,没有人机交互功能(用户提交自己的作业之后就只能等待计算机处理完成,中间不能控制自己的作业执行。如:无法调试程序
分时操作系统
分时操作系统:计算机以时间片为单位轮流为各个用户
主要优点:用户请求可以被即时响应,解决了人机交互问题。允许多个用户同时使用一台计算机,并且用户对计算机的操作相互独立,感受不到别人的存在。
主要缺点:不能优先处理一些紧急任务。操作系统对各个用户
实时操作系统
为了能让用户处理一些紧急任务。有了实时操作系统。
在实时操作系统的控制下,计算机系统接收到外部信号后及时进行处理,并且要在严格的时限内处理完事件。实时操作系统的主要特点是及时性和可靠性。而实时操作系统又可以细分为:
主要优点:能够优先响应--些紧急任务,某些紧急任务不需时间片排队。如火车订票系统。
其他操作系统
由程序员编写的程序是应用程序。而操作系统内核是由一个一个内核程序组成的,所以操作系统内核简称内核。内核是操作系统最核心的部分,也是最接近硬件的部分。甚至可以说,一个操作系统只需要内核就足够了,如Docker技术就仅需要Linux内核。而操作系统的功能也未必都在内核中,如GUI。
操作系统内核作为"管理者",有时会让CPU执行一些特权指令,如:内存清零指令。这些指令影响重大,只允许"管理者",即操作系统内核来使用。
普通的应用程序只能使用"非特权指令",如:加法指令、减法指令等。
在CPU设计和生产的时候就划分了特权指令和非特权指令,因此CPU执行一条指令前就能判断出其类型。但CPU无法区分特权指令是应用程序的指令还是内核程序的指令。为了能让CPU区分CPU会分为两种状态:内核态(管态或核心态)和用户态(目态)。
CPU中有一个寄存器叫程序状态字寄存器(PSW),其中有个二进制位,
内核态与用户态的切换:
所以内核态转换为用户态:执行一条特权指令,即修改PSW的标志位为"用户态",这个动作意味着操作系统将主动让出CPU使用权
而用户态转换为内核态:由"中断"引发,硬件自动完成变态过程,触发中断信号意味着操作系统将强行夺回CPU的使用权
除了非法使用特权指令之外,还有很多事件会触发中断信号。一个共性是,但凡需要操作系统介入的地方,都会触发中断信号
CPU上会运行两种程序,一种是操作系统内核程序,一种是应用程序。内核是整个系统管理者,在合适的情况下,操作系统内核会把CPU的使用权主动让给应用程序,这个应用程序运行过程中会发生中断,中断是让操作系统内核夺回CPU使用权的唯一途径。结合上一节内容这里不难理解中断重要性。
中断作用:让操作系统内核强行夺回CPU的控制权。使CPU从用户态变为内核态。
中断分为两种类型:内中断和外中断。
内中断产生和当前执行的指令有关,中断的信号来源于CPU内部。
如上面提到的例子,一个应用程序应用在用户态,假如这个应用程序有一个特权指令,CPU执行这条特权指令时发现此时正在处于用户态。于是这个非法的事件会触发一个中断信号,CPU会拒绝执行这条指令。接着CPU会自动切换到内核态开始处理中断信号相关的内核程序。
有时候即使用户执行的是非特权指令也会发生中断。如执行除法指令时发现除数为
另一个例子,一个应用程序运行在用户态,有时候应用程序想请求操作系统内核的服务,此时会执行一条特殊的指令,即陷入指令,该指令会引发一个内部中断信号。接着CPU会转向处理中断信号的内核程序。所以可以看出当一个应用程序执行陷入指令时,就意味着这个应用程序主动把CPU使用权还给操作系统内核,想让操作系统内核为其提供一些服务。之前提到的系统调用就是通过陷入指令完成的。
需要强调的是陷入指令是一个特殊指令,不是特权指令。
外中断产生和当前执行的指令无关,中断信号来源于CPU外部。
典型的例子是时钟中断,即由硬件时钟部件发送来的中断信号。这个部件会每隔一段时间给CPU发送一个时钟中断信号,通过时钟中断信号就可以实现多道程序并发运行了。
假设此时系统当中想要并发运行两个应用程序。而时钟部件每个
除了由时钟部件发出的中断信号,有时候也会由
有的教材会称内中断为异常或例外。外中断叫中断。
异常可以分为三类:陷阱(陷入)、故障、终止
陷阱
由陷入指令引发,是应用程序故意引发的,即当一个应用程序想要操作系统提供服务时候,就会故意引发这个异常。其实这也是系统调用的原理。
故障
是一种由错误条件引起的,可能被内核程序修复。内核程序修复故障后会把CPU使用权还给应用程序,让它继续执行下去。 如:整数除
终止
由致命错误引起,内核程序无法修复该错误,因此一般不再将CPU使用权还给引发终止的应用程序,而是直接终止该应用程序。如:非法使用特权指令。
不同的中断信号,需要用不同的中断处理程序来处理。当CPU检测到中断信号后,会根据中断信号的类型去查询"中断向量表",以此来找到相应的中断处理程序在内存中的存放位置。
显然这里的中断处理程序就是一种内核程序,需要运行在内核态。
总结:
操作系统作为用户和计算机硬件之间的接口,需要向上提供一些简单易用的服务。主要包括命令接口和程序接口。其中,程序接口由一组系统调用组成。
所以"系统调用"是操作系统提供给应用程序(程序员
这个平时编程时使用的函数调用其实是很类似的。但还是有区别,系统调用是比高级语言提供的库函数更为底层的一个接口。
另外一些库函数不涉及系统调用,如取绝对值函数。设计系统调用有创建一个新文件函数等。
有一种情况,当打印时,如果两个进程可以随意地、并发地共享打印机资源这样会得到错误的打印内容。所以系统调用是必须的。即解决方法是由操作系统内核对共享资源进行统一的管理, 并向上提供"系统调用",用户进程想要使用打印机这种共享资源,只能通过系统调用向操作系统内核发出请求。内核会对各个请求进行协调处理。
系统调用分类:
应用程序通过系统调用请求操作系统的服务。而系统中的各种共享资源都由操作系统内核统一掌管, 因此凡是与共享资源有关的操作(如存储分配、
假设一个应用程序想要进行系统调用,这个调用过程如下:
首先这个应用程序的指令会被处于用户态的CPU一个一个执行。当这个应用程序需要用到系统调用时,需要用传参数指令,给CPU寄存器中传递必要的参数。如在某一个寄存器中存放参数
当这些参数都放入寄存器之后,应用程序就会执行一条特殊的指令,即陷入指令。这个陷入指令的执行会引发一个内中断。CPU检测到这个内部中断信号后发现是由trap(陷阱)指令引起的,于是CPU就会暂停运行这个应用程序转而去处理陷入指令的应用程序。这个程序就是系统调用入口程序。
显然这个系统调用入口程序属于内核程序,因此CPU会切换为内核态。这个中断程序会检查CPU寄存器中的参数,通过第一个参数会知道应用程序需要的是哪种类型的系统调用服务。接着入口程序就会调用与之对应的处理程序。让处理程序在CPU上运行。
当系统调用处理完成后,CPU会再转回到用户态。接着执行之前的应用程序。
总的来说系统调用过程是:传递系统调用参数
注意:
经过之前的学习计算机系统结构如下:
操作系统的内部可以进一步进行划分:一部分是内核功能(如时钟管理,中断处理),另一部分是非内核功能(如GUI)。
这里的时钟管理就是利用时钟中断实现的计时功能。原语是一种特殊的程序,具有原子性。也就是说,这段程序的运行必须一气呵成,不可被"中断"。Ubuntu、CentOS 的开发团队,其主要工作是实现非内核功能,而内核都是用了Linux内核。
总之内核是操作系统最基本、最核心的部分。实现操作系统内核功能的那些程序就是内核程序。还有一种划分方式由于对进程管理、存储器管理、设备管理等功能属于对数据结构的操作,不会直接涉及硬件,所以这些不属于内核。按照这样思路划分如下:
上面这种划分方式会对系统性能造成一定的影响。
假设现在有个应用程序想要请求操作系统的服务,这个服务的处理同时涉及到进程管理、存储管理、设备管理。
因此采用大内核结构只需要两次变态即可。如果采用微内核体系结构整个过程就需要六次变态。而需要注意的是变态的过程是有成本的,要消耗不少时间,频繁地变态会降低系统性能。
操作系统体系结构总结:
操作系统体系结构还有分层结构、模块化结构和外核结构。
分层结构操作系统
内核分为多层,每层可以单向调用更低一层提供的接口。
最底层是硬件,最高层是用户接口,每层可调用更低一层,如第二层只能调用第一层为其提供的接口。
优点:
缺点:
模块化结构
一种很经典的程序设计思想,将内核分为多个模块,各模块之间相互协作。其内核
主模块:只负责核心功能,如进程调度、内存管理。
可加载内核模块:可以动态加载新模块到内核,而无需重新编译整个内核,如驱动程序。
优点:
缺点:
外核
内核负责进程调度、进程通信等功能, 外核负责为用户进程分配未经抽象的硬件资源(如磁盘存储空间),且由外核负责保证资源使用安全。
再普通的操作系统中,用户进程申请使用一片内存空间,操作系统分配这块内存空间是经过抽象的,经过虚拟化的,对于用户进程来说这些空间似乎拥有一整片连续的空间,但事实上这只是虚拟的地址空间,操作系统会将虚拟的地址空间映射到实际的物理物理空间当中。这些物理页框在实际中通常是离散的,类似于计算机组成原理中的虚拟存储技术。这里未经抽象资源指的是操作系统分配的空间在实际存储空间中是连续存放的。也就是说如果用户进程知道某些数据要被频繁的访问到,此时就可以向外核申请分配一整片连续的磁盘块。之后访问这些磁盘中的数据时,磁盘磁头移动距离会变小,性能会提升。这是外存的分配。同理内存分配也一样。
而且外核还要保证资源使用安全,如进程
优点:
缺点:
系统中有的进程会申请虚拟的地址空间,这种申请还需要映射。而有的会申请物理真实空间。这种情况后序管理需要考虑各种情况,所以降低了系统一致性,导致系统变得复杂。
大内核(宏内核)与微内核
大内核:所有的系统功能都放在内核里(大内核结构的OS通常也采用了"模块化"的设计思想)
微内核:只把中断、原语、进程通信等最核心的功能放入内核。进程管理、文件管理、设备管理等功能以用户进程的形式运行在用户态
大内核中各个功能也是可以相互调用的,就和函数一样。
而微内核就只会将与硬件最紧密的功能放在内核中,大多数的功能会被放到微内核之外,在这种情况下功能与功能之间的调用就不太方便了,两个管理功能之间调用就需要通过消息传递方式来进行。如进程管理模块调用存储管理模块,需要先向微内核发送消息,消息中参数就指明要调用谁,调用的参数等信息。之后会由微内核的进程通信功能把该消息传递给被调用者存储管理模块。当存储管理模块接收到进程管理消息后才会处理调用的请求。同时存储管理模块要返回调用结果,也需要通过消息传递方式让微内核协助各个模块间的调用和返回。
大内核优点:性能高,内核内部各种功能都可以直接相互调用
大内核缺点:
微内核优点:
微内核缺点:
当新硬盘安装好操作系统后,磁盘分区如下:
可以看到磁盘开头会留出一片区域,用于存储主引导记录(MBR)。其中包含两个重要部分,一部分是磁盘引导程序,另一部分是分区表。
分区表其实就是一个数据结构,这个数据结构说明了磁盘每个分区分别占多大空间,以及每个分区的地址范围。这些分区里最重要的是C盘。C盘是磁盘活动分区,其中安装了操作系统。C盘内部结构如下:
可以看到除了根目录还有一个引导记录(PBR),这个引导记录负责找到启动管理器。
启动过程是主板上的BIOS自举程序(本质上属于主存),会将磁盘的主引导记录读入内存。主引导记录包含磁盘引导程序,之后主存会执行磁盘引导程序,这个引导程序会根据分区表找到C盘所处位置。接着读入C盘中的引导记录,这个引导记录本质上也是一个程序,之后CPU执行引导记录程序知道启动管理器。这个启动管理器本质上也是一个程序,存放在C盘根目录下的某个位置。在根目录找到启动管理器后会放入主存,最后CPU再执行启动管理程序,完成一系列的初始化工作。
操作系统引导开机总结:
虚拟机:使用虚拟化技术,将一台物理机器虚拟化为多台虚拟机器(Virtual Machine, VM),每个虚拟机器都可以独立运行一个操作系统。
同义术语:虚拟机管理程序
虚拟机管理程序分为两类:直接运行在硬件上、运行在宿主操作系统上。
直接运行在硬件上
类似于传统的操作系统,会负责直接管理硬件资源,并且分配这些硬件资源。
这类VMM会直接运行在硬件上,由虚拟机管理程序将一台物理机器,虚拟化为多台虚拟机器。会把总的硬件资源划分为多个部分,分别给多个虚拟机使用。而每个虚拟机上可以安装各自的操作系统。
CPU资源分配通过时间片划分实现。一个虚拟机分配若干个时间片,这样在上层操作系统看来,似乎就是一个独立的CPU在为自己工作。磁盘喝内存也是类似按照空间分配给不同虚拟机。
值得一提的是只有虚拟机管理程序是运行在内核态的,可以使用特权最高的指令。而上层操作系统和应用程序实际上是运行在用户态的。如果上层操作系统需要用到特权指令,这个行为动作会被虚拟管理程序检测到。此时虚拟机管理程序会将特权转化模拟出对应的特权指令执行结果反馈给上层操作系统。
运行在宿主操作系统上
并不是运行在硬件之上,而是运行在 宿主操作系统之上的一类虚拟机管理软件。
这一类的虚拟机管理程序不是直接运行在硬件上的,而是运行在宿主操作系统上的。可以在操作系统上安装第二类虚拟机管理程序。如:VMware和VirtualBox。这类第二类虚拟管理程序上可以安装其他操作系统。
由于两个操作系统实现方式各不相同,会造成一些特性上的差异。
上面提到指令的特权级,实际上在CPU中特权指令是有分级的
上图Ring3最低权限的指令,而Ring0是最高权限的指令。这样划分为更多级别是有好处的,如第一类VMM当上层操作系统使用低一级别的特权指令虚拟机管理程序不需要做中断处理。除非要使用少数最高权限的指令,虚拟机管理程序才会介入。
要了解进程之前先看看程序的概念:程序是静态的,就是个存放在磁盘里的可执行文件,就是一系列的指令集合。
进程:进程是动态的,是程序的一次执行过程。而同一个程序多次执行会对应多个进程。
当这些进程被创建的时候,操作系统会给该进程分配一个分配一个唯一的、不重复的"身份证号"PID (Process ID,进程ID)。而操作系统不止要记录这个程序对应的进程PID,进程所属用户(UID)等;还要为了实现操作系统对资源的管理,要记录给进程分配了哪些资源(如:分配了多少内存、正在使用哪些
这些信息都被保存在一个数据结构PCB ( Process Control Block)中,即进程控制块。操作系统需要对各个并发运行的进程进行管理,但凡管理时所需要的信息,都会被放在PCB中。
所以操作系统的进程控制块很重要,因为PCB是进程存在的唯一标志,当进程被创建时,操作系统为其创建PCB,当进程结束时,会回收其PCB。PCB要保存内容如下:
除了PCB之外进程还有两个很重要组成部分:程序段和数据段。
PCB是给操作系统用的。而程序段、数据段是给进程自己用的。
当系统执行可执行文件(如exe)时,会先将这个可执行文件中保存的指令序列读入内存中,并且操作系统会建立一个与之相对应的进程,也就是要创建相对应的PCB。除了PCB这个可执行程序的一些列指令序列也要读入内存当中。而这一系列的指令序列被称为程序段。执行过程就是CPU从程序段中一条一条读入指令并执行。可执行程序中处理有指令之外,还有可能存在数据(如变量),这些变量的内容也需要放到内存当中,存放数据的区域就叫做数据段。
所以一个进程实体(进程映像)由PCB、程序段、数据段组成。进程是动态的,而进程实体(进程映像)是静态的。可以把进程实体理解为进程在动态执行过程当中某一时刻的快照。进程实体能够反映其在某一个时刻的状态(如:x++后,x=2)。所以更准确的说应该是进程实体是由PCB、程序段和数据段组成。
引入进程实体的概念后,可把进程定义为:进程是进程实体的运行过程,是系统进行资源分配和调度的一个独立单位。
上面的调度就是指操作系统决定让哪个进程上CPU运行。假如同时挂三个QQ号,会对应三个QQ进程,它们的PCB、数据段各不相同,但程序段的内容都是相同的(都是运行着相同的QQ程序)
程序是静态的,进程是动态的,相比于程序,进程拥有以下特征:
进程的状态有:创建状态、就绪状态、运行状态、阻塞状态、终止状态
创建状态
当可执行文件调入内存后,操作系统会为其建立一个PCB,即建立相应的进程。进程正在被创建时,它的状态是"创建态",在这个阶段操作系统会为进程分配资源、初始化PCB。
就绪状态
当进程创建完成后,便进入"就绪态",处于就绪态的进程已经具备运行条件,但由于没有空闲CPU,就暂时不能运行。
一个操作系统中可能会有很多进程处于就绪态,当CPU空闲时,操作系统就会选择一个就绪进程,让它上CPU运行。
运行状态
如果一个进程此时在CPU上运行,那么这个进程处于"运行态"。运行态CPU会执行该进程对应的程序(执行指令序列)。
阻塞状态
在进程运行的过程中,可能会请求等待某个事件的发生(如等待某种系统资源的分配,或者等待其他进程的响应)在这个事件发生之前,进程无法继续往下执行,此时操作系统会让这个进程下CPU,并让它进入"阻塞态"。
假如CPU上运行的某个进程中指令要使用到打印设备,但是打印设备正在为其他进程服务,那么这个程序在获得,所需资源之前,进程无法再往下执行。此时操作系统会剥夺这个进程对CPU使用权,并让这个进程处于阻塞状态。此时CPU空闲,又会选择另一个"就绪态"的进程上CPU运行。
终止状态
一个进程可以执行exit系统调用,请求操作系统终止该进程。此时该进程会进入"终止态",操作系统会让该进程下CPU,并回收内存空间等资源,最后还要回收该进程的PCB。
当终止进程的工作完成之后,这个进程就彻底消失了。
综上所述,如果一个进程正在被创建,此时就会处于创建态。当进程被创建后就具备让CPU执行的条件,这个时候进程就进入就绪态。如果处于就绪态的进程被操作系统调度,那这个进程就可以在CPU上运行,当进程在CPU上运行时,就处于运行态。而有的时候正在运行的进程会请求等待某些事件的发生,在这个事件发生之前,这个进程是没法继续执行的,这种情况下该进程会进入阻塞态。如果处于阻塞态的进程等待的事件发生了,这个进程就可以重新回到就绪态。而当进程进程运行结束,或运行过程中遇到不可修复的错误,就会处于终止态。
运行态到阻塞态的转换是一种进程自身做出的主动行为。而阻塞态到就绪态的转换并不是进程自身能够控制的,是一种被动行为。
注意:不能由阻塞态直接转换为运行态,也不能由就绪态直接转换为阻塞态(因为进入阻塞态是进程主动请求的,必然需要进程在运行时才能发出这种请求)。
有的时候进程可以直接从运行态转换为就绪态。如操作系统给进程分配的时间片用完,进程就会从运行态转化就绪态。
进程的整个生命周期中,大部分时间都处于运行态、就绪态、阻塞态三种基本状态。单核CPU情况下,同一时刻只会有一个进程处于运行态,多核CPU情况下,可能有多个进程处于运行态。
操作系统记录这些状态方法是在进程PCB中,会有一个变量state来表示进程的当前状态。如:
PCB组织也称进程组织,其组织方式有两种:链接方式和索引方式。
链式方式
是指操作系统会管理一系列的队列,每个队列都会指向相应状态的PCB。
就绪队列指针会指向就绪态的进程。为了方便CPU执行,会把优先级高的进程放在队头。
很多操作系统还会根据阻塞原因不同再将阻塞队列分为多个:
索引方式
操作系统会给各种状态的进程建立相应的索引表,每一个索引表的表项又会指向相应的PCD
大部分操作系统使用链式方式。
进程控制的主要功能是对系统中的所有进程实施有效的管理,它具有创建新进程、撤销已有进程、实现进程状态转换等功能。
进程控制简单理解就是要实现进程状态转换。进程控制实现需要用到"原语"。原语是一种特殊的程序,它的执行具有原子性。也就是说,这段程序的运行必须一气呵成,不可中断。
假设PCB中的变量state表示进程当前所处状态,1表示就绪态,2表示阻塞态。如果一个进程处于就绪态state
假设当前进程处于阻塞,而等待事件发生,则操作系统中,就需要将该进程从阻塞态转换为就绪态。负责进程控制的内核程序至少需要做这样两件事才能完成转换:
上面两步转换需要有原子性。假如现在将一个处于阻塞进程
上面提到过原语具有原子性的特征,其可以使用关中断和开中断这两个特权指令实现原子性。CPU执行了关中断指令之后,就不再例行检查中断信号,直到执行开中断指令之后才会恢复检查。这样,关中断与开中断之间的这些指令序列就是不可被中断信号中断的,从而就实现了"原子性"。
所以进程状态转换必须要一气呵成。可以用原语实现,原语的实现又需要开中断和关中断指令来配合完成。
如果一个进程需要创建就需要用到创建原语。过程是要先申请一个空白的PCB,另外再给新进程分配所需资源(如内存空间等)。之后对PCB内容进行初始化工作。最后将PCB插入到就绪队列。
引起创建原语事件有:
用户登录
分时系统中,用户登录成功,系统会建立为其建立一个新的进程
作业调度
多道批处理系统中,有新的作业放入内存时,会为其建立一个新的进程。这里的作业指的是此时还放在外存中还没有投入运行的程序。
提供服务
用户向操作系统提出某些请求时,会新建一个进程处理该请求
应用请求
由用户进程主动请求创建一个子进程
终止一个进程时使用。使用撤销原语会让进程从某一个状态转换为终止态,最终进程从系统中消失。
撤销过程是:
引起进程终止事件:
正常结束
一进程自己请求终止 (exit系统调用)
异常结束 整数除以0、非法使用特权指令,然后被操作系统强行杀掉
外界干预 Ctrl+Alt+delete,用户选择杀掉进程
有的时候一个进程会从运行态转换为阻塞态。这情况下操作系统会执行阻塞原语实现这个切换。
阻塞过程:
引起进程阻塞事件:
如果一个阻塞进程等待的事件发生后,操作系统会让这个阻塞进程从阻塞态转换为就绪态。
唤醒过程:
引起唤醒事件是阻塞进程等待的事件发生。要注意的是一个进程因何事阻塞,就应由何事唤醒。
所以阻塞原语和唤醒原语必须成对使用。
会让处于运行态的进程切换为就绪态,接着再让让处于就绪态的原语切换为运行态。所以会改变两个进程状态。
切换执行过程:
这里运行环境是指当程序在运行时会将数据保存在寄存器中,此时如果另一个程序用到寄存器则会覆盖原来程序存在寄存器中的数据。所以解决办法是在进程切换时先在PCB中保存这个进程的运行环境(只保存一些必要的寄存器信息)。当原来的进程再次投入运行时,可以通过PCB恢复它的运行环境。
总之运行环境就是进程运行过程中寄存器存放的数据。当一个进程下处理机时需要把该线程的运行环境存入PCB中,而当一个进程需要重新回到处理机运行时,就可以从PCB中恢复之间的运行环境。所以保存进程的运行环境和恢复进程的运行环境是实现进程并发执行很关键的技术。
引起进程切换事件:
无论哪个进程控制原语,要做的无非三类事情:
进程间通信(Inter-Process Communication,IPC)是指两个进程之间产生数据交互。
进程之间的通信需要操作系统内核的支持。原因如下:
进程是分配系统资源的单位(包括内存地址空间),因此各进程拥有的内存地址空间相互独立。
如上图进程P不能访问进程Q地址空间。这样的规定是处于安全考虑,因此两个进程之间的通信需要操作系统的支持。
通信方式有:共享存储、消息传递和管道通信。
各个进程虽然只能访问自己的存储空间。但是如果操作系统支持共享存储方式,那么一个进程可以申请一片共享存储区。
上图进程P与进程Q可以通过共享存储区进行通信。Linux实现共享内存方式如下:
1int shm_e(); //通过shm_open 系统调用, 申请一片共享内存区
2void * mmap(); //通过mmap系统调用,将共享内存区映射到进程自己的地址空间
可以通过"增加页表项
为避免出错,各个进程对共享空间的访问应该是互斥的。即共享存储区一次只能供一个进程进行访问。各个进程可使用操作系统内核提供的同步互斥工具(如P、V操作)。
上面共享存储方式是基于存储区的共享:操作系统在内存中划出一块共享存储区,数据的形式、存放位置都由通信进程控制,而不是操作系统。这种共享方式速度很快,是一种高级通信方式。
还有一种存储共享方式是基于数据结构的共享:比如共享空间里只能放一个长度为
进程间的数据交换以格式化的消息(Message) 为单位。进程通过操作系统提供的"发送消息
格式化的消息由两个部分组成:消息头
这种消息传递方式又可以详细划分为:直接通信方式和间接通信方式。
直接通信方式:消息发送进程要指明接收进程的ID
假如进程P要给进程Q发送消息,在操作系统的内核区域会管理各个进程的PCB,在各个进程的PCB中包含了一个PCB队列即进程消息队列。如进程Q的PCB中就包含了进程Q消息队列,其他进程要发送给进程Q的消息都放在消息队列中。所以进程P要先在自己的进程地址空间中来格式化消息。接着进程P会使用发送原语send(Q,msg)
,指明msg消息要发送给进程Q。这个原语会将消息挂在进程Q的消息队列中。之后进程Q可以使用接受原语receive(P,&msg)
,来接收进程P发来的消息。使用接收原语后,操作系统内核会检查进程Q消息队列中是否有进程P发来的消息。队列中找到消息后,操作系统会将这个消息体的数据复制到进程Q的地址空间内。
所谓的直接通信方式就是要点名道姓的消息传递。
间接通信方式:通过"信箱"间接地通信。因此又称"信箱通信方式"
当前进程P要和进程Q进行通信,进程P可以通过系统调用在操作系统内核中申请一个或多个邮箱。接着进程P会在自己的地址空间来完善消息体msg的内容。之后进程P可以用发送原语send(A,msg)
,来指明要发送到哪个信箱和要发送的是哪个消息体。注意这里是指明哪个信箱而不是进程。
接着进程Q会使用接受原语receive(A,&msg)
,指明从信箱A中接受一个消息体msg。之后信箱A消息msg就会被操作系统复制到进程Q地址空间中。
可以多个进程往同一个信箱send消息,也可以多个进程从同一个信箱中receive消息。
可以从管道一端写入数据,另一端读数据。这个管道的数据流向只能是单向的。
站在操作系统的层面,这里提到的管道是一个特殊的共享文件,又名pipe文件。其实就是在内存中开辟一个大小固定的内存缓冲区。
如果两个进程之间要用管道通信进程进程通信,首先需要通过某个进程进行系统调用的方式来申请一个管道文件。操作系统会新建这个管道文件,其实就是在内存中开辟了一个大小固定的内存缓冲区。然后两个进程可以往这个管道中写数据或者读数据。数据的读写具有先进先出(FIFO)特性。
这种方式与共享传递区别在于在共享传递中,两个进程开辟的共享空间可以在共享空间的内部任意位置写入或读出数据,没有任何限制。但是管道通信的方式其本质是一个循环队列,一端写入数据后另一端读出数据必须从队头开始读取,写入数据也必须从队头开始依次往后添加。
管道通信特点:
管道只能采用半双工通信,某一时间段内只能实现单向的传输。如果要实现双向同时通信,则需要设置两个管道。
各进程要互斥的访问管道(由操作系统实现)
当管道写满时,写进程将阻塞,直到读进程将管道中的数据取走,即可唤醒写进程。
当管道读空时,读进程将阻塞,直到写进程往管道中写入数据,即可唤醒读进程。
管道中的数据一旦被读出,就彻底消失。因此,当多个进程读同一个管道时,可能会错乱。对此,通常有两种解决方案:
①一个管道允许多个写进程,一个读进程(2014年408真题高教社官方答案) ; ②允许有多个写进程,多个读进程,但系统会让各个读进程轮流从管道中读数据(Linux的方案)。
写进程往管道写数据,即便管道没被写满,只要管道没空,读进程就可以从管道读数据
读进程从管道读数据,即便管道没被读空,只要管道没满,写进程就可以往管道写数据
进程通信总结
操作系统在引入了进程之后可以实现多个程序并发运行。进程是程序的一次执行。但如QQ视频、文字聊天、文件传送等功能显然不可能是由一个程序顺序处理就能实现的。所以引入线程,来增加并发度。
在传统的进程机制当中,CPU会轮流为各个进程进行服务,那么这些进程就可以并发进行。所以在传统的进程机制当中,进程是程序执行流的最小单位。
之后为了满足这种"同时"运行很多程序,又引入线程机制,用来增加系统的并发度。引入了线程机制后,CPU的服务对象不再是进程而是进程当中的一个一个线程。每个进程当中可能包含多个线程,CPU经过算法处理会轮流为这些线程进行服务。这样同一个进程被分为多个线程,像QQ视频和文件传送这两个事情,如果想要并发执行,就可以把这两件事情对应的处理程序放到两个不同的线程下,这两个不容的线程可以并发地执行,自然这两件事也可以并发完成。
所以再引入线程机制之后,线程就成了程序执行流的最小单位。在没有引入线程之前,一个进程就对应一份代码,这些代码只能顺序地依次往下执行。但是在引入线程之后,每一个进程可以有多个线程,并且这些线程可以有各自功能,这些功能可以相同,并且都会被CPU并发处理掉。所以线程可以理解为一种轻量级进程。
总之线程是一个基本的CPU执行单元,也是程序执行流的最小单位。在引入线程之后,不仅是进程之,间可以并发,进程内的各线程之间也可以并发,从而进一步提升了系统的并发度,使得一个进程内也可以并发处理各种任务(如QQ视频、文字聊天、传文件)。
引入线程后,进程只作为除CPU之外的系统资源的分配单元(如打印机、内存地址空间等都是分配给进程的而不是线程)。
引入线程后带来的变化:
资源分配、调度
传统进程机制中,进程是资源分配、调度的基本单位
引入线程后,进程是资源分配的基本单位,线程是调度的基本单位
并发性
传统进程机制中,只能进程间并发
引入线程后,各线程间也能并发,提升了并发度
系统开销
传统的进程间并发,需要切换进程的运行环境,系统开销很大
线程间并发,如果是同一进程内的线程切换,则不需要切换进程环境,系统开销小
引入线程后,并发所带来的系统开销减小
线程属性如下:
线程的实现方式分为:用户级线程和内核级线程。
多线程模型:一对一模型、多对一模型、多对多模型。
用户级线程
早期的操作系统(如:早期Unix)只支持进程,不支持线程。当时的"线程"是由线程库实现的。
这个时代操作系统视角也只有进程,但是程序员写的应用程序当中可以使用线程库来实现多个线程并发运行。
同样使用QQ视频、文字聊天和传送文件的例子,在不支持线程的系统中,可以分别建立三个进程,这三个进程分别是处理其中的某一个任务。
三个代码写入一个程序:
xxxxxxxxxx
91int main() {
2 int i=0;
3 while (true) {
4 if (i==0){处理视频聊天的代码; }
5 if (i==1){处理文字聊天的代码; }
6 if (i==2){处理文件传输的代码; }
7 i= (i+1)%3; //i的值为 0,1,2,0,1,2...
8 }
9}
从代码的角度看,线程其实就是一段代码逻辑。上述三段代码逻辑上可以看作三个线程while循环就是一个最弱智的线程库,线程库完成了对线程的管理工作( 如调度)。很多编程语言提供了强大的线程库,可以实现线程的创建、销毁、调度等功能。
可以看出操作系统只能看到进程,而线程其实是开发人员自己创建了一个逻辑上的线程。即上面的用户级线程。
这些用户级的线程的管理是由应用程序通过线程库完成的,并不是操作系统负责的。而线程的切换是由线程库即应用程序自己完成的,在用户态下就可以完成线程的切换工作,并不需要操作系统的介入。并且操作系统也并不能意识到用户级线程的存,只有用户才能感知到用户级线程的存在。因此这也是这种方式叫用户级线程的原因。
用户级线程优点:用户级线程的切换在用户空间即可完成,不需要切换到核心态,线程管理的系统开销小,效率高。
缺点:当一个用户级线程被阻塞后,整个进程都会被阻塞,并发度不高。多个线程不可在多核处理机上并行运行。
另外用用户级方式实现这种情况下CPU的调度单位依然是进程,操作系统是给进程分配调用时间的,所以即便电脑是多核处理机器,但是由于当前方式进程才是CPU调度的基本单位,因此一个进程只能分配一个核心,所以这些线程并不能并行运行。
内核级线程
又称内核支持的线程。这种内核级线程就是操作系统视角也可以看得到线程。大多数现代操作系统都实现了内核级线程,如:Windows、Linux
在引入内核级线程后,线程管理工作是由操作系统完成。既然内核级线程由操作系统管理那线程的切换就需要操作系统介入,因此线程的切换需要CPU从内核态转换为用户态。同时操作系统也能认识到内核级线程的存在。
内核级线程优点:当一个线程被阻塞后,别的线程还可以继续执行,并发能力强。多线程可在多核处理机上并行执行。这种情况下内核级线程是CPU分配的基本单位,这种情况下即便其中某一个线程阻塞,其他的线程依然可以继续执行。
缺点:一个用户进程会占用多个内核级线程,线程切换由操作系统内核完成,需要切换到核心态,因此线程管理的成本高,开销大。
在支持内核级线程的操作系统中再引入线程库,就可以实现把若干个用户级线程映射到某一个内核级线程。那么根据用户级线程和内核级线程的映射关系,可以划分为几种多线程模型:
一对一模型:一个用户级线程映射到一个内核级线程。每个用户进程有与用户级线程同数量的内核级线程。
优点:当一个线程被阻塞后,别的线程还可以继续执行,并发能力强。多线程可在多核处理机上并行执行。
缺点:一个用户进程会占用多个内核级线程,线程切换由操作系统内核完成,需要切换到核心态,因此线程管理的成本高,开销大。
多对一模型:多个用户级线程映射到一个内核级线程。且一个进程只被分配一个内核级线程。
这种模型就退化为了之前用户级线程。
优点:用户级线程的切换在用户空间即可完成,不需要切换到核心态,线程管理的系统开销小,效率高。
缺点:当一个用户级线程被阻塞后,整个进程都会被阻塞,并发度不高。多个线程不可在多核处理机上并行运行。
注意:操作系统只"看得见"内核级线程,因此只有内核级线程才是处理机分配的单位。
多对多模型:
克服了多对一模型并发度不高的缺点(一个阻塞全体阻塞),又克服了一对一模型中一个用户进程占用太多内核级线程,开销太大的缺点。
再区分一下用户级线程和内核级线程:用户级线程是"代码逻辑"的载体。内核级线程是"运行机会"的载体,因此操作系统在分配处理机资源时,是以内核级线程为单位的。所以一段"代码逻辑"只有获得了"运行机会"才能被CPU执行。这种情况下内核级线程才是处理机分配的单位。例如:多核CPU环境下,上图这个进程最多能被分配两个核心。
内核级线程中可以运行任意一个有映射关系的用户级线程代码,只有两个内核级线程中正在运行的代码逻辑都阻塞时,这个进程才会阻塞
线程的状态与转换与进程的几乎一样。
线程的状态转换最核心的是就绪态、运行态和阻塞态之间的转换。它们之间的转换和进程之间的转换完全一致。
而线程的组织与控制也与进程相似。对于进程来说,每个进程会建立一个与之对应的PCB模块,而进程也是一样,会建立TCB(线程控制模块)。每个TCB中包含的内容有:
有了TCB之后每一个TCB就可以表示一个线程,多个线程的TCB组织起来就是一个线程表。组织方式根据不同系统会有不同的策略。
调度是指当有一堆任务要处理,但由于资源有限,这些事情没法同时处理。这就需要确定某种规则来决定处理这些任务的顺序,这就是"调度"研究的问题。
程序发生调度情况,情况可以分为三个层次:高级调度(作业调度)、中级调度(内存调度)、低级调度(进程调度)。
高级调度(作业调度)
高级调度(作业调度):按一定的原则从外存的作业后备队列中挑选一个作业调入内存, 并创建进程。每个作业只调入一次,调出一次。作业调入时会建立PCB,调出时才撤销PCB。
这里需要补充"作业"概念,所谓的作业其实就是指某一个具体的任务。用户向系统提交一个作业可以理解为用户让操作系统启动一个程序(来处理一个具体的任务)。
而这个程序需要从外存调入内存,而内存空间有限,有时无法将用户提交的作业全部放入内存。为了解决好几个程序需要启动,但内存资源有限先启动哪一个问题。这里计算机采用高级调度(作业调度)。
低级调度(进程调度)
低级调度(进程调度
内存中会有很多进程,而CPU处理资源有限。所以操作系统也需要按照某种策略从就绪队列中选取一个进程,将处理机分配给它。
进程调度是操作系统中最基本的一种调度,在一般的操作系统中都必须配置进程调度。并且进程调度的频率很高,一般几十毫秒一次。
中级调度(内存调度)
中级调度(内存调度):按照某种策略决定将哪个处于挂起状态的进程重新调入内存。
内存不够时,可将某些进程的数据调出外存。等内存空闲或者进程需要运行时再重新调入内存。暂时调到外存等待的进程状态为挂起状态。被挂起的进程PCB会被组织成挂起队列,类似与之前的阻塞队列。
如果当前内存有空间,就要按照某种策略决定将哪个处于挂起状态的进程重新调入内存。一个进程可能会被多次调出、调入内存,因此中级调度发生的频率要比高级调度更高。
这里补充一个与挂起状态相关的七状态模型:暂时调到外存等待的进程状态为挂起状态(挂起态,suspend)。挂起态又可以进一步细分为就绪挂起、阻塞挂起两种状态。之前学过五状态模型:
在引入就绪挂起和阻塞挂起两种状态之后就成了七状态模型。如果当前内存不够用,操作系统可能会将一个处于就绪态的进程暂时放到外存中,这个被放入外存的进程就进入就绪挂起状态。一直到内存空间足够或者进程需要继续执行,这个进程会被激活,进程相对应的数据会再次调入内存,这样一个就绪挂起态进程有回到就绪态。
同时一个处于阻塞态的进程也会被挂起,也可以激活重新回到阻塞态。
而有的操作系统也可以使一个处于阻塞挂起进程当等待时间出现后这个进程就会转换为就绪挂起态。之后当重新调入内存时,会直接进入就绪态而不是阻塞态。
而有的时候,一个进程处于运行态结束后,会被放入外存中处于就绪挂起状态。有的时候当一个进程创建完PCB后有可能出现内存空间不够情况,这种情况下会先进入就绪挂起态。
注意"挂起"和"阻塞"的区别,两种状态都是暂时不能获得CPU的服务,但挂起态是将进程映像调到外存去了,而阻塞态下进程映像还在内存中。
有的操作系统会把就绪挂起、阻塞挂起分为两个挂起队列,甚至会根据阻塞原因不同再把阻塞挂起进程进一步细分为多个队列。
三层调度的联系、对比:
进程调度(低级调度),就是按照某种算法从就绪队列中选择一个进程为其分配处理机。
需要进行进程调度与切换的情况:
当前运行的进程主动放弃处理机
如:进程正常终止、运行过程中发生异常而终止、进程主动请求阻塞(如等待
当前运行的进程被动放弃处理机
如:分给进程的时间片用完、有更紧急的事需要处理(如
而进程调度也不是随时都可以进行的,一下情况不能进行调度:
补充:进程在操作系统内核程序临界区中不能进行调度与切换这是正确的表述。进程处于临界区时不能进行处理机调度是错误的表述。
先来简单看一下临界资源:一个时间段内只允许一个进程使用的资源。各进程需要互斥地访问临界资源。所以内核程序临界区和临界区含义如下:
当一个进程此时处于内核程序临界区,并且临界区是要访问就绪队列的情况下,在访问之前会将就绪队列上锁。如果进程还没退出临界区 (还没解锁)就进行进程调度,但是进程调度相关的程序也需要访问就绪队列,但此时就绪队列被锁住了,因此又无法顺利进行进程调度。所以内核程序临界区访问的临界资源(种内核数据结构)如果不尽快释放的话,极有可能影响到操作系统内核的其他管理工作。因此在访问内核程序临界区期间不能进行调度与切换。
另一种情况当一个进程要访问打印机(外部设备),在打印机打印完成之前,进程一直处于临界区内,临界资源不会解锁。但打印机又是慢速设备,此时如果一直不允许进程调度的话就会导致CPU一直空闲。所以进程在访问普通临界区访问的临界资源不会直接影响操作系统内核的管理工作。因此在访问普通临界区时可以进行调度与切换。
有的系统中,进程可以主动放弃处理机,当有更紧急的任务需要处理时,也会强行剥夺处理机(被动放弃)。所以根据当前运行的进程是否可以被强行剥夺处理及资源这个问题可以引出进程调度方式。
进程调度方式可以分为两种:
非剥夺调度方式
非剥夺调度方式,又称非抢占方式。即,只允许进程主动放弃处理机。在运行过程中即便有更紧迫的任务到达,当前进程依然会继续使用处理机,直到该进程终止或主动要求进入阻塞态。
这种方式实现简单,系统开销小但是无法及时处理紧急任务,适合于早期的批处理系统。
剥夺调度方式
剥夺调度方式,又称抢占方式。当一个进程正在处理机上执行时,如果有一个更重要或更紧迫的进程需要使用处理机,则立即暂停正在执行的进程,将处理机分配给更重要紧迫的那个进程。
这种方式可以优先处理更紧急的进程,也可实现让各进程按时间片轮流执行的功能(通过时钟中断)。适合于分时操作系统、实时操作系统。
既然选择一个进程要为其分配处理机,进程与进程切换的过程中会发生以下情况。
首先看看"狭义的进程调度"与"进程切换"的区别:
广义的进程调度包含了选择一个进程和进程切换两个步骤。
而进程切换过程主要是以下两步骤:
对原来运行进程各种数据(运行环境)的保存。将环境保存到PCB中。
对新的进程各种数据(运行环境)的恢复。从进程PCB中读出环境数据。
如:程序计数器、程序状态字、各种数据寄存器等处理机现场信息,这些信息一般保存在PCB中
注意:进程切换是有代价的,因此如果过于频繁的进行进程调度、切换,必然会使整个系统的效率降低,使系统大部分时间都花在了进程切换上,而真正用于执行进程的时间减少。
总结:
调度程序是操作系统内核重要程序模块。一个程序会在就绪、运行和阻塞三个状态切换。
上面②③之间状态的转换就是由调度程序决定的。操作系统要决定下面两件事情:
让谁运行?
具体看调度算法
运行多长时间?
具体看时间片大小
什么事件会触发调度程序(调度时机):
上面的是进程,如果一个操作系统支持的不仅是进程,还支持线程,那么调度程序调度对象就是线程。
闲逛进程:如果就绪队列中没有其他就绪进程时,调度程序就会选中闲逛进程上处理机运行。
闲逛进程的特性:
末尾理性检查中断会周期性唤醒调度程序,调度程序会检查当前是否有就绪进程,如果有就会让闲逛程序下处理机,就绪进程上处理机。
调度算法评价指标主要从CPU利用率、系统吞吐量、周转时间、等待时间和响应时间五个方面评价。
由于早期的CPU造价极其昂贵,因此人们会希望让CPU尽可能多地工作。
CPU利用率:指CPU"忙碌"的时间占总时间的比例。
有的题目还会要求计算某种设备的利用率。
例题:某计算机只支持单道程序,某个作业刚开始需要在CPU上运行
通常会考察多道程序并发执行的情况,可以用"甘特图"来辅助计算。
系统吞吐量
对于计算机来说,希望能用尽可能少的时间处理完尽可能多的作业。
系统吞吐量:单位时间内完成作业的数量
例题:某计算机系统处理完
周转时间
对于计算机的用户来说,他很关心自己的作业从提交到完成花了多少时间。
周转时间,是指从作业被提交给系统开始,到作业完成为止的这段时间间隔。
它包括四个部分:作业在外存后备队列上等待作业调度(高级调度)的时间、进程在就绪队列上等待进程调度(低级调度)的时间、进程在CPU上执行的时间、进程等待
有的作业运行时间短,有的作业运行时间长,因此在周转时间相同的情况下,运行时间不同的作业,给用户的感觉肯定是不一样的。如:排队上厕所,本来只用一分钟,但排队等待需要十分钟。而另一个人使用厕所十分钟,排队只用一分钟。因此又提出另一种指标:
因此对于周转时间相同的两个作业,实际运行时间长的作业在相同时间内被服务的时间更多,带权周转时间更小,用户满意度更高。
而对于实际运行时间相同的两个作业,周转时间短的带权周转时间更小,用户满意度更高
相应的也有一个平均带权时间
等待时间
计算机的用户希望自己的作业尽可能少的等待处理机。
所以等待时间,指进程
当一个作业刚开始提交的时候是放在外存中的作业后备队列当中,作业在后备队列当中需要被服务(调度),当调度之后作业就会放到内存当中,并且建立起相应的进程,当进程建立后会被CPU或
对于进程来说,等待时间就是指进程建立后等待被服务的时间之和,在等待
另外对于对于作业来说,不仅要考虑建立进程后的等待时间,还要加上作业在外存后备队列中等待的时间。
一个作业总共需要被CPU服务多久,被
这里的平均等待时间就是将所有进程或者作业的等待时间加和,再除以作业的数量即可。
响应时间
对于计算机用户来说,会希望自己的提交的请求(比如通过键盘输入了一个调试命令)尽早地开始被系统服务、回应。
响应时间,指从用户提交请求到首次产生响应所用的时间。
调度算法评价指标总结:
调度算法有先来先服务(FCFS)、短作业优先(SJF)、高响应比优先(HRRN)、时间片轮转(RR)、优先级调度、多级反馈队列
算法思想:主要从"公平"的角度考虑(类似于我们生活中排队买东西的例子)
算法规则:按照作业
用于作业
这种先来先服务算法一般是非抢占式算法,也就是说对于当前正在占用处理机的进程(作业),之有这个进程主动放弃处理机时,才会进行调度,才会用调度算法规则选择下一个上处理机运行的进程。
例题:各进程到达就绪队列的时间、需要的运行时间如下表所示。使用先来先服务调度算法,计算各进程的等待时间、平均等待时间、周转时间、平均周转时间、带权周转时间、平均带权周转时间。
上图可知,调度顺序为: P1
周转时间
故P1
带权周转时间
故P1
等待时间
P1
注意:本例中的进程都是纯计算型的进程,一个进程到达后要么在等待,要么在运行。如果是又有计算、又有
平均周转时间
平均带权周转时间
平均等待时间
可以看到上面P3的周转时间比运行时间多了
倍。
算法优点:公平、算法实现简单
缺点:排在长作业(进程)后面的短作业需要等待很长时间,带权周转时间很大,对短作业来说用户体验不好。即,FCFS算法对长作业有利,对短作业不利(如:排队买奶茶你只买一杯,但前面有个人买
如果某个进程
可以看出FCFS对于带权周转时间、平均等待时间这些指标不算优秀,为了追求最少的平均等待时间,最少的平均周转时间、最少的平均平均带权周转时间提出了短作业优先(SJF)算法。
算法规则:最短的作业
该算法即可用于作业调度,也可用于进程调度。用于进程调度时称为"短进程优先(SPF, Shortest Process First)算法"。
SJF和SPF是非抢占式的算法。但是也有抢占式的版本:最短剩余时间优先算法(SRTEN, Shortest Remaining Time Next)
例题:各进程到达就绪队列的时间、需要的运行时间如下表所示。使用短作业优先调度算法,计算各进程的等待时间、平均等待时间、周转时间、平均周转时间、带权周转时间、平均带权周转时间。
上图可知,当P1进程到达时会直接运行,运行时间为
周转时间
故P1
带权周转时间
故P1
等待时间
P1
平均周转时间
平均带权周转时间
平均等待时间
对比FCFS算法的结果,显然SPF算法的平均等待
当上题改为用最短剩余时间优先算法(SRTEN, Shortest Remaining Time Next),每当有进程加入就绪队列改变时就需要调度,如果新到达的进程剩余时间比当前运行的进程剩余时间更短,则由新进程抢占处理机,当前运行进程重新回到就绪队列。另外,当一个进程完成时也需要调度。
故需要注意的是,当有新进程到达时就绪队列就会改变,就要按照上述规则进行检查。各个时刻的情况如下:
周转时间
故P1
带权周转时间
故P1
等待时间
P1
平均周转时间
平均带权周转时间
平均等待时间
对比非抢占式的短作业优先算法,显然抢占式的这几个指标又要更低。
注意:
如果题目中未特别说明,所提到的"短作业
很多书上都会说"SJF调度算法的平均等待时间、平均周转时间最少"。
严格来说,这个表述是错误的,不严谨的。之前的例子表明,最短剩余时间优先算法得到的平均等待时间、平均周转时间还要更少。所以应该加上一个条件"在所有进程同时可运行时,采用SJF调度算法的平均等待时间、平均周转时间最少";或者说"在所有进程都几乎同时到达时,采用SJF调度算法的平均等待时间、平均周转时间最少"。
如果不加上述前提条件,则应该说"抢占式的短作业
虽然严格意义来说,SJF的平均等待时间、平均周转时间并不一定最少,但相比于其他算法(如FCFS),SJF依然可以获得较少的平均等待时间、平均周转时间。
短作业优先算法优点:"最短的"平均等待时间、平均周转时间。
缺点:不公平。对短作业有利,对长作业不利。可能产生饥饿现象。另外,作业
所以这种算法会产生"饥饿"。即如果源源不断地有短作业
FCFS算法是在每次调度的时候选择一个等待时间最长的作业(进程)为其服务。但是没有考虑到作业的运行时间,因此导致了对短作业不友好的问题。
SJF算法是选择-一个执行时间最短的作业为其服务。但是又完全不考虑各个作业的等待时间,因此导致了对长作业不友好的问题,甚至还会造成饥饿问题。
而高响应比优先(HRRN)算法,即考虑到各个作业的等待时间,也能兼顾运行时间。
算法思想:要综合考虑作业
算法规则:在每次调度时先计算各个作业
该算法即可用于作业调度,也可用于进程调度。并且是非抢占式的算法。因此只有当前运行的作业
例题:各进程到达就绪队列的时间、需要的运行时间如下表所示。使用高响应比优先(HRRN)算法,计算各进程的等待时间、平均等待时间、周转时间、平均周转时间、带权周转时间、平均带权周转时间。
各个时刻的情况如下:
高响应比优先算法优点:
对于长作业来说,随着等待时间越来越久,其响应比也会越来越大,从而避免了长作业饥饿的问题。
三个调度算法总结:
注:这几种算法主要关心对用户的公平性、平均周转时间、平均等待时间等评价系统整体性能的指标,但是不关心"响应时间",也并不区分任务的紧急程度,因此对于用户来说,交互性很糟糕。因此这三种算法一般适合用于早期的批处理系统,当然,FCFS 算法也常结合其他的算法使用,在现在也扮演着很重要的角色。而适合用于交互式系统的调度算法将在下面介绍。
算法思想:公平地、轮流地为各个进程服务,让每个进程在一定时间间隔内都可以得到响应。
算法规则:按照各进程到达就绪队列的顺序,轮流让各个进程执行一个时间片(如100ms)。若进程未在一个时间片内执行完,则剥夺处理机,将进程重新放到就绪队列队尾重新排队。
这种算法是用于进程调度的(只有作业放入内存建立了相应的进程后,才能被分配处理机时间片)
若进程未能在时间片内运行完,将被强行剥夺处理机使用权,因此时间片轮转调度算法属于抢占式的算法。由时钟装置发出时钟中断来通知CPU时间片已到。
例题:各进程到达就绪队列的时间、需要的运行时间如下表所示。使用时间片轮转调度算法,分析时间片大小分别是
注意:常用于分时操作系统,更注重"响应时间",因而此处不计算周转时间。
各个时刻的情况如下:
队头P1上处理机运行
此时队头P3上处理机运行
队头进程P2上处理机。
P4上处理机
P1上处理机
如果时间片太大,使得每个进程都可以在一个时间片内就完成,则时间片轮转调度算法退化为先来先服务调度算法,并且会增大进程响应时间。因此时间片不能太大。
时间片太大太小影响:
上面"增大进程响应时间":假如系统中有
另一方面,进程调度、切换是有时间代价的(保存、恢复运行环境),因此如果时间片太小,会导致进程切换过于频繁,系统会花大量的时间来处理进程切换,从而导致实际用于进程执行的时间比例减少。可见时间片也不能太小。
一般来说,设计时间片时要让切换进程的开销占比不超过
时间片轮转算法优点:公平、响应快,适用于分时操作系统。
缺点:由于高频率的进程切换,因此有一定开销;且不区分任务的紧急程度。
这种算法不会导致饥饿出现。
算法思想:随着计算机的发展,特别是实时操作系统的出现,越来越多的应用场景需要根据任务的紧急程度来决定处理顺序
算法规则:每个作业
这种算法既可用于作业调度,也可用于进程调度。甚至,还会用于在之后会学习的
优先级调度算法抢占式和非抢占式都有。做题时的区别在于:非抢占式只需在进程主动放弃处理机时进行调度即可,而抢占式还需在就绪队列变化时,检查是否会发生抢占。
例题:各进程到达就绪队列的时间、需要的运行时间、进程优先数如下表所示。使用非抢占式的优先级调度算法,分析进程运行情况。(注: 优先数越大,优先级越高)
非抢占式的优先级调度算法:每次调度时选择当前已到达且优先级最高的进程。当前进程主动放弃处理机时发生调度。
各个时刻的情况如下:
如果采用抢占式的优先级调度算法:每次调度时选择当前已到达且优先级最高的进程。当前进程主动放弃处理机时发生调度。另外,当就绪队列发生改变时也需要检查是会发生抢占。各个时刻的情况如下:
补充:就绪队列未必只有一个,可以按照不同优先级来组织。另外,也可以把优先级高的进程排在更靠近队头的位置。根据优先级是否可以动态改变,可将优先级分为静态优先级和动态优先级两种:
设置各类进程优先级原则:系统进程优先级高于用户进程、前台进程优先级高于后台进程、操作系统更偏好
注意:与
当采用动态优先级策略时,调整情况可以从追求公平、提升资源利用率等角度考虑。比如某进程在就绪队列中等待了很长时间,则可以适当提升其优先级。如果某进程占用处理机运行了很长时间, 则可适当降低其他先级。另外比如发现一个进程频繁地进行
优先级调度算法优点:用优先级区分紧急程度、重要程度,适用于实时操作系统。可灵活地调整对各种作业/进程的偏好程度。
缺点:若源源不断地有高优先级进程到来,则可能导致饥饿。
之前的FCFS算法的优点是公平、SJF算法的优点是能尽快处理完短作业,平均等待
算法思想:对其他调度算法的折中权衡。
算法规则:
该算法主要用于进程调度,而且是抢占式算法在
例题:各进程到达就绪队列的时间、需要的运行时间如下表所示。使用多级反馈队列调度算法,分析进程运行的过程。
首先会设置多级就绪队列,各级队列优先级从高到低,时间片从小到大。
新进程到达时先进入第
只有第
另外被抢占处理机的进程重新放回原队列队尾。上题各个时刻的情况如下:
多级反馈队列优点:对各类型进程相对公平(FCFS的优点)。每个新到达的进程都可以很快就得到响应(RR的优点)。短进程只用较少的时间就可完成(SPF的优点)。不必实现估计进程的运行时间(避免用户作假)可灵活地调整对各类进程的偏好程度,比如CPU密集型进程、
这种算法会导致饥饿出现。因为如果有源源不断地新进程到达,更低级队列的进程就有可能长期得不到服务(饥饿)。
多级调度算法在计算机中的应用:系统中按进程类型设置多个级别队列,进程创建成功后插入某个队列。
队列之间可采取固定优先级,或时间片划分固定优先级:高优先级空时低优先级进程才能被调度。时间片划分:如
各个队列可采用不同的调度策略,如:系统进程队列采用优先级调度、交互式队列采用RR、批处理队列采用FCFS。
三种交互式调度算法总结:
注:比起早期的批处理操作系统来说,由于计算机造价大幅降低,因此之后出现的交互式操作系统(包括分时操作系统、实时操作系统等)更注重系统的响应时间、公平性、平衡性等指标。而这几种算法恰好也能较好地满足交互式系统的需求。因此这三种算法适合用于交互式系统。(比如UNIX使用的就是多级反馈队列调度算法)
进程同步:
之间介绍过进程具有异步性的特征。异步性是指,各并发执行的进程以各自独立的、不可预知的速度向前推进。
而异步性会出现很多问题,如有时候一个变量必须等待用户进行
同步亦称直接制约关系,它是指为完成某种任务而建立的两个或多个进程,这些进程因为需要在某些位置上协调它们的工作次序而产生的制约关系。进程间的直接制约关系就是源于它们之间的相互合作。
进程同步又被称为进程间的直接制约关系。即进程之间是有直接合作的。
进程互斥:
在之前的学习中可以知道进程的"并发"需要"共享"的支持。各个并发执行的进程不可避免的需要共享一些系统资源(比如内存,又比如打印机、摄像头这样的
之前介绍过两种资源共享方式:互斥共享方式和同时共享方式。其中互斥共享指的是系统中的某些资源,虽然可以提供给多个进程使用,但一个时间段内只允许一个进程访问该资源。同时共享方式是指系统中的某些资源,允许一个时间段内由多个进程"同时"对它们进行访问。
把一个时间段内只允许一个进程使用的资源称为临界资源。许多物理设备(比如摄像头、打印机)都属于临界资源。此外还有许多变量、数据、内存缓冲区等都属于临界资源。对临界资源的访问,必须互斥地进行。互斥,亦称间接制约关系。进程互斥指当一个进程访问某临界资源时,另一个想要访问该临界资源的进程必须等待。当前访问临界资源的进程访问结束,释放该资源之后,另一个进程才能去访问临界资源。
对临界资源的互斥访问,可以在逻辑上分为如下四个部分:
xxxxxxxxxx
61do {
2 entry section; //进入区
3 critical section; //临界区
4 exit section; //退出区
5 remainder section; //剩余区
6} while (true)
进入区
负资检查是否可进入临界区,若可进入,则应设置正在访问临界资源的标志(可以理解为"上锁")。以阻止其他进星同时进入临界区。
临界区
访问临界资源的那段代码。如通过打印机打印输出,对打印机进行写操作的代码就会放到临界区中。
退出区
负责解除正在访问临界资源的标志(可以理解为"解锁")。
剩余区
可以做其他处理。
注意:临界区是进程中访问临界资源的代码段。进入区和退出区是负责实现互斥的代码段。临界区也可称为"临界段"。
为了实现对临界资源的互斥访问,同时保证系统整体性能,需要遵循以下原则:
进程互斥又被称为进程间的间接制约关系。因为进程之间并没有直接的合作关系,只是想要互斥的使用某种系统临界资源。
如果一个系统中没有进程互斥,假设进程A、进程B在系统中并发地运行:
先调度A上处理机运行当A在使用打印机的过程中,分配给它的时间片用完了,接下来操作系统调度B让它上处理机运行,进程B也使用到打印机。所以由于A进程打印机使用到一半时,B进程也开始使用,结果会导致A,B两个进程的内容会打印在一起混淆。
如果两个进程可以互斥访问打印机,即只有A进程使用完打印机后,B才可以接着访问打印机。这种情况下就不会出现上面的问题。
可以用软件来实现互斥。
算法思想:两个进程在访问完临界区后会把使用临界区的权限转交给另-一个进程。也就是说每个进程进入临界区的权限只能被另一个进程赋予。
首先设置一个变量turn
,这个变量表示当前允许进入临界区的进程号。如turn=0
表示当前允许进程
两个进程对临界区访问代码如上图。
每个进程都会在进入区(while
)判断此时是不是允许自己进入,即把turn
值和自己的编号进行对比。如果turn
的值不等于自己的编号,那么此时只允许另一个进程访问临界区。
上面代码种turn
的初值为turn
改为1后,P1才能进入临界区。
因此,该算法可以实现"同一时刻最多只允许一个进程访问临界区"。
可以看出这个算法只能按P0
算法思想:设置一个布尔型数组flag[]
,数组中各个元素用来标记各进程想进入临界区的意愿,比如flag[0]=ture
意味着0号进程P0现在想要进入临界区。每个进程在进入临界区之前先检查当前有没有别的进程想进入临界区,如果没有,则把自身对应的标志flag[i]
设为true
,之后开始访问临界区。
在并发运行的环境下若按照
算法思想:双标志先检查法的改版。前一个算法的问题是先"检查"后"上锁",但是这两个操作又无法一气呵成,因此导致了两个进程同时进入临界区的问题。因此,人们又想到先"上锁"后"检查"的方法,来避免上述问题。
在并发运行的环境下若按照
算法思想:结合双标志法、单标志法的思想。如果双方都争着想进入临界区,那可以让进程尝试谦让。做一个有礼貌的进程。
可以看到上面代码按照
Peterson算法用软件方法解决了进程互斥问题,遵循了空闲让进、忙则等待、有限等待三个原则,但是依然未遵循让权等待的原则,因为没有进入临界区的进程会一直卡在while
循环处,不能释放处理机资源。
之前的四种软件实现互斥的方法中,双标志先检查法只要配合硬件完成"检查"和"上锁"一气呵成就可以完美解决这个问题。
利用"开
这种方法优点:简单、高效、逻辑清晰。
缺点:不适用于多处理机;只适用于操作系统内核进程,不适用于用户进程(因为开
简称TS指令,也有地方称为TestAndSetLock指令,或TSL指令。
TSL指令是用硬件实现的,执行的过程不允许被中断,只能一气呵成。以下是用C语言描述的逻辑
x1//布尔型共享变量lock 表示当前临界区是否被加锁
2//true表示已加锁,false 表示未加锁
3bool TestAndSet (bool *lock){
4 bool old;
5 old = *lock; //old用来存放Zock原来的值
6 *lock = true; //无论之前是否已加锁,都将lock设为true
7 return old; //返回lock原来的值
8}
9
10//以下是使用TSL指令实现互斥的算法逻辑
11int main(){
12 while (TestAndSet (&lock)); //"上锁"并"检查"
13 临界区代码段...
14 lock = false; //"解锁”
15 剩余区代码段...
16}
指令实现的逻辑是当前进程在main
函数中的循环会检查有没有上锁,如果上锁,即TestAndSet
返回true
,会一直循环,一直到某个使用临界区的进程在使用完后将lock
修改为false
,此时循环就会结束,进而执行当前进程的临界区代码。
相比软件实现方法,TSL 指令把“上锁”和“检查”操作用硬件的方式变成了一气呵成的原子操作。
优点:实现简单,无需像软件实现方法那样严格检查是否会有逻辑漏洞;适用于多处理机环境。
缺点:不满足"让权等待"原则,暂时无法进入临界区的进程会占用CPU并循环执行TSL指令,从而导致"忙等"。
有的地方也叫Exchange指令,或简称XCHG指令。
Swap指令是用硬件实现的,执行的过程不允许被中断,只能一气呵成。以下是用C语言描述的逻辑
xxxxxxxxxx
151//Swap指令的作用是交换两个变的值
2Swap (bool *a,bool *b) {
3 bool temp;
4 temp = *a;
5 *a = *b;
6 *b = temp;
7}
8
9//以下是用Swap指令实现互斥的算法逻辑
10//lock表示当前临界区是否被加锁
11bool old = true;
12while (old == true) Swap (&lock, &old);
13临界区代码段. ..
14Lock = false;
15剩余区代码段...
逻辑上来看Swap和TSL并无太大区别,都是先记录下此时临界区是否已经被上锁(记录在old
变量上),再将上锁标记lock
设置为true
,最后检查old
,如果old
为false
则说明之前没有别的进程对临界区上锁,则可跳出循环,进入临界区。
优点:实现简单,无需像软件实现方法那样严格检查是否会有逻辑漏洞;适用于多处理机环境。
缺点:不满足"让权等待"原则,暂时无法进入临界区的进程会占用CPU并循环执行TSL指令,从而导致"忙等"。
锁是一种实现的互斥的方法。而解决临界区最简单的工具就是互斥锁(mutexlock)。一个进程在进入临界区时应获得锁;在退出临界区时释放锁。函数acquire()
获得锁,而函数release()
释放锁。
每个互斥锁有一个布尔变量available
,表示锁是否可用。如果锁是可用的,调用acqiure()
会成功,且锁不再可用。当一个进程试图获取不可用的锁时,会被阻塞(进入while
循环),直到锁被释放。
xxxxxxxxxx
71acquire ()
2 while(!available); //忙等待
3 available = false; //获得锁
4}
5release () {
6 available = true; //释放锁
7}
acquire()
或release()
的执行必须是原子操作,因此互斥锁通常采用硬件机制来实现。
互斥锁的主要缺点是忙等待,当有一个进程在临界区中,任何其他进程在进入临界区时必须连续循环调用acquire()
。当多个进程共享同一CPU时,就浪费了CPU周期。因此,互斥锁通常用于多处理器系统,一个线程可以在一个处理器上等待,不影响其他线程的执行。
需要连续循环忙等的互斥锁,都可称为自旋锁(spinlock) ,如TSL指令、 swap指令、单标志法
互斥锁特性:
信号量机制会介绍两种:整型信号量和记录型信号量。
之前学习过进程互斥的四种软件实现方式(单标志法、双标志先检查、双标志后检查、Peterson算法)和进程互斥的三种硬件实现方式(中断屏蔽方法、TS/TSL指令、Swap/XCHG指令)。
单标志法、双标志先检查、双标志后检查存在问题较大,并且在双标志先检查法中,进入区的"检查"、"上锁"操作无法一气呵成,从而导致了两个进程有可能同时进入临界区的问题。而剩下的四种方法问题较小但都无法实现"让权等待"的问题。
为了解决上面两问题,在1965年,荷兰学者Dijkstra提出了一种卓有成效的实现进程互斥、同步的方法:信号量机制。
信号量机制:用户进程可以通过使用操作系统提供的一对原语来对信号量进行操作,从而很方便的实现了进程互斥、进程同步。
信号量其实就是一个变量(可以是一个整数,也可以是更复杂的记录型变量),可以用一个信号量来表示系统中某种资源的数量,比如:系统中只有一台打印机,就可以设置一个初值为
上面的一对原语:指wait(S)原语和signal(S)原语,可以把原语理解为我们自己写的函数,函数名分别为wait和signal,括号里的倍号量
由于信号量是一个变量(可以是一个整数,也可以是更复杂的记录型变量),所以信号量机制可以分为整型信号量和记录型信号量。
即用一个整数型的变量作为信号量,用来表示系统中某种资源的数量。
不同于普通的整型变量,对信号量的操作只有三种:即初始化、P操作、V操作。
假如某计算机系统中有一台打印机,有
由于只有一个打印机,所以整型信号量
xxxxxxxxxx
81int S = 1; //初始化整型信号量s,表示当前系统中可用的打印机资源数
2void wait(int S){ //wait 原语,相当于“进入区”
3 while (S <= 0); //如果资源数不够,就一直循环等待
4 S=S-1; //如果资源数够,则占用一个资源
5}
6void signal(int S){ //signal原语,相当于“退出区”
7 S=S+1; //使用完资源后,在退出区释放资源
8}
当进程P0调用wait
函数时,如果当前信号量S<=
即打印机处于上锁状态就会一直进入循环,其他进程也一样。假设这里打印机处于空闲状态,P0进程会使用打印机资源。其他进程进入等待,当P0使用完资源后执行signal
函数会把S
信号量恢复,接下来的一个进程就可以跳出循环使用打印机。
由于使用原语所以上面的"检查"和"上锁"一气呵成,避免了并发、异步导致的问题。
但是这个方法还是会出现"忙等"现象,不满足"让权等待"原则。因为剩下进程会一wait
函数的循环中。
为了解决上面"让权等待"原则,提出记录型信号量。即用记录型数据结构表示信号量。
xxxxxxxxxx
51/*记录型信号的定义*/
2typedef struct {
3 int value; //剩余资源数
4 struct process *L; //等待队列
5}semaphore;
对应的wait
和signal
函数原语如下:
xxxxxxxxxx
141/*某进程需要使用资源时,通过wait原语申请*/
2void wait (semaphore S) {
3 S.value--;
4 if(S.valut<0){
5 block(S.L);
6 }
7}
8/*进程使用完资源后,通过signal 原语释放*/
9void signal ( semaphore S) {
10 s.value++;
11 if (S.value <= 0) {
12 wakeup(S.L);
13 }
14}
wait
函数中如果剩余资源数不够S.valut<0
,使用block
原语使进程从运行态进入阻塞态,并把当前进程挂到信号量S的等待队列(即阻塞队列)*L
中。
signal
函数中释放资源后s.value++
,若还有别的进程在等待(阻塞队列中有进程)这种资源S.value<=0
,则使用wakeup
原语唤醒等待队列中的一个进程,该进程从阻塞态转换为就绪态。
例子:某计算机系统中有S.value
的值设为S.L
设置为空。假设当前有四个进程:
如果当前先执行的是P0进程,wait
函数的S.value
减为S.value=0
,没有处于空闲的打印机了。接着CPU再为P2进程服务,P2同样要使用打印机,所以调用wait
函数后value
值同样会减一变为-1
,此时block(S.L);
函数会将P2进程挂到阻塞队列*L
中。S.value=-1
表明当前有一个进程在等待队列。接着CPU为P3进程服务,和P2一样,S.value=-2
,将P3放入阻塞队列*L
当中。
接着再为P0服务,当前P0正好使用完打印机执行signal
原语,此时S.value++=-1
,小于signal
原语会主动执行wakeup
原语,唤醒等待队列中的一个进程,这里是队头进程P2,此时P2会从阻塞队列转换为就绪队列,之后打印机会分配给P2进程。同样P3一样。
总结:在考研题目中wait(S)
、signal(S)
也可以记为P(S)、V(S),这对原语可用于实现系统资源的"申请"和"释放"。
S.value
的初值表示系统中某种资源的数目。对信号量S的一次P操作意味着进程请求一个单位的该类资源,因此需要执行S.value--
,表示资源数减S.value<0
时表示该类资源已分配完毕,因此进程应调用block
原语进行自我阻塞(当前运行的进程从运行态转换为阻塞态),主动放弃处理机,并插入该类资源的等待队列S.L
中。可见,该机制遵循了"让权等待"原则,不会出现"忙等"现象。
对信号量S的一次V操作意味着进程释放一个单位的该类资源,因此需要执行S.value++
,表示资源数加S.value<=0
,表示依然有进程在等待该类资源,因此应调用wakeup
原语唤醒等待队列中的第一个进程(被唤醒进程从阻塞态转换为就绪态)。
信号量的值
P(S):即申请一个资源S,如果资源不够就阻塞等待。
V(S):释放一个资源S,如果有进程在等待该资源,则唤醒一个进程。
可以使用信号量机制实现进程的同步和互斥。
系统当中的某一些资源必须进程互斥,而访问互斥资源的代码叫做临界区。这样就表明头一时刻只能有一个进程进入临界区代码。
所以首先要做的是分析并发进程的关键活动,划定临界区(如:对临界资源打印机的访问就应放在临界区)。另外还要设置互斥信号量mutex
, 初值为mutex
表示可以进入临界区的进程名额。
信号机制实现互斥代码如下:
xxxxxxxxxx
211/*记录型信号的定义*/
2typedef struct {
3 int value; //剩余资源数
4 struct process *L; //等待队列
5}semaphore;
6
7/*信号量机制实现互斥*/
8semaphore mutex=1;//初始化信号量
9P1(){
10 ...
11 P(mutex);//使用临界资源前需要加锁
12 临界区代码段...
13 V(mutex);//使用临界资源后需要解锁
14}
15P2(){
16 ...
17 P(mutex) ;
18 临界区代码段...
19 V(mutex);
20 ...
21}
这里的semaphore mutex=1;
实际上不能这么赋值,但要会自己定义记录型(semaphore)信号量,但如果题目中没特别说明,可以把信号量的声明简写成这种形式semaphore mutex=1
。
如果多个进程访问的临界资源不同,则要给不同临界区设置不同的信号量。
另外需要注意的是P、V操作必须成对出现。缺少P(mutex)
就不能保证临界资源的互斥访问。缺少V(mutex)
会导致资源永不被释放,等待进程永不被唤醒。
进程同步值得是要让各并发进程按要求有序地推进。
比如,P1、P2 并发执行,由于存在异步性,因此二者交替推进的次序是不确定的。
若P2的"代码4"要基于P1的"代码1"和"代码2"的运行结果才能执行,那么我们就必须保证"代码4"一定是在"代码2"之后才会执行。这就是进程同步问题,让本来异步并发的进程互相配合,有序推进。
用信号量实现进程同步:
假如有以下两个进程:先初始化同步信号量,初始值为semaphore S=0;
若先执行到V(S)操作,则S++
后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了。
这里保证了代码4一定是在代码2之后执行。
更简单的理解此时的信号量S代表"某种资源",刚开始是没有这种资源的。P2需要使用这种资源,而又只能由P1产生这种资源。
例题:进程P1中有句代码S1,P2 中有句代码S2,P3中有句代码S3
步骤:
要为每一对前驱关系各设置一个同步信号量。
这里用
在"前操作"之后对相应的同步信号量执行V操作
在"后操作"之前对相应的同步信号量执行P操作
以上两个操作统称为"前V后P",即S2执行需要S1执行完毕,则在S1执行最后设置V操作,在S2执行代码的开头执行P操作。结果如下:
假如CPU先执行P5,执行P(d)
后P5进程进入阻塞队列。接着假如执行P2,执行P(a)
后P2进程进入阻塞队列。CPU接着执行P1,当P1中的S1执行完毕后执行V(a)
,此时会唤醒P2进程,当P2执行V(d)
后又会唤醒P5进程。其他进程执行过程分析原理类似。
信号量机制实现同步互斥总结:
最后的进程前驱关系实际上就是上面的例题。这里除了互斥、同步问题外,还会考察有多个资源的问题:有多少资源就把信号量初值设为多少。申请资源时进行P操作,释放资源时进行V操作即可。
会上面介绍的信号量机制处理一些经典的进程同步互斥问题。
问题描述:系统中有一组生产者进程和一组消费者进程,生产者进程每次生产一个产品放入缓冲区,消费者进程每次从缓冲区中取出一个产品并使用。(注: 这里的产品理解为某种数据)。生产者、消费者共享一个初始为空、大小为
当缓冲区满时,生产者必须等待。只有缓冲区不空时,消费者才能从中取出产品,否则必须等待(缓冲区没空
缓冲区是临界资源,各进程必须互斥地访问(互斥关系)。因为假如有两个生产者进程,都同时往缓冲区的同一块内存写数据,则会出现覆盖。
PV操作题目分析步骤:
关系分析。找出题目中描述的各个进程,分析它们之间的同步、互斥关系。
整理思路。根据各进程的操作流程确定P、V操作的大致顺序。
根据题目,只有当缓冲区有产品,消费者进程才可以消费。另外只有缓冲区没满,生产者进程才可以生产,所以这样一前一后的关系就需要设置一个同步信号量,并且在前面动作完成之后需要执行V操作,后面动过开始之前执行P操作。
设置信号量。并根据题目条件确定信号量初值。(互斥信 号量初值一般为
消费者进程在消费之前需要消耗的是产品,所以P操作是在申请一个产品(数据)。所以full信号量对应的是非空缓冲区的数据。且由题中信息可知,缓冲区初始化时是空的。而这种进程只有生产者生产了商品之后才能接着往下执行。
对于同步信号量empty,生产者进程每生产一个进程,就需要消耗一个空闲的缓冲区,因此这个信号量对应的是就是缓冲区的数量。当一个消费者进程取走了产品,就可以释放一个空闲缓冲区(empty)。且由题可知空闲缓冲区的初始值是
各个信号量初始状态如下:
xxxxxxxxxx
31semaphore mutex = 1; //互斥信号量,实现对缓冲区的互斥访问
2semaphore empty = n; //同步信号量,表示空闲缓冲区的数量
3semaphore full=0; //同步信号量,表示产品的数量,也即非空缓冲区的数量
通过上面分析可以知道生产者进程在把产品放入缓冲区之前,需要申请一个空闲的缓冲区,因此在放入之前需要对empty这个信号量进行P操作,之后再对缓冲区资源量+1
。
xxxxxxxxxx
81producer(){
2 while(1){
3 生产一个产品;
4 P(empty); //消耗一个空闲缓冲区
5 把产品放入缓冲区;
6 V(full); //增加一个产品
7 }
8}
消费者进程分析也类似,当消费者从缓冲区取走一个产品之前,需要对full进程P操作。也就是要申请消耗一个产品资源。而当取走一个产品之后,空闲缓冲区的数量会
xxxxxxxxxx
81consumer() {
2 while (1) {
3 P(full); //消耗一个产品(非空缓冲区)
4 从缓冲区取出一个产品;
5 V(empty); //增加一个空闲缓冲区
6 使用产品;
7 }
8}
由于缓冲区是邻接资源,所以还需要对产品的取出和放入进行P和V操作。
xxxxxxxxxx
211producer(){
2 while(1){
3 生产一个产品;
4 P(empty); //消耗一个空闲缓冲区
5 P(mutex);
6 把产品放入缓冲区;
7 V(mutex);
8 V(full); //增加一个产品
9 }
10}
11
12consumer() {
13 while (1) {
14 P(full); //消耗一个产品(非空缓冲区)
15 P(mutex);
16 从缓冲区取出一个产品;
17 V(mutex);
18 V(empty); //增加一个空闲缓冲区
19 使用产品;
20 }
21}
上面代码不能改变相邻P、V的操作顺序,假如改变:
若此时缓冲区内已经放满产品,则empty=0
,full=n
。
则生产者进程执行
同样的,若缓冲区中没有产品,即full=0
,empty=n
。 按
而V操作不会导致进程阻塞,因此两个V操作顺序可以交换。并且还要保证临界区代码足够短。所以上面的生产一个产品
和使用产品
不能放在临界区中。
桌子上有一只盘子,每次只能向其中放入一个水果。爸爸专向盘子中放苹果,妈妈专向盘子中放橘子,儿子专等着吃盘子中的橘子,女儿专等着吃盘子中的苹果。只有盘子空时,爸爸或妈妈才可向盘子中放一个水果。仅当盘子中有自己需要的水果时,儿子或女儿可以从盘子中取出水果。用PV操作实现上述过程。
上节的问题生产者和消费者都是同一种东西,而这里的问题是生产者们和消费者们生产和消费的东西是不一样。
分析步骤:
关系分析。找出题目中描述的各个进程,分析它们之间的同步、互斥关系。
互斥关系:对于缓冲区(盘子)的访问要互斥的进行。
同步关系:父亲将苹果放入盘子后,女儿才能取苹果。母亲将橘子放入盘子后,儿子才能取橘子。并且只有盘子为空时,父亲或母亲才能放入水果。而"盘子为空"这个事件可以由儿子或女儿触发,事件发生后才允许父亲或母亲放水果。
整理思路。根据各进程的操作流程确定P、V操作的大致顺序。
设置信号量。设置需要的信号量,并根据题目条件确定信号量初值。(互斥信号量初值一般为
父亲进程需要把苹果放入盘子,女儿才能取到苹果,所以需要一个同步信号量apple=0
用来实现这个同步关系。并且刚开始盘子中没有苹果所以初始值为
同样的对于母亲进程,也需要设置一个同步信号量orange=0
。
另外当盘子为空的时候父亲和母亲进程才能往盘子中放水果。然而刚开始盘子本来都是空的,所以两个进程在刚开始就可以将水果放入盘子。所以需要一个plate=1
的同步信号量用来表示此时盘子是否为空。代码声明如下:
xxxxxxxxxx
41semaphore mutex =1; //实现互斥访问盘子(缓冲区)
2semaphore apple =0; //盘子中有几个苹果
3semaphore orange =0;//盘子中有几个橘子
4semaphore plate =1; //盘子中还可以放多少个水果
父亲和母亲进程在准备好水果后,需要检查盘子中是否为空进行P(plate)
操作。如果水果已经放入进程,那么需要对水果进行V操作告诉女儿和儿子此时盘子中已经有了水果。同时两个进程在实现访问盘子时需要进行互斥操作。
xxxxxxxxxx
211dad () {
2 while (1) {
3 准备一个苹果;
4 P(plate);
5 P(mutex);
6 把苹果放入盘子;
7 V(mutex);
8 V(apple);
9 }
10}
11
12mom() {
13 while(1){
14 准备一个橘子;
15 P(plate);
16 P(mutex);
17 把橘子放入盘子;
18 V(mutex);
19 V(orange);
20 }
21}
女儿进程和儿子进程,在取出自己喜欢的水果之前分别需要检查此时盘子中是否已经有自己喜欢的水果,所以先进行P操作。之后要进行V操作告诉父进程和母进程已经盘子已经为空。同时两个进程在实现访问盘子时需要进行互斥操作。
xxxxxxxxxx
211daughter(){
2 while(1){
3 P(apple);
4 P(mutex);
5 从盘中取出苹果;
6 V(mutex);
7 V(plate);
8 吃掉苹果;
9 }
10}
11
12son() {
13 while(1){
14 P(orange);
15 P(mutex);
16 从盘中取出橘子;
17 V(mutex);
18 V(plate);
19 吃掉橘子;
20 }
21}
假如这里不用互斥信号:
分析:刚开始,儿子、女儿进程即使上处理机运行也会被阻塞。如果刚开始是父亲进程先上处理机运行,则父亲P(plate),可以访问盘子,母亲P(plate),阻塞等待盘子。而父亲放入苹果V(apple),女儿进程被唤醒,其他进程即使运行也都会阻塞,暂时不可能访问临界资源(盘子),之后女儿P(apple),访问盘子V(plate)。等待盘子的母亲进程被唤醒,母亲进程访问盘子(其他进程暂时都无法进入临界区)之后会重复唤醒儿子进程。
所以可以得出一个结论:即使不设置专门的互斥变量mutex
,也不会出现多个进程同时访问盘子的现象。原因在于本题中的缓冲区大小为mutex
来保证互斥访问缓冲区。
建议:在考试中如果来不及仔细分析,可以加,上互斥信号量,保证各进程一定会互斥地访问缓冲区。但需要注意的是,实现互斥的P操作一定要在实现同步的P操作之后,否则可能引起"死锁"。
在分析同步问题(一前一后问题)的时候不能从单个进程行为的角度来分析,要把"一前一后"发生的事看做是两种"事件"的前后关系。
如:盘子变空事件导致放入水果事件。"盘子变空事件"既可由儿子引发,也可由女儿引发。"放水果事件"既可能是父亲执行,也可能是母亲执行。这样的话,就可以用一个同步信号量解决问题了。
假设一个系统有三个抽烟者进程和一个供应者进程。每个抽烟者不停地卷烟并抽掉它,但是要卷起并抽掉一支烟,抽烟者需要有三种材料:烟草、纸和胶水。三个抽烟者中,第一个拥有烟草、第二个拥有纸、第三个拥有胶水。供应者进程无限地提供三种材料,供应者每次将两种材料放桌子上,拥有剩下那种材料的抽烟者卷一根烟并抽掉它, 并给供应者进程一个信号告诉完成了,供应者就会放另外两种材料再桌上,这个过程一直重复 (让三个抽烟者轮流地抽烟)。
本质上这题也属于"生产者和消费者"问题,更详细的说应该是"可生产多种产品的单生产者和多消费者"。
关系分析。找出题目中描述的各个进程,分析它们之间的同步、互斥关系。
互斥关系:
这里供应者提供的材料会有三种组合:
每次供应者会将一种组合放到桌子上。所以桌子可以抽象为容量为
同步关系:
桌上有组合导致第一个抽烟者取走东西 桌上有组合导致第二个抽烟者取走东西 桌上有组合导致第三个抽烟者取走东西
对于这三种以前一后关系需要设置三个同步信号量。另外题目还指出"拥有剩下那种材料的抽烟者卷一根烟并抽掉它, 并给供应者进程一个信号告诉完成了"。所以发出完成信号会导致供应者将下一个组合放到桌上。
整理思路。根据各进程的操作流程确定P、V操作的大致顺序。
设置信号量。设置需要的信号量,并根据题目条件确定信号量初值。(互斥信号量初值一般为
桌子上有组合一,是第一个抽烟者取走东西之前发生,所以设置同步信号量offer1=0
。第二个和第三个关系一样。
发出完成信号这个时间要发生在,供应者将组合放到桌子上之前,而发出完成信号这个事件可以由三个吸烟者当中任何一个产生。所以设置同步信号量finish=0
代码定义如下:
xxxxxxxxxx
31semaphore offer1 = 0; //桌上组合一的数量
2semaphore offer2 = 0; //桌上组合二的数量
3semaphore offer3=0; //桌上组合三的数量
不用设置互斥信号量是因为容器的容量为
首先供应者需要提供三种组合,并且没提供一种组合要执行V操作,通知吸烟者。最后还要进行P操作等待吸烟者拿走材料。
xxxxxxxxxx
161provider () {
2 while (1) {
3 if (i==0) {
4 将组合一放桌上;
5 V(offer1);
6 } else if(i==1) {
7 将组合二放桌上;
8 V(offer2);
9 } else if(i==2) {
10 将组合三放桌上;
11 V(offer3);
12 }
13 i=(i+1)%3;
14 P(finish);
15 }
16}
各个吸烟者从桌子上拿走材料之前,需要检查是不是自己所需要的材料。当拿走材料之后还需要通知供应者可以放下一个材料。
xxxxxxxxxx
231smoker1() {
2 while(1){
3 P(offer1);
4 从桌上拿走组合一;卷烟;抽掉;
5 V(finish);
6 }
7}
8
9smoker2() {
10 while(1){
11 P(offer2);
12 从桌上拿走组合二;卷烟;抽掉;
13 V(finish);
14 }
15}
16
17smoker3() {
18 while(1){
19 P(offer3);
20 从桌上拿走组合三;卷烟;抽掉;
21 V(finish);
22 }
23}
有读者和写者两组并发进程,共享一个文件,当两个或两个以上的读进程同时访问共享数据时不会产生副作用,但若某个写进程和其他进程(读进程或写进程)同时访问共享数据时则可能导致数据不一致的错误。因此要求:①允许多个读者可以同时对文件执行读操作;②只允许一个写者往文件中写信息;③任一写者在完成写操作之前不允许其他读者或写者工作;④写者执行写操作前,应让已有的读者和写者全部退出。
由于多个进程进行读文件操作并不会更改文件数据信息,所以多个读进程同时读文件是可以被允许的。与消费者进程不同的是消费者进程读文件的时候是取走数据。而这里是只读。
后面三个要求概况来说就是当一个写者进程对文件进行写操作时,其他进程是不能访问这个文件的。或者也可以认为一个进程想要对一个文件进行写操作时,必须先等到其他进程对这个文件的操作结束后才能开始写入。
关系分析。找出题目中描述的各个进程,分析它们之间的同步、互斥关系。
对于共享文件资源的访问,读者和读者之间不存在互斥可以同时访问。但是写者进程之间是存在互斥的。而写者进程和读者进程之间也需要实现互斥。
整理思路。根据各进程的操作流程确定P、V操作的大致顺序。
设置信号量。设置需要的信号量,并根据题目条件确定信号量初值。(互斥信号量初值一般为
可以设置一个rw
信号量用于实现各个进程对共享文件的互斥访问。同时为了实现读者和读者之间能够同时访问共享文件代码,还需要设置count
信号量记录当前有几个进程在访问文件。最后为了保证count变量能够一气呵成,话要设置mutex
变量。声明如下:
xxxxxxxxxx
31semaphore rw=1; //用于实现对共享文件的互斥访问
2int count= 0; //记录当前有几个读进程在访问文件
3semaphore mutex = 1; //用于保证对count变量的互斥访问
写者进程要做的就是不断写文件。由于写者和读者之间需要互斥的访问文件资源。所以写者在写文件之间需要对rw
进行P操作。写完文件之后再进行V操作解锁。这样可以实现读写之间互斥的操作。
xxxxxxxxxx
71writer(){
2 while(1){
3 P(rw); //写之前"加锁"
4 写文件..;
5 V (rw); //写完了"解锁"
6 }
7}
同样读者进程也一样,读之前要进行P操作加锁,读之后进行V操作解锁。这样可以实现读写之间互斥的操作。同时为了实现读者和读者之间能够同时访问共享文件代码,还需要在加锁之前判断是不是第一个读的进程,由第一个读进程进行加锁操作。最后还要判断是不是最后一个访问完的进程,如果是最后一个访问完的进程需要进行解锁操作。
xxxxxxxxxx
91reader(){
2 while(1){
3 if(count==0) P(rw); //由第一个读进程负责加锁
4 count++; //访问文件的读进程数+1
5 读文件...;
6 count--; //访问文件的读进程数-1
7 if(count==0) V(rw);//最后一个读完的进程进行解锁
8 }
9}
思考:若两个读进程并发执行,则count=0
时两个进程也许都能满足if
条件,都会执行P(rw),从而使第二个读进程阻塞的情况。而出现上述问题的原因在于对count
变量的检查和赋值无法一气呵成,因此可以设置另一个互斥信号量来保证各读进程对count
的访问是互斥的,即设置互斥信号量mutex
。
xxxxxxxxxx
131reader(){
2 while(1){
3 P(mutex); //各读进程互斥访问count
4 if(count==0) P(rw); //由第一个读进程负责加锁
5 count++; //访问文件的读进程数+1
6 V(mutex);
7 读文件...;
8 P(mutex); //各读进程互斥访问count
9 count--; //访问文件的读进程数-1
10 if(count==0) V(rw);//最后一个读完的进程进行解锁
11 V(mutex);
12 }
13}
这样还是有一个潜在的问题,即只要有读进程还在读,写进程就要一直阻塞等待,如果有源源不断地读进程,那么写进程就可能"饿死"。因此,可以说这种算法读进程是优先的。
可以再设置一个信号量w
表示写优先。代码声明如下:
xxxxxxxxxx
41semaphore rw=1; //用于实现对共享文件的互斥访问
2int count= 0; //记录当前有几个读进程在访问文件
3semaphore mutex = 1; //用于保证对count变量的互斥访问
4semaphore W=1; //用于实现"写优先"
修改后的读者写者代码如下:
xxxxxxxxxx
251writer(){
2 while(1){
3 P(w);
4 P(rw); //写之前"加锁"
5 写文件..;
6 V (rw); //写完了"解锁"
7 V(w);
8 }
9}
10
11reader(){
12 while(1){
13 P(w);
14 P(mutex); //各读进程互斥访问count
15 if(count==0) P(rw); //由第一个读进程负责加锁
16 count++; //访问文件的读进程数+1
17 V(mutex);
18 V(w);
19 读文件...;
20 P(mutex); //各读进程互斥访问count
21 count--; //访问文件的读进程数-1
22 if(count==0) V(rw);//最后一个读完的进程进行解锁
23 V(mutex);
24 }
25}
P(w)工作情况:假设两个读者进程读者P(w)
和P(v)
之间进行了进程调度切换为读者P(w)
处。知道第一个读者进程读者V(w)
操作,读者P(mutex)
处也不会阻塞,可以实现多个进程共同访问文件。
假设两个写者进程写者P(w)
后切换写者P(w)
这里。直到第一个写进程写者
假如写者P(w)=0
,所以当切换到读者P(w)
处。直到写者V(w)
之后,读者
假如读者P(w)
和V(w)
后此时w=1
,之后开始读文件。在读文件期间切换为写者w=1
,写者P(w)
不会被阻塞可以执行,而在P(rw)
处,由于读者rw=0
所以写者P(w)
处。所以只有当读者V(rw)
后会唤醒写者V(w)
,此时会唤醒读者
所以利用P(w)
和V(w)
两对操作解决了写者可能会饥饿的问题。
结论:在这种算法中,连续进入的多个读者可以同时读文件;写者和其他进程不能同时访问文件;写者不会饥饿,但也并不是真正的"写优先",而是相对公平的先来先服务原则。有的书上把这种算法称为"读写公平法"。
读者与写者问题为我们解决复杂的互斥问题提供了一个参考思路。其核心思想在于设置了一个计数器count
用来记录当前正在访问共享文件的读进程数。我们可以用count
的值来判断当前进入的进程是否是第一个count
变量的检查和赋值不能一气呵成导致了一些错误,如果需要实现"一气呵成",自然应该想到用互斥信号量。最后,还要认真体会我们是如何解决"写进程饥饿"问题的。
正常考试中当遇到同步问题时,可以参考生产者与消费者问题。而当遇到复杂的互斥问题时,应该参考读者写者问题。
一张圆桌上坐着
与之前不同的是每位哲学家需要拿起两根筷子才能吃饭,而之前的互斥问题中每一个进程只需要持有一个临界资源就可以进行。
关系分析。
由于系统中有5个哲学家进程,5位哲学家与左右邻居对其中间筷子的访问是互斥关系。
整理思路。
这个问题中只有互斥关系,但与之前遇到的问题不同的事,每个哲学家进程需要同时持有两个临界资源才能开始吃饭。如何避免临界 资源分配不当造成的死锁现象,是哲学家问题的精髓。
信号量设置。
定义互斥信号量数组chopstick[5]={1,1,1,1,1}
用于实现对5个筷子的互斥访问。并对哲学家按
哲学家要做的事情只有两件,要么思考要么吃饭。当吃饭时候哲学家会拿起左边的筷子执行P(chopstick[i])
操作,接着拿起右边的筷子执行P(chopstick[(i+1)%5])
操作。如果此时五个进程同时运行,则会出现所有哲学家都拿起左边筷子,执行P(chopstick[i])
操作,但当拿起右边筷子时所有的哲学家进程都会被阻塞。每位哲学家循环等待右边的人放下筷子(阻塞)发生"死锁"。
防止死锁发生方法有如下几种:
可以对哲学家进程施加一些限制条件,比如最多允许四个哲学家同时进餐。这样可以保证至少有一个哲学家是可以拿到左右两只筷子的
要求奇数号哲学家先拿左边的筷子,然后再拿右边的筷子,而偶数号哲学家刚好相反。用这种方法可以保证如果相邻的两个奇偶号哲学家都想吃饭,那么只会有其中一个可以拿起第一只筷子,另一个会直接阻塞。从而避免了占有一支后再等待另一只的情况。
仅当一个哲学家左右两支筷子都可用时才允许他抓起筷子。
可以设置一个互斥信号量mutex
,之后在哲学拿起筷子前和拿起筷子后进行P
和V
操作。
xxxxxxxxxx
141semaphore chopstick[5]={1,1,1,1,1};
2semaphore mutex=1; //互斥地取筷子
3Pi(){ //i号哲学家的进程
4 while (1) {
5 P(mutex);
6 P(chopstick[i]); //拿左
7 P(chopstick[(i+1)%5]); //拿右
8 V(mutex);
9 吃饭...;
10 V(chopstick[i]); //放左
11 V(chopstick[(i+1)%5]); //放右
12 思考...;
13 }
14}
采用方法三,各哲学家拿筷子这件事必须互斥的执行。这就保证了即使一个哲学家在拿筷子拿到一半时被阻塞,也不会有别的哲学家会继续尝试拿筷子。这样的话,当前正在吃饭的哲学家放下筷子后,被阻塞的哲学家就可以获得等待的筷子了。
哲学家进餐问题的关键在于解决进程死锁。这些进程之间只存在互斥关系,但是与之前接触到的互斥关系不同的是,每个进程都需要同时持有两个临界资源,因此就有"死锁"问题的隐患。如果在考试中遇到了一个进程需要同时持有多个临界资源的情况,应该参考哲学家问题的思想,分析题中给出的进程之间是否会发生循环等待,是否会发生死锁。可以参考哲学家就餐问题解决死锁的三种思路。
在管程引入之前进程的同步和互斥主要使用信号量机制实现。信号量存在编写程序困难、易出错的问题。所以在1973年,Brinch Hansen首次在程序设计语言(Pascal)中引入了"管程"成分,即一种高级同步机制。
管程与之前的PV操作一样,也是用于实现进程的同步和互斥的。管程是一种特殊的软件模块。为了实现一些资源对共享资源的同步和互斥访问,管程需要有以下部分组成:
可以看出管程的定义和面向对象中的"类"很类似。
同时为了用管程实现进程之间的互斥和同步,管程有以下特征:
局部于管程的数据只能被局部于管程的过程所访问。
一个进程只有通过调用管程内的过程才能进入管程访问共享数据。
前两点简单理解就是管程中定义的数据结构只能被管程中定义的函数所修改。所以要想修改管程中的数据结构,只能通过调用管程提供的函数修改。
每次仅允许一个进程在管程内执行某个内部过程。
管程中虽然定义了很多函数,但是同一时刻肯定只有一个进程在使用管程中的某一个函数,别的进程如果也想使用函数的话,只要之前的进程还没有用完,别的进程就不能执行这些管程的函数。
有以下一段伪代码
xxxxxxxxxx
201monitor ProducerConsumer
2condition full,empty; //条件变用来实现同步(排队)
3int count=0; //缓冲区中的产品数
4void insert(Item item){ //把产品item放入缓冲区
5 if (count==N)
6 wait(full);
7 count++;
8 insert_item(item);
9 if (count == 1)
10 signal(empty);
11}
12Item remove() { //从缓冲区中取出一个产品
13 if (count == 0)
14 wait(empty) ;
15 count--;
16 if (count == N-1)
17 signal(full);
18 return remove_item();
19}
20end monitor;
上面的ProducerConsumer
相当于类名。之前进行生产操作时,生产者进程需要进行一堆PV操作,较为麻烦,用管程可以简化代码:
xxxxxxxxxx
71//生产者进程
2producer (){
3 while(1){
4 item=生产-个产品;
5 ProdecerConsumer.insert (item);
6 }
7}
同样消费者进程也可以很简单的调用管程中的函数,就可以实现从缓冲区取出一个产品。
xxxxxxxxxx
71//消费者进程
2consumer (){
3 while(1){
4 item=ProdecerConsumer.remove();
5 消费产品item;
6 }
7}
在定义管程之后可以由编译器负责实现各进程互斥地进入管程中的过程。每次仅允许一个进程在管程内执行某个内部过程。
假如有两个生产者进程并发执行,依次调用了insert
过程。第一个生产者进程在执行一半时候,第二个生产者进程开始执行,那么由于编译器实现的功能会暂时阻止第二个生产者进程,所以会把第二个进程阻塞在insert
函数后面,类似于一个排队器。
可以看出开发者在开发时,不需要再关心PV操作。引入管程的目的无非就是要更方便地实现进程互斥和同步。
管程实现如下:
程序员可以用某种特殊的语法定义一个管程(比如:monitor ProducerConsumer ... end monitor;
),之后其他程序员就可以使用这个管程提供的特定"入口"很方便地使用实现进程同步/互斥了。这也就是程序设计中"封装"思想。
Java中,如果用关键字synchronized
来描述一个函数, 那么这个函数同一时间段内只能被一个线程调用。
xxxxxxxxxx
71static class monitor{
2 private Item buffer[] = new Item[N];
3 private int count = 0;
4 public synchronized void insert (Item item) {
5 ...
6 }
7}
每次只能有一个线程进入insert
函数,如果多个线程同时调用insert
函数,则后来者需要排队等待。
管程总结:
之前的哲学家进餐问题中,如果
死锁问题指的是在并发环境下,各进程因竞争资源而造成的一种互相等待对方手里的资源,导致各进程都阻塞,都无法向前推进的现象,就是"死锁"发生死锁后若无外力干涉,这些进程都将无法向前推进。
死锁、饥饿和死循环的区别如下:
死锁饥饿死循环的区别:
死锁产生的必要条件有四个。只要其中任一条件不成立,死锁就不会发生。
互斥条件
只有对必须互斥使用的资源的争抢才会导致死锁(如哲学家的筷子、打印机设备)。像内存、扬声器这样可以同时让多个进程使用的资源是不会导致死锁的(因为进程不用阻塞等待这种资源)。
不剥夺条件
进程所获得的资源在未使用完之前,不能由其他进程强行夺走,只能主动释放。
请求和保持条件
进程已经保持了至少一个资源,但又提出了新的资源请求,而该资源又被其他进程占有,此时请求进程被阻塞,但又对自己已有的资源保持不放。
循环等待条件
存在一种进程资源的循环等待链,链中的每一个进程已获得的资源同时被下一个进程所请求。
注意:发生死锁时一定有循环等待,但是发生循环等待时未必死锁(循环等待是死锁的必要不充分条件)。如果同类资源数大于
发生死锁情况:
总之,对不可剥夺资源的不合理分配,可能导致死锁。死锁的处理策略如下:
预防死锁。
破坏死锁产生的四个必要条件中的一个或几个。
避免死锁。
用某种方法防止系统进入不安全状态,从而避免死锁(银行家算法)
死锁的检测和解除。
允许死锁的发生不过操作系统会负责检测出死锁的发生,然后采取某种措施解除死锁。
预防死锁:破坏死锁产生的四个必要条件中的一个或几个。
破坏互斥条件
互斥条件:只有对必须互斥使用的资源的争抢才会导致死锁。
如果把只能互斥使用的资源改造为允许共享使用,则系统不会进入死锁状态。比如:SPOOLing技术。操作系统可以采用SPOOLing技术把独占设备在逻辑上改造成共享设备。比如,用SPOOLing技术将打印机改造为共享设备:
打印机是一种互斥资源,如果进程
各个进程对打印机发出的请求会首先被输出进程接收,当两个进程的请求被接受并且被响应之后,这些进程就可以接着执行后面的代码。之后输出进程会根据各个进程的请求依次放入打印机,打印输出。
使用了SPOOLing技术后,在各进程看来,自己对打印机资源的使用请求立即就被接收处理了,不需要再阻塞等待。
该策略的缺点:并不是所有的资源都可以改造成可共享使用的资源。并且为了系统安全,很多地方还必须保护这种互斥性。因此,很多时候都无法破坏互斥条件。
破坏不剥夺条件
不剥夺条件:进程所获得的资源在未使用完之前,不能由其他进程强行夺走,只能主动释放。
方案一:当某个进程请求新的资源得不到满足时,它必须立即释放保持的所有资源,待以后需要时再重新申请。也就是说,即使某些资源尚未使用完,也需要主动释放,从而破坏了不可剥夺条件。
方案二:当某个进程需要的资源被其他进程所占有的时候,可以由操作系统协助,将想要的资源强行剥夺。这种方式一般需要考虑各进程的优先级(比如:剥夺调度方式,就是将处理机资源强行剥夺给优先级更高的进程使用)
该策略的缺点:
破坏请求和保持条件
请求和保持条件:进程已经保持了至少一个资源,但又提出了新的资源请求,而该资源又被其他进程占有,此时请求进程被阻塞,但又对自己已有的资源保持不放。
可以采用静态分配方法,即进程在运行前一次申请完它所需要的全部资源,在它的资源未满足前,不让它投入运行。一旦投入运行后,这些资源就一直归它所有,该进程就不会再请求别的任何资源了。
该策略实现起来简单,但也有明显的缺点:有些资源可能只需要用很短的时间,因此如果进程的整个运行期间都一直保持着所有资源,就会造,成严重的资源浪费,资源利用率极低。
另外,该策略也有可能导致某些进程饥饿。假如一个系统有资源
如果系统中源源不断地A类和B类进程到达,两个资源都会被立即分配给下一个A,B类进程。除非两个资源都没有进程使用,即都空闲状态下,才会分配给C类进程。很显然这种方式可能导致C类进程饥饿。
破坏循环等待条件
循环等待条件:存在一种进程资源的循环等待链,链中的每一个进程已获得的资源同时被下一个进程所请求。
可采用顺序资源分配法。首先给系统中的资源编号,规定每个进程必须按编号递增的顺序请求资源,同类资源(即编号相同的资源)一次申请完。
原理分析:一个进程只有已占有小编号的资源时,才有资格申请更大编号的资源。按此规则,已持有大编号资源的进程不可能逆向地回来申请小编号的资源,从而就不会产生循环等待的现象。
假设系统中有
所以不管在什么时刻,系统中肯定有一个进程所拥有地资源编号是最大的。如上面P3进程就意味着大于
该策略的缺点:
不方便增加新的设备,因为可能需要重新分配所有的编号
进程实际使用资源的顺序可能和编号递增顺序不一致,会导致资源浪费。
如P3进程需要使用
必须按规定次序申请资源,用户编程麻烦。
P3进程中,如果一个用户程序即需要
预防死锁总结:
假如你是一位成功的银行家,手里掌握着100个亿的资金。有三个企业想找你贷款,分别是企业B、企业A、企业T,为描述方便,简称BAT。B会借70亿、A会借40亿、T会借50亿。然而如果你借给企业的钱总数达不到企业提出的最大要求,那么不管你之前给企业借了多少钱,那些钱都拿不回来了。刚开始BAT三个企业分别从你这儿借了20、10、30亿。
此时三个企业共借走60亿,所以手上还有40亿。
接下来企业B再向你借30亿,如果借给他。情况如下:
之后B企业最多还会再借20亿,但是此时你的手上只有10亿。只剩下10亿,如果BAT都提出再借20亿的请求,那么任何一个企业的需求都得不到满足。所以综上所诉给B再借30亿是不安全的。
如果说此时是A企业向你借20亿,此时情况会变成如下:
此时手里还有20亿,接下来就可以可以先把20亿全部借给T企业,等T把钱全部还回来了,手里就会有
所以按
或者先借给A企业20亿,等A还钱了手里就有
所以给B借30亿是不安全的。之后手里只剩10亿,如果BAT都提出再借20亿的请求,那么任何一个企业的需求都得不到满足。而给A借20亿是安全的,因为存在
所谓安全序列,就是指如果系统按照这种序列分配资源,则每个进程都能顺利完成。只要能找出一个安全序列,系统就是安全状态。当然,安全序列可能有多个。
如果分配了资源之后,系统中找不出任何一个安全序列,系统就进入了不安全状态。这就意味着之后可能所有进程都无法顺利的执行下去。当然,如果有进程提前归还了一些资源,那系统也有可能重新回到安全状态(如上面的
如果系统处于安全状态,就一定不会发生死锁。如果系统进入不安全状态,就可能发生死锁(处于不安全状态未必就是发生了死锁,但发生死锁时一定是在不安全状态)
因此可以在资源分配之前预先判断这次分配是否会导致系统进入不安全状态,以此决定是否答应资源分配请求。这也是"银行家算法"的核心思想。
银行家算法是荷兰学者Dijkstra 为银行系统设计的,以确保银行在发放现金贷款时,不会发生不能满足所有客户需要的情况。后来该算法被用在操作系统中,用于避免死锁。
核心思想:在进程提出资源申请时,先预判此次分配是否会导致系统进入不安全状态。如果会进入不安全状态,就暂时不答应这次请求,让该进程先阻塞等待。
上面的BAT的例子中,只有一种类型的资源,即钱。但是在计算机系统中会有多种多样的资源,可以把单维的数字拓展为多维的向量。比如:系统中有
可以把系统已分配的资源按照列加起来,每列资源初始数量再减去已分配的资源数量,就可以得到此时总共分配出去
可以尝试找到一个安全序列。由于系统中剩余的资源是
首先进行第一轮对比尝试找到能满足的序列。先与P0对比,发现满足不了,接着喝P1进程对比,可以满足。
这也就意味着说明如果优先把资源分配给P1,那P1一定是可以顺利执行结束的。等P1结束了就会归还资源。于是,资源数就可以增加到
接着进行第二轮对比。依次检查剩余可用资源
经过对比可以知道P2进程可以满足。
如果优先把资源分配给P3,那P3一定是可以顺利执行结束的。等P3结束了就会归还资源。于是,资源数就可以增加到
以此类推,共五次循环检查即可将
实际做题(手算)时可用更快速的方法找到一个安全序列:
经对比发现
再看一个找不到安全序列的例子:
经对比发现
剩下的P0需要
算法实现:
假设系统中有n*m
的矩阵(可用二维数组实现)表示所有进程对各种资源的最大需求数。不妨称为最大需求矩阵Max
,Max[i,j]=K
表示进程Pi最多需要K
个资源Rj。同理,系统可以用一个n*m
的分配矩阵Allocation
表示对所有进程的资源分配情况。Max-Allocation=Need
矩阵,表示各进程最多还需要多少各类资源。另外,还要用一个长度为m
的一维数组Available
表示当前系统中还有多少可用资源。某进程Pi向系统申请资源,可用一个长度为m
的一维
数组Request
,来表示本次申请的各种资源量。
上图Available=(3,3,2)
表示现在进程剩余Request_0=(2,1,1)
资源。
可用银行家算法预判本次分配是否会导致系统进入不安全状态:
如果
如果
系统试探着把资源分配给进程Pi,并修改相应的数据(并非真的分配,修改数值只是为了做预判):
操作系统执行安全性算法,检查此次资源分配后,系统是否处于安全状态。若安全,才正式分配;否则, 恢复相应数据,让进程阻塞等待。
上面
之后进入第四步:系统尝试找到一个安全序列,如果说安全才可以真正分配给进程。如果出现系统不安全情况,需要将上一步修改的数据回退,并让进程阻塞等待。
总结:
系统处于不安全状态未必死锁,但死锁时一定处于不安全状态。系统处于安全状态一定不会死锁。
如果系统中既不采取预防死锁的措施,也不采取避免死锁的措施,系统就很可能发生死锁。在这种情况下,系统应当提供两个算法:
死锁的检测
为了能对系统是否已发生了死锁进行检测,必须:
如果系统中剩余的可用资源数足够满足进程的需求,那么这个进程暂时是不会阻塞的,可以顺利地执行下去。
可以用一种资源分配图的数据结构来保存系统当中的各种资源情况:
在这种图中会有两种结点,每个进程对应一个进程结点。每个结点对应一类资源,般用矩形表示资源结点,矩形中的小圆代表该类资源的数量。另外还有两种边,即进程结点指向资源结点的请求边,资源结点指向进程结点的分配边。
上图P1进程有两个R1资源,P2进程有一个R1资源和一个R2资源。
这里P1进程请求一个单位的R2资源,而R2资源现在只被分配出去一个,而R2总数有两个,空闲一个,所以P1进程可以被满足。
而P2进程申请一个R1资源,但是R1资源已经没有空闲了,因此P2进程请求不能被满足。但是P1进程可以顺利执行下去,当P1进程顺利执行完毕,就可以把资源全部归还给系统,即消除P1进程所有的边,此时P2进程能获得R1分配的一个资源,可以执行下去。则称该图是可完全简化的。
如果按上述过程分析,最终能消除所有边,就称这个图是可完全简化的。此时一定没有发生死锁(相当于能找到一个安全序列)
下面是资源分配不够出现死锁分配图:
可以看到当P3运行结束释放资源后仍然不能满足P1申请的两个R2资源。而P2进程申请R1资源,R1已经全部分配,所以两个进程无法结束不能消除边,即出现死锁。
如果最终不能消除所有边,那么此时就是发生了死锁。最终还连着边的那些进程就是处于死锁状态的进程。上面P1和P2是死锁进程。
死锁解除
一旦检测出死锁的发生,就应该立即解除死锁。
补充:并不是系统中所有的进程都是死锁状态,用死锁检测算法化简资源分配图后,还连着边的那些进程就是死锁进程。
上图P1和P2进程死锁,而P3进程不是处于死锁状态。
解除死锁的主要方法有:
资源剥夺法。
挂起(暂时放到外存上)某些死锁进程,并抢占它的资源,将这些资源分配给其他的死锁进程。但是应防止被挂起的进程长时间得不到资源而饥饿。
撤销进程法(或称终止进程法)。
强制撤销部分、甚至全部死锁进程,并剥夺这些进程的资源。这种方式的优点是实现简单,但所付出的代价可能会很大。因为有些进程可能已经运行了很长时间,已经接近结束了,一旦被终止可谓功亏一篑,以后还得从头再来。
进程回退法。
让一个或多个死锁进程回退到足以避免死锁的地步。这就要求系统要记录进程的历史信息,设置还原点。
如可以让P1进程一直回退到只持有一个R1资源的时候,这样就可以空出一个R1资源给P2进程使用。但是操作系统要设置还原点,这个方法其实也不太容易实现。
决定让哪个进程解除死锁(哪个进程要剥夺资源
死锁检测解除总结:
死锁检测算法可能与数据结构一起考察,要实现。