进程间通信---如何避免竞争条件

进程间通信—如何避免竞争条件

竞争条件:有两个或多个进程读写某些共享数据,而最后的结果取决于进程运行的精确时序,称为竞争条件(race condition)。

进程间通信的三个问题:

  1. 如何传递?
  2. 如何确保两个或更多的进程在关键的活动中不会出现交叉?
  3. 如何确保进程按正确的顺序?

这三个问题中的两个问题对于线程来说是同样适用的,第一个问题(即传递信息)对线程而言比较容易,因为他们共享一个地址空间(在不同地址空间需要通信的线程属于不同进程之间通信的情形)。但是另外两个问题(需要梳理清楚并保持恰当的顺序)同样适用于线程。同样的问题可用同样的方法解决。

如何避免竞争条件

  要避免这种错误,关键是要找出某种途径来阻止多个进程同时读写共享的数据。换言之,需要的是互斥(mutual exclusion),即以某种手段确保当一个进程在使用一个共享变量或文件时,其他进程不能做同样的操作。

  使用一种更抽象的方式来竞争条件。一个进程的一部分时间做内部计算或另外一些不会引发竞争条件的操作。把对共享内存进行访问的程序片段称作临界区域(critical region)或临界区(critical section)。

  对于一个好的避免竞争条件的解决方案。 需要满足以下四个条件:

  1. 任何两个进程不能同时处于临界区
  2. 不应对CPU速度和数量做任何假设
  3. 临界区外运行的进程不得阻塞其他进程
  4. 不得使进程无限期等待进入临界区

忙等待的互斥方案

1.屏蔽中断

  CPU只有发生时钟中断或其他中断时才会进行进程切换。在单处理系统中,最简单的方法就是在进程在刚刚进入临界区后立即屏蔽所有中断,并在离开前打开中断。屏蔽中断后CPU将不会切换到其他进程。于是,一旦某个进程屏蔽中断之后,就可以检查和修改共享内存,而不必担心其他进程介入。

  这个方案并不好①把屏蔽中断的权利交给用户进程是不明智的,若一个进程屏蔽中断后不再打开中断,整个系统可能会因此终止。②如果系统是多处理器,则屏蔽中断仅仅对执行屏蔽中断指令的那个CPU有效。其他CPU可以访问共享内存。

  屏蔽中断对于操作系统本身而言是一项很有用的技术,但是对于用户进程则不是一种合适的通用互斥机制。如今多核芯片数量的数量越来越多,因此屏蔽中断来达到互斥的可能性—甚至在内核中—变得日益减少。

2. 锁变量

  设想有一个共享锁变量,其初始值为0。当一个进程想进入其临界区,它首先测试这把锁。如果该锁的值为0,则该进程将其设置为1并进入临界区。若这把锁值已经为1,则该进程将等待直到其值为0。

  这个方法不能实现互斥。试想,一个进程读出锁变量的值并发现它为0,而在它将锁变量值设置为1之前,另一个进程被调度运行(此时锁边量仍为0),于是便有两个进程同时进入了临界区。

3. 严格轮换法

  如下代码所示,整型变量turn,初始值为0,用于记录轮到哪个进程进入临界区,并检查或更新共享内存。开始时,进程0检查turn,其值为0,于是进入临界区。进程1发现其值为0,则其在循环中不停检测turn,看其值何时变为1。

  连续测试一个变量直到某个值出现为止吗,称为忙等待(busy waiting)。(这种方式浪费CPU时间,因此通常需要避免,只有认为等待时间是非常短的情形下,才使用忙等待)。用于忙等待的所,称为自旋锁(spin lock)。

