1. 概述
1.1 程序、进程和作业
程序:完成特定任务的一系列指令集合,储存在硬盘的静态⽂件;
程序 = 代码段 + 数据段。
进程:程序运行时被加载到内存,就成了进程(即正在进行中的程序);
1、用户角度: 进程是程序的一次动态执行过程;
2、操作系统: 进程是操作系统分配资源的基本单位,也是最小实体。
程序与进程的区别:
(1)静态 / 动态;
(2)硬盘 / 内存(永久 / 暂时)。
程序与进程的联系:
一个程序可以对应多个进程,同一个程序可以在不同的数据集合上运行,因而构成若干个不同的进程。
几个进程能并发地执行相同的程序代码,而同一个进程能顺序地执行几个程序。
作业是用户需要计算机完成的某项任务。
一个作业的完成要经过作业提交、作业收容、作业执行和作业完成四个阶段。
进程和作业的区别主要为:
- 作业是用户向计算机提交任务的任务实体。在用户向计算机提交作业作业后,系统将它放入外存中的作业等待队列中等待执行。
而进程则是完成用户任务的执行实体,是向系统申请分配资源的基本单位。任一进程,只要它被创建,总有相应的部分存在于内存中。 - 一个作业至少由一个进程组成。
- 作业的概念主要用在批处理系统中,像UNIX这样的分时系统中就没有作业的概念。
而进程的概念则用在几乎所有的多道程序系统(并发)中。
1.2 进程的特征
- 动态性:进程的实质是程序的一次执行过程,进程是动态产生、动态消亡的。
- 并发性:任何进程都可以同其他进程一起并发执行。
- 独立性:进程是一个能独立运行的基本单位,同时也是系统分配资源和调度的独立单位。
- 异步性:由于进程间的相互制约,使进程具有执行的间断性,即进程按各自独立的、不可预知的速度向前推进。
- 结构性:进程由程序段、数据段和PCB组成。
1.3 进程的状态
七态模型:新建态、就绪挂起态、就绪态、运行态、阻塞态、阻塞挂起态、终止态
(1)执行:进程分到CPU时间片,正在执行
(2)就绪:进程已经就绪,只要分配到CPU时间片,随时可以执行
(3)阻塞:有IO事件或者等待其他资源(请求I/O,申请缓冲空间等);阻塞态的进程占⽤着物理内存。
(4)新建:进程刚被创建时的状态,尚未进入就绪队列。创建步骤包括:申请空白的 PCB,向 PCB 中填写一些控制和管理信息,系统向进程分配运行时所需的资源。
(5)终止:进程完成任务到达正常结束点,或出现无法克服的错误而异常终止,或被操作系统及有终止权的进程所被终止时所处的状态。
(6-7)挂起:把阻塞的进程置换到磁盘中,此时进程未占用物理内存,我们称之为挂起;挂起不仅仅可能是物理内存不足,比如sleep系统调用,或用户执行Ctrl+Z也可能导致挂起。
–(6)就绪挂起:进程在外存(硬盘),但只要进入内存,马上运⾏。
–(7)阻塞挂起:进程在外存(硬盘)并等待某个事件的出现(进入就绪挂起态)。
1.4 操作系统的进程管理
-
进程控制:创建和撤销进程,分配资源、资源回收,控制进程运行过程中的状态转换。
-
进程同步:多进程运行进行协调–进程互斥(临界资源上锁)、进程同步。
-
进程通信:实现相互合作之间的进程的信息交换。
-
调度:作业调度,进程调度。
1.5 进程死锁、饥饿和饿死
死锁(deadlock) 是指在多道程序系统中,一组进程中的每一个进程都无限期等待被该组进程中的另一个进程所占有且永远不会释放的资源。
死锁的发生必须同时满足四个条件:互斥,持有/等待,非抢占, 形成等待环。
饥饿(starvation) 是指系统不能保证某个进程的等待时间上界,从而使该进程长时间等待,当等待时间给进程推进和响应带来明显影响时,称发生了进程饥饿。
饿死(starve to death) 即是当饥饿到一定程度的进程所赋予的任务即使完成也不再具有实际意义。
- 相同点:死锁和饥饿都是由于竞争资源而引起的;
- 不同点:
(1)从进程状态考虑,死锁进程都处于等待状态,饥饿的进程并非处于等待状态(处于运行或就绪状态,忙等待),但却可能被饿死;
(2)死锁进程等待永远不会被释放的资源,饥饿进程等待会被释放但却不会分配给自己的资源(CPU),表现为等待时限没有上界(排队等待或忙式等待);
(3)死锁一定发生了循环等待,而饿死则不然。这也表明通过资源分配图可以检测死锁存在与否,但却不能检测是否有进程饿死;
(4)死锁一定涉及多个进程,而饥饿或被饿死的进程可能只有一个。
(5)在饥饿的情形下,系统中有至少一个进程能正常运行,只是饥饿进程得不到执行机会。而死锁则可能会最终使整个系统陷入死锁并崩溃。
2. 进程控制
在操作系统中,一般把进程控制用的程序段称为原语,原语的特点是执行期间不允许中断,它是一个不可分割的基本单位,是原子操作。
进程控制的主要功能是对系统中的所有进程实施有效的管理,它具有创建新进程、撤销已有进程、实现进程状态转换等功能。
对于通常的进程,其创建、撤销以及要求由系统设备完成的I/O操作都是利用系统调用而进入内核,再由内核中相应处理程序予以完成的。进程切换同样是在内核的支持下实现的,因此可以说,任何进程都是在操作系统内核的支持下运行的,是与内核紧密相关的。
2.1 创建进程
进程表加一项, 申请PCB并初始化,生成标识, 建立映像, 分配资源, 移入就绪队列:
- 为新进程分配一个唯一的进程标识号 pid,并申请一个空白的进程控制块 PCB。pid 和 PCB 都是有限的,若 PCB 申请失败,则进程创建失败;
- 为进程分配资源,为新进程的程序和数据、以及用户栈分配必要的内存空间(在 PCB 中体现)。这里如果资源不足(比如内存空间),并不是创建失败,而是处于阻塞状态,等待相关资源;
- 初始化 PCB,主要包括初始化标志信息、初始化处理机状态信息和初始化处理机控制信息,以及设置进程的优先级等;
- 如果进程就绪队列能够接纳新进程,就将新进程插入到就绪队列,等待被调度运行。
由于父子进程之间共享 fork 之前打开的文件描述符, 并且父子进程共用文件读写偏移量,所以, 在执行逻辑上, fork 之前打开的文件, 要 close 两次!
2.2 终止进程
进入终止状态的进程以后不能再执行,但在操作系统中保存状态码和一些计时统计数据供其他进程收集。
引起进程终止的事件主要有:
- 正常结束:进程的任务已经完成,并准备退出运行;
- 异常结束:进程在运行时,发生了某种异常事件,使程序无法继续运行,如存储区越界、非法指令、特权指令出错、I/O 故障等;
- 外界干预:进程响应外界的请求而终止运行,如操作员或操作系统干预、父进程请求和父进程终止。
进程终止 / 撤销的过程如下:(从队列中移除, 归还资源, 撤销标识,回收PCB, 移除进程表项)
- 根据被终止进程的标识符,检索 PCB,从中读出该进程的状态;
- 若被终止进程处于执行状态,立即终止该进程的执行,将处理机资源分配给其他进程;
- 若该进程还有子进程,则应将其所有子进程终止,避免孤儿进程;
- 将该进程所拥有的全部资源归还给其父进程或操作系统;
- 将该 PCB 从所在队列(链表)中删除。(操作系统建立多个进程队列, 包括就绪队列和等待队列)
2.3 阻塞 / 唤醒进程
正在执行的进程,由于需要的某些事件未发生,如请求系统资源失败、等待某种操作的完成、新数据尚未到达或等待新任务的到达等,则由系统自动执行阻塞原语,使自己由运行状态变为阻塞状态。
进程的阻塞是进程自身的一种主动行为,只有处于运行状态的进程,才可能将其自身转为阻塞状态。
进程阻塞(Block) 的执行过程如下:(保存现场信息, 修改PCB, 移入等待队列, 调度其他进程执行)
- 找到将要被阻塞进程的标识号对应的 PCB;
- 若该进程为运行状态,则保护其现场,将其状态转为阻塞状态,停止运行;
- 将该 PCB 插入到相应事件的等待队列中去。
当被阻塞进程所需要的事件发生时,如 I/O 操作已完成或其所需要的数据已到达,则由相关进程(例如,提供数据的进程)执行唤醒原语,将等待该事件的进程唤醒。
进程的唤醒是一种被动行为,需要其他进程触发。
进程唤醒(Wakeup) 的执行过程如下:(等待队列中移出, 修改PCB, 移入就绪队列)
- 在该事件的等待队列中找到相应进程的 PCB;
- 将其从等待队列中移出,并置为就绪状态;
- 把该 PCB 插入就绪队列中,等待调度程序调度。
阻塞原语是由被阻塞进程自我调用实现的,而唤醒原语则是由一个与被唤醒进程相合作或被其他相关的进程调用实现的。
2.4 挂起 / 激活进程
当系统资源紧张的时候,操作系统会对在内存中的资源进行更合理的安排,这时就会将将某些优先级不高的进程设为挂起状态,并将其移到外存,一段时间内不对其进行任何操作;当条件允许的时候(调试结束、被调度进程选中需要重新执行),会被操作系统再次激活调回内存。
进程挂起(Suspend) 的执行过程 如下:(修改状态并出入相关队列, 收回内存等资源送至对换区)
- 检查被挂起的进程的状态;
- 若是活动就绪状态,便将其改成挂起就绪;若是活动阻塞状态,便将其改成挂起阻塞;
- 为方便用户或父进程考察该进程的运行情况,把该进程的 PCB 复制到某指定的内存区域;
- 若被挂起的进程正在执行,则转向调度程序重新调度。
引起进程挂起 的几种情况:
- 用户的请求:可能是在程序运行期间发现了可疑的问题,需要暂停进程。
- 父进程的请求:考察,协调,或修改子进程。
- 操作系统的需要:对运行中资源的使用情况进行检查和记账。
- 负载调节的需要:有一些实时的任务非常重要,需要得到充足的内存空间,这个时候我们需要把非实时的任务进行挂起,优先使得实时任务执行。
- 对换的需要:为了缓和内存紧张的情况,将内存中处于阻塞状态的进程换至外存上。
- 定时任务:一个进程可能会周期性的执行某个任务,那么在一次执行完毕后挂起而不是阻塞,这样可以节省内存。
进程挂起的状态转换:
-
运行状态->就绪挂起状态
:对于抢先式分时系统(即可抢占CPU资源),当有高优先级阻塞进程因事件出现而进入就绪状态时,系统可能会把运行进程转到就绪挂起状态。 -
就绪状态->就绪挂起状态
:为了让优先级更高的进程得到更多的资源运行。
通常,操作系统更倾向于挂起阻塞态进程而不是就绪态进程,因为就绪态进程可以立即执行,而阻塞态进程占用了内存空间但不能执行。但如果释放内存以得到足够空间的唯一方法是挂起一个就绪态进程,那么这种转换也是必需的。并且,如果操作系统确信高优先级的阻塞态进程很快就会就绪,那么它可能选择挂起一个低优先级的就绪态进程,而不是一个高优先级的阻塞态进程。 -
阻塞状态->阻塞挂起状态
:为了提交新的进程或为运行就绪状态的进程提供更多资源。
当内存空间比较紧缺的时候,将存在内存中的阻塞状态进程进入阻塞挂起状态,PCB等数据存入外存,让更需要内存的程序占用内存。 -
阻塞挂起状态->就绪挂起状态
:当阻塞状态等待的IO事件或其他事件到来的时候状态发生改变。
挂起状态和阻塞状态的区别:
- 对系统资源占用不同:虽然都释放了CPU,但阻塞的进程仍处于内存中,而挂起的进程通过“对换”技术被换出到磁盘中。
- 发生时机不同:阻塞一般在进程等待资源(IO资源、信号量等)时发生;而挂起是由于用户和系统的需要,例如,终端用户需要暂停程序研究其执行情况或对其进行修改、OS为了提高内存利用率需要将暂时不能运行的进程(处于就绪或阻塞队列的进程)调出到磁盘
- 恢复时机不同:阻塞要在等待的资源得到满足(例如获得了锁)后,才会进入就绪状态,等待被调度而执行;被挂起的进程由将其挂起的对象(如用户、系统)在时机符合时(调试结束、被调度进程选中需要重新执行)将其主动激活。
进程激活(Active) 的执行过程如下:(分配内存, 修改状态并出入相关队列)
- 将进程从外存调入内存,检查该进程的现行状态;
- 若是挂起就绪,便将其改为(活跃)就绪;若是挂起阻塞,便将其改为(活跃)阻塞;
- 假如采用的是抢占调度策略,则每当有挂起就绪进程被激活而加入就绪队列时,便检查是否需要重新调度,即由调度程序将被激活的进程和当前进程两者优先级进行比较:若被激活进程优先级低,则不必重新调度;若当前进程优先级低,则把处理机分配给被激活的进程。
进程激活的状态转换(及条件):
就绪挂起状态->就绪状态
:如果内存中没有就绪态进程,操作系统需要调入一个进程继续执行;此外,就绪挂起进程的优先级高于就绪进程,这种情况的产生是由于操作系统设计者规定,调入高优先级的进程比减少交换量更重要。阻塞挂起状态->阻塞状态
:当一个进程释放足够的内存时,OS会把一个高优先级阻塞挂起进程转为阻塞进程。
2.5 进程切换(抢占)
进程切换是指处理机从一个进程的运行转到另一个进程上运行,这个过程中,进程的运行环境产生了实质性的变化。
进程切换实质上就是被中断运行进程与待运行进程的上下文切换。
进程切换的过程如下:(保存被中断进程的上下文,转向进程调度,恢复待运行进程的上下文)
- 保存处理机上下文,包括程序计数器和其他寄存器;
- 更新PCB信息;
- 把进程的 PCB 移入相应的队列,如就绪、在某事件阻塞等队列;
- 选择另一个进程执行,并更新其 PCB;
- 更新内存管理的数据结构;
- 恢复处理机上下文。
进程切换的发生时机:
进程切换一定发生在中断/异常/系统调用处理过程中, 常见的情况是:
- 阻塞式系统调用、 虚拟地址异常导致被中断进程进入等待态;
- 时间片中断、 I/O中断后发现更高优先级进程导致被中断进程转入就绪态;
- 终止用系统调用、 不能继续执行的异常导致被中断进程进入终止态;
把虚拟地址转换为物理地址需要查找页表,页表查找是⼀个很慢的过程(至少访问2次内存),因此通常使用Cache来缓存常⽤的地址映射,这样可以加速叶表查找,这个cache就是TLB(快表)。
由于每个进程都有自己的虚拟地址空间,那么每个进程都有自己的页表,而进程切换涉及虚拟地址空间的切换,那么页表也要进行切换,页表切换后TLB就失效了,cache失效导致命中率降低,那么虚拟地址转换为物理地址就会变慢,表现出来就是程序运行会变慢。
而线程是共享所在进程的虚拟地址空间的,因此同一个进程中的线程切换不涉及虚拟地址空间的转换,主要是栈空间和代码段的切换。
图. 请求分页中的地址变换过程图
2.6 模式切换
进程切换必须在操作系统内核模式下完成, 这就需要模式切换;
模式切换又称处理器状态切换, 包括:
- 用户模式到内核模式
由中断/异常/系统调用中断用户进程执行而触发; - 内核模式到用户模式
OS执行中断返回指令将控制权交还用户进程而触发。
进程切换与处理机模式切换是不同的,模式切换时,处理机逻辑上可能还在同一进程中运行。如果进程因中断或异常进入到核心态运行,执行完后又回到用户态刚被中断的程序运行,则操作系统只需恢复进程进入内核时所保存的CPU现场,无需改变当前进程的环境信息。但若要切换进程,当前运行进程改变了,则当前进程的环境信息也需要改变。
3. 进程同步
- 互斥锁 / 量(mutex):加锁机制,确保任意时刻仅有一个线程可以访问某项共享资源(临界区);当获取锁操作失败时,线程会进入睡眠(阻塞),等待锁释放时被唤醒。
- 自旋锁(spinlock):与互斥锁类似,但是当获取锁操作失败时,不会进入睡眠,而是会在原地自旋,直到锁被释放。这样节省了线程从睡眠状态到被唤醒期间的消耗,在加锁时间短暂的环境下会极大的提高效率。但如果加锁时间过长,则会非常浪费CPU资源。
- 读写锁(rwlock):与互斥锁类似,但允许多个读出,只允许一个写入的需求(读时不阻塞读,但阻塞写;写时阻塞读写);
- 条件变量(cond):通过条件变量通知操作的方式来保持多线程同步。
- 信号量(semaphore):计数器,允许多个线程同时访问同一个资源;不一定实现了互斥,肯定实现了同步。
4. 进程间通信
进程是一个独立的资源分配单元,不同进程(这里所说的进程通常指的是用户进程)之间的资源是独立的,没有关联,不能在一个进程中直接访问另一个进程的资源。
但是,进程不是孤立的,不同的进程需要进行信息的交互和状态的传递等,因此需要进程间通信(IPC, Inter Processes Communication)。
进程间通信的目的:
- 数据传输:一个进程需要将它的数据发送给另一个进程。
- 通知事件:一个进程需要向另一个或一组进程发送消息,通知它(它们)发生了某种事件(如进程终止时要通知父进程)。
- 资源共享:多个进程之间共享同样的资源。为了做到这一点,需要内核提供互斥和同步机制。
- 进程控制:有些进程希望完全控制另一个进程的执行(如 Debug 进程),此时控制进程希望能够拦截另一个进程的所有陷入和异常,并能够及时知道它的状态改变。
Linux 进程间通信的方式:
- 同一主机进程间通信:匿名管道、有名管道、信号、消息队列、共享内存、信号量
- 不同主机(网络)进程间通信:Socket
4.1 匿名管道
- 管道也叫无名(匿名)管道,它是是 UNIX 系统 IPC(进程间通信)的最古老形式,所有的 UNIX 系统都支持这种通信机制。
- 统计一个目录中文件的数目命令:
ls | wc –l
,为了执行该命令,shell 创建了两个进程来分别执行 ls 和 wc。
管道的特点:
-
管道其实是一个在内核内存中维护的缓冲器,这个缓冲器的存储能力是有限的,不同的操作系统大小不一定相同。
-
管道拥有文件的特质:读操作、写操作,匿名管道没有文件实体,有名管道有文件实体,但不存储数据。可以按照操作文件的方式对管道进行操作。
-
一个管道是一个字节流,使用管道时不存在消息或者消息边界的概念,从管道读取数据的进程可以读取任意大小的数据块,而不管写入进程写入管道的数据块的大小是多少。
-
通过管道传递的数据是顺序的,从管道中读取出来的字节的顺序和它们被写入管道的顺序是完全一样的。
-
在管道中的数据的传递方向是单向的,一端用于写入,一端用于读取,管道是半双工的。
-
从管道读数据是一次性操作,数据一旦被读走,它就从管道中被抛弃,释放空间以便写更多的数据,在管道中无法使用 lseek() 来随机的访问数据。
-
匿名管道只能在具有公共祖先的进程(父进程与子进程,或者两个兄弟进程,具有亲缘关系)之间使用。
-
管道默认是阻塞的:如果管道中没有数据,
read
阻塞;如果管道满了,write
阻塞。
-
为什么可以使用管道进行进程间通信?(管道具有文件的性质,可以按照操作文件的方式对管道进行操作)
-
管道的数据结构(类比循环队列)
-
匿名管道的使用:
◼ 创建匿名管道 #include <unistd.h> int pipe(int pipefd[2]); 功能:创建一个匿名管道,用来进程间通信。 参数:int pipefd[2] 这个数组是一个传出参数。 pipefd[0] 对应的是管道的读端 pipefd[1] 对应的是管道的写端 返回值: 成功 0 失败 -1 管道默认是阻塞的:如果管道中没有数据,read阻塞,如果管道满了,write阻塞 注意:匿名管道只能用于具有关系的进程之间的通信(父子进程,兄弟进程) ◼ 查看管道缓冲大小命令 ulimit –a ◼ 查看管道缓冲大小函数 #include <unistd.h> long fpathconf(int fd, int name); // name 可填宏参数 _PC_PIPE_BUF
利用匿名管道,在 fork() 之前创建管道,才能共享内核区数据(fd),父进程发送数据给子进程,子进程读取到数据输出:
管道是半双工的,两个进程无法同时读和写,因此父进程关闭管道读端fd[0],子进程关闭写端fd[1],保证数据的稳定性和有效性。
匿名管道的通信本质是文件的读写,可利用 fcntl()
修改为非阻塞I/O。
4.2 有名管道
- 匿名管道,由于没有名字,只能用于亲缘关系的进程间通信。
为了克服这个缺点,提出了有名管道,也叫命名管道、FIFO(First In First Out)文件。 - 有名管道(FIFO)提供了一个路径名与之关联,以 FIFO 的文件形式存在于文件系统中,并且其打开方式与打开一个普通文件是一样的,这样即使与 FIFO 的创建进程不存在亲缘关系的进程,只要可以访问该路径,就能够彼此通过 FIFO 相互通信,因此,通过 FIFO 不相关的进程也能交换数据。
- 一旦打开了 FIFO,就能在它上面使用与操作匿名管道和其他文件的系统调用一样的 I/O 系统调用了(如read()、write()和close())。
- 与管道一样,FIFO 也有一个写入端和读取端,并且从管道中读取数据的顺序与写入的顺序是一样的。严格遵循先进先出,对管道及 FIFO 的读总是从开始处返回数据,对它们的写则把数据添加到末尾。它们不支持诸如
lseek()
等文件定位操作。
有名管道(FIFO)和匿名管道(pipe)有一些特点是相同的,区别在于:
- FIFO 在文件系统中作为一个特殊文件存在,不存储数据,但 FIFO 中的内容却存放在内存中。
- 当使用 FIFO 的进程退出后,FIFO 文件将继续保存在文件系统中以便以后使用。
- FIFO 有名字,不相关的进程可以通过打开有名管道进行通信。
有名管道的使用:
-
创建有名管道
mkfifo
(命令 / 函数)1.通过命令: mkfifo 名字 2.通过函数: #include <sys/types.h> #include <sys/stat.h> int mkfifo(const char *pathname, mode_t mode); 参数: - pathname: 管道名称的路径 - mode: 文件的权限 和 open 的 mode 是一样的 是一个八进制的数 返回值:成功返回0,失败返回-1,并设置错误号
不同进程间的有名管道通信:
有名管道的注意事项:
1.一个为只读权限打开一个管道的进程会阻塞,直到另外一个进程为只写打开管道;
2.一个为只写权限打开一个管道的进程会阻塞,直到另外一个进程为只读打开管道。(都阻塞在open语句处)
匿名 / 有名管道的读写特点(假设都是阻塞I/O操作):
读管道:
1. 管道中有数据,read 返回实际读到的字节数。
2. 管道中无数据:
写端被全部关闭(写端引用计数等于0),read 返回0(相当于读到文件的末尾)
写端没有完全关闭(写端引用计数大于0),read 阻塞等待
写管道:
1. 管道读端全部被关闭(读端引用计数等于0),进程异常终止(进程收到SIGPIPE信号 [13] )
2. 管道读端没有全部关闭(读端引用计数大于0):
管道已满,write阻塞
管道没有满,write将数据写入,并返回实际写入的字节数
4.3 内存映射
内存映射(Memory-mapped I/O,mmap)是将磁盘文件的数据映射到内存,用户通过修改内存就能修改磁盘文件(在原文件大小范围内)。(实现进程间通信,直接对内存进行操作,效率相对较高)
普通磁盘I/O读写文件要先把内容拷贝到页缓存(内核空间)中,然后再拷贝到用户空间中供使用,有2次拷贝。
unix访问文件的传统方法使用open打开他们,如果有多个进程访问一个文件,则每一个进程在再记得地址空间都包含有该文件的副本,这不必要地浪费了存储空间。下面说明了两个进程同时读一个文件的同一页的情形,系统要将该页从磁盘读到高速缓冲区中,每个进程再执行一个内存期内的复制操作将数据从高速缓冲区读到自己的地址空间。
而mmap读写文件是利用缺页异常把文件内容从磁盘换到用户空间中,只有1次拷贝,因此效率较高。
进程A和进程B都将该页映射到自己的地址空间,当进程A第一次访问该页中的数据时,它生成一个缺页中断,内核此时读入这一页到内存并更新页表使之指向它,以后,当进程B访问同一页面而出现缺页中断时,该页已经在内存,内核只需要将进程B的页表登记项指向此页即可。
(缺页中断就是要访问的页不在主存,需要操作系统将其调入主存后再进行访问。在这个时候,被内存映射的文件实际上成了一个分页交换文件。)
mmap()系统调用使得进程之间通过映射同一个普通文件实现共享内存。普通文件被映射到进程地址空间后,进程可以像访问普通内存一样对文件进行访问,不必再调用read、write等操作。
#include <sys/mman.h>
void *mmap(void *addr, size_t length, int prot, int flags,int fd, off_t offset);
- 功能:将一个文件或者设备的数据映射到内存中
- 参数:
- void *addr: NULL, 由内核指定
- length : 要映射的数据的长度,这个值不能为0。建议使用文件的长度。
获取文件的长度:stat lseek
(最少为自动调整为系统分页大小的整数倍)
- prot : 对申请的内存映射区的操作权限
-PROT_EXEC :可执行的权限
-PROT_READ :读权限
-PROT_WRITE :写权限
-PROT_NONE :没有权限
要操作映射内存,必须要有读的权限。
e.g. PROT_READ、PROT_READ|PROT_WRITE
- flags :
- MAP_SHARED : 映射区的数据会自动和磁盘文件进行同步,进程间通信,必须要设置这个选项
- MAP_PRIVATE :不同步,内存映射区的数据改变了,对原来的文件不会修改,会重新创建一个新的文件。(copy on write)
- MAP_ANONYMOUS:匿名映射,不需要文件实体
- fd: 需要映射的那个文件的文件描述符
- 通过open得到,open的是一个磁盘文件
- 注意:文件的大小不能为0,open指定的权限不能和prot参数有冲突。
prot: PROT_READ open:只读 / 读写
prot: PROT_READ | PROT_WRITE open:读写
- offset:偏移量,一般不用。必须指定的是4k(1024)的整数倍,0表示不偏移。
- 返回值:返回创建的内存的首地址
失败返回MAP_FAILED,(void *) -1
int munmap(void *addr, size_t length);
- 功能:释放内存映射,映射的内存在用户区堆和栈之间的共享库中,进程结束后也会自动释放。
- 参数:
- addr : 要释放的内存的首地址
- length : 要释放的内存的大小,要和mmap函数中的length参数的值一样。
- 内存映射的注意事项
1.如果对mmap的返回值(ptr)做++操作(ptr++), munmap是否能够成功?
void * ptr = mmap(…);
ptr++; 可以对其进行++操作
munmap(ptr, len); // 错误,要保存地址
2.如果open时O_RDONLY, mmap时prot参数指定PROT_READ | PROT_WRITE会怎样?
错误,返回MAP_FAILED
open()函数中的权限建议和prot参数的权限保持一致。
3.如果文件偏移量为1000会怎样?
偏移量必须是4K(1024)的整数倍,返回MAP_FAILED
4.mmap什么情况下会调用失败?
- 第二个参数:length = 0时
- 第三个参数:prot
— 只指定了写权限
— prot PROT_READ | PROT_WRITE
第5个参数fd 通过open函数时指定的 O_RDONLY / O_WRONLY
5.可以open的时候O_CREAT一个新文件来创建映射区吗?
- 可以的,但是创建的文件的大小如果为0的话,肯定不行
- 可以对新的文件进行扩展
— lseek(),write()
— truncate()
6.mmap后关闭文件描述符,对mmap映射有没有影响?
int fd = open(“XXX”);
mmap(,fd,0);
close(fd);
映射区还存在,创建映射区的fd被关闭,没有任何影响。
7.对ptr越界操作会怎样?
void * ptr = mmap(NULL, 100,);
4K
越界操作操作的是非法的内存 -> 段错误
使用内存映射实现进程间通信(非阻塞)
- 有关系的进程(父子进程)
(1)准备一个大小不是 0 的磁盘文件
(2)还没有子进程的时候,通过唯一的父进程,先创建内存映射区
(3)有了内存映射区以后,创建子进程
(4)父子进程共享创建的内存映射区,对文件操作实现通信 - 没有关系的进程间通信
(1)准备一个大小不是 0 的磁盘文件
(2)进程1 通过磁盘文件创建内存映射区,得到一个操作这块内存的指针
(3)进程2 通过磁盘文件创建内存映射区,得到一个操作这块内存的指针
(4)使用内存映射区通信
使用内存映射实现文件拷贝
内存空间的大小限制了文件拷贝的内容
思路:
1.对原始的文件进行内存映射
2.创建一个新文件(拓展该文件)
3.把新文件的数据映射到内存中
4.通过内存拷贝将第一个文件的内存数据拷贝到新的文件内存中
5.释放资源
匿名映射实现进程间通信,父子进程间通信
文件映射:文件支持的内存映射,把文件的一个区间映射到进程的虚拟地址空间,数据源是存储设备上的文件。
匿名映射:不需要文件实体,没有文件支持的内存映射,把物理内存映射到进程的虚拟地址空间,没有数据源。
4.4 信号
信号是 Linux 进程间通信的最古老的方式之一,是事件发生时对进程的通知机制,有时也称之为软件中断,它是在软件层次上对中断机制的一种模拟,是一种异步通信的方式。
信号可以导致一个正在运行的进程被另一个正在运行的异步进程中断,转而处理某一个突发事件。
发往进程的诸多信号,通常都是源于内核。引发内核为进程产生信号的各类事件如下:
- 对于前台进程(占用操作终端),用户可以通过输入特殊的终端字符来给它发送信号。比如输入Ctrl+C 通常会给进程发送一个中断信号。
- 硬件发生异常,即硬件检测到一个错误条件并通知内核,随即再由内核发送相应信号给相关进程。比如执行一条异常的机器语言指令,诸如被 0 除,或者引用了无法访问的内存区域。
- 系统状态变化,比如 alarm 定时器到期将引起 SIGALRM 信号,进程执行的 CPU 时间超限,或者该进程的某个子进程退出。
- 运行 kill 命令或调用 kill 函数。
-
使用信号的两个主要目的是:
– 让进程知道已经发生了一个特定的事情。
– 强迫进程执行它自己代码中的信号处理程序。 -
信号的特点:
简单
不能携带大量信息
满足某个特定条件才发送
优先级比较高 -
查看系统定义的信号列表:
kill –l
,前 31 个信号为常规信号,其余为实时信号。 -
信号的几种状态:产生、未决、递达
-
SIGKILL
和SIGSTOP
信号不能被捕捉、阻塞或者忽略,只能执行默认动作。
信号集
- 许多信号相关的系统调用都需要能表示一组不同的信号,多个信号可使用一个称之为信号集的数据结构来表示,其系统数据类型为
sigset_t
。 - 在 PCB 中有两个非常重要的信号集。
一个称之为“未决信号集” (只能获取) ,另一个称之为 “阻塞信号集”(信号掩码,可以获取和设置)。
这两个信号集都是内核使用位图机制来实现的。但操作系统不允许我们直接对这两个信号集进行位操作。而需自定义另外一个集合,借助信号集操作函数来对 PCB 中的这两个信号集进行修改。 - 信号的 “未决” 是一种状态,指的是从信号的产生到信号被处理前的这一段时间。
- 信号的 “阻塞” 是一个开关动作,指的是阻止信号被处理,但不是阻止信号产生。
- 信号的阻塞就是让系统暂时保留信号留待以后发送。由于另外有办法让系统忽略信号,所以一般情况下信号的阻塞只是暂时的,只是为了防止信号打断敏感的操作。
信号处理流程:
1.用户通过键盘 Ctrl + C, 产生2号信号SIGINT (信号被创建)
2.信号产生但是没有被处理 (未决)
- 在内核中将所有的没有被处理的信号存储在一个集合中 (未决信号集)
- SIGINT信号状态被存储在第二个标志位上
- 这个标志位的值为0, 说明信号不是未决状态
- 这个标志位的值为1, 说明信号处于未决状态
3.这个未决状态的信号,需要被处理,处理之前需要和另一个信号集(阻塞信号集),进行比较
- 阻塞信号集**默认不阻塞任何的信号**
- 如果想要阻塞某些信号需要用户调用**系统的API**
4.在处理的时候和阻塞信号集中的标志位进行查询,看是不是对该信号设置阻塞了
- 如果没有阻塞,这个信号就被处理
- 如果阻塞了,这个信号就继续处于未决状态,直到阻塞解除,这个信号就被处理
内核实现信号捕捉的过程
两次相同的信号先后递送,在第一次相关回调函数执行完后,才回执行下一次的回调函数。
SIGCHLD
信号(避免僵尸进程)
SIGCHLD信号产生的3个条件:
1.子进程结束
2.子进程接收到 SIGSTOP 信号暂停时
3.子进程处在停止态,接受到 SIGCONT 后唤醒时
以上三种条件都会给父进程发送 SIGCHLD 信号,父进程默认会忽略该信号
使用SIGCHLD
信号解决僵尸进程的问题:
父进程捕捉到SIGCHLD
信号后,才中断调用 waitpid()
进行进程回收,可以避免父进程循环 wait()
占用资源。
4.5 共享内存
共享内存的实现方式分为两种,分别是基于物理内存实现和基于内存映射实现。
4.3 已讲到内存映射需要将磁盘文件的数据映射到内存,用户通过修改内存来修改磁盘文件,进而实现共享了这个磁盘文件映射的进程进行通信,但是涉及到磁盘文件的读写;虽然匿名映射不需要指定文件,但是只能用于具有亲缘关系的进程间通信。
共享内存和内存映射的区别:
- 共享内存可以直接创建,内存映射需要磁盘文件(匿名映射除外)
- 共享内存效果更好
- 内存
共享内存,所有的进程操作的是同一块共享内存。
内存映射,每个进程在自己的虚拟地址空间中有一个独立的内存映射到指定磁盘文件上。 - 数据安全
进程突然退出:
– 共享内存还存在
– 内存映射区自动释放
运行进程的电脑死机,宕机了:
– 数据存在在共享内存中,没有了
– 内存映射区的数据 ,由于磁盘文件中的数据还在,所以内存映射区的数据还存在。 - 生命周期
– 内存映射区:进程退出,内存映射区销毁
– 共享内存:进程退出,共享内存还在,标记删除(所有的关联的进程数为0),或者关机
如果一个进程退出,会自动和共享内存进行取消关联。
此处介绍基于物理内存实现的共享内存。
共享内存允许两个或者多个进程共享物理内存的同一块区域(通常被称为段),每个进程可以将自身的虚拟地址映射到物理内存中的特定区域,共享内存段会成为进程用户空间的一部分,因此这种 IPC 机制无需内核介入。
共享内存是最高效的进程间通信的方式。
共享内存只需要两次拷贝即可实现,即数据从进程A的用户空间到内存,再从内存到进程B的用户空间。
A用户空间 -> 内存 -> B用户空间
而管道等要求进程需要通过用户空间和内核内存间进行数据拷贝的做法则需要四次拷贝:首先将数据从进程A的用户空间拷贝到进程A的内核空间,其次将数据从进程A的内核空间拷贝到内存中,之后数据又从内存被拷贝到进程B的内核空间,最后数据从进程B的内核空间拷贝到进程B的用户空间中。
A用户空间 -> A内核空间 -> 内存 -> B内核空间-> B用户空间
共享内存没有进程间同步与互斥机制。例如,进程A对共享内存执行写操作,在A的写入结束之前,进程B就可以从共享内存区读取数据,并无某种自动的机制来阻止进程B的读操作。一般为了实现进程同步和互斥,常常将共享内存和信号量配合使用。
共享内存使用步骤:
- 调用
shmget()
创建一个新共享内存段或获取一个既有共享内存段的标识符(即由其他进程创建的共享内存段)。这个调用将返回后续调用中需要用到的共享内存标识符。 - 使用
shmat()
来关联共享内存段,即使该段成为调用进程的虚拟内存的一部分。 - 此刻在程序中可以像对待其他可用内存那样对待这个共享内存段。为引用这块共享内存,程序需要使用由
shmat()
调用返回的addr
值,它是一个指向进程的虚拟地址空间中该共享内存段的起点的指针。 - 调用
shmdt()
来分离共享内存段。在这个调用之后,进程就无法再引用这块共享内存了。这一步是可选的,并且在进程终止时会自动完成这一步。 - 调用
shmctl()
来删除共享内存段。只有当当前所有关联内存段的进程都与之分离之后内存段才会销毁。只有一个进程需要执行这一步。
问题1:操作系统如何知道一块共享内存被多少个进程关联?
- 共享内存维护了一个结构体 struct shmid_ds 这个结构体中有一个成员 shm_nattch
- shm_nattach 记录了关联的进程个数
终端执行命令 ipcs -m
问题2:可不可以对共享内存进行多次删除 shmctl ?
可以的
因为 shmctl 标记删除共享内存,不是直接删除
- 什么时候真正删除呢?
当和共享内存关联的进程数为 0 的时候,就真正被删除
- 当共享内存的 key为 0 的时候,表示共享内存被标记删除了
如果一个进程和共享内存取消关联,那么这个进程就不能继续操作这个共享内存。也不能进行关联。
4.6 消息队列
消息队列(Message Queue,简称MQ),指保存消息的一个容器,本质是个队列(先进先出)。
消息(Message)是指在应用之间传送的数据,消息可以非常简单,比如只包含文本字符串,也可以更复杂,可能包含嵌入对象。
下图便是消息队列的基本模型,向消息队列中存放数据的叫做生产者,从消息队列中获取数据的叫做消费者。
消息队列中间件是分布式系统中重要的组件,主要解决异步处理、应用解耦、流量削锋等问题,实现高性能、高可用、可伸缩和最终一致性架构。
1. 异步处理
引入消息队列,将非主要的业务逻辑进行异步处理,主要目的是减少请求响应时间,实现非核心流程异步化,提高系统响应性能。
场景说明:用户注册后,需要发注册邮件和注册短信提醒。传统的做法有两种 1.串行方式;2.并行方式
假设三个业务节点每个使用50毫秒钟,不考虑网络等其他开销,则串行方式的时间是150毫秒,并行的时间可能是100毫秒。
消息队列在异步的典型场景就是将比较耗时而且不需要即时(同步)返回结果的操作,通过消息队列来实现异步化。
按照以上约定,用户的响应时间相当于是注册信息写入数据库的时间,也就是50毫秒。注册邮件、发送短信写入消息队列后,直接返回,因此写入消息队列的速度很快,基本可以忽略,因此用户的响应时间可能是50毫秒。因此架构改变后,系统的吞吐量提高到每秒20 QPS。比串行提高了3倍,比并行提高了2倍。
2. 应用解耦
如果模块之间不存在直接调用,那么新增模块或者修改模块就对其他模块影响较小,耦合性低,这样系统的可扩展性无疑更好一些。
使用了消息队列后,只要保证消息格式不变,消息的发送方和接收方并不需要彼此联系,也不需要受对方的影响,即解耦。
每个成员不必受其他成员影响,可以更独立自主,只通过消息队列MQ来联系,典型的上下游解耦如下图所示:
3. 流量削峰
流量削锋也是消息队列中的常用场景,一般在秒杀或团抢活动中使用广泛。
应用场景:秒杀活动,一般会因为流量过大,导致流量暴增,应用挂掉。为解决这个问题,一般需要在应用前端加入消息队列,相当于做了一次缓冲。
a、可以控制活动的人数
b、可以缓解短时间内高流量压垮应用
用户的请求,服务器接收后,首先写入消息队列;
假如消息队列长度超过最大数量,则直接抛弃用户请求或跳转到错误页面;
秒杀业务根据消息队列中的请求信息,再做后续处理。
4. 日志处理
日志处理是指将消息队列用在日志处理中,比如Kafka的应用,解决大量日志传输的问题。架构简化如下:
日志采集客户端:负责日志数据采集,定时写受写入Kafka队列;
Kafka消息队列:负责日志数据的接收,存储和转发;
日志处理应用:订阅并消费kafka队列中的日志数据。
5. 消息通讯
消息队列一般都内置了高效的通信机制,因此也可以用在纯的消息通讯。比如实现点对点消息队列,或者聊天室等。
- 点对点通讯:
客户端A(发送者)和客户端B(接受者)使用同一消息队列,进行消息通讯。
- 聊天室通讯:
客户端A,客户端B…客户端N订阅同一主题,进行消息发布和接收。实现类似聊天室效果。
以上实际是消息队列的两种消息模式,点对点和发布订阅模式。
每个消息只有一个消费者(Consumer)(即一旦被消费,消息就不再在消息队列中);
发送者和接收者之间在时间上没有依赖性;
接收者在成功接收消息之后需向队列应答成功。
每个消息可以有多个消费者:和点对点方式不同,发布消息可以被所有订阅者消费;
发布者和订阅者之间有时间上的依赖性;
针对某个主题(Topic)的订阅者,它必须创建一个订阅者之后,才能消费发布者的消息;
为了消费消息,订阅者必须保持运行的状态。
4.7 信号量
信号量(semaphore)是操作系统用来解决并发中的(进程或者线程)互斥和同步问题的一种方法。
对于信号量的值 n(允许进入临界区的线程/进程数):
n > 0:当前有可用资源,可用资源数量为 n
n = 0:资源都被占用,可用资源数量为 0
n < 0:资源都被占用,并且还有 n 个进程正在排队
信号量可以但不一定实现互斥(不是说不能,一种情况是不存在共享临界区,谈不上互斥,另一种情况是允许共同进入临界区,比如读操作),肯定实现了同步。
可用于进程间共享内存的进程同步问题。
sem_t sem; // 信号量的类型
int sem_init(sem_t *sem, int pshared, unsigned int value);
- 初始化信号量
- 参数:
- sem : 信号量变量的地址
- pshared : 【0 用在线程间 ,非 0 用在进程间】
- value : 信号量中的值
int sem_destroy(sem_t *sem);
- 释放资源
int sem_wait(sem_t *sem);
- 对信号量加锁,调用一次对信号量的值-1,如果值为0,就阻塞
int sem_trywait(sem_t *sem);
int sem_timedwait(sem_t *sem, const struct timespec *abs_timeout);
int sem_post(sem_t *sem);
- 对信号量解锁,调用一次对信号量的值+1
int sem_getvalue(sem_t *sem, int *sval);
4.8 socket 套接字
用于网络中不同主机之间的进程间通信
5. 进程调度
进程调度的目的:在进程间切换CPU,最大化CPU利用率,通过操作系统的调度使得计算机资源分配和使用更加高效。
需要CPU调度的4种情况:
运行状态->阻塞状态
(例如I/O请求,或wait()调用)运行状态->就绪状态
(例如当出现中断)阻塞状态->就绪状态
(例如I/O完成)进程终止
调度方案分为两种:
(1)非抢占式调度(nonpreemptive)或协作(cooperative):
一旦某个进程分配到CPU,该进程会一直使用CPU,直到它终止或切换到阻塞状态。
(2)抢占式调度(preemptive):
允许其他进程抢占运行中程序的CPU资源,这中间可能涉及进程共享数据的一致性问题、进程同步问题,同时带来了更多的切换次数,这会造成更高的上下文切换的成本。
为了比较不同的CPU调度算法,采用一些比较准则来评价CPU调度算法的特性,具体的一些比较准则包括:
- CPU使用率;
- 吞吐量(throughput):一个时间单元内进程完成的数量;
- 周转时间(turnaround time):进程从提交到进程完成的时间段;
- 等待时间:进程在就绪队列种因等待所需的时间;
- 响应时间:进程从提交请求到产生第一响应的时间。
批处理系统中的调度
批处理是指用户将一批作业提交给操作系统后就不再干预,由操作系统控制它们自动运行。
批处理操作系统的目的是提高系统资源的利用率,不具有交互性。
1. 先到先服务(FCFS,First-Come First-Served)
通过FIFO队列实现,按照作业到达任务队列的顺序调度:
当一个进程进入就绪队列中的时候,它的PCB会被链接到队列尾部;当CPU空闲时,它会分配给位于队列头部的进程,并且这个进程从队列中移去。
- 对进程公平;
- 非抢占式,可能导致一个进程占用CPU时间过长;
- 有利于长进程,而不利于短进程;
- 利于 CPU 密集型进程,而不利于 I/O 密集型进程;
- 平均等待时间往往很长。
2. 最短作业优先(SJF,Shortest-Job-First)
每次从队列里选择预计时间最短的作业运行。
- 非抢占式,优先照顾短作业(进程);
- 降低平均等待时间,提高吞吐量;
- 不利于长作业,长作业可能一直处于等待状态,出现饥饿现象;
- 完全未考虑作业的优先紧迫程度,不能用于实时系统。
3. 最短剩余时间优先(SRTF,Shortest Remaining Time First):
首先按照作业的服务时间挑选最短的作业运行,在该作业运行期间,一旦有新作业到达系统,并且该新作业的服务时间比当前运行作业的剩余服务时间短,则发生抢占;否则,当前作业继续运行。
该算法考虑到了有IO 阻塞 / 唤醒的情况,确保一旦新的短作业或短进程进入系统,能够很快得到处理。
- 抢占式
- 不利于长作业,长作业可能一直处于等待状态,出现饥饿现象
4. 最高响应比优先法(HRRN,Highest Response Ratio Next)
HRN调度策略同时考虑每个作业的等待时间 W 和估计需要的执行时间 T ,从中选出响应比 R = (W+T)/T = 1+W/T 最高的作业投入执行,是介于FCFS和SJF之间的一种折中算法。
- 随着等待时间的增加,长作业也有机会获得调度执行,此时吞吐量将小于SJF算法;
- 由于每次调度前要计算响应比,系统开销也要相应增加。
交互式系统中的调度
分时操作系统是利用分时技术的一种联机的多用户交互式操作系统,每个用户可以通过自己的终端向系统发出各种操作控制命令,完成作业的运行。分时是指把处理机的运行时间分成很短的时间片,按时间片轮流把处理机分配给各联机作业使用。
5. 时间片轮转调度(RR,Round robin)
系统将CPU处理时间划分为若干个时间片(q),进程按照到达先后顺序排列。每次调度选择队首的进程,执行完1个时间片 q 后,计时器发出时钟中断请求,发生抢占,该进程移至队尾,并通过上下文切换执行当前的队首进程;进程可以未使用完一个时间片,就出让CPU(如阻塞)。
RR算法的性能很大程度上取决于时间片的大小:
时间片太小,频繁的进程上下文切换耗费大量资源;
时间片太大,RR退化成FCFS。一般根据经验,80%的CPU执行应小于时间片。
- 响应快,各个进程都能得到及时响应,适用于分时系统;
- 不会导致进程饥饿;
- 抢占式
- 不区分任务的紧急程度;
- 频换切换进程会有一定的开销。
6. 优先级调度(priority-scheduling)
为每个进程关联一个优先级,具有最高优先级的进程会分到CPU;具有相同优先级的进程按照FCFS的顺序调度。
SJF算法是一个简单的优先级算法,其优先级(p)为下次(预测的)CPU执行时间的倒数。
- 优先级算法可以是抢占的或非抢占的(即优先级更高的进程到达时,是否中断正在运行的程序的执行);
- 优先级区分重要、紧急程度,适用于实时操作系统;
- 主要问题是会导致无穷阻塞(indefinite blocking)或饥饿(starvation);
解决低优先级进程的无穷等待的方案之一:老化(aging),即逐渐增加在系统中等待时间很长的进程的优先级。
7. 多级队列调度(multilevel queue)
将就绪队列分成多个单独的队列,根据进程属性(如内存大小、进程优先级、进程类型等),一个进程被分到其中一个队列(不再改变),每个队列有自己的调度算法,并且以最高优先级选择进程。
- 在较高优先级队列都为空之前,低优先级队列中的任何进程都无法执行, 可能导致饥饿;
- 抢占式,如果一个高优先级进程进入就绪队列,那么正在运行的低优先级进程会被抢占,并回到原来队尾。
多级队列调度算法示例:
8. 多级反馈队列调度(multilevel feedback queue)
允许进程在多级队列之间迁移,该算法规则为:
- 设置多级就绪队列,各级队列优先级从高到低,时间片从小到大;
- 新进程到达时先进入第1级队列,按FCFS原则排队等待被分配时间片,若用完时间片进程还未结束,则进程进入下一级队列队尾。如果此时已经是在最下级的队列,则重新放回该队尾;
- 只有第 k 级队列为空时,才会为 k+1 级队头的进程分配时间片。
- 性能优秀:
– 对各类型进程相对公平(FCFS的优点);
– 每个新到达的进程都可以快速响应(RR的优点);
– 短进程只用较少的时间就可完成(SPF的优点);
– 不必实现估计进程的运行时间(避免用户作假);
– 可灵活地调整对各类进程的偏好程度,比如CPU密集型进程、I/O密集型进程。 - 抢占式,如果一个高优先级进程进入就绪队列,那么正在运行的低优先级进程会被抢占,并回到原来队尾。
- 如果进程使用过多的CPU时间,其将会被放到更低的优先级队列;
- I/O密集型和交互进程放在更高优先级队列上;
- 可能产生饥饿,可以通过将在较低优先级队列中等待过长的进程移到更高优先级队列,以阻止饥饿的发生。
多级反馈队列调度示例:
Q1:表示时间周期为8毫秒的 Round Robin 队列;
Q2:表示时间周期为16毫秒的 Round Robin 队列;
Q3:先到先服务队列。
当进程进入Q1 时,它被允许执行,如果它在 8 毫秒内未完成则转移到 Q2 队尾;如果它在16 毫秒内还未完成,它将再次被转移到 Q3 队尾。