进程是现代操作系统中最重要的概念之一, 进程模型是现代操作系统多任务处理的基础.
进程与线程
进程
在进程模型中, 在计算机上运行的每一个程序实例都被抽象为一个进程. 每个进程拥有自己的程序计数器, 寄存器和数据堆栈, 从概念上说拥有一个虚拟的CPU.
实际上, 物理CPU通过更换程序计数器, 寄存器和数据栈中的数据实现进程间的切换, 这种实现多进程的方法称为上下文切换(context switch).
进程会在下面这些情况下创建:
操作系统启动
用户执行了一个系统调用
用户请求创建一个新的用户进程
启动一个新的批处理作业
fork命令是UNIX系列操作系统中的一个重要命令, 用它在当前进程下创建一个子进程作为当前进程的副本.若成功则父进程返回子进程pid, 子进程返回0.
这样, UNIX操作系统中进程之间形成了层次结构, UNIX操作系统开机加载内核后, 内核便会启动init进程, 由init进程完成剩下的开机过程, 包括运行级别, 加载服务, 引导Shell/GUI等.
init进程是UNIX操作启动后创建的第一个进程, pid始终为1. 其它所有进程均由init进程创建, 形成了树状结构.
每个进程拥有三种状态:
运行: 实际占用CPU
就绪: 运行条件已具备,但CPU被其它进程占据
阻塞: 除CPU外的某些运行条件不具备(主要是IO未就绪), 无法运行
进程根据内核的调度在运行和就绪之间相互转换, 随着外部条件的变化进程可能解除或者进入阻塞.
为了实现多进程模型, 操作系统会维护一个称为进程控制块(Processing Control Block, PCB)的数据表.
PCB中保存的信息包括:
进程管理 | 存储管理 | 文件管理 |
---|---|---|
进程id | 正文段指针 | 根目录 |
寄存器 | 数据段指针 | 工作目录 |
程序计数器 | 堆栈段指针 | 文件描述符 |
程序状态字 | 用户和组 | |
进程状态 | ||
父进程 | ||
进程组 | ||
信号 |
为控制篇幅写作表格形式, 表格中的行没有意义
线程
线程是一种轻量级的进程, 传统的进程模型中每个进程拥有一个控制流和一段地址空间.多线程模型则允许一个进程拥有多个控制流,它们共享同一段地址空间, 这里的控制流就是线程.
多个线程共享同一个进程的资源,意味着线程之间可以通过共享内存进行通讯, 并且共享文件,IO设备等资源.
当然多个线程并发访问资源也产生很多问题, 不过这些问题由应用程序开发者来解决, 不属于操作系统控制.
下表中第一列给出了同一进程下不同线程共享和私有的数据:
共享 | 私有 |
---|---|
地址空间 | 程序计数器 |
打开文件 | 寄存器 |
子进程 | 堆栈指针 |
工作目录 | 程序状态字 |
线程的创建和切换都比进程快得多,使得多线程协作效率更高.
共享资源和调度迅速的特点使得多线程模型广泛应用, Master-Worker结构即是使用Master线程监听事件然后创建Worker线程处理, Master与Worker之间的交替执行使得程序即可以在处理事件的同时保持对新事件的响应.
这种架构在Web服务器等软件中得到了广泛应用.
线程上下文切换同样是更换程序计数器,寄存器和段指针的数据,
多线程可以在用户模式或内核模式下实现.
在用户模式中实现多线程, 系统内核不关心进程内部线程包的情况, 仍按照单线程进程的方式进行调度. 这一方法可以在不支持多线程的操作系统上实现多线程.
在高级编程语言实现的软件中, 进程内部的线程调度通常由运行时环境提供.
在内核模式下创建线程可以使用内核的调度系统, 通常使用系统调用创建或销毁一个内核线程.
切换内核线程要完成上下文切换,修改内存映像, 重新写入高速缓存, 而JVM这类运行环境切换用户线程的时候只需要执行跳转等几条指令即可.
进程调度
进程调度是操作系统内核最重要的功能之一, 进程调度的目的是保证CPU利用率最大化, 并尽可能减少进程的等待和响应时间.
在不同系统中调度算法的优化目标也是不同的, 常见的情景包括:
交互式系统
批处理
实时
进程调度包括:
内核调度程序获得CPU
切换上下文
切换到用户态
继续执行进程
甘特图(Gantt Chart)用于描述进程占用CPU的情况:
或者:
在抢占式调度中, 后来的高优先级的进程会从正在执行的进程中抢占CPU, 迫使低优先级进程提前回到等待状态.
进程调度算法同样会让未执行完毕的进程进入等待, 但是在抢占式调度中新来的进程会改变原有的调度计划.
批处理系统
在交互式系统中通常存在一些进程使用事件循环来监听用户操作,比如bash, web服务器等. 因为内部包含无限循环, 它们运行时间极长.而批处理操作系统中的进程IO等待时间和执行事件都很短.
因为这些事件循环的存在, 批处理操作系统和交互式操作系统的进程调度策略存在很大不同.
先来先服务
先来先服务(First-Come First-Served, FCFS)是指先创建的进程先执行, 在其执行完毕后释放CPU服务下一个进程.
Process | Burst Time |
---|---|
P1 | 24 |
P2 | 3 |
P3 | 3 |
这种调度策略可能导致跟在长进程后的短进程等待时间过长的问题, 不幸的是在实际应用中, 短进程的比例通常高于长进程.
最短任务优先
最短任务优先(Shortest Job First)调度分为抢占式调度和非抢占式调度两种.
抢占式调度后到的短进程会迫使正在执行的长进程放弃CPU, 而非抢占式调度则会等待当前进程执行完毕后再执行.
非抢占式调度示例:
Process | Arrival Time | Burst |
---|---|---|
P1 | 0 | 7 |
P2 | 2 | 4 |
P3 | 4 | 1 |
P4 | 5 | 4 |
抢占式调度示例:
最短任务优先调度的问题在于长进程可能等待时间过长, 造成饥饿问题.
交互式系统调度
交互式系统调度必须考虑到各种事件循环.
时间片轮转
时间片轮转(Round Robin, RR)调度将CPU时间分为较短的时间片, 进程每次执行一个时间片然后执行其它进程:
Process | Burst Time |
---|---|
P1 | 53 |
P2 | 17 |
P3 | 68 |
P4 | 24 |
等待队列按照进程创建的顺序排列.
优先级调度
上文的时间片轮转中, 进程按照创建时间排队. 而优先级调度允许后来创建的高优先级进程插队.
多级队列
为CPU密集型的进程分配较长的时间片减少切换次数可以提高效率,但是会造成IO密集进程饥饿的问题.
多级队列是UNIX等操作系统中实际采用的一种调度方案, 就绪的进程按照优先级不同进入不同的队列等待.
优先级最高的队列时间片最短, 优先级低的队列时间片长.进程刚创建时进入最高级队列, 若该进程一个时间片内没有执行完毕, 则降低其优先级进入低级队列等待.
优先级最低的队列内部可以采用非抢占式调度, 直到该进程完成再执行队列中的下一个进程, 此时高优先级进程仍然可以抢占CPU.
在占用CPU时间较少的IO密集进程数量较少时低优先级的CPU密集进程才有机会去执行. 这种方法保证了IO密集型进程的快速响应, 同时CPU密集型进程得以在CPU空闲时长时间执行.
示例:
进程间通信IPC
进程间通信(Inter Processing Communication, IPC), bash中的管道是进程间通信一个常见的应用:
$ls | grep .*\.md进程与线程.md
共享内存是进程间通信最常用的方法, 此外也可以采用较慢的共享文件作为缓冲区.进程之间也可以通过socket进行通信, 比如Web服务器和数据库服务器之间经常采用TCP Socket连接.
进程的交替执行导致使用共享资源时会产生一系列并发问题, 下面对进程的讨论同样适合线程.因为应用开发者需要自行管理线程对资源的访问, 应用开发者同样需要考虑IPC问题.
进程A删除了一个文件但在消息写入共享区域前时间片已用完, 进程B读取共享信息之后认为给文件仍然存在, 于是继续读取而产生错误.这种隐患称为竞争条件(race condition).
另外一个需要解决的问题时, 进程B需要在进程A执行完某一步骤后才能继续执行.这个问题经典示例称为生产者-消费者问题(producer-cosumer)又称作有界缓冲区问题(bounded buffer).
两个进程共享一块大小有限的缓冲区, 生产者进程向其中添加数据, 消费者从中取出数据.这样可能产生缓冲区已满而生产者仍然想添加数据,或者缓冲区已空而消费者仍然想取数据的情况.
信号量
信号量(semaphore)是一种解决共享内存问题的方法, 信号量是一个非负整数.
信号量定义了两个原子性的操作:
up: 首先检查信号量, 若其未达到上限则令信号量加1, 否则当前进程阻塞, 直至信号量低于下限时才会结束阻塞.
down: 首先检查信号量, 若其未达到0则令信号量减1, 否则当前进程阻塞, 直至信号量高于0时才会结束阻塞.
原子性操作是指不可分割的操作, 该操作仅有已执行和未开始两种状态, 不会出现竞争条件中执行被中断的情况.具体来讲操作系统保证,在操作执行过程中即使进程因为时间片耗尽进入等待, 任何其它进程也无法访问信号量.
下面的示例给出了一个用信号量解决生产者消费者问题的经典实现:
#define N 100typedef int sema;sema mutex=1; // unlocksema vacant=N;sema full=0;void producer() { int item; while(1) { item = produce(); down(&vacant); down(&mutex); insert(item); up(&mutex); up(&full); }}void consumer() { int item; while(1) { down(&full); down(&mutex); item = fetch(); // get and remove item from buffer consume(item); up(&mutex); up(&empty); }}
示例中mutex是一种特殊的信号量, 它的取值只有0和1用于控制进程对资源的访问,这种信号量又称为互斥量(mutex).
管程
直接操作信号量在实际开发中非常不便, 管程(monitor)是对共享变量及其操作的封装.管程是一个相对独立的模块,其中的数据对其它进程是不透明的, 进程只能管程定义的操作进行并发控制.
管程拥有等待队列和优先级更高的紧急队列, 它们用于控制进程调用管程的顺序.
管程定义几种行为:
enter(): 进程进入管程的等待队列
leave(): 进程退出管程, 若紧急队列不空, 该操作将唤醒紧急队列中的一个进程.
wait(c): c代表某种资源, 若资源可用则进程获得资源, 否则进程阻塞进入紧急队列等待
signal(c): 管程中某进程占有的资源将被释放, 紧急队列中一个等待该资源的进程将被唤醒
我们可以用管程来解决生产者消费者问题:
const int n = 100int monitor::vacant=n;int monitor::reserve=0;void monitor::add(int item) { if (this->count==n) { wait(this->vacant); // repo full, waiting for vacant } this->buf.add(item); up(this->reserve); down(this->vacant); signal(this->reserve); // wake consumer}void monitor::fetch() { if (this->count == 0) { wait(this->reserve); // repo empty, waiting for item } item = this->buf.pop(); up(this->vacant); down(this->reserve); signal(this->vacant); // wake producer}void provider() { int item; while(1) { item = produce(); monitor.enter(); monitor.add(item); monitor.leave(); }}void consumer() { int item; while(1) { monitor.enter(); item = monior.fetch(); consume(item); monitor.leave(); }}
协程
不管是运行时环境还是操作系统提供线程调度, 生产者和消费者执行的顺序都是不可知的.协程可以控制生产者和消费者执行的顺序, 从而实现可控的协作.
yield将一个值返回给调用者, 然后下次调用时从yield下面的一条语句继续执行, 整个过程中进程中的数据不变.
下面使用协程来结局生产者消费者问题:
void producer() { int item; while(1) { item = produce(); yield item; }}void consumer() { int item; item = producer(); consume(item);}
这个模型没有存储区, 当consumer需要item时便会通知producer进行生产.
死锁
本节将讨论进程资源分配中死锁问题的处理.举例来说,进程A和进程B均试图打印同一个文件, 进程A得到了打印机但没得到文件而阻塞, 进程B得到了文件却没有得到打印机而阻塞, 这样的循环等待使得两个进程陷入无法退出的阻塞状态.
我们用圆形表示进程, 用矩形表示资源, 从资源开始的有向边表示资源被分配给了进程, 从进程开始的有向边表示进程正在等待资源.
资源分为可抢占资源和不可抢占资源. 不可抢占资源以打印机为例, 任务完成前即使进程进入等待状态也不可分配给其它进程.内存空间是典型的可抢占资源, 一个进程的内存可以通过迁移等方法分配给另一个进程.引起死锁问题的通常是不可抢占资源.
死锁发生有四个必要条件:
互斥条件: 资源最多分配给一个进程
占有等待条件: 占有一个资源的进程可以等待其它资源
不可抢占条件: 资源是不可抢占的
环路等待条件: 存在两个以上进程组成的环路, 其中的进程都在等待其它进程持有的资源
死锁的发生必须同时满足上述四个条件, 破坏或避免上述条件可以解除或避免死锁.
死锁的检测与恢复
死锁检测算法如下:
遍历所有进程, 寻找所需资源可以被当前可用资源满足的进程.
若找到进程p, 将其占用的资源添加到可用资源表中, 并将其标记为可完成进程.
算法寻找请求的资源均可被闲置资源满足的进程, 这些进程一定可以完成并释放资源.
将可以完成的进程占用的资源添加到可用资源表中, 并标记该进程为可完成的.
当算法执行完成后, 未被标记的进程一定是陷入死锁状态的进程.
破坏产生死锁的某一个条件即可解除死锁, 主要是破坏等待环路.
周期性的创建检查点, 在发现死锁后回滚到一个较早的状态解除死锁.
杀死进程迫使其释放资源是也可以用于解除死锁.
避免死锁
死锁在大多数系统上发生的并不频繁, 而检测和避免死锁却需要很大开销.
很多操作系统,特别是桌面操作系统采用鸵鸟策略, 即无视死锁问题.
从理论上说彻底解决死锁问题是不可能的, 因为操作系统不可能预知所有资源请求, 但是可以减少死锁发生的概率.
破坏死锁发生的某个必要条件可以有效阻止死锁发生.
破坏互斥条件
允许多个进程操作打印机可能出现死锁问题, 我们可以创建一个打印机的守护进程.
只有守护进程可以访问打印机,其它进程进行打印操作只能通过与打印守护进程通信来完成.
这种资源虚拟化的解决方式也可以认为是破坏了不可抢占条件.
破坏等待环路
我们通过一个图来说明危险状态的概念, 横轴表示进程A请求的资源, 纵轴表示进程B请求的资源, 平面上一个点表示某时刻资源请求的状态.
在单核系统中虚线只能水平或竖直移动, 阴影部分表示两个进程请求同一个资源, 右倾斜线表示同时请求打印机, 左倾斜线表示同时请求绘图仪.
当虚线进入阴影部分时可能会出现死锁, 因此我们将其称为危险区. 在危险区时系统必须谨慎分配资源.