//进程0
void thread0(){
    while(true){
        while(turn!=0);//循环检查
        
        critical_region();//临界区
        turn = 1;
        noncritical_region();//非临界区代码
    }
}
//进程1
void thread1(){
    while(true){
        while(turn!=1);//循环检查
        
        critical_region();//临界区
        turn = 0;
        noncritical_region();//非临界区代码
    }
}

  在一个进程比另一个慢了很多的情况下,轮流进入临界区并不是一个好办法。这种情况(两个进程同处于非临界区,需要等一个进程修改turn),违反了前述判断一个好的互斥方案的情况三,临界区之外进程阻塞了想进入临界区的进程。

3.严格轮换法

4.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;//表示离开临界区
}

  看看这个方案是如何工作的。一开始,没有任何进程处于临界区中,现在进程0调用enter_region,它通过设置数组元素和将turn置为0来标识它希望进入临界区。假设进程1还没有调用enter_region,则进程0会进入临界区。进程1再调用enter_region,此时进程1会进入检测循环。知道进程0离开临界区,并调用leave_region。

  考虑两个进程几乎同时调用enter_region的情况。它们都将自己的进程号存入turn,但只有后被保存进去的进程好才有效。假设进程1是后存入的,则turn为1。此时turn=1,interested[0]和interested[1]均为true。则当两个进程运行到while循环时,则进程0将循环0次并进入临界区,而进程1将不停的循环且不能进入临界区,直到进程0退出临界区为止。

5.TSL指令

  这是一种硬件支持的方案。在某些计算机中,特别是那些设计为多处理器的计算机,都有下面一条指令:

TSL RX,LOCK。这被称为测试并加锁(test and set lock)。它将一个内存字lock读到寄存器RX中,然后再该内存地址上存一个非零值。读字和写字的操作保证是不可分割的(具有原子性),即该指令结束之前其他处理器均不允许访问该内存字。

  执行TSL指令的CPU将锁住内存总线,以禁止其他CPU在本指令结束之前访问内存。锁住存储总线不同与屏蔽中断。

  为了使用TSL指令,要使用一个共享变量lock来协调对共享内存的访问。当lock为0时,任何进程都可以使用TSL指令将其设置为1,并读写共享内存。当操作结束时,进程再将lock设置为1。如何使用这条指令来防止两个进程同时进入临界区?如下代码所示

enter_region:
	TSL REGISTER,LOCK |复制锁到寄存器并将锁设为1
	CMP REGISTER,#0   |锁是0嘛?
	JNE enter_region  |若不是0,说明锁已被设置,所以循环
	RET               |若是0,说明锁之前未被设置,所以进入临界区
	
leave_region:
	MOVE LOCK,#0      |在锁中存入0
	RET               |返回调用者

  Peterson解法和TSL解法都是正确的,但它们都是正确的,但它们都有忙等待的缺点。这些解法本质上是这样的:**当一个进程想进入临界区时,先检查是否允许进入,若不允许,则该进程将原地等待,直到允许为止。**①这种方法浪费CPU时间。②还可能引起预想不到的结果。考虑一台计算机有两个进程,H优先级较高,L优先级较低。调度规则规定,只要H处于就绪态它就可以运行。**在某一时刻,L处于临界区中,此时H变到就绪态,准备运行。现在H开始忙等待,但由于当H就绪时,L不会被调度,也就无法离开临界区,所以H将永远忙等待下去。**这种情况有时被称作优先级反转问题(priority inversion problem)。

  使用进程间的通信原语可以解决忙等待问题,使用它们将在***无法进入临界区时将阻塞而不是忙等待***。最简单的是sleep和wakeup。sleep是一个将引起调用进程阻塞的系统调用,即被挂起,直到另外一个进程将其唤醒。wakeup调用有一个参数,即要被唤醒的进程。

