前言
本文旨在阐明进程与线程,包括它们是什么、有什么区别、面临什么问题。
为什么要有进程
对于计算机的使用者来说,会通过运行的程序来完成种种任务。而“程序,是为了实现特定目标或解决特定问题而用计算机语言编写的命令序列的集合。” 用大白话来说,程序就是告诉计算机怎样一步步地完成我们交给它的任务。
既然程序要运行,要完成某种任务,那么怎样完成,需要什么资源进行协助,这是必然要面对的问题。进而在此之上抽象出了“进程“的概念。“进程是资源分配的最小单位”这个常用的概念能贴切地说明一些情况。试想一下,如果各个要运行的程序,对所涉及的资源没有被管理、隔离、保护,势必会出乱子。联想到个人的资产,如果可以被他人随意地获取、使用,也就会带来各式各样的问题。而资源,就是程序的“资产”。
进程的出现,不仅仅解决了资源分配的为题,还解决了另一个重要问题,并发———使CPU资源能够充分地被利用。
程序的运行离不开CPU的参与,进程作为程序的抽象自然也需要。因而引出一个概念,“每个进程都有自己的虚拟CPU”。是的,进程都认为CPU就应该是自己的,都想尽可能地运行足够多的时间。但是CPU只有一个或者若干个,进程可以有更多个,因此不管是单CPU还是多CPU,面对的都是“如何将特有资源分配给更多需求者使用”的问题。此后文章就取单核CPU的情况来看,足以说明问题,也能简化问题。
CPU由谁来使用,就成了必须面对的问题,由此引出了“进程间如何进行调度”的问题,所以进程也是进行调度的独立单位。CPU被视为一种资源,按照某种规则,让不同的进程可以在特定的时机占用。有时候,不同的进程间还可以拥有其他的共同资源,就像人可以拥有共同资产一样。既然涉及到了共同资源,那么各方希望就会对这份资源的变动保持敏感,希望能得知这份共同资源的真实变动信息。因此引出了另一个问题,“进程间的通信”问题,即资源或数据的变动,在各种情况下保持正确性。
针对这两个问题,下文会针对性地做梳理,这里不妨先了解进程是什么样子。
进程模型
“小明,18岁,185cm,在球场上打球”,与人一样,进程有自己的样子与正在做的事情,操作系统维护着一份表格来描述这份信息,这张表格即为进程表。每个进程占用一个进程表,表里有各种字段来表示不同的信息。大致分为三类信息,进程管理、存储管理、文件管理。其中:
进程管理包括:寄存器、程序计数器、程序状态字、堆栈指针、进程状态、优先级、调度参数、进程ID、父进程、进程组、信息、进程开始时间、使用CPU的时间、子进程的CPU时间、下次定时器时间等。
存储管理包括:正文段指针、数据段指针、堆栈段指针。
文件管理包括:根目录、工作目录、文件描述符、用户ID、组ID。
与此同时,每个进程还拥有自己的内存地址空间以可以对各类数据进行操作,一般而言,进程之间是不可以访问对方的内存地址空间的。这就像人的房子,其他人不能随便的访问,往自己的房子里增添物件也只是个人的事。
有了这些信息,进程就有了轮廓,操作系统才可能正确地调度运行这些进程,完成并发。
并发,即使指在一段时间内,有若干事情在进行,但在任一时刻,仅有一件事情在进行。区别于并行,并行则为某一时刻,可以有若干事情在进行。如图所示:
在多CPU的情况下,能够实现一定范围的内并行,但更多时候,以并发来看进程的执行更加合适。并发所解决的问题无疑是必要的。日常工作中,挂着音乐听着歌,开着微信聊着天,后台还运行着邮箱等着邮件的到来,这些运行的程序是各式各样进程在进行并发。试想一下,如果没有并发,使用某个程序的时候其他的程序就不能被使用,会发生什么。聊天的时候不能听歌,听歌的时候不能工作,等邮箱的时候就只能干巴巴地等着。效率低下,又不愉快。实际上,很多进程更多的时候是在等待一些条件如I/O等才能继续进行下一步,真正需要执行的地方需要时间很短,如果在等待的时间还要占用CPU,则白白浪费了CPU资源。就像一个邮件程序,可以几十秒甚至几分钟去检查一下有没有新的邮件即可,相比于等待邮件到来的时间,“检查”这一操作需要的时间非常短,也因此只要在特定的规律时间内让它占用一下CPU即可。当有很多类似的进程时,并发就能让更多的进程在一段时间内做更多的事情。
并发释放了CPU的劳动力,让更多进程能参与使用,提高了CPU的利用率。CPU利用率 = 1 - p^n, 其中p为进程等待条件的时间/使用CPU的时间,n为进程数,此公式可仅做了解。
进程状态
进程要占用CPU的,自身要达到可以运行的状态才能参与执行。进程的状态为运行、阻塞、就绪,在每一次并发时,也就是进程调度时,被选中的进程只有处于就绪时才能被运行。进程状态的转换如下图:
- 进程因为等待其他条件到来时,而从运行转换阻塞
- 进程调度选择了另外一个进程时,从运行转到就绪
- 进程调度选择了这个进程时,从就绪转到了运行
- 等待的条件满足了,从阻塞转换到就绪
运行状态与就绪状态实际是差不多的,区别在于,有没有占用CPU。
进程的创建、终止、层次结构
进程的创建与销毁也可简单了解,其中,四种主要事件会导致进程的创建:
- 系统初始化
- 正在运行的程序执行了创建进程的系统调用
- 用户请求创建一个新进程
- 一个批处理作业的初始化
终止条件包括:
- 正常退出(自愿)
- 出错退出(自愿)
- 严重错误(非自愿)
- 被其他进程杀死(非资源)
终止条件出错退出与严重错误退出的区别在于,前者是进程发现某些条件不满足,无法进行下去,因而决定终止运行;后者则是发生了一些预期外的错误,比如零除、非法访问内存、空指针等。
进程之间是会有层次结构的,而不同的系统则有不同的层级关系。进程可以通过系统调用创建另一个进程,新创建出来的进程和创建它的进程有着相同的内容,也称副本,只是内存地址空间不同,之后的改动也是在自己的内存地址空间中发生效用。
对于UNIX中,这种层次关系以父进程和子进程的关系保持联系,一个父进程可以有多个子进程,一个子进程只有一个父进程。也就是说,以init进程为根结点构造了一颗进程树。
情况在Windows中不同,其间没有严格的父子关系,进程的地位是平等的。可以通过把进程的句柄交给另一个进程,以到达控制这个进程的目的。(句柄可以理解为一个人的电话号码,通过这个电话号码可以找到这个人)
当把程序抽象出了进程模型之后,程序也就有了表象,就能按照严格的规则对进程进行管理,分配资源。
为什么要有线程
有了进程之后,程序已能如期运行,那为什么何还要有线程呢。试想一下,当需要使用共同的资源,并通过若干个可同时进行的事件共同达成某个任务时,如果使用进程间进行合作,会很复杂与困难,因为进程的设计,希望资源是自己的,别的进程不能轻易访问。
线程的出现,使这样的问题迎刃而解。就和写代码一样,完成了解耦,也就是将程序的资源管理与执行隔离开来,线程就是负责执行的。所以,“线程是调度和分配的最小单位”。如同大家处于同一个小组,小组如同进程,大家拥有共同的资源,当一起执行某个任务时,A负责冲锋陷阵,B负责后勤... 这即是将执行分开的由来。
线程除了访问共同资源简单,分离了执行之外,另一个有点是更轻量级。通过某些手段,进程间也能在使用共同资源的情况下合作某个任务,但是进程的创建、切换更消耗时间。在许多系统中,可达到10 ~ 100倍。无论进程、线程,他们的运行都需要CPU的参与,当进程间需要相互切换时,将发生系统调用。
系统调用为发生了从 用户态 -> 内核态 -> 用户态 的过程,我们的程序运行在用户态,但有些时候需要借助系统提供的服务才能进行某项事务,就要通过系统调用从用户态进入内核态,继而进行这项事务。完成之后,再从内核态回到用户态,继续运行我们的程序。从用户态到内核态,需要暂停当前用户态的执行,然后存储上下文,对于用户态发来的系统调用,内核态是不信任的,因此需要做各项的检查。内核态执行完之后,再回复之前存储的上下文,回到用户态继续执行。
对于进程间的切换来说,最重要的耗时部分,就在于切换上下文。进程模型抽象出了进程的虚拟地址空间,进程间的虚拟地址空间的切换,会发生页表查找,而页表查找,是一个很慢的过程。
线程间的切换则轻量得多(指同一进程内的)。其中的区别就像,你在自己的房子里,在客厅看电视,然后去厨房倒了一杯水, 与从你家,走到你的朋友家讨杯水喝的区别。
线程模型
与进程类似,线程也抽象出了模型,也拥有对应的数据结构来描述它们是什么样子。有如程序计数器、寄存器、堆栈、状态等。相比于进程,线程所要维护的信息更少,他们有着完全一样的地址空间,共享着同样的资源。线程之间是没有保护的,即不可能也没必要,他们知道彼此的关系是合作,不同于进程之间可能有敌意。
线程借由线程表来进行管理,可分别在用户空间、内核空间进行实现。
对于用户进程来说:
- 由进程来管理线程表
- 线程切换非常快,不需要陷入内核,几条指令就可以完成线程切换
- 进程可以实现定制的调度算法
- 扩展性更好,线程需要一些固定的表格空间和堆栈空间,当线程数量很多事,用户线程面对的问题没有内核线程那么尖锐
- 内在不支持多线程的操作系统上实现,因为内核是不知道用户线程的存在的
而用户线程要面对的问题有
- 要允许每个线程使用阻塞调用,但需避免影响到其他的进程,如果以系统调用的方式进行阻塞,则削弱了用户线程的设计初衷,系统调用往往更慢
- 对于CPU的分配使用更困难,用户级线程在一个单独的进程内部,没有时钟中断,调度程序无法调度,除非一个线程让出CPU,否则其他线程获取不到执行机会。
对于内核线程来说:
- 由内核来维护线程表
- 在内核中创建线程开销更大,需要会缓存回收利用缓解
- 内核线程的阻塞调用都是由系统调用来实现,代价更大
- CPU分配更简单,当以线程发生阻塞调用是,调度程序可以寻找同一个进程中的线程运行
而内核线程要面对的问题有:
- 当一个进程创建子进程时,它该拥有多少个线程,是否与父进程一样,这需要根据不同的情况做更细的处理
- 当进程受到一个信号时,由哪一个线程来处理?如果很多进程都对这个信号感兴趣,将会发生什么?
用户线程与内核线程各有利弊,当然也有将两者结合起来的模型,但实现起来也就更困难了。
进程和线程引入了什么问题
前文有提到过,进程需要CPU的参与以及访问公共资源,对于进程来说的共享资源比如某些公用的文件,希望使用的I/0设备如打印机等。这就涉及到了两个重要的问题需要处理:
- CPU的分配,也就是如何进行调度,即某一时间,由谁来使用CPU
- 通信问题,当使用共享资源时,需要知道它的真实状态,如果按照资源的过时信息进行使用,将使程序进入异常状态
线程与进程面对的问题,是相似的。
如何解决IPC(进程间通信)问题
对于为何要解决通信问题,一个容易理解的例子是: 大家同处于一栋楼里工作或者学习,WC对于大家来说就是公共资源,每一间在任意时刻都有它的状态,使用中或未使用。试想一下,如果使用几分钟前或者更久之前的信息 —— “此间未使用” 作为当前要使用它的行动决断,破门而入,或者当一个人占用时,没有对外放出“使用中”这一信息,是不是很有可能出现令人尴尬的一幕。这也就是依据过时信息使用公共资源,将使程序进入异常状态的侧照。
例子中的“每一间”,指的是竞争条件,在程序中,是指协作的进程程共享的,彼此都能读写的公共区域,当多个进程使用这些共享数据时,最后的结果,取决于进程运行的精准时序。也就是,当竞争条件发生时,如果有一方使用了过时的信息,那么就很可能出现例子中令人尴尬的一幕。
如何避免竞争条件呢,这就需要了解什么是临界区:
它的定义为:把对共享内存进行访问的程序片段称为临界区。如途中,圆形为程序片段,方形为共享内存,圆形进入方形,就是进入了临界区,当某一进程进入了临界区时,其他进程不应该进入临界区。因此,一个好避免条件竞争的解决方案,要满足以下条件:
- 任何两个进程不能同时处于临界区
- 不对CPU的速度和数量做任何假设
- 临界区外运行的进程不能阻塞其他进程
- 不得使进程无期限等待进入临界区
要对临界区的访问达到,达到互斥,也就有了各种可考虑的方案
忙等待的互斥
屏蔽中断
在单处理器系统中,最简单的方法就是,当一个进程进入临界区后,立即屏蔽所有中断,并在要离开之前,再打开中断。CPU只有在发生时钟中断或其他中断时才会进行进程切换。因此,可以保证不会发生竞争条件。
但是这个方案并不好:
- 如果进入临界区的进程不再打开进程,那整个系统可能因此终止
- 屏蔽中断只对执行指令的那个CPU有效,如果是多核系统,无法阻止使用其他CPU的进程继续进入临界区
锁变量
寻找一种软件解决方案。假设有一个共享锁变量,初始值为0,当一个进程进入临界区前,先测试这把锁的指,为0则进入,其他值则等待。
但是这不起作用,因为这个共享锁本身就是竞争条件,对锁的测试代码片段本身就是进入临界区
严格轮转法
while(true){
// 检查 turn 的值
while(turn != 预期值){}
// 进入临界区
enter_region();
// 更新 turn 的指
updateTurn();
}
严格轮转法也是忙等待,也称自旋锁。进程在进入临界区前,会检查锁的值是否符合预期值(例子伪代码为turn),满足则进入临界区,否则重复检查锁值。
此方法有两个问题:
- 浪费CPU的时间
- 如果进程间,一些进程比其他进程慢了很多的情况下(指进入临界区的时间),这些进程很容易等待更久
因此,此方案要求进程严格地轮流进入临界区,并有理由认为等待时间很短,不同进程进入临界区执行时间不会差太多事时,才会考虑使用。
Peterson 解法
#define FALSE 0
#define TRUE 1
#define N 2 //进程数量
int turn; // 现在到谁想进入临界区
int interested[N]; // 所有初始值为0
void enter_region(int process){
int other; // 另一个进程号
other = 1 - process;
interested[process] = TRUE; // 表示想进入临界区
turn = process;
while(turn == process && interested[other] == TRUE){}
}
void leave_region(int process){
interested[process] == FALSE; // 表示离开临界区
}
以上为荷兰数学界T.Dekker提出的解法。 当两个进程同时调用enter_region()想进入临界区是,turn的指为最后执行的进程号,因此,最后执行的进程不断执行 while(), 直到另一个进程离开临界区。
TSL 指令
这是需要硬件支持的一种方法,在一些计算机中,有如下指令:
TSL RX,LOCK
称为测试并加锁:将一个内存字lock读到寄存器RX中,然后在这个内存地址上存一个非零值,保证读写这个内存字lock时,其他处理器不允许访问此内存字。也就是说,执行TSL指令的CPU将锁住内存总线,以禁止其他CPU在指令结束之前访问内存。这也是屏蔽中断不能达到的效果。
enter_region:
TSL REGISTER,LOCK | 复制锁到寄存器并将锁设置为1
CMP REGISTER,#0 | 锁是零?
JNE enter_region | 若不是零,说明锁已经被设置,回到起点执行
RET | 返回给调用者,进入了临界区
leave_region:
MOVE LOCK,#0 | 在锁中存入0
RET | 返回调用者
假设存在如上4条指令,想进入临界区的进程,使用TSL读取锁值到寄存器,CMP检查寄存器的值,如果寄存器的值非零,说明已经被上锁,此时重新回到TSL执行,直到其他进程释放了锁,然后进程可以进入临界区。释放锁只需将0存入LOCK即可,不需要特殊的同步指令。
忙等待小结
基于忙等待的解决方式,本质是,进入临界区前,检查是否允许进入,不允许则原地等待,直到允许为止。缺点除了浪费CPU时间,还要求会进入临界区的进程,大抵上是相同的,执行时间相差不多,且进程的优先级最好相同。
试想一下,如果有A和B两个进程,A的优先级大于B,如果调度规则规定,A处于就绪状态时就可以运行。某一时刻,B进入了临界区,此时A就绪了,A想进入临界区时,只能无限忙等待,因为B不会获得执行机会,无法从临界区离开。这个情况也被称为优先级反转问题
除了忙等待,可以考虑的另一种方案就是,在进程无法进入临界区时,让他们阻塞。
信号量
信号量是指一种特殊的整形标量,用来累计唤醒次数。需要保证的是,对于信息量的操作是原子性操作,一个进程在对信号量进行操作时,其他进程不允许对此信号量进行操作。
原子性操作是指,一组相关联的操作要么都不间断地执行,要么都不执行。
信号量建立两种操作,down和up,其中down检查信号量的值,大于0则减一,然后继续执行,如果为0,进程将睡眠。up操作则信号量值加1,up操作之后,系统将选择之前某一个无法执行down操作的进程,允许它完成down操作。表达过程如图:
可看信号量是如何解决生产者-消费者问题的。生产者-消费者问题是指,两个进程共享一个公共的固定大小的缓冲区,其中一个生产者,把消息放入缓冲区,另一个是消费者,从缓冲区中取消息。
#define N 100 // 缓冲区容量
semaphore mutex; // 控制访问区的信号量
semaphore empty = N; // 缓冲区中剩余的空间
semaphore fill = 0; // 缓冲区中当前的数据数
void producer(){
while(true){
down(&empty); // 剩余空间减一
down(&mutex); // 进入临界区
insert_item(); // 将新数据放入缓冲区
up(&mutex); // 离开临界区
up(&up); // 当前数据数加1
}
}
void consumer(){
while(true){
down(&up); // 当前数据减一
down(&mutex); // 进入临界区
remove_item(); // 从缓冲区中取出数据然后处理
up(&mutex); // 离开临界区
up(&empty); // 缓冲区剩余空间加1
}
}
像 mutex 这样的,提供给多个进程使用的信号量,也称为二元信号量。信号量 mutex 用来控制进入临界区,用法在于 互斥 。而像信号量 full 和 empty, 则用来保证某种事件(例子中为产生新数据、处理数据)的顺序发生或者不发生,这种用法为 同步
更好理解信号量的一个例子是,进入临界区,意味着大家就要拿到挂在墙上的钥匙,然后进入房间。看到没有钥匙时,就知道不能进入房间。而从房间出来的人,再把钥匙挂到墙上,其他的人就有机会拿到钥匙进入房间。
互斥量
如果不需要信号量的计数能力,可以使用更简化的版本,互斥量,在信号量小节中,信号量 mutex 使用互斥量更为合适。互斥量体现的是,任何时刻下,只能有一者进行访问。
互斥量只能有两种状态之一:解锁和加锁。因此,理论上,使用一个二进制位就可以表示它。互斥量的实现非常简单,可以很容易地在用户空间中实现,比如可以在有 TSL 或 XCHG 指令的情况下实现。
mutex_lock:
TSL REGISTER, MUTEX | 将互斥量复制到寄存器,并置互斥量为1
CMP REGISTER,#0 | 寄存器的值是0吗
JZE ok | 为0,说明拿到锁,跳转到 ok
CALL thread_yield | 为1,调度另一个线程
JMP mutex_lock | 稍后再重新试
ok: RET | 返回调用者,进入临界区
mutex_unlock:
MOVE MUTEX, #0 | 设置互斥量为0,即解锁
RET | 返回调用者
需要注意的是,和XCHG的不同在于,XCHG是通过忙等待不断重试知道获得机会,而互斥量是让出了执行机会,等下次获得执行机会时,再尝试获取锁。
对于前面信号量解决生产者-消费者问题的例子,使用互斥量替换信号量 mutex 即可。与此例子相似,大部分情况下,互斥量和信号量常常共同使用。
管程
有了信号量和互斥量的配合,能达到进程间通信的目的。但是,这远远不够。信号量和互斥量,把对并发的控制交给了程序的编写者,这要求编写程序的程序员需要有足够的熟练度和嗅觉能察觉到其中可能出现的问题。
在上面的生产者-消费者的例子中,如果将生产者的down操作互换,就会出现严重的问题,死锁。
void producer(){
while(true){
down(&mutex); // 进入临界区
down(&empty); // 剩余空间减一
insert_item(); // 将新数据放入缓冲区
up(&mutex); // 离开临界区
up(&up); // 当前数据数加1
}
}
void consumer(){
while(true){
down(&up); // 当前数据减一
down(&mutex); // 进入临界区
remove_item(); // 从缓冲区中取出数据然后处理
up(&mutex); // 离开临界区
up(&empty); // 缓冲区剩余空间加1
}
}
某一时刻,缓存满之后,生产者进入了临界区,此时mutex为0,想要再往缓存加入数据时,需要等待信号量empty。当消费者想要逍遥缓存区数据时,需要先进入临界区,也就是需要等待信号量 mutex,可此时生产者处于临界区中,无法释放 mutex。此时,生产者、消费者只能永远阻塞。这样的情况,也就是死锁。
为了避免充斥于程序之中的这种情况,也为了更易于编写正确的程序,引入了管程,一种原子性更高的高级同步原语。对于管程来说:
- 任意时刻中,只能有一个活跃进程
- 由过程、变量及数据结构组成的一个合集,他们组成一个特殊的模块,或者软件包
- 进程能在任何需要的时候调用管程,但不能在管程之外的过程,直接访问管程内的数据结构
管程引入了条件变量,以及相关的操作 wait 和 signal,当管程发现无法再运行下去时,即一个条件变量不满足时,对这个条件变量调用 wait,然后阻塞当前调用的进程,直到有某个进程调用了这个条件变量的signal之后,系统调度再从所有因调用 wait 而阻塞的进程挑选一个恢复执行。
可参考的管程解决生产者-消费者问题的解法框架是
monitor
condition full, empty; //condition 为条件变量
int count;
producer insert()
begin
if count = N then wait(full)
insert_item()
count += count+1
if count 1 then signal(empty)
end
consumer remove()
begin
if count = 0 then wait(empty)
remove = remove_item()
count = count - 1
if count = N - 1 then signal(full)
end
end monitor
与信号量解法的不同之处在于,在信号量中,多个进程能同时运行到临界区前,然后进行信号量测试,测试不通过则进行阻塞。而在管程的解法中,在 monitor 的范围内,同时只有一个进程能运行到,当这个进程因为条件变量无法再进行下去时,他会被管程挂起,然后,管程让另一个进程进入。当条件变量满足,并且发出signal的进程退出管程后,管程再从被挂起的进程中选择某一个进行恢复。
因此,管程中最重要的一点是,保证管程中的运行过程,有且只有一个进程处于运行状态。
一个可以用于理解的例子是:在之前的例子中,每一个想要进入的人,都会去看挂在墙上的钥匙,拿到了钥匙的人,就可以进入。这里隐含的条件是,每个人需要正确地遵守这个规则,但凡有一个人不遵守这个规则,就会造成无能为力。而管程的例子是,取消了钥匙,请来了一个管理人,所有想进入的人,都要经过这个管理人的同意。如果进去的人,发现少了需要的东西,那么管理人就让这个人到一边等着,先让别的人进去,等到有了之后,再挑出某个需要这些东西的人。过程如图
管程的问题在于,他是一个编程语言概念,因此并不是所有的编程语言都支持,并且,需要编译器能正确地识别管程以及完成管程的原语。如对于Java,编辑器就要识别 synchronized 关键字。当然,管程提供了更高级的原子语句覆盖到了更多的执行过程,对一些执行效率苛刻的过程,要小心这些过程能否以及是否被管程覆盖。
屏障
屏障是为了一组合作式的任务准备的,即把一个任务拆分成几个阶段,每个阶段能分别执行,但只有每个阶段都完成了,才技能继续进行下一步操作。屏障相比于之前的同步方式,理解要容易得多:
消息传递
消息传递要解决的分布式系统上的通信问题,他们之间的通信需要通过网络链接,在这样的情况下,上面的方案提供的原语将失效。而在实现出的方案中,更像是信号量。在消息传递中,更多的要解决的问题,是使原语奏效,这里不做过多深入:
- 可以提供 send() 和 receive() 的对于消息的操作
- 消息可能因网络而消失,因此双方要有特殊的确认消息
- 有确认消息的要求,就要考虑如何识别新老消息,此情况是因为没有确认对方有收到,而进行的消息重试而导致
- 要解决身份认证问题,即如何识别一个进程是预期内的进程而不是冒充者
RCU(读-复制-更新)
在一些程序上,对于共享资源,更多的是读取而非修改或极少的修改。在这种类型的程序中,无论上面的哪种锁机制,都会大大降低程序本能达到的效率。
RCU的机制为,任意时刻允许读取共享资源,发生写操作时,延时更新,也就是将“写”和“更新”分两步进行。那么什么时候更新呢?就引入了宽限期:在这个时期内,每个线程至少有一次在读端临界区之外。也就是说,在进行更新的时候,进行更新的线程,知道其他线程不会再使用旧资源。在RCU中,是没有锁的存在的。
既然任意时刻都能读取共享资源,也就是说,读取资源时候,是禁止阻塞的,因此,一个简单的宽限期的周期为:在写操作之后,所有线程都被调度过。此时,更新的线程再去更新资源,从而就保证了所有的线程不会读取到旧的公共资源。
进程间通信小结
进程间的通信问题,旨在与如何让多者在访问共享资源时,是保持同步的。无论是寻求软件侧的方案,还是硬件上的实现,都要满足 “访问共享资源” 这一操作的原子性,这一操作无法分割,操作期间其他进程不能干扰。也因此有了各种的互斥、同步方式来应对不同的场景:
- 自旋锁:通过忙等待,浪费CPU来得到互斥,进程、线程知道自己将在在很短的时间能访问到共享资源。
- 信号量、互斥量:使用具备原子性操作的数据结构,达到对临界区的访问条件控制,从而保证了互斥同步。
- 管程:覆盖程序执行范围更大的编程语言概念,由编译器识别编写的程序中需要使用到管程的代码片段,并完成互斥同步的自动化实现调度,保证对临界区的访问有且只有一个进程在运行,从而降低由程序员编写的锁方案造成的种种问题。
- 屏障:解决同步问题的方案,程序能分段执行,每一段执行完的进程、线程进行等待,等所有的段执行完后,大家再一齐继续执行。
- 消息传递:与信号量、互斥量相似,但更多的问题在于,如何保证原子性以及身份问题。
- RCU:没有锁的存在,禁止读时阻塞,在宽限期后,能进行对资源的更新操作,适用于写操作很少的场景。
如何进行调度
了解完了进程通信问题,就需要了解进程调度问题(线程也适用)。通信问题痛点在于,进程如何正确访问共享资源,调度问题的痛点在于,轮到谁执行。
进程的执行离不开CPU的参与,在更多的情况下,CPU的数量远小于进程的数量。每个进程有自己的虚拟CPU,都认为CPU是自己的,但是没有哪一个进程应该无期限地使用CPU。那么如何进行调度势必要得到解决。
有如下几个场景出现时,就要考虑进行调度:
- 创建一个进程后,由谁先运行,以保证程序的继续执行
- 进程退出
- 进程发生阻塞
- 发生了I/O中断
“轮到谁执行”,实际上是为了提高CPU的使用率并更贴合系统目的。
对于CPU的使用率的衡量,可以将进程分成两种类型看待,CPU密集型和I/O密集型。对于CPU密集型的进程来说,在完成执行任务的时间周期里,更多的时间分配在使用CPU进行计算任务。对于I/O密集型的进程来说,在完成执行任务的时间周期里,它更多的时间是在等待外部I/O活动而被阻塞,此过程不需要CPU的参与。
对于CPU密集型进程,频繁的进行调度,是不能提高CPU的使用率,反而会因为进行调度时,上下文切换所引起的消耗而降低CPU的使用率。那么对CPU密集型进程进行调度,更多的则是对于系统目的的考虑。对于CPU密集型进程,在发生I/O活动而进行调度,减少了因阻塞等待而造成的CPU时间的浪费,从而提高了CPU的使用率。且随着计算机能力的发展,更多的进程偏向了I/O密集型。当然,I/0密集型进程的调度,也要考虑系统目的。
对于不同的系统,系统目的有所不同。对于所有系统来说:
- 公平,每个进程得到公平的CPU时间
- 策略强制执行,保证规定的策略被执行
- 平衡,保证系统的各部分忙绿
对于批处理系统:
- 吞吐量,每小时最大作业数
- 周转时间,从提交到终止间的最小时间
- CPU利用率,保持CPU时钟忙绿
交互式系统:
- 响应时间,快速响应请求
- 均衡性,满足用户的期望
实时系统:
- 满足截止时间,避免丢失数据
- 可预测性,在多媒体系统中避免品质降低
而从调度的行为来看,可以讲调度分为两种,抢占式和非抢占式。抢占式是指,让某个进程运行一段时间,然后发生时钟中断,调度程序让这个程序停止运行,再选出一个程序令其运行。非抢占式是指,不管有没有时钟中断,除非一个进程运行完毕或者发生阻塞,否则不会调度其他的进程运行。
非抢占式调度算法
FCFS(先来先服务)
顾名思义,此调度算法为最简单的算法,谁先来,谁就先运行。如果运行过程中,发生了阻塞,自行回到队末排队。如同排队买票,依次购票。
先来先服务,更多地运行于批处理系统中,问题是,它可能造成某些进程的周转时间过长。
如图中,提交了三个进程任务,执行顺序分别不同。两者的吞吐量是相同的。对于第一种,平均周转时间 = (10 + 30 + 60)/ 3 = 33.33,对于第二种,平均周转时间 = (30 + 50 + 60) / 3 = 46.66 。
SJF(最短作业优先)
当运行时间可预知时,可以考虑最短作业优先算法,即每次选取运行时间最短的进程运行。
最短作业算法用于批处理系统中,并且在所有的作业可同时运行的情况下,最短作业算法为最优化。
假设有作业按照A、B、C、D运转,第一个作业周转时间为A,第二个为 A + B,以此类推,平均周转时间为(4A + 3B + 2C + D)/ 2,从而,对于平均值影响更最大的为A值,其次为B...因此可以证明在所有的作业可同时运行的情况下,最短作业算法为最优化。
抢占式调度算法
SRTF(最短剩余时间算法)
SJF的抢占式版本,每当有新的作业到达或者一段时间后,从所有作业中挑选离执行完毕剩余时间最少的作业运行。此算法对于短作业非常友好,但如果出一某一作业时间比其他作业时间都长,并且源源不断添加新的短作业,很可能使这个长作业饥饿,一直得不到运行。
轮转调度
最古老、简单、公平的调度算法。每个进程被分配一个时间片,在时间片结束后,选择下一个进程运行。轮转调度的运行机制和FCFS相似,只不过,多了一个进程运行一段时间后,重新到队未排队的规则。
而与FCFS不同的是,在轮转调度中,一个进程在完成任务的时间周期中,很可能发生多次的重排队操作,也就是发生了进程切换,也是上下文切换(并非FCFS没有进程且过,只是相比于轮转调度,要少得多)。每一次上下文切换都会浪费一定的CPU时间。
假设时间片为4ms,上下文切换为1ms,那么CPU时间将有25%的浪费。如果时间片远大于上下文切换的时间,CPU时间浪费将会降低,但是却会让其他短的交互请求进程的响应时间变得更长。因此,时间片的时间需要达到微妙的平衡,一般为20~50ms。
优先级调度
在轮转调度中,做了一个假设,即所有的进程同等重要。而在实际的场景中,一些进程应该更重要。为了能识别哪一些进程更应该被运行,引入了优先级,每次调度时,选择优先级最高的进程运行。为了防止优先级低的进程饥饿,还可以动态得调整进程的优先级。
一个简单的调整优先级的方法是,将优先级设为 1/f, f为该进程在上一时间片中占的部分。比如,时间片为50,进程占用的时间为1,则优先级=1/(1/50) = 50。
无论使用何种优先级调整的方式,会形成多个优先级队列,每个队列有若干进程。在同一级的队列上,使用轮转调度,并调整进程的优先级。当当前优先级的队列没有进程之后,再从下一级队列轮转调度。
多级队列
在一些系统上,进程切换的消耗比较大,但依旧要满足进程调度的可区分重要性。在这样的系统上,可以为不同阶段的进程分配更多的时间片,来动态调整进程优先级以及减少进程切换的目的。 比如,一个进程需要100个时间片才能完成工作,那么一次为它分配1、2、4、8、16、32、64个时间片,这样这个进程发生了7次交换就能完成工作。
这个调整,也会形成多个优先级队列,同一个队列上亦是使用轮转调度完成工作。这个算法,对短进程友好。但存在的问题是,有些进程一开始需要大量的计算,但在后来则需要更多的交互(交互耗时短得多),但是这个进程早已进入了优先级很低的队列,因此还需要有方法能让这样的进程进入更高的优先级。因此这个算法实际应用将有更多的限制。
最短进程优先
对于交互进程来说,往往需要很短的响应时间,在这样的进程上,可以使用SJF,为了达到这样的目的,使用老化来推测进程可能的时间长短。
假设首次运行时间为T0,下一次为T1,那么估值时间为 aT0 + (1-a)T1 。当a = 1/2 时,每次预测的时间为 T0, T0/2 + T1/2, T0/4 + T1/4 + T2/2, T0/8 + T1/8 + T2/4 + T3/2 ... 可以看出,越老的运行时间,预测中占的权重越低。 通过控制a的指来调整权重降低的程度,并且在a为1/2的整数次方时很容易实现,只需新值和当前预测值相加,然后右移若干位即可。
保证调度
与前面算法不同的是,这个算法是向用户作出保证,能平分CPU处理能力。比如,当有n个用户时,每个用户应得到 1/n 的CPU处理能力。每个用户进程记录当前已获得的CPU时间,在调度时,偏向选择 已获得CPU时间 / 应获得CPU时间 比率最低的进程即可。
彩票调度
把对CPU使用机会分成一些份额,每个进程得到一些份额,类似于有一些彩票,每次调度时,彩票开奖,彩票在哪个进程的手上,就运行那个进程。
这个调度算法,即划分了进程的重要程度(拿到的份额越多越重要),又能让进程按照他们的重要程度获得运行机会(拿到的份额越多越有机会得到运行)。
调度小结
以上的调度算法,是不同场景下可参考的基本模型。在实际运用中,有更多的更负责的算法。而基于系统目的的不同,相同的算法,也会有不同的变更。但无论何种算法,都需要考虑如下因素:
- 公平,每个进程尽可能地得到公平的CPU时间
- 平衡,系统的各部分忙碌
- 策略强制执行,算法能如期执行
- 优先级,进程的重要程度不同,应得到运行机会也应不同,也就是“一些进程应该更公平”,在这样的场景下,还要避免进程饥饿问题
总结
“进程是资源分配的最小单位,线程是调度的最小单位。” 综合上面所述,这句话就能区分出了进程和线程间的不同。而从上面所谈论的问题中,不管是通信问题、或是调度问题,进程和线程又是相似的。如果抛开资源分配不谈,进程与线程几乎没有区别。
对于进程和线程的学习,重要的是,知悉他们所扮演的角色,以及面对的问题等等。在面对实际任务时,根据任务的本身特征,与进程和线程的特点,决定是以多进程完成或是多线程,亦或二者行融合。
本文更多的是对《现代操作系统》的学习总结,因此大部分的内容来自于《现代操作系统》。如有错误之处,妄请指出。