本文是对进程同步机制的一个大总结(9000+字吐血总结),涵盖面非常的全,包括了进程同步的一些概念、软件同步机制、硬件同步机制、信号量机制和管程机制,对每种机制结合代码做了详细的介绍,并且对琐碎的知识点和概念解释的非常清晰。
在前面的博客中讲述了进程的状态及其状态的转换,每种状态的含义和转换的原因。同样我们也知道,在OS引入了进程后,可以使系统中的多道程序可以并发的执行,进程的并发执行一方面极大的提高了系统的资源利用率和吞吐量,但是另一方面却使系统变得更加复杂,如果不能采取有效的措施,对多个进程的并发执行进行妥善的管理,必然会因为这些进程对系统资源的无序争夺给系统造成混乱,致使每次的处理结果显现出不可再现性。
对于上面的问题,大家想一想这么一个场景,如果我们在买火车票(just for 举栗子)时,没有排队这个机制,大家乱糟糟的围在售票员旁边,手里举着钱大叫来一张到xxx的硬座、来张到xxx的卧铺。。。咦,不寒而栗、可怕、脑壳痛。但是如果我们有序的排队购票,大家就都可以快速的买到自己想要的通往幸福的车票。
进程同步机制就是这么一个保障OS中多个进程能够有条不紊的运行的“规则”。本文中,我们将会详细的介绍几种进程同步机制。(本章中所讲的OS是单处理机系统,多处理机系统中的情况过于复杂,不利于理解)
进程同步机制的主要任务,是对多个相关的进程在执行次序上进行协调,使并发执行的诸多进程之间能够按照一定的规则共享系统资源,并能很好的相互合作,从而是程序之间的执行具有可再现性。
进程间的两种制约关系:
临界资源:只同一时刻只允许一个进程可以访问的资源称之为临界资源,诸进程之间应采取互斥方式,实现对临界资源的共享。比如打印机、磁带机等都是临界资源。我们通过打印机来说明为什么临界资源同一时刻只允许一个进程使用,假设同一时刻A、B进程同时访问打印机,两个进程同时执行打印任务,因为进程的并发性,最后可能导致的就是打印机打出来的内容就是混杂着两方的文字,这样得到的打印结果既不是A进程想要的也不是B进程想要的,只会造成资源的浪费。
临界区:进程中访问临界资源的那段代码。显然若能保证诸进程互斥的进入自己的临界区,便可实现进程间对临界资源的互斥访问。因为每个进程每个进程在进入临界区之前,应先对欲访问的临界资源的“大门”状态进行检测(主要检查该临界资源是否有进程正在访问,如果此时临界资源未被访问,对应的“大门”是敞开的状态),如果“大门”敞开,进程便可进入临界区,并将临界区的“大门”关上;否则就表示有进程在临界区内,当前进程无法进入临界区。
指令:指令就是机器语言的一个语句,它是一组有意义的二进制代码。因为是机器语言的一条指令,所以指令就可以等价于是原子性的,只有在执行完一条指令后才会去响应中断(如果有的话)。
原语:由若干条指令组成的,用户完成一定功能的一个过程。原语操作的一个特点就是“原子操作”,因此原语在执行的过程中不允许被中断。原子操作在系统态下执行,常驻内存。
同步机制应遵循的规则:
在这里解释一下“死等”和“忙等”两个状态,因为这两个状态在我们看来似乎是没有什么区别的,比如“死等”和“忙等”都是没能进入临界区,那两者的区别到底是什么呢?
其实不管是“忙等”还是“死等”,都是对OS有害的,都是应该努力避免的。
进程的同步机制包含软件同步机制、硬件同步机制、信号量机制、管程机制等,也就是这些机制,可以保证程序并发执行时的可再现性。
其实说到使用软件来实现同步机制,大家想到的最多的应该就是Java多线程了,Java通过锁、synchronized、信号量等机制实现了线程的同步,但是线程间是共享父进程中的所有资源的,就比如多个职员坐在一间办公室里,大家可以面对面的交谈,这样可以方便的解决共享资源的问题;但是在线程之间(就像是分布在世界各地的多个办公室),如果需要共享系统资源时,进程之间很难直接通过软件来进行交流,对系统资源的互斥共享就很麻烦了,需要借助硬件资源来完成同步了,需要在内存中单独使用一块区域来保存进程是否在临界区内的标志。
我们先来看下面一段代码:
//inside1、inside2是进程P1和P2在内存中保存的标志,表示进程是否在临界区内
inside1 = false;
inside2 = false;
//进程P1
process P1
begin
while(inside2) ; //循环测试
inside1 := true;
临界区;
inside1 := false;
end;
//进程P2
process P2
begin
while(inside1) ; //循环测试
inside2 := true;
临界区;
inside2 := false;
end;
代码逻辑很清晰,就是进程P1或者P2想进入临界区之前,先去判断对方是否在临界区内,如果在的话,就一直循环等待,否则就进入临界区,然后“关门”(挂锁)。第一眼看似乎是没什么问题,但是如果进程在执行期间,比如P1先执行,在执行挂锁(inside1 = true)之前,发生了中断,进程P2也开始了执行,此钱P1的锁还没有挂上,因此进程P2可以进入临界区,在临界区内执行的时候,P2也发生了中断,P1恢复执行,因为之前已经执行过判断是否可以进入临界区的代码,因此此时同样的可以进入临界区,在这种情况下,两个进程同时的进入了临界区,进程的执行就会出现错误。
虽然在上面的进程P1和P2执行的过程中发生了很多恰巧的事(小概率事件),P1在挂锁前中断、P2在临界区执行时中断、P1和P2进程能在对方在中断时抢占CPU,这几个事件组合在一起,概率就更加的小了,但是仍然的是存在问题的。
这个时候你可能会想,我在判断之前先挂上锁呢,我们把代码的顺序调整一下,大家请看:
//...
//进程P1
process P1
begin
inside1 := true;
while(inside2) ; //循环测试
临界区;
inside1 := false;
end;
//进程P2
process P2
begin
inside2 := true;
while(inside1) ; //循环测试
临界区;
inside2 := false;
end;
这样的话,进程P1、P2在并发执行的时候就没有问题了么,我们来看这样一种情况,P1先执行,挂锁成功,假设在成功之后,P1发生了中断,进程P2开始执行,此时P2同样可以挂锁,但是在判断是否可以进入临界区时,则无法成功,会一直在循环中判断,当P1再次恢复执行时,尴尬的事情发生了,P1也无法进入临界区了,因为P2同样把锁给挂上了。
上面是两种软件同步机制的实现,第一个是双标志法先检查,第二个是双标志法后检查,但是两个方法都无法真正的解决进程同步问题。双标志法先检查法可能会让两个进程同时进入临界区,双标志法后检查法可能会让两个进程都无法进入临界区,形成死锁问题。
虽然通过软件方式也可以实现诸进程互斥的进入临界区的问题,比如Peterson算法,但是有一定的难度,并且存在很大的局限性,因而现在已经很少使用了,下面我们来看下别的几种方式。
我们在软件同步机制里讲的两个例子,都是在落锁和判断之间发生中断,乃至导致无法实现互斥的进入临界区,那么,我们是不是可以在这个期间不允许发生中断呢?这个就需要用到硬件了,下面我们就一起来看一下。
这个方法就非常之霸气了,进程在落锁和判断之间不是有可能会发生中断么,那么我在开始测试之前关闭中断(OS内核不响应中断信号),到测试并上锁之后在打开中断。这样可以保证两个操作之间的连续性,保证临界资源的互斥访问。
但是关中断也必然会存在许多缺点:1.滥用关中断的权利可能导致严重后果;2.关中断时间过长会影响系统的并发性,直接的影响系统的资源利用率;3.关中断无法适应多CPU系统(多CPU系统不在本文的讨论范围内)
我们使用关中断来解决落锁和判断之间不允许响应中断,但是我们如果把这两个执行变成一条指令呢,这样是不是就可以保证中断不会再落锁和判断之间被响应?
我们可以借助一条硬件指令-----“测试并建立”指令TS(Test-and-Set),来实现临界资源的互斥访问。TS指令的一般性描述如下:
//TS指令
boolean TS(boolean *lock){
if(*lock == false){
*lock = true;
return true;
}else{
return false;
}
}
//内存中保存的锁的值
boolean lock;
lock = false; //临界区可以进入
//进程P1,P2,P3...Pn
process Pi{
//...
while(!TS(&lock));//循环请求锁
临界区;
lock = false;//解锁,归还临界资源
}
我们可以把TS指令看成上面的TS函数的执行过程,其执行过程是不可分割的,即是一条原语。当lock的值为false时,表示临界资源空闲,当lock的值为true时,表示该资源正在被使用。
使用TS指令来管理临界区时,需要为每个临界资源设置一个布尔变量lock。当有进程需要进入临界区时,需要先用TS指令测试临界区对应的那把“锁”,如果返回true,临界区空闲,可以进入,并落锁,阻止别的进程再进入临界区;如果TS返回false,则必须要循环请求,直到TS返回的值变为true。
该指令也称为swap指令,用于交换两个字的内容,其处理过程描述如下:
void swap(boolean *a, boolean *b){
boolean tmp;
tmp = *a;
*a = *b;
*b = tmp;
}
//内存中保存的锁的值
boolean lock;
lock = false; //临界区可以进入
//进程P1,P2,P3...Pn
process Pi{
//...
boolean key = true;
do{
swap(&lock, &key);
}while(key != false)//循环请求锁
临界区;
lock = false;//解锁,归还临界资源
}
Swap指令和TS指令类似,也需要为每个临界资源设置一个布尔变量lock,不同的在进程中使用一个局部变量Key字段去替换出lock中的值,通过判断key的值就可以判断临界资源是否空闲。
有一点需要注意的,因为原语是将多个指令合并成一个指令,在原语的执行过程中也是不响应中断的,使之成为原子操作,这个期间,等于是屏蔽中断,也就等价于我们讲的第一种硬件方式—关中断,因此原语操作的指令长度应该是短小精悍的,这样才能保证系统的效率。
利用上述的硬件指令能有效的实现进程的互斥,但是当资源忙碌时,其他访问进程的必须不断的进程测试,处于一种“忙等”的状态,违背了让权等待的原则,造成处理机时间的浪费,同时也很难将它们用于解决复杂的进程问题。
信号量机制是1965年迪杰斯特拉(Edsger Wybe Dijkstra)提出的一种卓有成效的进程同步工具。信号量机制在长期的应用中得到了很大的发展,从整型信号量经记录型信号量,进而发展为“信号量集”机制,在目前来讲,信号量。
需要着重说明的一点是:信号量除了初始化外,仅能被通过两个标准的原子操作wait(S)和signal(S)来访问,这两个操作也被称为P、V操作,这几个操作都是原语操作。
整型信号量是最开始由迪杰斯特拉定义的,整型信号量也很简单,里面只有一个表示资源的数量的整形量,一般使用S符号来表示,整型信号量下的wait和signal操作可描述如下:
//整型信号量定义
int S;
//P操作
wait(S){
while(S<=0);
S--;
}
//V操作
signal(S){
S++;
}
因为wait和signal操作是原子的,因此他们在执行的过程中是不可被中断的。也就是说当一个进程在修改某个信号量时,没有其他的进程可以同时对该信号量进行修改。
需要注意的是,整型信号量的wait操作,只要是信号量S<=0,就会不断的测试,让进程处于一种“忙等”的状态,没有遵循让权等待的原则。还有就是,把信号量的初值置为1,表示只允许一个进程访问临界资源,此时的信号量就可以转换为互斥信号量,用于完成进程的互斥(对所有的信号量机制都是一样)。
为了解决整型信号量存在的忙等问题,应当采取让权等待原则。但是又会出现一个新的问题,如果多个进程并发请求访问临界资源,除了第一个抢到了信号量外,其余的进程都应该释放处理机,但是这些等待的进程要如何保存呢?为此,除了wait操作需要遵循让权等待原则,还需在信号量中增加一个进程的链表指针list,用与链接上面描述的多个等待访问临界资源的进程。也因为记录了等待的进程,这种信号量集之被称为记录型信号量。
记录型信号量具体的描述以及对应的wait和signal操作如下所示:
//记录型信号量定义
typedef struct{
int value;
struct process_control_block *list;
}semaphore;
//P操作
wait(semaphore *S){
S->value--;
if(s->value < 0) {
block(S->list);
}
}
//V操作
signal(semaphore *S){
S->value++;
if(S->value <= 0){
wakeup(S->list);
}
}
在记录型型号量中,S->value的初值表示系统中某类资源的数目;对它每次wait操作,意味进程请求一个单位的该类资源,使得系统中可分配的该类资源数减少一个,因此描述为S->value–;当S.value<0时,表示在执行此次分配之前,系统中的该类资源已经全部分配完了,因此该访问进程应调用block原语进行自我阻塞,放弃处理机并插入到等待进程的队列S->list中。我们再来分析下signal操作,首先释放一个单位资源(S->value++),然后判断是否有进程在等待申请信号量,如果有的话,就应该调用wakeup原语从等待队列(list所链接的进程队列)中唤醒一个进程。
通过上面的描述,我们来说一下在记录型型号量中,value值所代表的意义:1.value>0,此时表示系统中还剩余的该类资源的数量;2.value=0,此时恰好处于一个平衡状态,系统中的资源分配完了,同样也没有进程在等待资源,即list队列中是没有等待进程的;3.value<0,此时,value的绝对值表示有多少个进程在等待申请信号量,也即是list队列的长度。并且P、V操作必须成对的出现,有一个P操作就必定有一个与之配对的V操作(当为互斥操作时,它们同处于同一进程当为同步操作时,则不在同一进程中出现)。
AND型号量正如其名,其基本思想是:将进程在整个运行过程中需要的所有资源,一次性的全部分配给进程,待进程使用完后再一起释放。AND型号量可以满足某些进程需要申请多个资源后才可以执行任务的场景,并且AND型信号量可以解决死锁问题,比如哲学家进餐问题中,一次给哲学家分配左右两支筷子,那么就不会有哲学家会因为吃不到空心粉而饿死了。
AND型信号量使用的还是记录型信号量的数据结构,下面是Swait操作和Ssignal操作(此处和下面的信号量集都用此符号):
//记录型信号量定义
...
//P操作
Swait(S1, S2, ..., Sn){
while(true){
if(S1>=1&&...&&Sn>=1){
for(i=1;i<n;i++) Si--;
break;
} else {
将进程插入到第一个无法满足条件(即Si<1)的信号量对应的等待队列中,并且将程序计数器放置到
Swait操作的开始处;
}
}
}
//V操作
Ssignal(S1, S2, ..., Sn){
while(true){
for(i=1;i<n;i++) {
Si++;
将Si中的等待队列中的所有进程全部移除,插入到就绪队列中;
}
break;
}
}
需要注意的就是,因为一次申请多个资源,所以在申请的过程中,如果因为哪一类资源不足而阻塞(请求N多个资源时第一个发现不满足的资源,即资源数<1),就要将进程插入到对应信号量的list中;与之对应的唤醒操作也有所不同,不再是唤醒阻塞队列中的某一个进程,而是将等待队列中的所有进程全部移除,插入到就绪队列中,让这些进程再次执行一次资源请求操作(这里因为是一次请求多个资源,后面可能依旧有资源无法满足进程的需求)。
在前面讲的信号量机制中,wait、signal操作仅能对信号量施以加1或者减1操作,当一次需要N个单位的资源时,便要执行N次的wait(S)操作,这显然是低效的,并且会增大发生死锁的概率(需要执行N次,在这N次执行的过程中可能会发生中断,资源也可能会被别的进程抢占)。此外,在某些情况下,为了确保系统的安全性,当所申请的资源数量低于某一个下限时,就不予分配(保证地主家里有余粮)。
为了满足上述的两个需求,信号量机制又升级了,在AND型信号量的基础上进行扩充,对进程所申请的所有资源,在一次P、V操作中完成申请或释放。并且进程对每类信号量的测试值也不在是1,而是该资源的分配下限ti,也就是要求Si≥ti,否则不予分配。因此这里就不在给出具体的Swait和Ssignal的代码了,而是给出函数声明:
Swait(S1, t1, d1, ... , Sn, tn, dn);
Ssignal(S1, d1, ... , Sn, dn);
这里与记录性型号量稍有不同的地方就是判断每类资源是否满足需求时,判断的条件由Si>=1
变为Si>=ti
,并且分配资源由Si--
变为Si=Si-ti
;与之对应的Ssignal操作,不同的一点就是,一次可以归还多个资源,相对应的资源释放代码由Si++
变为Si=Si+ti
。
需要注意的是,因为AND型信号量和信号量集一次申请进程执行所需的全部资源,这样的优点就是简单、易行且安全,但是缺点也很明显:1.资源被严重浪费,严重的恶化了资源的利用率;2.使进程经常会发生饥饿现象(因为个别资源被占用的概率很大,会导致进程因为申请不到所有资源迟迟得不到执行)。
虽然信号量机制是一种既方便、又有效的进程同步机制,但是每个访问临界资源的进程都需要自备同步操作wait(S)和signal(S)操作,这就使大量的同步操作分散在各个进程中,不利于大家去集中的思考、抽象,使得程序设计的时候难度非常大,容易产生各种各样的程序设计错误。在这样的情况下,便产生了一种新的进程同步工具----管程(Monitors)。值得一提的是,管程也是迪杰斯特拉提出的。
hansen对管程的定义如下:一个管程定义了一个数据结构和能力为并发进程所执行(在该数据结构上)的一组操作,这组操作能同步进程和改变管程中的数据。
由上述的定义可知,管程有四部分组成:1.管程的名称;2.共享数据结构说明;3.对数据结构进行操作的一组过程;4.初始化语句。下面我们来看下管程的语法描述:
//管程的描述
Monitor monitor_name {//管程名
share variable declarations; //共享变量说明
cond cond_declarationas; //条件变量说明
public: //能被进程调用的过程
void P1(...){ //对数据结构操作过程
...
}
void P2(...){
...
}
...
void(...){
...
}
...
{
initilization code; //初始化代码
}
}
通过上面的代码描述,你是不是觉得很熟悉!实际上,管程中包含了面向对象的思想,它将共享资源、对共享资源的操作抽象成变量和方法,并与同步机制一同封装在一个对象内部,隐藏了实现细节。但是你所不知道的是,在管程出现的时候,还没有面向对象程序设计,是不是很意外。
封装于管程内部的数据结构仅能被管程内部的过程访问(类似于Java中的私有变量),如果想在管程外部访问管程内部的数据结构,就必须使用内部过程(管程内部的public修饰的方法,Java 类中的公共方法)。所有的进程想要访问临界资源,都只能通过管程间接的访问,并且管程每次只允许一个进程进入管程,从而实现了进程互斥。
有一点需要说明的是,管程为了实现更加复杂的进程同步方式,增加了一个条件变量。通常会根据进程被阻塞或者挂起的原因,设置不同的条件变量,每个条件变量保存一个链表指针,用与链接所有因为该条件变量而阻塞或挂起的所有进程,同时提供两个P、V操作也可以表示为x.wait和x.signal,这两个操作的含义如下:
对于上面的操作,其实是有些问题的,我们设想一下,如果进程P1因x条件处于阻塞状态,那么当进程P2执行了x.signal操作唤醒P1后,进程P1和P2此时同时处于管程中了,这是不被允许的,那么如何确定哪个执行哪个等待?这个问题也很简单,可采用下面的两种方式之一进行处理:
采用哪种处理方式,也存在很多争论。Hoare采用了第一种处理方式,而Hansen采用了两者的折中,它规定管程中的所有过程执行的signal操作是过程体的最后一个操作,于是,进程P2执行完signal操作后立即退出管程,因此进程P1马上被恢复执行。
但是hansen的这种折中的办法,规定死了释放的时机,增加了程序设计的难度。因此现在的管程普遍采用Hoare的方式,也被称为霍尔管程。霍尔管程相比于管程、汉森管程,他的一个更大的优势是可以基于PV操作原语来实现,换一句话说就是,wait和signal可以是程序过程而不需要是原语实现(可以使用语言机制实现霍尔管程)。这个就很厉害了,霍尔管程可以基于操作系统的程序库或者是高级程序设计语言,在基础的P、V操作原语上实现wait和signal操作,而不用扩展操作系统的内核。
管程的示意图如下,从图中我们可以看到,每个条件变量都有一个对应的等待队列,除了等待调用管程的进程队列外,还有一个紧急队列(优先级高,由进程调用signal操作后插入),对于具体的操作,我们可以结合霍尔管程中条件变量中的wait、signal操作来讲。
下面是霍尔管程的条件变量上的wait操作和signal操作的描述:
//霍尔管程使用的信号量定义
//semaphore为本文之前定义的记录型信号量
typedef struct{
semaphore mutex; //用与管程调用的互斥信号量
semaphore next; //发出signal的进程挂起自己的信号量,信号量中记录着等待调用管程的进程
int next_count; //在next上等待的进程数
}interf;
//霍尔管程中的条件变量
typedef struct{
semaphore x_sem; //与资源相关的信号量
int x_count; //在x_sem上等待的进程数
}cond;
//条件变量对应的wait操作
wait(cond declar, interf IM){
declar->x_count++; //在条件变量declar上等待的进程数量加1
if(IM->next_count > 0){ //判断是否有进程在高优先级队列中
V(IM->next); //唤醒因调用signal操作的进程
} else {
V(IM->mutex); //没有的话,唤醒一个等待进入管程的进程
}
P(declar->x_sem); //释放资源后,立即把自己挂起
declar->xcount--; //恢复执行后,重新开始执行,退出管程,条件变量declar等待的进程数量减1
}
//条件变量对应的signal操作
signal(cond declar, interf IM){
if(declar->x_count > 0){ //判断是否有等待条件变量的进程
IM->next_count++; //挂起自己后,因为调用signal挂起自己的进程数量加1
V(declar->x_sem); //唤醒一个等待条件变量的进程
P(IM->next); //释放资源后,立即把自己挂起,进入高优先级队列
IM->next_count--; //恢复执行后,等待调用的管程的进程数量减1
}
}
我们看上面的代码,此时的wait操作和signal操作与P、V原语是有区别的,wait和signal是在P、V的基础上实现的。首先我们来看下wait操作,唤醒进程(高优先级队列或者是等待调用管程的队列)后,立即将自己挂起,并将自己插入到对应的条件变量的等待队列中(上图中左上方的队列),当被唤醒再次执行时,将对应的等待数量减1。我们在来看下signal操作,首先判断是否有等待条件变量的进程,如果没有的话,就可以什么都不用执行;如果有进程在条件变量的等待队列中,则从队列中唤醒一个,并挂起自己,插入到紧急队列中。
霍尔管程中的wait、signal操作比较抽象,可以结合图片查看,可以帮助理解。
本文主要讲了OS中为了解决进程同步问题才采取的措施,进程同步机制也是经过逐步的发展,慢慢的变得完善,可以满足复杂的并发程序设计,本文的内容主要是偏理论,也结合了代码来讲解各个操作,能帮助大家理解。通过本文的学习,希望可以让你在并发程序设计上能获得理论的依据,因为我们做的更多的应该是多线程编程,如果你接触过并发编程,我觉得本文里的许多内容能引起你的共鸣。
本文所涉及到的代码都是使用C语言写的,如果有任何错误,烦请批评指正。
又到了分隔符以下,本文到此就结束了,本文内容全部都是由博主自己进行整理并结合自身的理解进行总结,如果有什么错误,还请批评指正,当然,如果有什么疑惑可以评论留言。
本篇博文全文多达9000余字(本科毕业论文都没这么多字),在写这篇博客的时候,对于一些小的概念,折腾的都快抑郁了。原创不易,如果本文对你有所帮助,还请留下个赞,以表支持。
如有兴趣,还可以查看我的其他几篇博客,都是OS的干货(目录),喜欢的话还请点赞、评论加关注_。