生产者-消费者问题(producer-consumer)

  这也被称为有界缓冲区(bounded-buffer)问题。连个进程共享一个公共的固定大小的缓冲区。其中一个是生产者,将信息放入缓冲区;另一个是消费者,从缓冲区中取出信息。(为了简化解决方案,只讨论一个生产者和一个消费者的情况)。

  问题在于当缓冲区已满,而此时生产者还想向其中放入一个新的数据项情况。其解决办法是让生产者睡眠,待消费者从缓冲区中取出一个或多个数据项时在唤醒它。同样地,当消费者试图从缓冲区中取数据而发现缓冲区为空时,消费者就睡眠,直到生产者向其中放入一些数据时再将其唤醒。

  为了跟踪缓冲区中的数据项数,需要一个变量count。假设缓冲区最多存放N个数据项。

  生产者:首先检查count是否达到N,若是睡眠。否则生产者向缓冲区中放入一个数据项,并增加count的值。

  消费者:首先检查count是否为0,若是睡眠。否则从缓冲区中取走一个数据项,并减少count的值。每个进程同时也检测另一个进程是否应该唤醒,若是则唤醒之。其代码如下所示:

#define N 100 //缓冲区中的槽数目
int count = 0;//缓冲区中的数据项数目

void producer(){
    int item;
    
    while(true){
        item = produce_item();
        if(count==N) sleep();//如果缓冲区满了,睡眠
        insert_item(item);
        count = count + 1;
        if(count == 1 ) wakeup(consumer);//缓冲区空嘛,非空是唤醒消费者
    }
}

void consumer(){
    int item;
    
    while(true){
        if(count==0) sleep();//如果缓冲区空了,睡眠
        item = remove_item(item);
        count = count - 1;
        if(count == N - 1 ) wakeup(producer);//缓冲区满嘛,不满唤醒生产者
        consume_item(item);//消费数据
    }
}

  这里有可能出现竞争条件,其原因是对count的访问未加限制。有可能出现以下情况:缓冲区为空,消费者刚刚读取count的值发现它为0,此时消费者并未休眠。然而调度程序决定暂停消费者并启动运行生产者。生产者向缓冲区中加入一个数据项,count加1。现在count的值为1。它推断由于count刚才为0,所以消费者此时一定在睡眠,于是生产者调用wakeup来唤醒消费者。**但是消费者此时并未休眠,所以wakeup信号丢失。**消费者再次运行时,将会睡眠。而生产者迟早会填满整个缓冲区,然后睡眠。这样一来,两个进程将永远睡眠下去。

  问题的实质在于发给一个尚未睡眠进程的wakeup信号丢失了。如果它没有丢失,则一切都很正常。一种快速的弥补方法是修改规则,加上一个唤醒等待位。当一个wakeup信号发送给一个清醒的进程信号时,将该位置设置为1。**随后,当该进程要睡眠时,如果唤醒等待位为1,则该位清除,而该进程仍然保持清醒。**尽管在这个简单的例子中用唤醒等待位的方法解决了问题,但是如果有一个三个或更多的进程,一个唤醒等待位就不够用了,于是我们可以再加入更多唤醒等待位。从原则上讲,我们并没有从根本上解决问题。(设置唤醒等待位,与设置count一样同样会出现竞争条件,与count的例子类似)

信号量

  信号量是Dijkstra在1965年提出的一种方法,他使用一个整型变量来累计唤醒次数,供以后使用。在他的建议中引入一个新的变量类型,称作信号量(semaphore)。一个信号量的取值可以为0(表示没有保存下来的唤醒操作)或者为正值(表示有一个或多个唤醒操作)。

  Dijkstra建议设立两种操作:P和V操作。(分别为一般化的sleep和wakeup)。***对一个信号量执行P操作,是检查其值是否大于0。若该值大于0,则将其值减1(即用掉一个保存的唤醒信号)并继续;若该值为0,则进程将睡眠。*****检查数值、修改变量值以及可能发生的睡眠操作均作为一个单一的、不可分割的原子操作完成。**V操作对信号量的值增1。如果一个或多个进程在该信号量上睡眠,无法完成一个先前的P操作,则由系统选择其中一个并允许该进程完成它的P操作。于是对一个有进程在其上睡眠的信号量执行一次V操作之后,该信号量的值仍旧是0,但在其上睡眠的进程却少了一个。**信号量的增1与唤醒一个进程同样是不可分割的。**不会有某个进程因执行UP而阻塞,正如前述中不会有进程因执行wakeup而阻塞一样。

  为确保信号量能正确工作,最重要的是采用一种不可分割的方式来实现它。通常是吧P和V作为系统调用实现,而且操作系统只需执行以下操作是暂时屏蔽全部中断:测试信号量、更新信号量以及在需要是使某个进程睡眠。由于这些动作只需要几条指令,所以屏蔽中断不会带来什么副作用。如果使用多个CPU则每个信号量应由一个锁变量进行保护。通过TSL指令来确保同一时刻只有一个CPU在对信号量进行操作。

