有关进程通讯的知识主要分为五个部分:算法
①什么是进程通讯;数据库
②实现进程通讯的误区;编程
③如何正确实现进程通讯;并发
④经典的进程通讯问题与信号量机制;spa
⑤避免编程失误的“管程”。操作系统
本文将按照这五个部分的提出顺序进行讲解,力求通俗易懂、融会贯通。线程
①什么是进程通讯?debug
须要首先明确的是,进程通讯并非指进程间“传递数据”。指针
为了说明进程通讯,须要先介绍一下进程通讯的背景。现代操做系统中的进程间可能存在着共享的内存区,好比字处理进程A(能够想象为Word)、字处理进程B(能够想象为记事本)和打印机进程C共享一小块内存:待打印文件地址队列。该队列中有一个指针out指向队列中下一个被打印的文件地址,还有一个指针in指向队列尾的后一位置,即新的待打印文件地址应存入的位置。显然,指针out是供进程C访问的,每当打印机空闲且out!=in,进程C就打印out所指的文件。而指针in则是供进程A与进程B访问的,每当它们有但愿打印的文件时就执行以下三步:“读取in”、“向in所指位置写入待打印文件地址”、“修改in使其指向下一位置”。code
可是A和B都能读写指针in就会带来冲突问题:假设如今A占用着CPU并准备打印文件,A读取了in并将待打印文件名写入了in所指位置,可是A还没来得及修改in,CPU就切换到了进程B执行,B在执行过程当中也准备打印文件,而且完成了对in的全部操做。一段时间后,CPU又切换到了进程A,但此时的进程A并不知道本身写入到队列的文件名已经被B给覆盖了,A只会继续执行“修改in使其指向下一位置”的操做,从而出现了进程A与进程B的“冲突”。
这种存在共享内存区的进程间的冲突问题,解决方法的思路是统一的:当某个进程正在操做共享内存区时,其余进程不得操做共享内存区。这个思路实现的关键点就是:令其余进程知道“有一个进程在操做共享内存区”,所以这类问题就被称为进程通讯问题,通讯的“内容”就是:有没有其余进程在操做共享内存区。(讲解到信号量机制时进程通讯将广义化,但依然不是进程间的“实际通讯”,而是某些信号的共享)
由于“操做共享内存区”太长,因此人们通常称正在操做共享内存区的进程是在临界区内,同时将进程中须要操做共享内存区的部分代码称之为临界区代码。思路也就能够称做:当有进程在临界区时,其余进程不得进入临界区。
②实现进程通讯的误区
由于实现进程通讯的关键,就是令其余进程知道如今已经有进程在临界区了,因此一个很简单的解决思路就出来了:
将临界区想象成一个房子,同一时间内房子内只能有一个进程,那么确保这一点的方法就是给房子加锁(mutex),若是锁是锁上的,则准备进入的进程不得进入,若是锁是打开的,则准备进入的进程能够进入,而且进入后要将锁锁上,此外退出时也要负责将锁打开。
将上述想法转换为代码表示,就是令每一个进程在临界区代码的先后,分别添加以下代码,其中mutex为共享内存区中的一个变量:
1 int mutex=1; //mutex为1表示锁打开,为0表示锁关闭 2 while(true) 3 { 4 //执行非临界区代码 5 6 //准备执行临界区代码,即准备进入临界区 7 8 while(mutex==0);//若是mutex为0,说明有其余进程在临界区内,当前进程应卡在此处 9 mutex=0;//若代码能执行至此处,说明mutex为假,即没有其余进程在临界区,因而将mutex设为真,告知其余进程当前有(本)进程在临界区 10 11 /*临界区代码*/ 12 13 //准备退出临界区,解开锁 14 mutex=1; 15 16 //执行非临界区代码 17 }
可是上述代码是没法解决进程通讯问题的!缘由就是:若是没有计算机底层(硬件或操做系统)的限制,那么进程间的切换可能发生在任意两条机器指令之间,更遑论高级程序语言的两条语句之间。
如今假设三个进程A、B、C共享某内存区,A已进入临界区,因而欲进入临界区的B在第8行代码处卡住(想进入房子,一直循环判断“锁”的状态)。忽然,A退出了临界区,而且CPU切换到了B,因而B结束了第8行的循环(进入了房子),准备执行第9行代码(上锁),可是B还没来得及“上锁”,CPU又由于某特殊缘由如中断,被切换到了进程C,而且进程C也想进入临界区,因为此时“锁”是打开的,因而C直接结束了第8行的循环(直接进入房子),准备执行第9行代码。显然,此时临界区内有两个进程了。
所以,实现进程通讯时须要注意的最大误区就是:若是代码中的语句(指令)不是特殊的,那么任意两条语句(指令)间均有被“打断”的可能性。
③如何正确实现进程通讯
咱们先看看一种不须要借助计算机底层支持的解决进程通讯的方法:严格轮换法。而后再说说现代计算机实现进程通讯的基本技术。
所谓严格轮换法,依然能够抽象地将临界区当作一个房子,可是此次咱们不是靠锁来实现房子内只有一个进程,而是靠“钥匙”,钥匙在哪一个进程手上,哪一个进程就能够进入临界区,当该进程退出临界区时,须要将钥匙交给下一个进程(无论它要不要进入临界区,反正“轮到你了”)。
以三个进程0,1,2共享内存区为例,则三个进程的临界区代码分别以下,假设key初始化为0:
int key=0;
//进程0 while(true) { //不断判断钥匙是否在本身手上,若是不在则一直循环等下去 while(key!=0); //执行临界区代码 //退出临界区,将钥匙交给下一个进程 key=1; } //进程1 while(true) { //不断判断钥匙是否在本身手上,若是不在则一直循环等下去 while(key!=1); //执行临界区代码 //退出临界区,将钥匙交给下一个进程 key=2; } //进程2 while(true) { //不断判断钥匙是否在本身手上,若是不在则一直循环等下去 while(key!=2); //执行临界区代码 //退出临界区,将钥匙交给下一个进程 key=0; }
严格轮换法的确能够解决进程通讯,可是其效率很是低,缘由以下:
1.严格轮换:若是此时key为2,那么只有进程2能够进入临界区,哪怕进程2从始至终都没有进入临界区,进程0和进程1也不得进入临界区。
2.忙等待:若是此时进程0占用着CPU而key为1或2,那么进程0不能进入临界区,只能一直循环判断key的值,从整个系统的角度来讲,CPU在“忙”,但进程却在“等待”,也就是说CPU此时一直在空转(就像汽车空挡猛踩油门)
为了解决忙等待和严格轮换的缺陷,一种新的思路被提了出来,我称之为沉睡与唤醒策略:令进程的共享内存区中保存一个特殊的“沉睡进程队列”,若是进程A在准备进入临界区时发现已有其余进程在临界区内,则进程A将本身的信息加入到沉睡进程队列,即“沉睡”,而后自我阻塞,从而让出CPU、避免忙等待,当临界区内的进程退出临界区时,须要负责检查沉睡进程队列,若队列不为空,则须要将其中的某个进程移出队列,并将其“唤醒”,使其从阻塞态进入就绪态。固然,根据实现方式的不一样,沉睡、阻塞多是一个操做,移出队列和唤醒也是一个操做。
int mutex=1;//mutex为1表示锁打开,为0表示锁关闭
//沉睡 void sleep(int process) { //将process所表示的进程加入到沉睡进程队列 //将process由运行态转换为阻塞态 } //唤醒 void wakeup() { //检查沉睡进程队列,若不为空,则移出某进程,并将其由阻塞态转换为就绪态 } //进程中的代码 while(true) { //准备进入临界区 if(mutex==0) sleep();
//上锁
mutex=0; //临界区代码 //退出临界区,开锁,唤醒沉睡进程
mutex=1; wakeup();
}
显然,上述代码又踩了②中所提到的误区。
假设进程A在临界区内,如今进程B占用CPU而且想进入临界区,B检查mutex发现为0,因而准备执行sleep(),可是此时CPU被进程A抢去了,进程A在本身的时间片断内退出了临界区,试着去“唤醒”沉睡的进程,可是却没有沉睡的进程,因而唤醒操做不了了之,接着进程A任务完成、结束。因而CPU又切换到了B,B继续执行sleep(),进入沉睡并阻塞,可是再也没有进程会来叫醒B了……
上述现象能够这么说:B原本应该由A来唤醒,但恰恰A的唤醒没有被B收到,由于B后来才沉睡。该现象出现的根本缘由就是:B决定沉睡和执行沉睡是两个操做,这两个操做之间可能被“打断”。
再假设,进程B沉睡,进程A在临界区内且占用CPU,进程A在时间片断内执行完了临界区代码,解开了锁,准备执行wakeup(),可是CPU此时被进程C抢走,由于锁已解开,因此C进入了临界区,一段时间后,C还没有退出临界区,但CPU再次切换至进程A,A继续执行wakeup(),将B叫醒,因而B上锁(其实锁已经上了),进入临界区,可是此刻C已在临界区内!
这个现象能够这么说:A原本打算解开锁、叫醒B,但A解开锁后,C乘虚而入了。这个现象出现的根本缘由就是:A解开锁、叫醒沉睡进程是两个操做,这两个操做之间可能被“打断”。此外,即便A先叫醒了B,B也不会再去上锁,由于对于B来讲被叫醒即sleep()结束,能够直接开始临界区操做,所以其余进程仍是会由于锁打开而进入临界区!
要想解决上述两个问题,最直接的想法就是令“判断是否沉睡”和“沉睡”合为“一个操做”,“解开锁”和“叫醒沉睡进程”也合为“一个操做”,从而避免被打断,假设下面的sleep()、wakeup()为“原子操做”,即执行时不会被中断,则沉睡与唤醒策略能够实现:
//沉睡,假设为原子操做 void sleep(int mutex) { //检查锁mutex,若为锁上,则沉睡并阻塞调用者,不然上锁并返回 } //唤醒,假设为原子操做 void wakeup(int mutex) { //检查沉睡进程队列,若队列不为空,则唤醒其中某进程(不开锁,由于被唤醒的进程不会再去上锁),若队列空,则解开锁 } //进程代码 while(true) { //准备进入临界区 sleep(mutex); /*临界区代码*/ //退出临界区,并唤醒存在的沉睡进程 wakeup(mutex); }
在只有一个CPU的系统中,令sleep()、wakeup()成为原子操做并不复杂,只要将sleep()设为一个系统调用,而且操做系统在执行时(仅仅几条指令的时间而已)暂时屏蔽中断,从而避免执行sleep()、wakeup()时出现进程切换便可,多CPU系统中令sleep()成为原子操做须要一些更特殊的指令或技术。
④经典的进程通讯问题与信号量机制
首先看看“生产者-消费者”问题,该问题将引出沉睡与唤醒策略的升级版——信号量机制。
生产者-消费者问题的背景以下:有两个或多个进程分别为producer和consumer,它们共享的内存区最多能够存放N个产品,producer负责生产产品,consumer负责消费产品,若是共享内存区中已有N个产品,则producer须要沉睡,若是共享内存区中没有产品,则consumer须要沉睡,而且producer和consumer不能同时访问共享内存区。
一个简单的想法是这样的:
int count=0; //表示共享内存区中的产品个数
int mutex=1; //进入临界区的锁,临界区内的进程能够操做共享内存区
//num即生产者编号,容许存在多个生产者进程
void producer(int num) {
produce(); if(count==N) //沉睡 //欲操做共享区,即欲进入临界区 sleep(mutex); put_product(); count++; if(count==1) //count为1说明以前count为0,consumer可能已沉睡,因此唤醒consumer //离开临界区 wakeup(mutex); }
//num即消费者编号,容许存在多个消费者 void consumer(int num) { if(count==0) //沉睡 //欲操做共享区,即欲进入临界区 sleep(mutex); take_product(); count--; if(count==N-1) //说明以前count为N,producer可能已沉睡,因此唤醒producer //离开临界区 wakeup(mutex);
consume(); }
很显然,上述代码又踩了误区,对count的判断和对应的操做之间可能被打断,从而可能错过另外一方的唤醒:假设只有一个生产者和一个消费者,生产者发现count为N,而后CPU就被消费者占用,消费者取走一个产品并发现须要唤醒沉睡的生产者,可是此刻生产者没有沉睡,因此唤醒操做不了了之,接着消费者退出临界区,CPU被生产者占用,生产者执行因count已满而致使的沉睡,可是消费者再也不有机会叫醒它……
不幸的是,sleep()和wakeup()并不能解决对count的判断,由于count并非一把“锁”(锁只有两个状态,count不是),count是一种“信号量”,进程们经过count这个信号量的值来判断本身是否须要沉睡,与进入临界区而沉睡不一样,这种沉睡是受条件所限而沉睡。可是解决这个问题只须要将sleep()和wakeup()稍加修改就能够:
//down即新的sleep,也是一个“原子操做”,semaphore表示信号量,对应sleep()中的锁 void down(int semaphore) { //检查semaphore,若大于0则使其减一并返回,若为0则沉睡调用者 } //up即新的wakeup,也是一个“原子操做”,semaphore表示信号量,对应wakeup()中的锁 void up(int semaphore) { //检查semaphore,若为0则唤醒沉睡进程队列中的某沉睡进程,不然令semaphore+1 }
与sleep()和wakeup()的参数为锁不一样,信号量机制的参数为“信号量”,从而能够解决生产者-消费者问题,同时也能够替代sleep()和wakeup(),由于锁也能够当作是一个信号量:
//对共享内存区稍做修改,新增变量empty表示空位置个数,count依然表示产品个数
int count=0;
int empty=N;
int mutex=1; //进入临界区的锁,1开0闭,临界区内的进程容许操做共享内存区
//num表示生产者编号,容许存在多个生产者 void producer(int num) { produce(); down(empty);//减小一个空位置,若已为0则沉睡 //进入临界区 down(mutex); put_product(); //离开临界区 up(mutex); up(count);//若产品数原先为0,则唤醒由于没有产品而沉睡的消费者进程,不然令产品数+1 }
//num表示消费者编号,容许存在多个消费者 void consumer(int num) { down(count);//减小一个产品数,若已为0则沉睡 //进入临界区 down(mutex); take_product(); //离开临界区 up(mutex); up(empty);//若空位置原先为0,则唤醒由于没有空位置而沉睡的生产者进程,不然令空位置+1 consume(); }
信号量机制的理解并不困难,我的估计惟一的困惑点就是为何当semaphore为0时,up()不须要令semaphore+1,这一点的解释用一句话来讲就是:由于当semaphore为0时,down()没有令semaphore-1。也能够类比wakeup(),wakeup()在有沉睡进程时是不打开锁的,由于被唤醒的进程不会再去上锁。
生产者-消费者问题,以及信号量机制带来了一个新的思考:进程间可能不只仅存在共享内存区的读写冲突问题,还可能存在“资源”共享的问题。在生-消背景下,空位置就是生产者须要的资源,而产品就是消费者须要的资源,进程得在有了须要的资源后才能作本身要作的事。本文最初提到的字处理-打印问题就是生-消问题,只是咱们忽略了打印机进程在发现out==in时的操做,若是打印机进程在out==in时选择沉睡,那么字处理进程就得负责将其唤醒。
接下来看看哲学家就餐问题,该问题能够进一步地体现进程间资源抢占可能致使的问题。假设有5个哲学家围着桌子坐,每一个人面前都有足够的食物,但筷子只有5根,见图:
每一个哲学家只会作两件事:思考、吃饭。而每当须要吃饭时,哲学家必须取两根筷子才能吃,而且只能取本身左手和右手的筷子,如上图哲学家A只能用筷子0和筷子1吃饭。
显然,各个哲学家就至关于各个进程,筷子就是它们须要共享的资源(而且共享方式是连锁的)。先来看看错误的代码:
int chopMutex[5]={1}; //5根筷子各自的信号量(锁),1可取0不可取
//哲学家进程,num表示哲学家编号,从0到4对应从A到E void philosopher(int num) { while(true) { think(); //思考 down(chopMutex[num]); //拿左边的筷子,若已被左边的哲学家取走,则阻塞 down(chopMutex[(num+1)%5]); //拿右边的筷子,若已被右边的哲学家取走,则阻塞 eat(); //吃饭 //逐个放下筷子 up(chopMutex[num]); up(chopMutex[(num+1)%5]); } }
上述代码初看仿佛没有问题,每一个哲学家都利用信号量机制(此处的信号量即单根筷子的锁)来取筷子。但其实上述代码是有问题的:若A拿起了左边的筷子后就切换到了B,B也拿起了左边的筷子,而后又切换到了C,C也拿起了左边的筷子……最后,每一个哲学家都拿到了本身左手边的筷子,可是每一个哲学家都会由于拿不到右边的筷子而一直阻塞下去。这种现象咱们称之为“死锁”:一个进程的集合中,每个进程都在等待只能由同一集合中的其余进程才能触发的事件(好比释放某资源)。
解决哲学家问题的简单解法是:令同一时间只容许一个哲学家吃饭。
int qualification=1; //表明吃饭的权利
//哲学家进程,num表示哲学家编号,从0到4对应从A到E void philosopher(int num) { while(true) { think(); //思考 down(qualification); //试图获取吃饭的权利 //拿筷子,吃饭 takeChopsticks(num); takeChopsticks((num+1)%5); eat(); //放下筷子,中止吃饭 putChopsticks(num); putChopsticks((num+1)%5); up(qualification); //交出吃饭权利,即吃完了 } }
上述解法没有问题,只是有缺陷:5根筷子明明能够支持两个哲学家吃饭,好比A和C或者A和D一块儿吃,上述解法却只让一我的吃。
能够解决该缺陷的一种解法是,设置一个mutex做为进入临界区的锁,再令每一个哲学家对应两个信号量,state和qualification,state表示该哲学家的“状态”:思考、想吃饭、在吃饭;qualification表示该哲学家的“资格”:如今有没有资格吃饭。每一个哲学家均可以读、写任一哲学家的状态和资格,所以临界区即读写哲学家状态、资格的代码。
当哲学家X准备吃饭即想拿筷子时,先进入临界区(从而能够读写任一哲学家的state和qualification),而后将本身的状态改成想吃饭,接着检查本身左右两边的哲学家是否在吃饭,若是均不在吃饭,则本身有资格吃饭,因而经过up()使本身的qualification+1,而后退出临界区,再经过down()使用掉本身的资格;若是左右两边有哲学家在吃饭,则不使本身的qualification+1,退出临界区,再经过down()使用本身的资格,可是由于没有资格,X将阻塞于此,直到正在吃饭的旁边哲学家吃完饭,而后给予本身资格。
当哲学家Y吃完饭即放下筷子时,先进入临界区,将本身的状态改成思考,接着检查本身左右两边的哲学家是否想吃饭且有资格吃饭,如果则令其qualification+1从而使其得以吃饭,左右哲学家均处理完毕后Y退出临界区。
#define THINKING 0 //在思考 #define HUNGRY 1 //想吃饭 #define EATING 2 //在吃饭 int state[5]={THINKING}; //表示各个哲学家的状态 int mutex=1; //临界区的锁,为0表示锁上 int qualification[5]={0}; //哲学家的资格,为1时表示能够吃饭,0表示不能够
//检查哲学家i是否想吃饭且有资格吃饭 void check(int i) { //若哲学家i想吃饭,且其左右哲学家均不在吃饭,则i的吃饭资格+1,而且将i的状态改成正在吃饭 if(state[i]==HUNGRY && state[(i+4)%5]!=EATING && state[(i+1)%5]!=EATING) {
state[i]=EATING;
up(qualification[i]); }
} void takeChopsticks(int i) { down(mutex); //进入临界区(临界区内可读、写哲学家的状态和资格) state[i]=HUNGRY; //代表i想吃饭 check(i); //检查本身是否有资格吃饭 up(mutex); //离开临界区 down(qualification[i]); //若check(i)时确认本身有资格吃饭,则此处用去吃饭资格,不然阻塞直至被给予吃饭资格 } void putChopsticks(int i) { down(mutex); //进入临界区(临界区内可读、写哲学家的状态和资格) state[i]=THINKING; //代表本身不在吃饭也不想吃饭 check((i+4)%5); //检查左边的哲学家是否想且有资格吃饭,如果则给予他资格 check((i+1)%5); //检查右边的哲学家是否想且有资格吃饭,如果则给予他资格 up(mutex); //离开临界区 } void philosopher(int i) { while(true) { think(); takeChopsticks(i); putChopsticks(i); } }
哲学家进餐问题比生产者-消费者问题要更复杂,由于进程须要的资源不是一种而是两种,并且这两种资源的竞争对象不同。解决这类问题的关键点就是:若是进程X须要x个资源,则X要么一次性占用这x个资源,要么一个都不占用直到能够一次性占用着x个资源,不能出现占用一部分资源而后等待的状况。
最后提出的问题是最复杂的,叫读者-写者问题,在哲学家进餐问题中,资源是“独享”式的:一根筷子若是被一个哲学家取走了,则这根筷子只能属于该哲学家,除非他放下筷子。可是在读者-写者问题中,资源是既“独享”又“共享”的,咱们先看看其背景:
假设存在大量进程共享一个数据库(或文件),为了简化问题,咱们再假设进程要么是只会读取数据库的“读者”,要么是只会写入数据库的“写者”,同一时间数据库要么有一个写者在写、要么有不限量个读者在读、要么没有进程访问。
根据上述要求,该数据库做为一种资源,在读者与写者之间、写者与写者之间是“独享”的:有读者在数据库则写者得等,有写者在数据库则读者、其余写者得等。可是在读者与读者之间又是“共享”的:有读者在数据库则其余后到的读者能够进去读。
若是不容许读者-读者共享,那么问题就变得很简单,只要给数据库上一把“锁”便可,有进程在数据库,其余想进去的进程就得沉睡。因此读者-写者问题的关键难点就是:如何令已有读者在读的状况下,后来的读者能够进去?
根据关键难点的描述,一种被称为“读者优先”的解决思路被提出来:
设置共享变量rd_count,表示当前数据库中读者的数量,设置一把锁rdc_mutex,进程要想操做rd_count,必须利用锁rdc_mutex进出“rd_count的临界区”。再设置一把锁db_mutex,表示数据库的锁。
写者想进入数据库,必须在数据库内无人的状况下才行,即db_mutex解开时才能够进入数据库。同理,写者退出数据库时,必须解开db_mutex锁。
若读者想进入数据库,则必须知足两个条件其中一个:
1.数据库内无人且锁打开 2.数据库内有人可是是读者。
同理,读者退出数据库时,若数据库内还有人(读者)则直接推出,不然退出并解开db_mutex。咱们能够借助rd_count来实现对第二点的判断,详情见代码:
//读者优先解法 int db_mutex=1; //数据库的锁,db即database,1开0闭 int rd_count=0; //表示数据库中读者的数量,rd即reader int rdc_mutex=1; //rd_count的锁,rdc即reader_count,1开0闭 //读者进程,num即读者编号 void reader(int num) { while(true) { /***准备进入数据库***/ //先经过rdc_mutex进入rd_count临界区 down(rdc_mutex); //判断数据库内是否已有读者,如果,则负责抢数据库的锁 if(rd_count==0) down(db_mutex); //若本身是“第一个”读者,则执行至此时已抢到数据库并上了锁,因此令rd_count++ //若本身不是“第一个”读者,则直接令rd_count++ rd_count++; up(rdc_mutex); //离开rd_count临界区 read_data(); //读取数据库数据 /***准备离开数据库***/ //经过rdc_mutex进入rd_count临界区 down(rdc_mutex); //令rd_count--后判断数据库内是否已无读者,如果则解开数据库的锁,唤醒沉睡进程(如有,必为写者) rd_count--; if(rd_count==0) up(db_mutex); up(rdc_mutex); //离开rd_count临界区 use_data(); } } //写者进程,num即编号 void writer(int num) { while(true) { produce_data(); //欲进入数据库,检查锁,若锁上沉睡,不然锁上并进入 down(db_mutex); write_data(); up(db_mutex); //离开数据库,唤醒沉睡进程(多是读者也多是写者) } }/
显然,上述代码能够知足读者-写者问题的问题,只是存在一点“缺陷”:若是不断地有读者到来,以至于数据库内老是至少有一个读者,那么写者将永远没有机会进入数据库。这也是该解法被称为“读者优先”的缘由。由于只要数据库内有读者,那么后面来的读者就能够进入数据库,而不须要在乎是否有先到的写者想进入数据库。
可是在某些状况下,咱们但愿算法能知足:即便数据库内有读者,若是有写者先到达(在等待),那么后到达的读者也不能进入数据库,必须让先到达的、等待中的写者使用完数据库后才能够进入数据库。
要想知足该要求,被称为“读写平等”的解决思路被提了出来:在数据库“门前”设立一个“候选人”位置,每一个进程必须先成为候选人,再判断可否进入数据库。利用候选人机制,即便数据库内有读者(数量不定),只要写者抢占了候选人位置,后到达的读者就不能进入数据库,同理后到达的写者也需等待。若是令因没抢到候选人而沉睡的进程按到达时间顺序排成队列,而且唤醒时按队列顺序进行,那么进程访问数据库的顺序就是时间顺序的,所以这个算法也被称为“读写平等”算法。举例来讲,数据库内有进程,而后写者A到达、成为候选人、沉睡,多个读者到达、等待候选人位置、沉睡,写者B到达、等待候选人位置、沉睡,那么最后这些进程必定是按“写者A”、“读者群”、“写者B”的顺序进入数据库,也即按时间顺序进入的数据库,从而实现“读写平等”。
//读写平等 int candidate=1; //候选人资格锁,1无候选人0有候选人 int rd_count=0; //读者数量 int db_mutex=1; //数据库锁,1开0闭 int rdc_mutex=1; //rd_count的锁,须要锁是由于候选人读者和欲离开数据库的读者都须要读写rd_count void reader() { while(true) { //欲进入数据库,先争夺候选人 down(candidate); //成为候选人后,进入rd_count临界区 down(rd_count); //若数据库内无读者,则本身是第一个读者,负责给数据库上锁,以防止写者进入 if(rd_count==0) down(db_mutex); //修改读者数量,离开rd_count临界区,解开候选人锁(由于本身进入数据库) rd_count++; up(rd_count); up(candidate); read_data();//数据库内操做 //欲离开数据库,进入rd_count临界区,若本身是最后一个读者,解开数据库锁 down(rd_count); rd_count--; if(rd_count==0) up(db_mutex); up(rd_count); use_data(); //数据库外操做 } } void writer() { while(true) { produce_data(); //数据库外操做 //欲进入数据库,先夺得候选人资格 down(candidate); //成为候选人后,给数据库上锁,以保证只有本身在内 down(db_mutex); up(candidate); //进入数据库,解开候选人资格锁 write_data(); //数据库内操做 //离开数据库,解开数据库锁 up(db_mutex); } }
在某些特殊状况下,咱们可能须要一个更加极端的读者-写者算法,那就是“写者优先”:
1.只要有写者在等待,想进入数据库的读者就必须等待
2.数据库锁由锁上变为打开时,优先唤醒写者进程,不管是否有先于其到达的读者(从而打破了时间顺序,令写者有了优先权)
回顾读写平等算法,能够发现当数据库锁解开时,离开数据库的要么是写者,要么是最后的读者。
若是离开的是读者,并且有沉睡候选人,那么沉睡候选人必定是写者(读者不会由于数据库内有读者而沉睡),因此最后的读者只须要唤醒候选人便可保证“写者优先”。
问题出在离开的是写者的状况,写者离开时的沉睡候选人既多是写者,也多是读者,但写者离开时并无考虑这一点。所以要想实现写者优先,须要下手的是写者进程,让它们变得更为本身人考虑。
在读者优先算法中,读者能“给本身人优先权”的根本缘由在于读者掌控着数据库锁,只要读者不解开这把锁,写者就没法进入,但其它读者经过rd_count,获得了必定状况下无视数据库锁的“特权”。
所以,一种相似的解法被提了出来:设置变量wt_count表示数据库内以及想进入数据库的写者总数,想进入数据库的写者先经过wt_count判断数据库内是否有写者,若无则竞争候选人,再等待数据库锁,如有则直接等待数据库锁;想离开数据库的写者直接释放数据库锁(如有等待中的写者,此后便可进入),再经过wt_count判断是否还有其余写者,若无则解开候选人锁,如有则直接走人,由“最后一个”写者负责解开候选人锁。这个想法就是利用候选人锁,使得写者得以“卡住”数据库、保证如有其余写者则将数据库让给其余写者。从而实现了“写者优先”
//写者优先 int candidate=1; //候选人资格锁,1无候选人0有候选人 int wt_count=0; //数据库内及想进入数据库的写者数量 int rd_count=0; //数据库内的读者数量 int db_mutex=1; //数据库锁,1开0闭 int rdc_mutex=1; //rd_count的锁,须要锁是由于候选人读者和欲离开数据库的读者都须要读写rd_count int wtc_mutex=1; //wt_count的锁,须要锁是由于欲进入数据库的写者和欲离开数据库的写者都须要读写wt_count //reader进程与读写平等时相同 void reader() { while(true) { //欲进入数据库,先争夺候选人 down(candidate); //成为候选人后,进入rd_count临界区 down(rd_count); //若数据库内无读者,则本身是第一个读者,负责给数据库上锁,以防止写者进入 if(rd_count==0) down(db_mutex); //修改读者数量,离开rd_count临界区,解开候选人锁(由于本身进入数据库) rd_count++; up(rd_count); up(candidate); read_data();//数据库内操做 //欲离开数据库,进入rd_count临界区,若本身是最后一个读者,解开数据库锁 down(rd_count); rd_count--; if(rd_count==0) up(db_mutex); up(rd_count); use_data(); //数据库外操做 } } void writer() { while(true) { produce_data(); //数据库外操做 //欲进入数据库,先进入wt_count临界区,判断本身是不是“第一个”写者 down(wtc_mutex); wt_count++; if(wt_count==1) //若本身是“第一个”写者,则须要抢夺候选人 down(candidate); down(db_mutex); //不论本身是不是“第一个”写者,都须要等待数据库锁 write_data(); //数据库内操做 //欲离开数据库,直接解开数据库锁,再进入wt_count临界区,判断本身是不是“最后一个”写者 up(db_mutex); down(wtc_mutex); wt_count--; if(wt_count==0) //若本身是“最后一个”写者,则解开候选人锁,从而令读者有机会抢夺候选人、进入数据库 up(candidate); up(wtc_mutex); } }
⑤避免编程失误的“管程”
回顾三个经典进程通讯问题,能够发现,信号量机制的确能够解决进程通讯的问题,可是编程较为麻烦且容易出错形成死锁,以生产者-消费者问题为例,若是由于编程时的失误,某个生产者进程对信号量的操做顺序从
down(empty);//减小一个空位置,若已为0则沉睡 down(mutex);//进入临界区
变成了
down(mutex);//进入临界区 down(empty);//减小一个空位置,若已为0则沉睡
那么生产者就可能由于进入临界区后发现已无空位置而沉睡,而且没有解开mutex从而致使消费者无法取走产品,形成进程间的死锁。也就是说,经过直接对进程编程来使用信号量是“比较危险”的作法,一不当心就可能形成死锁等异常状况。所以,一种新的利用信号量实现进程通讯的思想被提了出来:管程。
经过对进程通讯的分析,能够发现,同一类进程对信号量的操做是相同的,好比生-消问题中的生产者,都是执行以下代码
down(empty);
down(mutex);
put_product();
up(mutex);
up(count);
而消费者都是执行以下代码
down(count);
down(mutex);
take_product();
up(mutex);
up(empty);
那么,咱们是否能够作出以下的一个独立的“模块”
int empty=N; int count=0; void put_product(productType x) { down(empty); put(x); up(count); } void take_product(productType &x) { down(count); x=take(); up(empty); }
而后作出以下限制(为简便,put_product()简记为p(),take_product()简记为t()):
1.同一时间只能有一个进程在执行p()或t(),其它调用了p()或t()的进程排队等待
2.一个进程若在执行p()或t()时由于down()某个信号量而阻塞,则挂起,让等待执行p()或t()的另外一个进程执行其调用的p()或t()
3.一个进程若在执行p()或t()时由于up()某个信号量而唤醒了某沉睡进程,则在当前进程退出p()或t()后,令被唤醒进程执行其以前调用的p()或t()
若是能实现这一限制,那么生产者和消费者就能够简单的完成本身想作的事,避免编程失误或者说方便进程通讯出错时debug
void producer() { productType x; while(true) { x=produce(); //生产产品 put_product(x); //放置产品 } } void consumer() { productType x; while(true) { take_product(&x); //取得产品 consume(x); //消费产品 } }
上述的所谓“模块”就是所谓的管程,习惯面向对象编程的人也能够将其视为一个类。之因此将这种实现进程通讯的技术称之为管程,是由于在旁人看来,管程就是一个管理员,其负责保证同一时间只能有一个进程调用某些方式,而且负责这些进程的沉睡与唤醒。
须要注意的是,就像信号量机制须要计算机底层的支持同样,管程也不是任意状况下均能实现。好比C语言就不可能实现管程,由于C语言没法知足管程须要的条件。可是有一些语言是能够实现管程的,好比JAVA,利用关键词synchronized,可使同一个类中的某些方法不能被“同时”执行,借助此支持,再将生产者、消费者、管程写在同一个类中,就能够实现(线程级别的)管程思想。
有关进程通讯的基础知识就是上面这些,可是进程通讯问题引出了另外一个问题——死锁。虽然本文提到过死锁,但一直是在避免死锁的出现。那么死锁万一出现了,该如何令操做系统知晓呢?操做系统知道有死锁发生后,能不能解开死锁呢?这类问题与进程通讯有关,但又自成一派,所以将其留做往后单独讨论。