进程间通信相关。
参考:操作系统设计与实现(摘录并总结概念) 百度百科(摘录)
竞争条件
竞争条件:指多个线程或者进程在读写一个共享数据时,结果依赖于它们执行的相对时间的情形。(共享数据之间的读写矛盾)
如何避免竞争条件?
实际上凡牵涉到共享内存、共享文件、以及共享任何资源的情况都会引发与前边类似的错误。
要避免这种错误,关键是要找到某种途径来阻止多于一个的进程同时读写共享的数据。
换言之,我们需要的是互斥(mutual exclusion) - 即某种手段以确保当一个进程在使用一个共享变量或文件时,其他进程不能做同样的操作。为实现互斥而选择适当的原语是任何操作系统的主要设计内容之一。
我们把对共享内存进行访问的程序片段称作临界区(critical region),或临界段(criticalsection)。如果我们能够适当地安排使得两个进程不可能同时处于临界区,则就能够避免竞争条件。
尽管这样的要求防止了竞争条件,但它还不能保证使用共享数据的并发进程能够正确和高效地进行操作。对于一个好的解决方案,我们需要以下4个条件:
1 任何两个进程不能同时处于临界区。
2 不应对CPU的速度和数目作任何假设。
3 临界区外的进程不得阻塞其他进程。
4 不得使进程在临界区外无休止地等待。
解决方法
1.关 中 断
这种最简单的方法是使每个进程在进入临界区后先关中断,在离开之前再开中断。
中断被关掉后,****时钟中断也被屏蔽。CPU只有在发生时钟或其他中断时才会进行进程切换,这样关中断之后CPU将不会被切换到其他进程。于是,一旦进程关中断之后,它就可以检查和修改共享内存,而不必担心其他进程的介入。
这种方案不好,因为把关中断的权力交给用户进程是不明智的。设想一下若一个进程关中断之后不再开中断,其结果将会如何?系统可能会因此终止。而且,如系统有两个或多个共享内存的处理器,则关中断仅仅对执行本指令的CPU有效,其他CPU仍将继续运行,并可以访问共享内存。(多CPU,多进程)
另一方面,对核心来说,当它在更新变量或列表的几条指令期间将中断关掉是很方便的。因为当诸如就绪进程队列之类的数据状态不一致时发生中断,则将导致竞争条件。所以得出结论:关中断对于操作系统本身是一项很有用的技术,但对于用户进程则不是一种合适的通用互斥机制。
2.锁变量
作为第二种尝试,我们来寻找一种软件解决方案。设想有一个共享(锁)变量,初值为0。当一个进程想进入其临界区时,它首先测试这把锁。如果锁的值为0,则进程将其置为1并进入临界区。若锁已经为1,则进程将等待直到其变成0。于是,0就表示临界区内没有进程,1表示已经有某个进程进入了临界区。
不幸的是,这种想法也含有与Spooler目录一样的纰漏。假设一个进程读锁变量的值并发现它为0,而恰好在它将其置为1之前,另一个进程被调度运行,将锁变量置为1。当第一个进程再次能运行时,它同样也将锁置为1,则此时同时有两个进程处于临界区中。可能你会想,先读取锁变量,紧挨着在改变其值之前再检查一遍它的值,这样便可以解决问题。但这实际上无济于事。如果第二个进程恰好在第一个进程完成第二次检查之后修改锁变量,则同样还会发生竞争条件。
3.还有就是用while(true)不停检测,确定是会出现忙等待。
Peterson 解决方法(记忆中就是,只关乎A/B两个线程,各有一个钥匙,A要进不仅需要A表示自己想进还需要B表示自己不想进)
TSL解决方法(大概主要作用,多CPU只有一个可以进行操作)
其工作如下所述:它将一个存储器字读到一个寄存器中,然后在该内存地址上存一个非零值。读数和写数操作保证是不可分割的-即该指令结束之前其他处理机均不允许访问该存储器字。执行TSL指令的CPU将锁住内存总线以禁止其他CPU在本指令结束之前访问内存。
Peterson解法和TSL解法都是正确的,但它们都有忙等待的缺点。这些解法在本质上是这样的:当一个进程想进入临界区时,先检查是否允许进入,若不允许,则进程将踏步等待,直到许可为止。
这种方法不仅浪费CPU时间,还可能引起预料不到的结果。考虑一台计算机有两个进程,H优先级较高,L优先级较低。调度规则规定只要H处于就绪态它就可以运行。在某一时刻,L处于临界区中,此时H变到就绪态准备运行(例如,一条I/O操作结束)。现在H开始忙等待,****但由于当H就绪时L不会被调度,也就无法离开临界区,所以H将永远忙等待下去。这种情况有时被称作优先级翻转问题(priority inversion problem)。然后H为了可以运行有时就借优先权。
但其实上述两种方法依然有忙等待,所以在无法进入临界区时将阻塞,而不是忙等待。最简单的是睡眠(SLEEP)和唤醒(WAKEUP)。SLEEP系统调用将引起调用进程阻塞,即被挂起,直到另一进程将其唤醒。
生产者 - 消费者问题
作为如何使用这些原语的一个例子,我们考虑生产者-消费者问题(也称作有界缓冲区问题)。两个进程共享一个公共的固定大小的缓冲区。其中的一个,生产者,将信息放入缓冲区;另一个,消费者,从缓冲区中取出信息(该问题也可被推广到m个生产者,n个消费者的情况,但出于简单起见,我们只考虑一个生产者,一个消费者的情况)。
麻烦之处在于当缓冲区已满,而此时生产者还想向其中放入一个新的数据项的情况。解决办法是让生产者睡眠,待消费者从缓冲区中取走一个或多个数据项时再唤醒它。同样地,当消费者试图从缓冲区中取数据而发现缓冲区为空时,它就睡眠,直到生产者向其中放入一些数据时再将其唤醒。
这种方法听起来很简单,但它包含与前边Spooler目录问题一样的竞争条件。为了跟踪缓冲区中的数据项数,我们需要一个变量count。
如果缓冲区最多存放N个数据项,则生产者代码将首先检查count是否达到N,若是,则生产者睡眠;否则生产者向缓冲区中放入一个数据项并递增count的值。
消费者的代码与此类似:首先看count是否为0,若是则睡眠;否则从中取走一个数据项并递减count的值。每个进程同时也检测另一个是否应睡眠,若不应睡眠则唤醒之。
现在回到竞争条件的问题。这里有可能会出现竞争条件,其原因是对count的访问未加****限制(count算是共享变量)。有可能出现以下情况:缓冲区为空,消费者刚刚读取count的值发现它为0。此时调度程序决定暂停消费者并启动运行生产者。生产者向缓冲区中加入一个数据项,将count加1。现在count的值变成了1。它推断认为由于count刚才为0,所以消费者此时很可能在睡眠,于是生产者调用wakeup来唤醒消费者。
不幸的是,消费者此时在逻辑上并未睡眠,所以唤醒信号丢失。当消费者下次运行时,它将测试先前读到的count值,发现它为0,于是去睡眠。这样生产者迟早会添满整个缓冲区,然后睡眠。这样一来两个进程都将永远睡眠下去。这里问题的实质在于发给一个(尚)未睡眠进程的唤醒信号丢失了。如果它没有丢失,则一切都很正常。
一种快速的弥补方法是修改规则,加上一个唤醒等待位(wakeup waitingbit)。当向一个清醒的进程发送一个唤醒信号时,将该位置位。随后,当进程要睡眠时,如果唤醒等待位为1,则将该位清除(唤醒位间接表示是否有资源(即先检测资源,再检测唤醒位),由生产者操作,比较新),而进程仍然保持清醒。
尽管在本例中唤醒等待位解决了问题,但很容易就可以构造出一些例子,其中有两个或更多的进程,这时一个唤醒等待位就不敷使用。我们可以再打一个补丁,加入第二个唤醒等待位,或者甚至是8个、32个,但原则上讲这并未解决问题。
信号量
2.2.5 信号量
信号量是E. W. Dijkstra在1965年提出的一种方法,它使用一个整型变量来累记唤醒次数,以供以后使用。在他的建议中引入一个新的变量类型,称作信号量(semaphore)。一个信号量的值可以为0,表示没有积累下来的唤醒操作;或者为正值,表示有一个或多个被积累下来的唤醒操作。
P、V均为原子性操作。
Dijkstra建议设两种操作:DOWN和UP(分别为推广后的SLEEP和WAKEUP)。对一信号量执行DOWN(sleep)操作是检查其值是否大于0。若是则将其值减1(即,用掉一个保存的唤醒信号)并继续。(以信号量代替资源数目)若值为0,则进程将睡眠,而且此时DOWN操作并未结束。
检查数值、改变数值、以及可能发生的睡眠操作均作为一个单一的、不可分割的原子操作(atomic action)完成。****即保证一旦一个信号量操作开始,则在操作完成或阻塞之前****别的进程****均不允许访问该信号量。****(对信号量的改变等操作一定是原子的,同样属于共享数据)(但是关中断是必须的,关中断才不会有时钟中断,进程才不会切换。)
这种原子性对于解决同步问题和避免竞争条件是非常重要的。
UP操作递增信号量的值。如果一个或多个进程在该信号量上睡眠,无法完成一个先前的DOWN操作,则由系统选择其中的一个(例如,随机挑选)并允许其完成它的DOWN操作。于是,对一个有进程在其上睡眠的信号量执行一次UP操作之后,该信号量的值仍旧是0,但在其上睡眠的进程却少了一个。递增信号量的值和唤醒一个进程同样也是不可分割的。不会有进程因执行UP而阻塞,正如在前面的模型中不会有进程因执行wakeup而阻塞一样在Dijkstra最早的论文中。他使用P和V而不是DOWN和UP,但因为对于不讲荷兰语的读者来说采用什么记号并无大的干系,所以我们将使用DOWN和UP。它们在Algol 68中首被引入。
用信号量解决丢失的唤醒问题。最重要的是它采用一种不可分割的方式来实现。通常是将UP和DOWN作为系统调用实现,而且操作系统只需在执行以下操作时短暂地关掉中断(没有中断),这些操作包括:检测信号量、修改信号量、以及在需要时使进程睡眠。由于这些动作只需要几条指令,所以关中断不会带来什么副作用。如果使用多个CPU,则每个信号量应由一个锁变量进行保护。通过TSL指令来确保同一时刻只有一个CPU在对信号量进行操作。
读者必须搞清楚使用TSL来防止几个CPU同时访问信号量,与生产者或消费者使用忙等待来等待对方腾出或填充缓冲区是完全不同的。信号量操作仅需几个微秒,而生产者或消费者则可能需要任意长的时间。
该解决方案使用了三个信号量:full用来记录满的缓冲槽数目,empty记录空的缓冲槽总数,mutex用来确保生产者和消费者不会同时访问缓冲区。full的初值为0,empty的初值为缓冲区内槽的数目,mutex初值为1。两个或多个进程使用的初值为1的信号量保证同时只有一个进程可以进入临界区,它被称作二进制信号量(binary semaphore)。如果每个进程在进入临界区前都执行一个DOWN操作,并在退出时执行一个UP操作,则能够实现互斥。
信号量可以表示资源,为0就睡。但是信号量也可以表示互斥。
eg:在使用信号量的系统中,隐藏中断机构的最自然的方法是为每一个I/O设备设置一个信号量,初值设为0。在启动一个I/O设备之后,其管理进程对相关联的信号量执行一DOWN操作,于是立即被阻塞。当中断到来时,中断处理程序随后对相关联的信号量执行一个UP操作,这将使相关的进程再次成为就绪。
信号量mutex用于互斥。它用于保证任一时刻只有一个进程读写缓冲区和相关的变量。互斥是避免混乱所必需的。
信号量的另一种用途是同步(synchronization)。信号量full和empty用来保证一定的事件顺序发生或不发生。在本例中,它们保证当缓冲区满的时候生产者停止运行,以及当缓冲区空的时候消费者停止运行。
这种用法与互斥是不同的。
管程:
管程 (英语:Monitors,也称为监视器) 是一种类操作系统,结构内的多个子程序(对象或模块)形成的多个工作线程互斥访问共享资源。这些共享资源一般是硬件设备或一群变量。
管程实现了在一个时间点,最多只有一个线程在执行管程的某个子程序。与那些通过修改数据结构实现互斥访问的并发程序设计相比,管程实现很大程度上简化了程序设计
为了更易于编写正确的程序,Hoare(1974)和Brinch Hansen(1975)提出了一种高级的同步原语,称为管程(monitor)。他们两人提出的方案略有不同,如下面将要描述的。一个管程是一个由过程、变量及数据结构等组成的一个集合,它们组成一个特殊的模块或软件包。进程可在任何需要的时候调用管程中的过程,但它们不能在管程外的过程中直接访问管程内的数据结构。
当一个进程调用管程中的过程时,前几条指令将检查在管程中是否有其他的活跃进程。如果有,调用进程将挂起,直到另一个进程离开管程。如果没有,则调用进程便进入管程。
尽管如我们上边所看到的,管程提供了一种实现互斥的简便途径,但这还不够。我们还需要一种办法以使得进程在无法继续运行时被阻塞。在生产者-消费者问题中,很容易将针对缓冲区满和缓冲区空的测试放到管程的过程中,但是生产者在发现缓冲区满的时候如何阻塞?
解决方法在于引入条件变量(condition variables),及相关的两个操作:WAIT和SIGNAL。当一个管程过程发现它无法继续时(例如,生产者发现缓冲区满),它在某些条件变量上执行WAIT,如full。这个动作引起调用进程阻塞。它也允许另一个先前被挡在管程之外的进程现在进入管程。另一个进程,如消费者,可以通过对其伙伴正在其上等待的一个条件变量执行SIGNAL来唤醒正在睡眠的伙伴进程。为避免管程中同时有两个活跃进程,我们需要一条规则来通知在SIGNAL之后该怎么办。
Hoare建议让新唤醒的进程运行,而挂起另一个进程。Brinch Hansen则建议要求执行SIGNAL的进程必须立即退出管程。换言之,SIGNAL语句只可能作为一个管程过程的最后一条语句。
我们将采纳Brinch Hansen的建议,因为它在概念上更简单,并且容易实现。如果在一个条件变量上正有若干进程等待,则对该条件变量执行SIGNAL操作,调度程序将在其中选择一个使其恢复运行。
条件变量不是计数器,它们并不像信号量那样积累信号供以后使用,所以如果向一个其上没有等待进程的条件变量发送信号,则该信号将丢失。WAIT操作必须在SIGNAL之前。这条规则使得实现简单了许多。实际上这不是一个问题,因为用变量很容易跟踪每个进程的状态。一个原本要执行SIGNAL的进程通过检查这些变量便可以知道该操作是不需要的。
你可能会觉得WAIT和SIGNAL操作看起来很象前面提到的SLEEP和WAKEUP。它们确实很象,但存在一点很关键的区别:SLEEP和WAKEUP之所以失败是因为当一个进程想睡眠时另一个进程试图去唤醒它。
使用管程将不会发生这种情况。对管程过程的互斥保证了这样一点:
如果管程中的过程发现缓冲区满,它将能够完成WAIT操作而不用担心调度程序可能会在WAIT完成之前切换到消费者进程。消费者进程甚至在WAIT完成且生产者被标志为不可运行之前根本不允许进入管程。,管程是一个编程语言概念。编译器必须要识别出管程并用某种方式对互斥
作出安排。C、Pascal、及多数其他语言都没有管程,所以指望这些编译器来实现互斥规则是不可靠的。实际上,编译器如何知道哪些过程属于管程内部,哪些不属于管程也是一个问题。
上述语言同样也没有信号量,但增加信号量是很容易的:你需要做的就是向库里加入两段短小的汇编程序代码,以执行UP和DOWN系统调用。编译器甚至用不着知道它们的存在。
当然,操作系统必须知道信号量,但至少如果你有一个基于信号量的操作系统,你仍旧可以使用C或C++(或者甚至是BASIC,如果你喜欢的话)来写用户程序。如果使用管程,你就需要一种带有管程的语言。有几种语言带有管程,如并发Euclid(Holt,1983),但非常少见。
与管程和信号量有关的另一个问题,是它们都被设计用来解决访问一块公共存储器的一个或多个CPU上的互斥问题。通过将信号量放在共享内存中并用TSL指令来保护它们,我们可以避免竟争。对于一个具有多个CPU、各CPU拥有自己的私有内存、由一个局域网相连的分布式系统,这些原语将失效。得出的结论是:信号量太低级,而管程在少数几种编程语言以外无法使用。同时,这些原语均未提供机器间的信息交换方法,所以我们还需要其他的东西。
信号量——多个进程间(可能)访问共享资源时互斥(可以用来)。还可以表示同步。
条件变量:wait(full)因满,生产者阻塞。signal(empty)因空,生产者被唤醒