用信号量来解决生产者-消费者问题

  解决方案中使用了三个信号量:一个称为full,用来记录充满的缓冲槽的数目;一个称为empty,记录空的缓冲槽数目;一个称为mutex,用来确保生产者和消费者不会同时访问缓冲区。full的初值为0,empty初值为缓冲区的槽数量,mutex初值为1。具体方案如下代码所示。

  供两个或多个进程使用的信号量,其初值为1,保证同时只有一个进程可以进入临界区,称作二元信号量(binary semaphore)。如果每个进程在进入临界区前对mutex做一个P操作,并在刚刚推出时执行一个V操作,就能实现互斥。

#define N 100
typedef int semaphore;
semaphore mutex = 1;
semaphore empty = N;
semaphore full  = 0;

void producer(){
    int item;
    while(true){
        item = produce_item();
        P(&empty);
        P(&mutex);
        insert_item(item);
        V(&mutex);
        V(&full);
    }
}

void consumer(){
    int item;
    
    while(true){
        P(&full);
        P(&mutex);
        item = remove_item();
        v(&mutex);
        V(&empty);
        consume_item(item);
    }
}

  本例找那个mutex信号量用来实现互斥。信号量的另一种用途是用于实现同步(synhronization)。信号量full和empty用来保证某种事件的顺序发生或不发生。在本例中,它们保证当缓冲区满的时候生产者停止运行,以及当缓冲区空的时候消费者停止运行。这与互斥是不同的。

互斥量

  如果不需要信号量的计数能力,有时可以使用信号量的一个简化版本,称为互斥量(mutex)。互斥量仅仅适用于管理共享资源或一小段代码。

  互斥量是一个可以处于两态之一的变量:解锁和加锁。这样只需一个二进制位表示它,不过实际上,常常使用一个整形量,0表示解锁,而其他所有的值则表示加锁。互斥量使用两个过程。当一个线程(或进程)需要访问临界区时,它调用mutex_lock。如果该互斥量当前是解锁的(即临界区可用),此调用成功,调用线程可以自由进入该临界区。 另一方面,如果该互斥量已经加锁,调用线程被阻塞,直到在临界区中的线程完成并调用mutex_unlock。如果多个线程被阻塞在该互斥量上,将随机选择一个线程并允许他获得所锁。用户级线程包的mutex_lock和mutex_unlock代码如下所示:

mutex_lock:
	TSL REGISTER,MUTEX		|将互斥信号量复制到寄存器,并且经互斥信号量置为1
    CMP REGISTER,#0         |互斥信号量是0吗?
    JZE ok					|如果互斥信号量为0,它被解锁,所以返回
    CALL thread_yield       |互斥信号量忙:调度另一个线程
    JMP mutex_lock			|稍后再试
ok:RET						|返回调用者:进入临界区
 
mutex_unlock;
    MOVE MUTEX,#0			|将mutex置为0
    RET						|返回调用者

mutex_lock不会进入忙等待(重复测试锁)。在取锁失败后,它调用thread_yield将CPU放弃给另一个线程。

Reference

  1. 现代操作系统 第四版
  • 2
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值