zoukankan      html  css  js  c++  java
  • 操作系统知识——进程通信

      有关进程通信的知识主要分为五个部分:

      ①什么是进程通信;

      ②实现进程通信的误区;

      ③如何正确实现进程通信;

      ④经典的进程通信问题与信号量机制;

      ⑤避免编程失误的“管程”。

      本文将按照这五个部分的提出顺序进行讲解,力求通俗易懂、融会贯通。

     

      ①什么是进程通信?

      需要首先明确的是,进程通信并不是指进程间“传递数据”。

      为了说明进程通信,需要先介绍一下进程通信的背景。现代操作系统中的进程间可能存在着共享的内存区,比如字处理进程A(可以想象为Word)、字处理进程B(可以想象为记事本)和打印机进程C共享一小块内存:待打印文件地址队列。该队列中有一个指针out指向队列中下一个被打印的文件地址,还有一个指针in指向队列尾的后一位置,即新的待打印文件地址应存入的位置。显然,指针out是供进程C访问的,每当打印机空闲且out!=in,进程C就打印out所指的文件。而指针in则是供进程A与进程B访问的,每当它们有希望打印的文件时就执行如下三步:“读取in”、“向in所指位置写入待打印文件地址”、“修改in使其指向下一位置”。

      但是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,可以使同一个类中的某些方法不能被“同时”执行,借助此支持,再将生产者、消费者、管程写在同一个类中,就可以实现(线程级别的)管程思想。

     

     

     

      有关进程通信的基础知识就是上面这些,但是进程通信问题引出了另一个问题——死锁。虽然本文提到过死锁,但一直是在避免死锁的出现。那么死锁万一出现了,该如何令操作系统知晓呢?操作系统知道有死锁发生后,能不能解开死锁呢?这类问题与进程通信有关,但又自成一派,因此将其留作日后单独讨论。

  • 相关阅读:
    常见错误--06.18
    类目-延展-协议
    iOS页面传值-wang
    ios常见的页面传值方式
    delegate和protocol
    沙盒路径及文件 操作
    tableView优化性能
    IOS面试题
    浅谈Runloop
    面试题
  • 原文地址:https://www.cnblogs.com/mm93/p/7705145.html
Copyright © 2011-2022 走看看