操作系统(二)- 进程管理
1. 进程
1.1 概念
进程(Process)是操作系统中运行的一个程序实例,是操作系统进行资源分配和管理的基本单位。一个进程不仅包括程序的可执行代码,还包括程序运行时所需的资源和状态信息。
1.2 组成
- 可执行代码段
- 代码段:包含程序的可执行指令,是程序运行的核心逻辑。
- 进程控制块PCB
- 进程ID:唯一标识一个进程的标识符。
- 程序计数器:指明下一个指令要执行的指令地址。
- CPU寄存器值:用于保存进程执行期间的各个寄存器的内容。
- 内存管理信息:包括页表、段表。用于存储内存地址映射信息。
- I/O状态信息:分配的I/O设备,打开的文件列表
- 进程状态:进程当前的状态(如就绪、阻塞、运行等)。
- 进程优先级:进程的优先级信息。
- 上下文信息:保存进程切换时的状态,以便恢复运行。
- 数据段
- 静态数据区:包含程序静态分配的数据,比如全局变量,静态变量。
- 堆
- 动态分配的内存区域,用于存放程序运行时候生成的对象以及其他数据结构。
- 栈
- 用于存放函数调用时的局部变量,参数,方法返回地址等信息。
- 栈是动态增长和伸缩的,随着函数的调用和返回而变化。
- 文件描述符表
- 包含进程打开的文件列表,每一个文件描述符指向文件表中的一个条目。
- 进程间通信信息
- 包含管道、共享内存、消息队列等用于进程间通信的数据结构。
1.3 特征
-
独立性
- 一个进程是一个独立的实体,是计算机资源的使用单位。
- 进程和进程之间的内存是相互独立的,每个进程只能访问自己的内存空间。
- 每个进程都有自己的逻辑寄存器和内部状态。
-
动态性
- 进程的创建和消亡是动态的,可以在系统运行的过程中随时创建和销毁进程。
- 进程状态(如就绪、阻塞、运行等)也会随着程序的执行而变化。
- 程序在运行的过程中,其对应的内存中的数据也在不断变化。
-
并发性
- 从宏观上来看,在单核CPU中各个进程是同时在系统中相互独立的运行。从微观上看,在某一个特定的时刻只有一个进程在运行。
- 操作系统通过进程调度算法来管理和控制进程的执行顺序。
-
通信性
- 不同进程间可以通过通信机制来进行数据交换和信息传递。
- 常见的通信机制有管道、消息队列、共享内存等。
-
异步性
- 各个进程以不可预知的速度向前推进,可能导致运行的结果的不确定性。
- 指进程可以独立于其他进程执行,而不需要等待其他进程完成。
1.4 进程状态
创建
按照个人理解,进程的创建态是逻辑意义上的,实际上此状态并没有一个具体的变量或数据结构进行记录。它在更多意义上是一个过程,此过程大概包括以下步骤。
- 分配进程控制块(PCB)。
- PCB是操作系统用来管理和监控内存的重要数据结构,包含进程的基本信息和状态。
- 分配内存。
- 为新进程分配所需内存空间,包括代码块、数据段、堆、栈。操作系统会为进程不同的部分分配适当的内存区域,并设置内存管理结构(如页表,段表)。
- 加载程序。
- 将可执行程序文件从内存中加载到分配给的进程内存中。
- 初始化进程上下文。
- 设置进程的初始上下文,包括程序计数器指向的程序入口,CPU寄存器初始化,以及设置堆栈指针。
- 设置文件描述符。
- 初始化进程的文件描述符列表。
- 插入就绪队列。
- 将新创建的进程初始化到就绪队列中。等待调度程序调度到CPU上执行。
- 进程间关系设置。
- 如果新进程是由现有进程创建的,需要维护新进程(子进程)和现有进程(父进程)之间的关系。
就绪
进程在就绪态时,完全具备了执行的所有条件,只是由于CPU忙,正在运行其他进程,所以暂时不能运行。
- 特征
- 就绪状态的进程位于就绪队列中,等待调度程序将其调度到CPU上执行。
- 进程保存了上次运行的状态,以便被调度时能继续执行。
- 在操作系统的进程管理中,进程必须先处于就绪态才能被调度到CPU上运行。
运行
指进程被调度程度从就绪队列中调度到CPU上运行的状态。进程当前正在CPU上执行。在这一状态下,进程占用CPU并执行其代码,进行计算、访问内存、执行I/O操作等。
-
特征
-
进程占用CPU。
-
进程的上下文(如寄存器、程序计数器)加载在CPU中。
-
进程执行其代码,包括用户代码和可能的系统调用。
-
系统中处于运行态的数量肯定是少于或等于CPU的数量的。
-
阻塞
指进程因为等待某种事件的发生而暂时不能运行的状态。例如等待I/O操作的完成,或者是它与其他进程存在某种同步关系,需要等待其他进程给它输入数据。
- 特征
- 进程在此状态下不占用CPU。
- 通常情况下,只有处于运行态的进程才能转换为阻塞态。
- 在进入阻塞态之前,进程的当前状态(如寄存器内容、程序计数器等)会被保存。操作系统需要保存这些信息,以便在进程从阻塞态恢复到运行态时能够恢复执行。
终止
指的是进程的生命周期结束,操作系统释放进程所占用的资源,并将其状态标记为终止。
- 正常结束
- 进程完成自身的任务以及运算时候,会主动调用系统提供的退出函数来结束自身的执行。
- 异常终止
- 进程在执行过程中,由于程序设计的缺陷,造成了一些致命的错误,例如执行了非法指令,除0错误等,这时候操作系统会终止此进程的运行。
- 被其他进程杀死
- 操作系统会提供一些系统调用函数用来把想要杀死的进程清除出局。例如linux的kill函数。
1.5 挂起状态
内存不够时,可将某些进程的数据调出外存。等内存空闲或者进程需要运行时再重新调入内存。暂时调到外存等待的进程状态为挂起状态。被挂起的进程PCB会被组织成挂起队列。
1.6 进程的七状态模型
暂时调到外村等待的进程状态为挂起状态。挂起状态又可以进一步细分为就绪挂起和阻塞挂起状态。
注意“挂起”和“阻塞”的区别,两种状态都是暂时不能获得CPU的服务,但挂起态是将进程映像调到外存去了,而阻塞态下进程映像还在内存中。
1.7 进程控制
进程的控制就是要实现进程状态的转换。而进程状态的转换往往依赖于进程管理原语。
1.7.1 原语
- 原语(Primitive)是指一组基础操作或基本操作单元,它们通常是系统或语言提供的最基本的、不可再分解的操作。
1.7.2 关中断和开中断
- 关中断:是指禁用中断处理程序的执行,防止系统响应外部设备或内部事件产生的中断信号。这个操作通常用于保护关键区域的执行,确保在特定操作期间系统状态不会被中断干扰。
- 开中断:是指允许系统响应外部设备或内部事件产生的中断信号。开启中断后,处理器能够处理中断请求,从而实现异步事件的响应和处理。
- 在大多数现代计算机体系结构中,这些操作在硬件层面由控制寄存器和指令来实现。
1.7.3 进程管理原语
- 是操作系统提供的一组基本操作,用于管理进程的创建、终止、调度和控制。进程管理原语是操作系统的核心功能之一,用于确保系统资源的有效利用和进程的协调运行。
- 进程管理原语有时需要依赖于开中断和关中断的机制,以确保操作的原子性和数据一致性,特别是在涉及到进程调度、进程状态更新等关键操作时。
2. 线程
2.1 为什么要引入线程
举个例子,假设我们现在有一个简单的视频播放器。主要有三个功能模块:一是从磁盘的媒体文件中读取数据,并将数据保存到进程对应的内存缓冲区中;二是CPU解压内存缓冲区中的数据从而得到原始的视频数据;三是把视频数据在屏幕上播放出来。以下是此程序的简单代码。
main(){
// 假设数据很大,需要循环分片读取,解压、播放。
while(1){
Read();// 从磁盘中读取文件并放在内存缓冲区中。
Decompress();// 解压数据
Play();// 播放
}
}
在这种处理方式下,只需要一个进程即可,但是存在以下问题:
- 播放不连贯,因为视频的播放必须要等数据从磁盘中读出并解压完成,而去磁盘中寻找和读取数据,以及CPU解压数据往往是耗时较长的。
- 系统资源使用不充分,因为在这种实现方式下,三个函数的执行并不是并发的执行的,也就是说,进程在读取数据(
Read()
)访问外部设备时,操作系统会将此进程从CPU上拿下来,进程处于阻塞状态,如果此时系统中没有其他进程,CPU就会处于空闲状态。而当解压(Decompress()
)时,CPU会将内存中的数据解压,此时磁盘I/O设备就处于空闲状态。当播放(Play()
)时,跟视频播放的外部I/O设备会处于繁忙状态,而CPU又会处于空闲。
而线程的出现就是解决以上问题的,首先需要知道的一个前提是,一个进程中的多个线程是可以共享进程中的内存数据的。所以如果以上三个函数使用三个线程去处理的话,就会是这个情况:Read()函数对应的线程只需要不停地从磁盘中寻找并读取数据然后放在内存中即可,Decompress()函数对应的线程只需要不停将内存中的数据解压就行,而Play()函数对应的线程只需要不停地将解压后的数据进行播放就行。当然在这里只是为了更好的理解为什么要引入线程,并没有考虑到内存的分配问题。
2.2 概念
线程是进程(Process)中的一个执行单元。一个进程可以包含一个或多个线程,这些线程共享进程的资源(如内存、文件描述符等),但它们有各自的独立执行路径。
回顾一下进程的概念,可以从两个方面来了解进程,一方面是从资源组合的角度来看待进程,它把一组相关的资源组合起来,构成了一个资源平台,其中包括地址空间,打开的文件等资源。另一方面可以从运行的角度看待进程,因为进程就是一个正在运行的程序。所以,可以把线程是看成代码在资源平台上的一条执行流程。
在进程这个概念刚刚提出的时候,资源平台和执行流程这两者的关系是密不可分、一一对应的,当说到一个进程时,既是指它的资源平台,又是指它的执行流程,常常把它们混为一谈,不进行区分。但是后来,由于实际应用的需要,人们觉着必须把这两者进行区分,资源平台就是资源平台,代码的执行流程就是线程。
这样就可以得到一个式子:进程 = 线程+资源平台。这样做的优点是:
- 在同一个进程中,可以存在多个线程。
- 可以把线程作为CPU的基本调度单位,使各个线程之间可以并发地执行。
- 由于各个线程运行在相同的资源平台上,因此他们可以共享相同的地址空间,可以很方便的进行数据的共享与交流。
当然在同一个资源平台上并不是所有的资源都是能够共享的,这些资源可以分为两部分:
-
共享资源:进程管理方面的大部分信息,存储管理方面的信息。例如,一个进程中的代码都是共享的。全局变量和动态内存空间。在一个线程中打开的文件,在另一个线程中也可以进行读写操作。
-
独享资源:主要是两个,即CPU寄存器和栈。
-
为什么寄存器是独享的?
因为在线程运行的过程中,寄存器是必不可少的硬件资源,在执行每一条指令时候,寄存器的值都有可能会发生变化。例如,在执行完一条指令后,程序计数器的值肯定会发生变化,如果线程和线程之间的程序计数器的值是共享的,可以相互更改的,一旦当前线程的程序计数器的值被其他线程更改,那么此线程对应的执行流程很有可能会发生变化。因此为了防止各个线程在并发的时候相互干扰,每个线程都需要有一组独立的逻辑寄存器。
-
为什么栈资源也是线程独享的?
因为在一个线程的运行过程中,可能会发生函数调用,即在一个函数中调用了另一个函数。当函数调用发生时候,需要在栈中分配一段内存空间(即栈帧),栈帧用来保存此次函数调用的形参和局部变量,在这种情况下,栈必须是独享的,如果是共享的,肯定会发生问题,比如,在A线程运行时,进栈了两个数据,然后它就被打断,此时B线程也访问了这两个数据并对其进行了更改,然后A线程拿到这两数据继续执行,这样肯定就会出错的。
-
2.3 线程控制块
线程的组成同样也包括两个方面的内容:数据结构和算法。在数据结构上用来描述和管理一个线程的数据结构就是线程控制块(Thread Control Block,TCB)。对于每一个线程,都会创建一个相应的TCB,用来保存与该线程有关的各种信息,系统通过TCB来实现线程的管理。
TCB主要包括两个部分的内容。一是线程自身的管理信息,如线程标识符、线程状态、调度信息等。二是在进程资源平台上线程所独享的资源,包括寄存器的值和栈指针。
2.4 实现方式
线程的实现方式主要分为两种:用户级线程和内核基线程;
-
用户级线程
即在用户空间中实现的一种线程机制,它不依赖于系统内核,而是由相应的进程调用线程库函数来完成的,不需要操作系统内核去了解这些线程的存在。所以操作系统还是面向进程,以进程作为资源分配和调度的基本单位。所以用户级多线程可以理解为伪多线程,现在大部分操作系统中都已经不采用这种实现方式了。
特点
-
每个进程都需要有自己的TCB列表,TCB列表由用户级的线程库函数来维护。
-
当进程中线程切换时,不需要从用户态切换到内核态,所以速度就非常快。
-
用户级线程适用于不支持线程技术的操作系统。
-
当进程中的一个线程发起系统调用而发生阻塞时,整个进程都会处于阻塞的状态,其他线程自然也就会发生阻塞。
-
-
内核级线程
即在操作系统内核中实现的一种线程机制,由操作系统内核来完成线程的创建、终止和管理。操作系统的调度单位是线程,即CPU的调度单位是线程。
特点
- TCB存在于内核空间中,由内核程序来进行维护。
- 线程的创建、终止和切换都是通过系统调用的方式来进行的,需要从用户态切换到内核态,因此系统开销比较大。
- 在同一个进程中,一个线程发生阻塞并不会影响其他线程的运行,因为操作系统调度的单位是线程,当一个线程发生阻塞时,操作系统会选择其他线程去执行。
- 由于CPU的调度单位是线程,时间片分配给了线程,因此如果一个进程内的线程越多,那么它获得的时间片也就越多。
2.5 多线程模型
-
一对一模型
一个用户级线程映射到一个内核级线程。每个用户进程有与用户级线程同数量的内核级线程。
优点:当一个线程被阻塞后,别的线程还可以继续执行,并发能力强。多线程可在多核处理机上并行执行。
缺点:一个用户进程会占用多个内核级线程:线程切换由操作系统内核完成,需要切换到核心态,因此线程管理的成本高,开销大。
-
多对多模型:
n用户级线程映射到m个内核级线程(n>=m)。每个用户进程对应m个内核级线程。
克服了多对一模型并发度不高的缺点(一个阻塞全体阻塞),又克服了一对一模型中一个用户进程占用太多内核级线程,开销太大的缺点。
可以这么理解
用户级线程是“代码逻辑”的载体内核级线程是“运行机会”的载体
2.6 线程状态
因为线程是运行在进程之上的,所以线程和进程的状态很相似。
3. 进程间通信
3.1 为什么进程间需要通信
进程间通信(Inter-Process Communication, IPC)是多进程系统的核心功能之一,允许不同进程之间有效地交换数据和信息,实现数据共享、资源协调、并发处理和模块化设计等功能。通过IPC机制,可以构建高效、灵活和可靠的系统架构。
-
数据共享:不同进程可能需要访问或修改相同的数据。例如,多个进程可能需要访问一个数据库、文件或共享的缓存。IPC机制允许这些进程安全地共享和同步数据。
-
资源共享:在多进程系统中,资源如文件、设备或数据库通常由多个进程共享。IPC机制提供了方法来协调和管理对这些共享资源的访问,防止冲突和数据损坏。
-
进程协作:复杂的应用程序往往由多个进程组成,这些进程执行不同的任务并相互配合。例如,一个服务器可能有多个进程处理不同的请求。IPC机制使得这些进程能够协调工作,共同完成任务。
-
并发处理:多进程系统允许并行执行多个任务或操作。通过IPC,不同的进程可以在不同的核心或处理器上并行执行,并通过通信机制进行协调,从而提高系统性能和响应速度。
-
分布式系统:在分布式系统中,多个进程可能运行在不同的计算机上。IPC机制(如网络套接字)允许这些分布式进程进行通信和协调,形成一个协作的系统。
-
模块化设计:大型应用程序通常被设计为多个进程,以便于模块化和分离不同的功能。IPC机制使得这些模块化的进程能够有效地进行通信和协作,从而提高系统的可维护性和扩展性。
-
安全性:通过IPC机制,可以实现进程之间的隔离和访问控制,从而提高系统的安全性。例如,通过消息队列和共享内存可以实现进程间的数据传输,同时控制和监控数据的访问权限。
-
异步操作:IPC机制允许进程之间以异步方式进行通信,这意味着一个进程可以在等待另一个进程完成操作时继续执行其他任务,从而提高系统的响应能力和吞吐量。
3.2 通信方式
-
共享内存
共享内存是操作系统提供的一种功能,现代操作系统普遍采用虚拟存储管理,每个进程都会有自己独立的虚拟地址空间,然后由操作系统负责把这些虚拟地址空间映射到物理内存地址。所以对于不同的两个进程,即使使用相同的虚拟内存地址去访问磁盘,实际访问的物理内存也不一定是相同的。而所谓的共享内存地址就是操作系统提供一些特定的API函数,允许多个进程把自己的虚拟地址空间中的一部分共享出来,映射到同一块物理内存。这样一个进程对这块物理内存进行了更改,另一个进程也能看到修改后的结果。
-
消息队列
允许不同的进程通过发送和接收消息进行数据交换。消息队列在内核中实现,支持异步和同步的消息传递,适合于需要处理消息的应用程序。
消息队列是一个数据结构,用于存储进程发送的消息。消息按照队列的顺序存储,也可以根据优先级进行处理。
一个消息通常包括消息头和消息体。消息头包含类型、优先级等信息,消息体则包含实际的数据。
-
管道
管道是一种简单的进程间通信机制,允许一个进程将数据写入管道,另一个进程从管道中读取数据。
匿名管道:用于创建进程及其子进程之间的通信。通常通过
pipe()
系统调用创建。匿名管道的创建和管理完全由操作系统内核负责。内核为管道分配一个缓冲区,用于存储从写端写入的数据,直到这些数据被读端读取。匿名管道在创建时不需要一个文件系统中的路径名。它们存在于进程的内存空间中,不会在文件系统中创建一个实际的文件。命名管道(FIFO):允许不相关的进程之间通信。通过文件系统中的路径访问,创建命名管道使用
mkfifo()
系统调用。命名管道会在文件系统中创建一个特殊类型的文件,并通过该文件实现进程间通信。 -
信号量
信号量用于进程同步和互斥,防止多个进程同时访问共享资源。
分为计数信号量和二进制信号量。计数信号量允许多个进程共享资源,二进制信号量用于互斥控制。
4. 进程的调度
进程调度是操作系统中管理和控制进程执行的一部分,负责决定哪些进程在何时获得 CPU 时间。调度的主要目标是确保系统资源的有效利用,提高系统的整体性能,并实现公平和响应迅速的系统操作。
4.1 概念
进程调度是操作系统根据一定的算法和策略,决定哪个进程在给定时间获得 CPU 时间的过程。
4.2 作业
一个具体的任务
用户向系统提交一个作业≈用户让操作系统启动一个应用程序
4.3 进程调度的层次
**高级调度(作业调度):**按一定的原则从外存的作业后备队列中挑选一个作业调入内存,并创建进程。每个作业只调入一次,调出一次。作业调入时会建立PCB,调出时才撤销PCB。
低级调度(进程调度/处理机调度):按照某种策略从就绪队列中选取一个进程,将处理机分配给它。
**中级调度(内存调度):**按照某种策略决定将哪个处于挂起状态的进程重新调入内存。
4.4 临界资源和临界区
**临界资源:**一个时间段内只允许一个进程使用的资源。各进程需要互斥地访问临界资源。
**临界区:**访问临界资源的那段代码。
4.5 进程调度的时机
- 什么时候需要进程调度与切换
- 当前进程主动放弃处理机
- 进程正常终止
- 进程请求阻塞资源
- 运行过程中因为异常而终止
- 当前进程被动放弃处理机
- 分给进程的时间片用完
- 有更加紧急的事情需要进行解决(比如I/O中断)
- 有优先级更高的进程进入就绪队列
- 当前进程主动放弃处理机
- 什么时候不能发生进程调度和切换
- 在处理中断过程中。中断处理过程复杂,与硬件密切相关,很难做到在中断处理过程中进行进程调度与切换。
- 进程在操作系统的内核临界区中不能被中断,非操作系统的内核临界区是有可能会被中断的。 (比如当临界资源为打印机的时候 )。
- 在原子操作过程中。原子操作不可中断,要一气呵成。
4.6 进程调度的方式
-
非剥夺调度方式,又称非抢占方式。
只允许进程主动放弃处理机。在运行过程中即便有更紧迫的任务到达,当前进程依然会继续使用处理机,直到该进程终止或主动要求进入阻塞态。
-
剥夺调度方式,又称抢占方式。
当一个进程正在处理机上执行时,如果有一个更重要或更紧迫的进程需要使用处理机,则立即暂停正在执行的进程,将处理机分配给更重要紧迫的那个进程。
4.7 调度器(调度程序)
②、③由调度程序引起,调度程序决定
什么事件会触发调度程序
- 创建新进程
- 进程退出
- 运行进程阻塞
- I/O中断发生(可能唤醒某些阻塞进程)
- 非抢占式调度策略,只有运行进程阻寒或退出才触发调度程序工作
- 抢占式调度策略,每个时钟中断或k个时钟中断会触发调度程序工作
4.8 闲逛进程
调度程序永远的备胎,没有其他就绪进程时,运行闲逛进程(idle)
闲逛进程的特性
- 优先级最低
- 可以是0地址指令,占一个完整的指令周期(指令周期末尾例行检查中断)
- 能耗低
4.9 调度算法
4.9.1 评价指标
-
CPU利用率 = 忙碌时间/总时间
-
系统吞吐量 = 单位时间内完成的作业数量 = 总共完成了多少道作业/总共花费的时间
-
周转时间 = 作业完成时刻 - 作业提交时刻
指的是作业被提交给操作系统开始,到作业完成为止的这段时间间隔。它包括四个部分:作业在外存后备队列上等待作业调度(高级调度)的时间,进程在就绪队列上等待进程调度(低级调度)的时间,进程在CPU上执行的时间,进程等待I/O操作完成的时间。后三项在一个作业的处理过程中有可能会发生多次。
-
平均周转时间 = 各个作业周转时间之和/作业数
-
带权周转时间 = 作业周转时间/作业实际运行时间 = (作业完成时刻 - 作业提交时刻)/作业实际运行时间
带权周转时间一定是大于1的。带权周转时间越小,用户满意度越高。
-
评价带权周转时间 = 各作业带权周转时间之和/作业数
-
等待时间:指进程/作业处于等待处理机状态时间之和,等待时间越长,用户满意度越低。对于进程来说,等待时间就是指进程建立后等待被服务的时间之和,在等待I/O完成的期间其实进程也是在被服务的,所以不计入等待时间。一个作业总共需要被CPU服务多久,被I/O设备服务多久一般是确定不变的,因此调度算法其实只会影响作业/进程的等待时间。当然,与前面指标类似,也有“平均等待时间”来评价整体性能。
-
响应时间:指从用户提交请求到首次产生响应所用的时间。对于计算机用户来说,会希望自己的提交的请求尽早地开始被系统服务、回应。(比如通过键盘输入了一个调试命令)
4.9.2 算法的学习思路
- 算法思想
- 算法规则
- 用于作业调度还是进程调度
- 抢占式还是非抢占式
- 优点和缺点
- 是否会导饥饿
4.9.3 适合早期批处理系统的算法
一、先来先服务
-
算法思想
主要从公平角度考虑。类似于我们生活中排队买东西
-
算法规则
按照作业/进程到达的先后顺序进行服务
-
用于作业调度还是进程调度
用于作业调度时,考虑的是哪个作业先到达后备队列;用于进程调度时,考虑的是哪个进程先到达就绪队列
-
抢占式还是非抢占式
非抢占式的算法
-
优点和缺点
优点:公平、算法实现简单
缺点:排在长作业(进程)后面的短作业需要等待很长时间,带权周转时间很大,对短作业来说用户体验不好。即FCFS算法对长作业有利,对短作业不利。
-
是否会导饥饿
不会导致饥饿
二、短作业优先
-
算法思想
追求最少的平均等待时间,最少的平均周转时间、最少的平均平均带权周转时间
-
算法规则
最短的作业/进程优先得到服务(所谓“最短”,是指要求服务时间最短)
-
用于作业调度还是进程调度
即可用于作业调度,也可用于进程调度。用于进程调度时称为“短进程优先(SPF, Shortest Process First)算法”
-
抢占式还是非抢占式
SJF和SPF是非抢占式的算法。但是也有抢占式的版本–最短剩余时间优先算法(SRTN,Shortest Remaining Time Next)
-
优点和缺点
优点:“最短的”平均等待时间、平均周转时间
缺点:不公平。对短作业有利,对长作业不利。可能产生饥饿现象。另外,作业/进程的运行时间是由用户提供的,并不一定真实,不一定能做到真正的短作业优先
-
是否会导饥饿
会。如果源源不断地有短作业/进程到来,可能使长作业/进程长时间得不到服务,产生“饥饿”现象。如果一直得不到服务,则称为“饿死”
① 非抢占式
② 抢占式
三、高响应比优先
-
算法思想
要综合考虑作业/进程的等待时间和要求服务的时间
-
算法规则
在每次调度时先计算各个作业/进程的响应比,选择响应比最高的作业/进程为其服务
响应比 = (等待时间/要求服务时间)/要求服务时间
-
用于作业调度还是进程调度
即可用于作业调度,也可用于进程调度
-
抢占式还是非抢占式
非抢占式的算法。因此只有当前运行的作业/进程主动放弃处理机时,才需要调度,才需要计算响应比
-
优点和缺点
综合考虑了等待时间和运行时间(要求服务时间)等待时间相同时,要求服务时间短的优先(SJF的优点)要求服务时间相同时,等待时间长的优先(FCFS的优点)对于长作业来说,随着等待时间越来越久,其响应比也会越来越大,从而避免了长作业饥饿的问题
-
是否会导饥饿
不会
4.9.4 适合交互式系统的算法
一、时间片轮转算法
常用于分时操作系统,更注重“响应时间”。因此这里我们不计算周转时间。
-
算法思想
公平地、轮流地为各个进程服务,让每个进程在一定时间间隔内都可以得到响应
-
算法规则
按照各进程到过就绪队列的顺序,轮流让各个进程执行一个时间片(如100ms),若进程未在一个时间片内执行完,则剥夺处理机,将进程重新放到就绪队列队尾重新排队。
-
用于作业调度还是进程调度
用于进程调度(只有作业放入内存建立了相应的进程后,才能被分配处理机时间片)
-
抢占式还是非抢占式
若进程未能在时间片内运行完,将被强行剥夺处理机使用权,因此时间片轮转调度算法属于抢占式的算法。由时钟装置发出时钟中断来通知CPU时间片已到。
-
优点和缺点
优点:公平;响应快,适用于分时操作系统;
缺点:由于高频率的进程切换,因此有一定开销;不区分任务的紧急程度。
如果时间片太大,使得每个进程都可以在一个时间片内就完成,则时间片轮转调度算法退化为先来先服务调度算法,并且会增大进程响应时间。因此时间片不能太大。另一方面,进程调度、切换是有时间代价的(保存、恢复运行环境),因此如果时间片太小,会导致进程切换过于频繁,系统会花大量的时间来处理进程切换,从而导致实际用于进程执行的时间比例减少。可见时间片也不能太小。
-
是否会导饥饿
不会
时间片大小为5时
二、优先级调度算法
-
算法思想
随着计算机的发展,特别是实时操作系统的出现,越来越多的应用场景需要根据任务的紧急程度来决定处理顺序。
-
算法规则
每个作业/进程有各自的优先级,调度时选择优先级最高的作业/进程。
-
用于作业调度还是进程调度
既可用于作业调度,也可用于进程调度。甚至,还会用于在之后会学习的I/O调度中。
-
抢占式还是非抢占式
抢占式、非抢占式都有。做题时的区别在于:非抢占式只需在进程主动放弃处理机时进行调度即可,而抢占式还需在就绪队列变化时,检查是否会发生抢占。
-
优点和缺点
-
是否会导饥饿
会
① 非抢占式
② 抢占式
三、多级反馈队列调度算法
-
算法思想
对其他调度算法的折中权衡。
-
算法规则
- 设置多级就绪队列,各级队列优先级从高到低,时间片从小到大。
- 新进程到达时先进入第1级队列,按FCFS原则排队等待被分配时间片,若用完时间片进程还未结束,则进程进入下一级队列队尾、如果此时已经是在最下级的队列,则重新放回该队列队尾。
- 只有第k级队列为空时,才会为k+1 级队头的进程分配时间片。
-
用于作业调度还是进程调度
用于进程调度
-
抢占式还是非抢占式
抢占式的算法。在k级队列的进程运行过程中,若更上级的队列(1~k-1级)中进入了一个新进程,则由于新进程处于优先级更高的队列中,因此新进程会抢占处理机,原来运行的进程放回k级队列队尾。
-
优点和缺点
优点:对各类型进程相对公平(FCFS的优点);每个新到达的进程都可以很快就得到响应(RR的优点);短进程只用较少的时间就可完成(SPF的优点):不必实现估计进程的运行时间(避免用户作假)可灵活地调整对各类进程的偏好程度,比如CPU密集型进程、I/0密集型进程(拓展:可以将因1/0而阻塞的进程重新放回原队列,这样I/o型进程就可以保持较高优先级)
缺点:实现复杂
-
是否会导饥饿
会,如果有源源不断的短进程进入队列的话,就会导致优先级低的队列中的进程无法被调度。
4.9.5 多级队列调度算法
5. 死锁
死锁(Deadlock)是指在多线程或多进程环境中,两个或多个线程或进程因为相互持有对方所需的资源而进入一种永远等待的状态,导致它们都无法继续执行。
5.1 死锁的四个必要条件
要发生死锁,必须同时满足以下四个条件:
- 互斥条件(Mutual Exclusion):
- 至少有一个资源只能由一个线程或进程占用,即资源是独占的。
- 占有且等待条件(Hold and Wait):
- 一个线程或进程已经持有一个资源,并且还在等待其他资源,而这些资源被其他线程或进程占有。
- 不可剥夺条件(No Preemption):
- 一个资源不能被强制性地从占有它的线程或进程中夺走,必须由占有资源的线程或进程主动释放。
- 循环等待条件(Circular Wait):
- 存在一个线程或进程的循环等待链,即线程或进程 A 持有资源 1,并等待资源 2,而资源 2 被线程或进程 B 持有,线程或进程 B 又等待资源 3,而资源 3 被线程或进程 A 持有,形成一个循环等待链。
5.2 例子
考虑以下场景,假设有两个线程 T1 和 T2,以及两个资源 R1 和 R2:
- T1 获取了 R1,T2 获取了 R2。
- T1 尝试获取 R2,但 R2 已经被 T2 持有,所以 T1 进入等待状态。
- T2 尝试获取 R1,但 R1 已经被 T1 持有,所以 T2 也进入等待状态。
在这个例子中,T1 和 T2 都进入了等待对方释放资源的状态,导致双方都无法继续执行,这就是死锁。
5.3 预防与避免死锁
避免死锁通常有以下几种策略:
- 破坏互斥条件:
- 尽可能减少对资源的独占性访问,允许资源共享。
- 破坏占有且等待条件:
- 在一个线程或进程开始执行之前,必须一次性申请它所需要的所有资源。
- 破坏不可剥夺条件:
- 如果一个线程或进程已经持有了某些资源,并且在尝试获取其他资源时失败,它必须释放已经持有的资源。
- 破坏循环等待条件:
- 对所有资源进行统一编号,并规定线程或进程必须按照编号顺序请求资源,这样就可以避免循环等待。
5.4 Java中的死锁示例
下面是一个简单的 Java 示例,演示了死锁的发生:
public class DeadlockDemo {
private static final Object lock1 = new Object();
private static final Object lock2 = new Object();
public static void main(String[] args) {
Thread t1 = new Thread(() -> {
synchronized (lock1) {
System.out.println("Thread 1: Holding lock 1...");
try { Thread.sleep(100); } catch (InterruptedException e) {}
System.out.println("Thread 1: Waiting for lock 2...");
synchronized (lock2) {
System.out.println("Thread 1: Holding lock 1 & 2...");
}
}
});
Thread t2 = new Thread(() -> {
synchronized (lock2) {
System.out.println("Thread 2: Holding lock 2...");
try { Thread.sleep(100); } catch (InterruptedException e) {}
System.out.println("Thread 2: Waiting for lock 1...");
synchronized (lock1) {
System.out.println("Thread 2: Holding lock 1 & 2...");
}
}
});
t1.start();
t2.start();
}
}
5.5 代码分析
- 线程1 (T1):先持有
lock1
,然后等待lock2
。 - 线程2 (T2):先持有
lock2
,然后等待lock1
。
在这种情况下,两个线程都会永远等待对方释放资源,导致死锁。
6. 进程的同步
6.1 概念
进程同步是指在多个进程中发生的时间之间存在着某种时序关系,因此在各个进程之间必须协同合作、相互配合,使各个进程按照 的顺序执行,以共同完成某项任务。
可以把进程之间的同步比喻成人与人之间的合作,比如在一条装箱流水线上,第一个人必须先将物品装入箱子中,第二个人才能将箱子进行加封。
7. 进程的互斥
进程的互斥是指在并发编程中,确保多个进程或线程在同一时间内不能同时访问共享资源或临界区。互斥的主要目的是避免竞态条件,从而保证数据的一致性和系统的稳定性。
还是上面的装箱的例子,箱子就是共享资源,两个人不能同时对箱子进行操作。
我们把一个时间段内只允许一个进程使用的资源称为临界资源。许多物理设备(比如摄像头、打印机)都属于临界资源。此外还有许多变量、数据、内存缓冲区等都属于临界资源。对临界资源的访问,必须互斥地进行。互斥,亦称间接制约关系。进程互斥指当一个进程访问某临界资源时,另一个想要访问该临界资源的进程必须等待。当前访问临界资源的进程访问结束,释放该资源之后,另一个进程才能去访问临界资源。
7.1 进程互斥的软件实现方法
7.1.1 单标志法
设置一个标识,将首次执行的权利给其中一个进程,当这个进程执行完后,将此标识设置为下一个进程可以执行的标识。
标识
int turn = 0; // turn表示当前允许进入临界区的进程号
P0进程
while(turn != 0); // 进入区
critical section; // 临界区
turn = 1; // 退出区
remainder section; // 剩余区
P1进程
while(turn != 1); // 进入区
critical section; // 临界区
turn = 1; // 退出区
remainder section; // 剩余区
**优点:**实现简单;
**缺点:**只能按照p0->p1->p0->p…这种顺序执行,如果p0进程程将执行权让出去后,p1进程没有运行,p0线程就会一直处于等待状态。因此,单标志法存在的问题是:违背“空闲让进”原则。
7.1.2 双标志先检查
设置一个bool数组类型的标识。当一个进程想要执行的时候,先检查另一个进程有没有想要执行。如果另一个进程没有想要执行(即false),就可以将自己的标识设置为想要执行的状态(即true),执行完成后将自己的状态设置为不想再执行。如果另一个进程想要执行,就等待。
标识
bool flag[2];
flag[0] = false;
flag[1] = false;
P0进程
while(flag[1]); //① 先看看P1进程有没有访问临界区的意愿,如果有,就等待。
flag[0] = true; //② 标记为本进程
critical section; //③ 临界区
flag[0] = false; //④ 退出区
remainder section; // 剩余区
P1进程
while(flag[0]); //⑤ 先看看P0进程有没有访问临界区的意愿,如果有,就等待。
flag[1] = true; //⑥ 标记为本进程
critical section; //⑦ 临界区
flag[1] = false; //⑧ 退出区
remainder section; // 剩余区
优点:能够避免“空闲让进”的原则。
缺点:存在并发问题,如果程序按照①⑤②⑥的顺序执行的话,两个进程就会同时进入临界区。
7.1.3 双标志后检查
标识
bool flag[2];
flag[0] = false;
flag[1] = false;
P0进程
flag[0] = true; //① 先看看P1进程有没有访问临界区的意愿,如果有,就等待。
while(flag[1]); //② 标记为本进程
critical section; //③ 临界区
flag[0] = false; //④ 退出区
remainder section; // 剩余区
P1进程
flag[1] = true; //⑤ 先看看P0进程有没有访问临界区的意愿,如果有,就等待。
while(flag[0]); //⑥ 标记为本进程
critical section; //⑦ 临界区
flag[1] = false; //⑧ 退出区
remainder section; // 剩余区
优点:能够避免“空闲让进”与同时进入临界区的情况。
缺点:存在并发问题,如果程序按照①⑤②⑥的顺序执行的话,就会出现死锁的情况。
7.1.4 Peterson 算法
结合双标志法和单标志法的算法。
- 进程先表示自己想要进入临界资源
- 进程表示谦让,将执行权赋予另一个进程。
- 判断另一个进程是否想要进入临界资源,且执行权是不是在另一个进程,如果是,就等待;如果不是,就访问临界资源
- 访问临界资源
- 退出临界区
标识
bool flag[2];
flag[0] = false;
flag[1] = false;
int turn = 0;
P0进程
flag[0] = true; //① 表示自己想要执行
turn = 1; //② 将执行权先转让给P1进程
while(flag[1] && turn == 1); //③ 判断另一个进程有没有在执行
critical section; //④ 临界区
flag[0] = false; //⑤ 退出区
remainder section; // 剩余区
P1进程
flag[1] = true; //⑥ 表示自己想要执行
turn = 0; //⑦ 将执行权先转让给P0进程
while(flag[0] && turn == 0); //⑧ 判断另一个进程有没有在执行
critical section; //⑨ 临界区
flag[1] = false; //⑩ 退出区
remainder section; // 剩余区
按照①⑥②⑦③⑧的顺序执行,
- P0表示自己想要执行
- P1表示自己想要执行
- P0先谦让
- P1后谦让
- P0检查到P1想要执行单执行权并没有在P1,自己就执行了
- P1检查到执且P0想要执行且执行权在P0,就只能等待
7.2 进程互斥的硬件实现方法
-
中断屏蔽(Disable Interrupts):
在单处理器系统中,可以通过在临界区代码运行之前禁用中断来实现互斥。这种方法简单有效,但是不适用于多处理器系统,因为它只能影响单处理器。
-
**测试并设置(Test And Set)😗*有的地方也称之为TSL(Test And Set Lock)。
这是一种硬件提供的原子性操作,常常用于实现互斥锁,它可以在一条指令内测试一个标志位的值并将其设置为一个新值。如果一个测试的标志位为空闲状态,则可以将其值设置为1,并且进程可以进入临界区,否则进程将被阻塞等待。
这种算法的容易导致忙等待,当进程持有锁的时候会持续占用CPU执行时间。
以下是C语言的模拟逻辑:
bool TestAndSwap(bool *lock){ bool old = *lock; // 先将旧值存储 *lock = true; // 在将当前锁设置为已上锁状态 return old; // 返回旧值 }
使用该指令的逻辑:
// 锁 bool lock = false; // 假设此方法会有多个线程调用 void test(){ while(TestAndSwap(&lock)); 临界区代码... // 访问临界资源 lock = false;// 解锁 其他代码... // 临界区外的代码 }
-
交换指令(Swap):
有的地方也叫 Exchange 指令,或简称 XCHG 指令。
Swap 指令是用硬件实现的,执行的过程不允许被中断,只能一气呵成。以下是用C语言描述的逻辑
Swap指令
void Swap(bool *a ,bool *b){ bool tmp = *a; *a = *b; *b =tmp; }
使用代码:
bool lock = false; // 假设此方法有多个线程调用 void test(){ bool old = true; while(old == true){ Swap(&old,&lock) } 临界区代码... // 访问临界区代码 lock = false; // 解锁 临界区外的代码... // 访问临界区外的代码 }
逻辑上来看 swap 和 TSL 并无太大区别,都是先记录下此时临界区是否已经被上锁(记录在 old 变量上),再将上锁标记 lock 设置为 true,最后检査 old,如果 old 为false,则说明之前没有别的进程对临界区上锁,则可跳出循环,进入临界区。
优点:实现简单,无需像软件实现方法那样严格检查是否会有逻辑漏洞:适用于多处理机环境
缺点:不满足“让权等待”原则,暂时无法进入临界区的进程会占用CPU并循环执行TSL指令,从而导致“忙等”。
8. 互斥锁
8.1 概念
解决临界区最简单的工具就是互斥锁(mutexlock)。一个进程在进入临界区时应获得锁;在退出临界区时释放锁。函数 acquire()获得锁,而函数 release()释放锁。
每个互斥锁有一个布尔变量 available,表示锁是否可用。如果锁是可用的,调用 acquire()会成功,且锁不再可用。当一个进程试图获取不可用的锁时,会被阻塞,直到锁被释放。
acquire函数
void acquire(){
while(!available){}
available = false;
}
release函数
void release(){
available = true;
}
acquire()或 release()的执行必须是原子操作,因此互斥锁通常采用硬件机制来实现。
互斥锁的主要缺点是忙等待,当有一个进程在临界区中,任何其他进程在进入临界区时必须连续循环调用 acquire()。当多个进程共享同一 CPU时,就浪费了 CPU周期。因此,互斥锁通常用于多处理器系统,一个线程可以在一个处理器上等待,不影响其他线程的执行。
需要连续循环忙等的互斥锁,都可称为自旋锁(spinlock),如TSL指令、swap指令、单标志法
9. 信号量机制
复习回顾+思考:之前学习的这些进程互斥的解决方案分别存在哪些问题?
进程互斥的四种软件实现方式(单标志法、双标志先检查、双标志后检查、Peterson算法)
进程互斥的三种硬件实现方式(中断屏蔽方法、TS/TSL指令、Swap/XCHG指令)
问题1:在双标志先检查法中,进入区的检查和上锁无法一气呵成,从而可能会导致两个进程同时访问临界区的情况。
问题2:所有的解决方案都无法实现“让权等待”;
用户进程可以通过使用操作系统提供的一对原语来对信号量进行操作,从而很方便的实现了进程互斥、进程同步。
信号量其实就是一个变量(可以是一个整数,也可以是更复杂的记录型变量),可以用一个信号量来表示系统中某种资源的数量,比如:系统中只有一台打印机,就可以设置一个初值为1的信号量
原语是一种特殊的程序段,其执行只能一气呵成,不可被中断。
原语是由关中断/开中断指令实现的。
软件解决方案的主要问题是由“进入区的各种操作无法一气呵成”,因此如果能把进入区、退出区的操作都用“原语”实现,使这些操作能“一气呵成”就能避免问题。
一对原语:wait(S) 原语和 signal(s) 原语,可以把原语理解为我们自己写的函数,函数名分别为 wait和 signal,括号里的信号量S其实就是函数调用时传入的一个参数。
wait、signal 原语常简称为P、V操作(来自荷兰语 proberen 和verhogen)。因此,做题的时候常把wait(S)、signal(s)两个操作分别写为 P(S)、V(S)
9.1 整型信号量
用一个整型的变量作为信号量,用来表示计算机中资源的数量。与普通整数变量的区别:对信号量的操作只有三种,即 初始化、P操作、v操作。
例如:计算机中有一台打印机,会有多个进程访问的情况。
int S = 1;
// wait原语,相当于进入区,检查和上锁一气呵成,避免了并发和异步导致的问题。
void wait(int S){
while(S<=0);
S -= 1;
}
// signal原语,相当于退出区。释放资源。
void signal(int S){
S += 1;
}
以上方案我们不难发现,它是不满足让权等待原则的,会出现忙等的情况。
9.2 记录型信号量
整型信号量存在忙等的问题,所以人们又提出了记录型信号量,即用记录型的数据结构来表示信号量。
记录型信号量结构体定义
typedef struct{
int value; //系统中剩余资源数
struct process *L; // 等待队列。
} semaphore;
进程需要使用资源时
如果进程所需的资源不够,就使用block原语使进程工运行态转为阻塞态,并将其挂到s的队列中(即阻塞队列)中。
void wait (semaphore s){
s.value--;
if(s.value < 0){
block(s.L);
}
}
进程使用完资源之后需要释放资源。
释放资源后,若资源数量还是小于等于零,则证明阻塞队列中还是有进程在阻塞,就使用wakeup唤醒一个进程,使进程从阻塞态转为就绪态。
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,当S.value<0时表示该类资源已分配完毕,因此进程应调用 block 原语进行自我阻塞(当前运行的进程从运行态>阻塞态),主动放弃处理机,并插入该类资源的等待队列 S.L 中。可见,该机制遵循了“让权等待”原则不会出现“忙等”现象。
对信号量S的一次V操作意味着进程释放一个单位的该类资源,因此需要执行 s.value++,表示资源数加1,若加1后仍是 s.value<=0,表示依然有进程在等待该类资源,因此应调用 wakeup 原语唤醒等待队列中的第个进程(被唤醒进程从阻塞态>就绪态)
9.3 用信号量实现进程互斥
前提:一个信号量对应一种资源。
信号量的值=这种资源的剩余数量(信号量的值如果小于0,说明此时有进程在等待这种资源)
P(S):申请一个资源S,如果资源不够就等待。
V(S):释放一个资源S,如果阻塞队列中还有进程等待,就唤醒一个进程。
9.4 用信号量实现进程同步
进程同步:要让各个进程按照要求有序的执行。
P1进程
void P1(){
代码1;
代码2;
代码3;
}
P2进程
void P2(){
代码4;
代码5;
代码6;
}
比如,P1,P2并发执行,由于存在异步性,因此二者交替推进的次序是不确定的。
若 P2 的“代码4”要基于 P1 的“代码1”和“代码2”的运行结果才能执行,那么我们就必须保证“代码4”一定是在“代码2”之后才会执行。
这就是进程同步问题,让本来异步并发的进程互相配合,有序推进。
用信号量实现同步
- 必须先分析哪一个地方需要两个操作或者两个代码的“一前一后”的关系。
- 设置同步信号量s,并设置初始资源量为0。
- 在前操作后执行V操作,
- 在后操作之前执行P操作。
所以在以上的P1和P2的同步问题上,我们可以有以下的实现逻辑:
同步信号量
semaphore s = 0;
P1进程
void P1(){
代码1;
代码2;
V(s);
代码3;
}
P2进程
void P2(){
P(s);
代码4;
代码5;
代码6;
}
以上的场景大概可以这样理解,信号量s代表某种资源,刚刚开始时没有这种资源的。P2进程需要这种资源,而又只能由P1生成这种资源。
若先执行到 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 了。
9.5 用信号量实现前驱关系
进程 P1中有句代码 S1,P2 中有句代码S2,P3中有句代码S3.…… P6 中有句代码S6。这些代码要求按如下前驱图所示的顺序来执行:
-
必须先分析哪一个地方需要两个操作或者两个代码的“一前一后”的关系。
S1->S2,S1->S3,S2->S4,S2->S5,S4->S6,S5->S6,S3->S6。
-
设置同步信号量,并设置初始资源量为0。
通过第一步骤,我们发现总共有7组一前一后的关系,所以我们需要设置7个同步信号量,并设置初始资源值为0;
int a = 0,b = 0,c = 0,d = 0,e = 0,f = 0,g = 0;
-
在前操作后执行V操作,
-
在后操作之前执行P操作。
10. 经典IPC问题
10.1 生产者-消费者问题
系统中有一组生产者进程和一组消费者进程,生产者进程每次生产一个产品放入缓冲区,消费者进程每次从缓冲区中取出一个产品并使用。
生产者、消费者共享一个初始为空大小为5的缓冲区。
只有缓冲区没满时,生产者才能把产品放入缓冲区,否则必须等待。
只有缓冲区没有空时,消费者才能从中取出产品,否则必须等待。
缓冲区是临界资源,各个进程必须互斥地访问。
信号量
semaphore mutex = 1;
semaphore empty = 5;
semaphore full = 0;
生产者进程
void productor(){
while(1){
P(mutex);
P(empty);
生产数据代码...;
V(full);
V(mutex);
}
}
消费者进程
void consumer(){
while(1){
P(mutex);
P(full);
消费数据代码...;
V(empty);
V(mutex);
}
}
我们一不小心就会写出以上代码,这样是有问题的,如果按照生产者进程先抢占到缓冲区资源的话,消费者被阻塞,这种情况是没有什么问题的。但是如果是消费者先抢占到缓冲区资源,就会造成生产者和消费者同时被阻塞,这就是死锁现象。当然,如果初始条件是缓冲区大小为5且有5个待消费的数据,生产者先抢到缓冲区资源同样也会发生死锁现象。所以,以上代码应该改为下面这样。
信号量
semaphore mutex = 1;
semaphore empty = 5;
semaphore full = 0;
生产者进程
void productor(){
while(1){
P(empty);
P(mutex);
生产数据代码...;
V(mutex);
V(full);
}
}
消费者进程
void consumer(){
while(1){
P(full);
P(mutex);
消费数据代码...;
V(mutex);
V(empty);
}
}
当然,释放锁的顺序就没有那么重要了。
PV 操作题目的解题思路:
- 关系分析。找出题目中各个进程之间的同步、互斥关系。
- 设置信号量。设置需要的信号量,并根据题目中条件确定信号量初值。(互斥信号量初值一般为1,同步信号量初始值一般要看对应资源量的初始值是多少)
- 整理思路。根据各个进程的操作流程确定P、V操作的大致顺序
10.2 多生产者-多消费者问题
桌子上有一只盘子,每次只能向其中放入一个水果。爸爸专向盘子中放苹果,妈妈专向盘子中放橘子,儿子专等着吃盘子中的橘子,女儿专等着吃盘子中的苹果。只有盘子空时,爸爸或妈妈才可向盘子中放一个水果。仅当盘子中有自己需要的水果时,儿子或女儿可以从盘子中取出水果。
-
关系分析。找出题目中各个进程之间的同步、互斥关系。
同步关系:爸爸在盘子里面放苹果后女儿才能从盘子里取出苹果,妈妈在盘子里放入橘子后儿子才能从盘子里取出橘子。当盘子为空时爸爸或妈妈才能在盘子里放入水果。
互斥关系:盘子在同一时刻只能有一个人访问。
-
爸爸和妈妈都需要一个盘子为空的信号量。女儿需要一个盘子上有苹果的信号量,儿子需要一个盘子上有橘子的信号量。各个进程之间对盘子的访问是互斥的,需要一个互斥信号量。
-
爸爸应先检查盘子有没有空,没有空的话,对盘子上互斥锁,在盘子里放入苹果后,释放互斥锁,苹果数量加1。
妈妈同样应先检查盘子有没有空,没有空的话,对盘子上互斥锁,在盘子里放入橘子后,释放互斥锁,橘子数量加1。
儿子应先尝试从盘子里获取橘子,能获取到的话,就先对盘子上互斥锁,拿到橘子后,释放互斥锁,将盘子置空。
女儿同样应先尝试从盘子里获取苹果,能获取到的话,就先对盘子上互斥锁,拿到苹果后,释放互斥锁,将盘子置空。
通过以上分析我们可以得到以下代码示例
信号量
semaphore apple = 0;
semaphore orange = 0;
semaphore empty = 1;
semaphore mutex = 1;
爸爸进程
void father(){
while(1){
P(empty);
P(mutex);
放入橘子...;
V(mutex);
V(apple);
}
}
妈妈进程
void mother(){
while(1){
P(empty);
P(mutex);
放入橘子...;
V(mutex);
V(orange);
}
}
儿子进程
void son(){
while(1){
P(orange);
P(mutex);
放入橘子...;
V(mutex);
V(empty);
}
}
女儿进程
void daughter(){
while(1){
P(apple);
P(mutex);
放入橘子...;
V(mutex);
V(empty);
}
}
仔细想想代码还有没有优化的空间,当缓冲区的大小为1的时候,还有没有必要单独设置一个互斥信号量来维护进程和进程之间的互斥关系呢?
答案是没有必要的,所以可以省略掉互斥逻辑,代码如下:
信号量
semaphore apple = 0;
semaphore orange = 0;
semaphore empty = 1;
爸爸进程
void father(){
while(1){
P(empty);
放入橘子...;
V(apple);
}
}
妈妈进程
void mother(){
while(1){
P(empty);
放入橘子...;
V(orange);
}
}
儿子进程
void son(){
while(1){
P(orange);
放入橘子...;
V(empty);
}
}
女儿进程
void daughter(){
while(1){
P(apple);
放入橘子...;
V(empty);
}
}
10.3 吸烟者问题
假设一个系统有三个抽烟者进程和一个供应者进程。每个抽烟者不停地卷烟并抽掉它,但是要卷起并抽掉一支烟,抽烟者需要有三种材料:烟草、纸和胶水。三个抽烟者中,第一个拥有烟草、第二个拥有纸、第三个拥有胶水。供应者进程无限地提供三种材料,供应者每次将两种材料放桌子上,拥有剩下那种材料的抽烟者卷一根烟并抽掉它,并给供应者进程一个信号告诉完成了,供应者就会放另外两种材料再桌上,这个过程一直重复(让三个抽烟者轮流地抽烟)
-
关系分析。找出题目中各个进程之间的同步、互斥关系。
同步关系:当供应者供应好所需材料后,吸烟者才能吸烟。吸烟者洗完烟后,供应者才能提供材料。
互斥关系:供应者和吸烟者不能同时访问缓冲区
-
设置信号量。设置需要的信号量,并根据题目中条件确定信号量初值。(互斥信号量初值一般为1,同步信号量初始值一般要看对应资源量的初始值是多少)
供应者需要等待吸烟者给出一个桌子空了的信号(emptyTable, 初始值为1),才能提供原材料。而每一个抽烟者需要一个桌子上已经有自己所需材料组合的信号量(paperGlue,leafGlue,paperLeaf。初始值都为0)。正常情况下各个进程需要互斥的访问缓冲区,需要一个互斥信号量(mutex),但是这里缓冲区大小只有1,所以并不需要一个互斥信号量解决互斥问题。
-
整理思路。根据各个进程的操作流程确定P、V操作的大致顺序。
首先需要注意的是供应者是只能对emptyTable信号进行P操作,不能对其进行V操作。而各个吸烟者只能对自己所需材料组合对应的信号量进行P操作,而不能对其进行V操作。
然后再根据进程之间的同步关系可以确定P、V操作的大致顺序。供应者先对emptyTable进行P操作,材料组合放在桌子上后,将其对应的材料组合进行V操作,各个吸烟者先对自己对应的信号量进行P操作,吸烟完成后,对emptyTable进行V操作。
代码如下:
信号量
semaphore paperGlue = 0;
semaphore leafGlue = 0;
semaphore paperLeaf = 0;
semaphore emptyTable = 1;
供应者
void supplier(){
int i = 0;
while(1){
P(emptyTable);
if(i == 0){
放入纸巾,胶水;
V(paperglue);
}
if(i == 1){
放入纸巾,胶水;
V(leafGlue);
}
if(i == 2){
放入纸巾,胶水;
V(paperLeaf);
}
i++;
i=i%3;
}
}
吸烟者进程
void a(){
while(1){
P(paperLeaf);
卷烟并开始抽烟;
V(table);
}
}
void b(){
while(1){
P(leafGlue);
卷烟并开始抽烟;
V(table);
}
}
void c(){
while(1){
P(paperGlue);
卷烟并开始抽烟;
V(table);
}
}
10.4 读者写者问题
有读者和写者两组并发进程,共享一个文件,当两个或两个以上的读进程同时访问共享数据时不会产生副作用,但若某个写进程和其他进程(读进程或写进程)同时访问共享数据时则可能导致数据不一致的错误。
因此要求:
①允许多个读者可以同时对文件执行读操作;
②只允许一个写者往文件中写信息;
③任一写者在完成写操作之前不允许其他读者或写者工作;
④写者执行写操作前,应让已有的读者和写者全部退出。
-
关系分析。找出题目中各个进程之间的同步、互斥关系。
同步关系:个人感觉这个问题上并没有很强的同步关系。并不是说读完了,就一定要通知写进程写入数据。写完了,就一定要通知读进程读取数据。
互斥关系:读进程和写进程不能同时访问文件。写进程和写进程之间也存在互斥关系。但是读进程和读进程不存在互斥关系。
-
整理思路并设置信号量。设置需要的信号量,并根据题目中条件确定信号量初值。(互斥信号量初值一般为1)
文件在这里可以作为一个互斥资源(file,初始值为1),需要注意的是读进程和读进程之间并不存在互斥关系,所以我们需要想办法避免读进程和读进程之间去重复对文件加锁(这里加锁就指的是对信号量进行P操作)的过程。
写进程在操作之前先尝试能不能加锁成功,不能则阻塞;能则进行操作,操作完成后将资源进行释放。
读进程就稍稍麻烦一些,大概分为以下步骤:
- 首先需要维护一个当前正在读取文件的进程计数器。
- 在读进程读取文件之前先判断计数器是不是为0。
- 如果为0的话,就证明当前没有任何读进程在读取文件。所以此时需要尝试获取锁,如果不能获取到则证明有写进程在写文件,那么当前进程只能阻塞。如果能获取到锁,就可以读取文件,在真正I/O操作之前需要将计数器加1。
- 如果不是为0的话,就证明当前还有读进程在读取文件,此时读进程一定是占有锁的,那么当前进程就不用再去获取锁了,可以直接读取文件,同样在读文件之前需要将计数器加1。
- 在读取文件之后需要判断计数器是不是为1。
- 如果为不为1的话,则证明还有进程在读取文件,此时锁肯定是不能释放的。计数器需要减1。
- 如果为1,则证明当前没有进程在读取文件,此时需要释放锁,然后将计数器减1.
按照以上思路,可以简单的先实现一下代码,至于有什么其他问题,实现后再进行分析。
信号量
semapore file = 1;
int i = 0;//读者数量
写进程
void writer(){
P(file);
写入数据到文件;
V(file);
}
读进程
void reader(){
if(i == 0){
P(file);
}
i++;
从文件中读取数据;
i--;
if(i == 0){
V(file);
}
}
}
仔细分析一下,读进程的代码存在并发问题,就是在if(i == 0)
的地方,如果有n个进程同时进入此判断,那么,必定会有n-1个进程会进入阻塞状态。所以各个读进程对计数器的判断也是属于互斥关系,那么就需要设置一个互斥信号量解决这个互斥问题。
信号量
semapore file = 1; // 解决文件互斥问题的信号量
int i = 0;//读者数量
semapore mutex = 1; // 解决读者数量互斥问题的信号量
写进程
void writer(){
P(file);
写入数据到文件;
V(file);
}
读进程
void reader(){
P(mutex);
if(i == 0){
P(file);
}
i++;
V(mutex);
从文件中读取数据;
P(mutex);
i--;
if(i == 0){
V(file);
}
V(mutex);
}
}
这样一看,确实是解决了读进程的并发问题,但是如果一直有读进程在读数据的话,写进程就会一直被阻塞,直到饿死。所以还得再想一个方案。
仔细想想,出现读进程一直占用资源的问题无非就是,读写进程对信号量file的上锁的整个过程并不是互斥的,所以再增加一个锁对这个过程进行上锁就可以了。
信号量
semapore file = 1; // 解决文件互斥问题的信号量
int i = 0;//读者数量
semapore mutex = 1; // 解决读者数量互斥问题的信号量
semapore fair = 1;
写进程
void writer(){
P(fair);
P(file);
V(fair);
写入数据到文件;
V(file);
}
读进程
void reader(){
P(fair);
P(mutex);
if(i == 0){
P(file);
}
i++;
V(mutex);
V(fair);
从文件中读取数据;
P(mutex);
i--;
if(i == 0){
V(file);
}
V(mutex);
}
}
10.5 哲学家进餐问题
一张圆桌上坐着5名哲学家,每两个哲学家(philosopher)之间的桌上摆一根筷子(chopstick),桌子的中间是一碗米饭。哲学家们倾注毕生的精力用于思考和进餐,哲学家在思考时,并不影响他人。只有当哲学家饥饿时,才试图拿起左、右两根筷子(一根一根地拿起)。如果筷子已在他人手上,则需等待。饥饿的哲学家只有同时拿起两根筷子才可以开始进餐,当进餐完毕后,放下筷子继续思考。
5位哲学家和5根筷子都进行了编号,从0到4。例如,第0号哲学家坐在最北面,他的左手边是0号筷子,右手边是1号筷子。以此类推,对于第i位哲学家,它的左手边是第i根筷子,右手边是第i+1根筷子。当然,最后一位的哲学家的左手边是第4根筷子,右手边是第0根筷子。为了方便起见,从编程的角度,右手边的筷子编号可以统一用(i+1)%5来表示。
-
关系分析
在这个问题中,感觉同样没有同步关系,每两个相邻的哲学家都会互相竞争他们中间的筷子,显然这个筷子就是他们的互斥资源。
-
整理思路并设置互斥信号量
此类问题处理不得当的话就会产生死锁的问题。先来一个简单粗暴的方案,我们只这是一个信号量mutex,且初始值为1。每个哲学家想要拿起筷子之前都直接上锁,吃完饭后再将这个锁进行释放。
信号量
semophore mutex = 1;
哲学家进程
#define N 5 // 哲学家个数 void philosopher(int i){ // 哲学家编号 0~4 while(1){ P(mutex); // 进入临界区 take_stick(i); //拿取左边的筷子 take_stick((i+1)%N); // 拿取右边的筷子 eat(); // 吃饭 put_stick(i);//放下左边的筷子 put_stick((i+1)%N);// 放下右边的筷子 V(mutex); // 退出临界区 think();// 思考 } }
这种做法虽然避免了哲学家之间的死锁问题,但是,当一个哲学家在吃饭的时候,他对面的哲学家i+2两边的筷子都是空闲的,此时i+2实际是可以吃饭的,但是i+2只能坐在那里干等。这样CPU资源利用率肯定是比较低的。
那锁的粒度再小一些,每根筷子都对应一个信号量。这样就定义一个信号量数组 chopstick[5] = {1,1,1,1,1};
首先,先来一个简单的代码示例。
semaphore chopstick[5] = {1,1,1,1,1}; void philosopher(int i){ // 哲学家编号 0~4 while(1){ P(chopstick[i];// 先拿起左边筷子 P(chopstick[(i+1)%5];// 再拿起右边筷子 eat();// 吃饭 V(chopstick[(i+1)%5];// 先放下右边筷子 V(chopstick[i];// 再放下左边筷子 think();// 思考 } }
以上代码有很明显的问题就是会发生死锁的情况,没关系,一步一步解决它。
解决的思路是,不要让每个哲学家都先去拿起左手边的筷子。比如,让奇数编号的哲学家先去抢占右手边的筷子,让偶数编号的哲学家先去抢占左手边的筷子;代码大致如下
semaphore chopstick[5] = {1,1,1,1,1}; void philosopher(int i){ // 哲学家编号 0~4 while(1){ if(i%2 == 0){ P(chopstick[(i+1)%5];// 拿起右边筷子 P(chopstick[i];// 拿起左边筷子 eat();// 吃饭 V(chopstick[i];// 放下左边筷子 V(chopstick[(i+1)%5];// 放下右边筷子 think();// 思考 }else{ P(chopstick[i];// 拿起左边筷子 P(chopstick[(i+1)%5];// 拿起右边筷子 eat();// 吃饭 V(chopstick[(i+1)%5];// 放下右边筷子 V(chopstick[i];// 放下左边筷子 think();// 思考 } } }
以上代码看起来可能会有点迷糊,那就将每一个进程实际执行的代码择出来就行了
0号进程
while(1){ P(chopstick[1]); // 锁定1号筷子 P(chopstick[0]); // 锁定0号筷子 eat(); // 吃饭 V(chopstick[0]); // 解锁0号筷子 V(chopstick[1]); // 解锁1号筷子 think();// 思考 }
1号进程
while(1){ P(chopstick[1]); // 锁定1号筷子 P(chopstick[2]); // 锁定2号筷子 eat(); // 吃饭 V(chopstick[2]); // 解锁2号筷子 V(chopstick[1]); // 解锁1号筷子 think();// 思考 }
2号进程
while(1){ P(chopstick[3]); // 锁定3号筷子 P(chopstick[2]); // 锁定2号筷子 eat(); // 吃饭 V(chopstick[2]); // 解锁2号筷子 V(chopstick[3]); // 解锁3号筷子 think();// 思考 }
3号进程
while(1){ P(chopstick[3]); // 锁定3号筷子 P(chopstick[4]); // 锁定4号筷子 eat(); // 吃饭 V(chopstick[4]); // 解锁4号筷子 V(chopstick[3]); // 解锁3号筷子 think();// 思考 }
4号进程
while(1){ P(chopstick[0]); // 锁定0号筷子 P(chopstick[4]); // 锁定4号筷子 eat(); // 吃饭 V(chopstick[4]); // 解锁4号筷子 V(chopstick[0]); // 解锁0号筷子 think();// 思考 }
竞争过程可以假设一下,大概如下
11. 管程(Monitor)
11.1 为什么引入管程
信号量存在的问题:编写程序困难,容易出错。
11.2 定义
管程(Monitor)可以被视为一种并发编程的规范或模式。它封装了共享资源的管理和操作,提供了一种结构化的方式来确保线程之间对共享资源的互斥访问和正确的同步。换句话说,管程将数据和用于操控这些数据的方法捆绑在一起,并通过自动的锁机制来管理多个线程对这些方法的访问。
从Java的角度来看,管程可以被理解为一种将共享数据封装在一个类中的设计模式。这种封装确保了数据的私有性,并通过提供线程安全的方法来对数据进行操作。
关键要素:
- 互斥:在任何时刻,只有一个线程能够进入管程执行其中的方法,从而保证了对共享资源的互斥访问。
- 条件变量:管程提供了条件变量,允许线程在某些条件不满足时等待,直到条件满足再继续执行。条件变量通常与
wait
和signal
(或notify
和notifyAll
)操作相关联。 - 共享资源的封装:管程封装了共享资源以及对这些资源的操作,使得资源的访问方式集中化和规范化,避免了直接访问共享资源带来的问题。
简单的java代码示例
public class MonitorExample {
// 私有的共享数据
private int sharedData;
// 同步方法,确保线程安全
public synchronized void increment() {
sharedData++;
}
public synchronized int getSharedData() {
return sharedData;
}
}
11.3 意义
-
**抽象层次:**管程提供了一个更高层次的抽象,用于隐藏底层的锁机制与线程管理,开发者只需要关注操作的逻辑顺序,而不需要关心线程管理的细节。
-
**可移植性和可重用性:**作为一种规范,管程的概念可以用于多种编程语言和平台,定义了一种普遍适用的并发控制模型,使程序具有更好的可移植性和重用性。
-
**简单并发编程:**管程将并发问题抽象封装成一个实体,通过这种规范化的结构,开发者可以更加直观和安全的实现多线程程序。
11.4 案例
java是面向对象的语言,所以使用java能可以有效地解决经典的生产者-消费者问题。
11.4.1 使用synchronized实现管程
import java.util.LinkedList;
import java.util.Queue;
class Monitor {
private final Queue<Integer> queue = new LinkedList<>();
private final int capacity;
public Monitor(int capacity) {
this.capacity = capacity;
}
// 生产者方法
public synchronized void produce(int value) throws InterruptedException {
// 如果队列已满,则等待
while (queue.size() == capacity) {
wait();
}
// 生产者添加数据
queue.offer(value);
System.out.println("Produced: " + value);
// 通知消费者有新数据
notifyAll();
}
// 消费者方法
public synchronized int consume() throws InterruptedException {
// 如果队列为空,则等待
while (queue.isEmpty()) {
wait();
}
// 消费者取出数据
int value = queue.poll();
System.out.println("Consumed: " + value);
// 通知生产者有空位
notifyAll();
return value;
}
}
class Producer implements Runnable {
private final Monitor monitor;
public Producer(Monitor monitor) {
this.monitor = monitor;
}
@Override
public void run() {
int value = 0;
try {
while (true) {
monitor.produce(value++);
Thread.sleep(100); // 模拟生产者的工作
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
class Consumer implements Runnable {
private final Monitor monitor;
public Consumer(Monitor monitor) {
this.monitor = monitor;
}
@Override
public void run() {
try {
while (true) {
monitor.consume();
Thread.sleep(150); // 模拟消费者的工作
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
public class ProducerConsumerExample {
public static void main(String[] args) {
Monitor monitor = new Monitor(5); // 设置容量为5
Thread producerThread = new Thread(new Producer(monitor));
Thread consumerThread = new Thread(new Consumer(monitor));
producerThread.start();
consumerThread.start();
}
}
代码解释
-
Monitor 类:
-
queue
:一个LinkedList
作为共享的缓冲区。 -
capacity
:队列的最大容量。 -
produce(int value)
:生产者调用此方法向队列中添加数据。
- 使用
synchronized
关键字保证方法的互斥访问。 - 当队列已满时,生产者线程调用
wait()
进入等待状态,直到有空位为止。 - 当数据被添加后,调用
notifyAll()
唤醒所有等待的消费者线程。
- 使用
-
consume()
:消费者调用此方法从队列中取出数据。
- 同样使用
synchronized
关键字保证方法的互斥访问。 - 当队列为空时,消费者线程调用
wait()
进入等待状态,直到有新数据为止。 - 当数据被取出后,调用
notifyAll()
唤醒所有等待的生产者线程。
- 同样使用
-
-
Producer 类和Consumer 类:
Producer
是生产者线程,不断调用produce
方法生成数据。Consumer
是消费者线程,不断调用consume
方法消费数据。
-
ProducerConsumerExample 类:
- 在
main
方法中创建并启动生产者和消费者线程。
- 在
注意
此处需要特别注意的地方就是千万不要把Monitor类中produce方法和consume方法中的while判断换成if判断,因为一旦换为if判断,阻塞的进程一旦被唤醒就会在不去判断缓冲区是否已满或者是否已空的情况下直接进行生产或者消费,这样会造成缓冲区溢出或者缓冲区下标越界的情况。
11.4.2 使用ReentrantLock实现管程
class BoundedBuffer {
private final LinkedList<Integer> buffer = new LinkedList<>();
private final int bufferMaxSize;
private final Lock lock = new ReentrantLock();
private final Condition producerQueue = lock.newCondition();
private final Condition consumerQueue = lock.newCondition();
public BoundedBuffer(int bufferMaxSize) {
this.bufferMaxSize = bufferMaxSize;
}
public void put(int value) throws InterruptedException {
lock.lock();
try {
while (buffer.size() == bufferMaxSize) {
producerQueue.await(); // 缓冲区已满,将当前生产者放入生产者等待队列
}
buffer.offer(value);
System.out.println("Produced: " + value);
consumerQueue.signal(); // 通知消费者队列可以消费
} finally {
lock.unlock();
}
}
public int take() throws InterruptedException {
lock.lock();
try {
while (buffer.isEmpty()) {
consumerQueue.await(); // 缓冲区已空,将消费者放入消费者等待
}
int value = buffer.poll();
System.out.println("Consumed: " + value);
producerQueue.signal(); // 通知生产者队列可以生产
return value;
} finally {
lock.unlock();
}
}
}
public class Main {
public static void main(String[] args) {
BoundedBuffer buffer = new BoundedBuffer(5);
// 生产者线程
Thread producer = new Thread(() -> {
try {
for (int i = 0; i < 10; i++) {
buffer.put(i);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
// 消费者线程
Thread consumer = new Thread(() -> {
try {
for (int i = 0; i < 10; i++) {
buffer.take();
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
producer.start();
consumer.start();
try {
producer.join();
consumer.join();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
优点:
ReentrantLock足够灵活,能够在想要上锁的地方上锁,并且能够声明指定的等待队列,可以在合适的时机唤醒合适的等待队列,而synchronized相对而言没有那么灵活,而且唤醒进程其他线程的时候,只能将所有的线程唤醒,这样会造成太多资源竞争锁。
缺点:
实现起来相对复杂,上锁和解锁需要成对出现,且需要手动实现。