文章目录
进程间通信
进程经常需要与其他进程通信。例如,在一个shell
管道中,第一个进程的输出必须传送给第二个进程,这样沿着管道传递下去。因此在进程之间需要通信,必须要使用一种良好的数据结构以至于不能被中断。
关于进程间通信,这里有三个问题。
- 一个进程如何把信息传递给另一个。
- 确保两个或更多的进程在关键活动中不会出现交叉。
- 第三个问题是数据的先后顺序的问题,如果进程A产生数据并且进程B打印数据,则进程B打印数据之前需要先等A产生数据之后才能够进行打印。
有必要说明,这三个问题中的两个问题对于线程来说是同样适用的。第一个问题(即传递信息)对线程而言比较容易,因为它们共享一个地址空间(在不同地址空间需要通信的线程属于不同进程之间通信的情形)。但是另外两个问题同样适用于线程。同样的问题可用同样的方法解决。同样的问题和解决方法也适用于线程。
竞争条件
在一些操作系统中,协作的进程可能共享一些彼此都能读写的公用存储区。这个公用存储区可能在内存中,也可能是一个共享文件。
为了理解实际中进程间通信如何工作,我们考虑一个简单但很普遍的例子:一个后台打印程序,当一个进程需要打印某个文件时,它会将文件名放在一个特殊的假脱机目录下。另一个进程(打印机守护进程)会定期的检查是否有文件需要被打印,如果有的话就打印并将该文件名从目录下删除。
设想打印机目录中有许多槽位,编号依次为0,1,2,…,每个槽位存放一个文件名。同时假设有两个共享变量:out
,指向下一个要打印的文件;in
,指向目录中下一个空闲槽位。可以把这两个变量保存在一个所有进程都能访问的文件中,该文件的长度为两个字。在某一时刻,0号至3号槽位空(其中的文件已经打印完毕),4号至6号被占用(其中存有排好队列的要打印的文件名)。几乎在同一时刻,进程A和进程B都决定将一个文件排队打印,这种情况如图所示。
可能发生以下的情况。进程A读到in
的值为7,将7存放到一个局部变量next_free_slot
中,此时发生一次时钟中断,CPU认为进程A已经运行了足够长的时间,决定切换到进程B。进程B也读取in
,于是将7存在B的局部变量next_free_slot
中。在这一时刻两个进程都认为下一个可用槽位是7。
进程B现在继续运行,它将其文件名存在槽位7中并将in
的值更新为8。然后它离开,继续执行其他操作。
最后进程A接着从上次中断的地方再次运行。它检查变量next_free_slot
,发现其值为7,于是将打印文件名存入7号槽位,这样就把进程B存在哪里的文件名覆盖掉。然后它将next_free_slot
加1,得到值为8,就将8存放到in
中。此时,假脱机目录内部是一致的,所以打印机守护进程发现不了任何错误,但进程B却永远得不到任何打印输出。
两个或多个进程读写某些共享数据,而最后的结果取决于进程运行的精确时序,称为竞争条件。
临界区
怎样避免竞争条件?实际上凡涉及共享内存,共享文件以及共享任何资源的情况都会引发与前面类似的错误,要避免这种错误,关键是要找出某种途径来阻止多个进程同时读写共享的数据。换言之,我们需要的是互斥,即以某种手段确保当一个进程在使用一个共享变量或文件时,其他进程不能做同样的操作。
避免竞争条件的问题也可以用一种抽象的方式进行描述。一个进程的一部分时间做内部计算或另外一些不会引发竞争条件的操作。在某些时候进程可能需要访问共享内存或共享文件,或执行另外一些会导致竞争的操作。我们对共享内存进行访问的程序片段称为临界区域或临界区。如果我们能够适当的安排,使得两个进程不可能同时处于临界区中,就能够避免竞争条件。
尽管这样的要求避免了竞争条件,但它还不能保证使用共享数据的并发进程能够正确和高效地进行协作。
对于一个好的解决方案,需要满足以下4个条件。
- 任何两个进程不能同时处于其临界区。
- 不应对CPU的速度和数量做任何假设。
- 临界区外运行的进程不得阻塞其他进程。
- 不得使进程无限期等待进入临界区。
从抽象的角度来看,人们所希望的进程行为如图所示。图中进程A在T1时刻进入临界区。稍后,在T2时刻进程B试图进入临界区,但是失败了,因为另一个进程已经在该临界区内,而一个时刻只允许一个进程在临界区内。随后,B被暂时挂起直到T3时刻A离开临界区为止,从而允许B立即进入。最后,B离开(在时刻T4),回到了在临界区中没有进程的原始状态。
忙等待的互斥
下面讨论几种实现互斥的方案。在这些方案中,当一个进程在临界区中更新共享内存时,其他进程将不会进入其临界区,也不会带来任何麻烦。
屏蔽中断
在单处理器系统中,最简单的方法是使每个进程在刚刚进入临界区后立即屏蔽所有中断,并在就要离开之前再打开中断。屏蔽中断后,时钟中断也被屏蔽。CPU只有发生时钟中断或其他中断时才会进行进程切换,这样,在屏蔽中断之后CPU将不会被切换到其他进程。于是,一旦某个进程屏蔽中断之后,它就可以检查和修改共享内存,而不必担心其他进程介入。
这个方案并不好,因为把屏蔽中断的权利交给用户进程是不明智地。设想一下,若一个进程屏蔽中断后不再打开中断,其结果将会如何?整个系统可能会因此终止。而且,如果系统是多处理器,则屏蔽中断仅仅对执行disable
指令的那个CPU有效。其他CPU仍将继续运行,并可以访问共享内存。
另一方面,对内核来说,当它在更新变量或列表的几条指令期间将中断屏蔽是很方便的。当就绪进程队列之类的数据状态不一致时发生中断,则将导致竞争条件。所以结论是:屏蔽中断对操作系统本身而言是一项很有用的技术,但对于用户进程则不是一种合适的通用互斥机制。
锁变量
设想有一个共享锁变量,其初始值为0。当一个进程想进入其临界区时,它首先测试这把锁。如果该锁的值为0,则该进程将其设置为1并进入临界区。若这把锁的值已经为1,则该进程将等待直到其值变为0。于是,0标识临界区内没有进程,1表示已经有某个进程进入临界区。
但是,这种想法包含了与假脱机目录一样的疏漏。假设一个进程读出锁变量的值并发现它为0,而恰好在它将其值设置为1之前,另一个进程被调度运行,将该锁变量设置为1。当第一个进程再次运行时,它同样也将该锁设置为1,则此时同时有两个进程进入临界区中。
可能会想,先读出锁变量,紧接着在改变其值之前再检查一遍它的值,这样可以解决问题。但这无济于事,如果第二个进程恰好在第一个进程完成第二次检查之后修改了锁变量的值,则同样还会发生竞争条件。
严格轮换法
在图2-23中,整型变量turn
,初始值为0,用于记录轮到哪个进程进入临界区,并检查或更新共享内存。开始时,进程0检查turn
,发现其值为0,于是进入临界区。进程1也发现其值为0,所以在一个等待循环中不停的测试turn
,看其值何时变为1。连续测试一个变量直到某个值出现为止,称为忙等待。由于这种方式浪费CPU时间,所以通常应该避免。只有在有理由认为等待时间是非常短的情况下,才使用忙等待。用于忙等待的锁,称为自旋锁。
进程0离开临界区时,它将trun
的值设置为1,以便允许进程1进入其临界区。假设进程1很快便离开了临界区,则此时两个进程都处于临界区之外,turn
的值又被设置为0。现在进程0很快就执行完整个循环,它退出临界区,并将turn
的值设置为1。此时,turn
的值为1,两个进程都在其临界区外执行。
突然,进程0结束了非临界区的操作并且返回到循环的开始。但是,这时它不能进入临界区,因为turn
的当前值为1,而此时进程1还在忙于非临界区的操作,进程0只有继续while循环,直到进程1把turn
的值改为0。这说明,在一个进程比另一个慢了很多的情况下,轮流进入临界区并不是一个好办法。
这种情况违反了前面叙述的条件3:进程0被一个临界区之外的进程阻塞。
Peterson解法
Peterson算法如下所示
#define FALSE 0
#define TRUE 1
#define N 2 //进程数量
int turn; //现在轮到谁
int interested[N]; //所有值初始化为0(FALSE)
void enter_region(int process) //进程是0或1
{
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或1作为参数来调用enter_region
。该调用在需要时将使进程等待,直到能安全地进入临界区。在完成对共享变量的操作之后,进程将调用leave_region
,表示操作已完成,若其他的进程希望进入临界区,则现在就可以进入。
一开始,没有任何进程处于临界区中,现在进程0调用enter_region
,它通过设置其数组元素和将turn
置为0来标识它希望进入临界区。由于进程1并不想进入临界区,所以enter_region
很快便返回。如果进程1现在调用enter_region
,进程1将在此处挂起直到interested[0]
变成FALSE,该事件只有在进程0调用leave_region
退出临界区时才会发生。
现在考虑两个进程同时调用enter_region
的情况。它们都将自己的进程号存入turn
,但只有后被保存进去的进程号才有效,前一个因被重写而丢失。假设进程1是后存入的,则turn
为1。当两个进程都运行到while
语句时,进程0将循环0次并进入临界区,而进程1则将不停地循环且不能进入临界区,直到进程0退出临界区为止。
TSL指令
现在来看需要硬件支持的一种方案。某些计算机中,特别是那些设计为多处理器的计算机,都有下面一条指令。
TSL RX,LOCK
称为测试并加锁,它将一个内存字lock
读到寄存器RX
中,然后在该内存地址上存一个非零值。读字和写字操作保证是不可分割的,即该指令结束之前其他处理器均不允许访问该内存字。执行TLS指令的CPU将锁住内存总线,以禁止其他CPU在本指令结束之前访问内存。
着重说明一下,锁住存储总线不同于屏蔽中断。屏蔽中断,然后在读内存字之后跟着写操作并不能阻止总线上的第二个处理器在读操作和写操作之间访问该内存字。事实上,在处理器1上屏蔽中断对处理器2根本没有任何影响。让处理器2远离内存直到处理器1完成的唯一方法就是锁住总线,这需要一个特殊的硬件设施。基本上,一根总线就可以确保总线由锁住它的处理器使用,而其他的处理器不能用。
为了使用TSL指令,要使用一个共享变量lock
来协调对共享内存的访问。当lock
为0时,任何进程都可以使用TSL指令将其设置为1,并读写共享内存。当操作结束时,进程用一条普通的move
指令将lock
的值重新设置为0。
这条指令如何防止两个进程同时进入临界区呢?解决方案如下所示。假定存在如下共4条指令的汇编语言子程序。第一条指令将lock
原来的值复制到寄存器中并将lock
设置为1,随后这个原来的值与0相比较。如果它非零,则说明以前已被加锁,则程序将回到开始并再次测试。经过或长或短的一段时间后,该值将变为0(当前处于临界去中的进程退出临界区时),于是过程返回,此时已加锁。要清除这个锁非常简单,程序只需将0存入lock
即可,不需要特殊的同步指令。
enter_region:
TSL REGISTER,LOCK |复制锁到寄存器并将锁设为1
CMP REGISTER,0 |锁是零么?
JNE enter_region |若不是零,说明锁已被设置,所以循环
RET |返回调用者,进入临界区
leave_region:
MOVE LOCK,0 |在锁中存入0
RET |返回调用者
现在有一种很明确的解法了。进程在进入临界区之前先调用enter_region
,这将导致忙等待,直到锁空闲为止,随后它获得该锁并返回。在进程从临界区返回时它调用leave_region
,这将把lock
设置为0。与基于临界区问题的所有解法一样,进程必须在正确的时间调用enter_region
和leave_region
,解法才能奏效。只有进程合作,临界区才能工作。
一个可替代TSL的指令是XCHG,它原子性的交换了两个位置的内容,例如,一个寄存器与一个存储器字。它本质上与TSL的解决方法一样。
enter_region:
MOVE REGISTER,1 |在寄存器中放一个1
XCHG REGISTER,LOCK |交换寄存器与锁变量的内容
CMP REGISTER,0 |锁是零么?
JNE enter_region |若不是零,说明锁已被设置,所以循环
RET |返回调用者,进入临界区
leave_region:
MOVE LOCK,0 |在锁中存入0
RET |返回调用者
睡眠与唤醒
Peterson解法和TSL
或XCHG
解法都是正确的,但它们都有忙等待的缺点。这些解法在本质上是这样的:当一个进程想进入临界区时,先检查是否允许进入,若不允许,则该进程将原地等待,直到允许为止。
这种方法不仅浪费了CPU时间,而且还可能引起预想不到的结果。考虑一台计算机有两个进程,H优先级较高,L优先级较低。调度规则规定,只要H处于就绪态它就可以运行。在某一时刻,L处于临界区中,此时H变为就绪态,准备运行。现在H开始忙等待,但由于当H就绪时L不会被调度,也就无法离开临界区,所以H将永远忙等待下去。这种情况有时被称作优先级反转问题。
现在考察几条进程间通信原语,它们在无法进入临界区时将阻塞,而不是忙等待。最简单的是sleep
和wakeup
。sleep
是一个将引起调用进程阻塞的系统调用,即被挂起,直到另外一个进程将其唤醒。wakeup
调用有一个参数,即要被唤醒的进程。另一种方法是让sleep
和wakeup
各有一个参数,即有一个用于匹配sleep
和wakeup
的内存地址。
生产者-消费者问题
生产者-消费者问题,也叫做有界缓冲区问题。两个进程共享一个公共的固定大小的缓冲区。其中一个是生产者,将信息放入缓冲区;另一个是消费者,从缓冲区中取出信息。(也可以把这个问题一般化为m个生产者和n个消费者问题)。
问题在于当缓冲区已满,而此时生产者还想向其中放入一个新的数据项的情况。其解决办法是让生产者睡眠,待消费者从缓冲区中取出一个或多个数据项时再唤醒它。同样地,当消费者试图从缓冲区中取数据而发现缓冲区为空时,消费者就睡眠,直到生产者向其中放入一些数据时再将其唤醒。
这个方法听起来很简单,但它包含与前面假脱机目录问题一样的竞争条件。为了跟踪缓冲区中的数据项数,需要一个变量count
。如果缓冲区最多存放N个数据项,则生产者代码将首先检查count
是否达到N,若是,则生产者睡眠;否则生产者向缓冲区中放入一个数据项并增加count
的值。
消费者的代码与此类似:首先测试count
是否为0,若是,则睡眠;否则从中取走一个数据项并递减count
的值。每个进程同时也检测另一个进程是否应被唤醒,若是则唤醒。
生产者-消费者代码如下所示
#define N 100 //缓冲区中的槽数目
int count = 0; //缓冲区中的数据项数目
void produce(void) //生产者
{
int item;
while(TRUE) //无限循环
{
item = produce_item(); //产生下一新数据项
if(count == N)
sleep(); //如果缓冲区满了,就进入休眠状态
insert_item(item); //将新数据项放入缓冲区中
count = count + 1; //将缓冲区的数据项计数器加1
if(count == 1) //缓冲区空么?
wakeup(consumer)
}
}
void consumer(void) //消费者
{
int item;
while(TRUE) //无限循环
{
if(count == 0) //如果缓冲区空,则进入休眠状态
sleep();
item = remove_item(); //从缓冲区中取出一个数据项
count = count - 1; //将缓冲区的数据项计数器减1
if(count == N - 1)
wakeup(producer); //缓冲区满么?
consume_item(item); //打印数据项
}
}
这里有可能会出现竞争条件,其原因是对count
的访问没有限制。有可能出现以下情况:缓冲区为空,消费者刚刚读取count
的值发现它为0。此时调度程序决定暂停消费者并启动运行生产者。生产者向缓冲区中加入一个数据项,count
加1,现在count
的值变为1。它推断认为由于count
刚才为0,所以消费者此时一定在睡眠,于是生产者调用wakeup
来唤醒消费者。
但是,消费者此时在逻辑上并未睡眠,所以wakeup
信号丢失。如果它没有丢失,则一切都很正常。一种快速的弥补方法是修改规则,加上一个唤醒等待位。当一个wakeup
信号发送给一个清醒的进程信号时,将该位置1。随后,当该进程要睡眠时,如果唤醒等待位为1,则将该位清除,而该进程仍然保持清醒。唤醒等待为实际上就是wakeup
信号的一个小仓库。
尽管在这个简单例子中使用唤醒等待位的方法解决了问题,但是我们可以很容易就构造出一些例子,其中有三个或更多的进程,这时一个唤醒等待位就不够使用了。这没有从根本上解决问题。
信号量
信号量使用一个整型变量来累计唤醒次数,供以后使用。一个信号量的取值可以为0(表示没有保存下来的唤醒操作)或者正值(表示有一个或多个唤醒操作)。
Dijkstra建议设置两种操作:down
和up
(分别为一般化后的sleep
和wakeup
)。对一信号量执行down
操作,则是检查其值是否大于0。若该值大于0,则将其值减1(即用掉一个保存的唤醒信号)并继续;若该值为0,则进程将睡眠,而且此时down
操作并未结束。检查数值,修改变量值以及可能发生的书面操作均作为一个单一的,不可分割的原子操作完成。保证一旦一个信号量操作开始,则在该操作完成或阻塞之前,其他进程均不允许访问该信号量。这种原子性对于解决同步问题和避免竞争条件是绝对必要的。所谓原子操作,是指一组相关联的操作要么都不间断地执行,要么都不执行。
up
操作对信号量的值增1。如果一个或多个进程在该信号量上睡眠,无法完成一个先前的down
操作,则由系统选择其中的一个并允许该进程完成它的down
操作。于是,对一个有进程在其上睡眠的信号量执行一次up
操作之后,该信号量的值仍旧是0,但在其上睡眠的进程却少了一个。信号量的值增1和唤醒一个进程同样也是不可分割的。不会有某个进程因执行up而阻塞。
用信号量解决生产者-消费者问题
用信号量解决丢失的wakeup
问题。为确保信号量能正确工作,最重要的是要采用一种不可分割的方式来实现它。通常是将up
和down
作为系统调用实现,而且操作系统只需在执行以下操作时暂时屏蔽全部中断:测试信号量,更新信号量以及在需要时使某个进程睡眠。由于这些动作只需要几条指令,所以屏蔽中断不会带来什么副作用。如果使用多个CPU,则每个信号量应由一个锁变量进行保护。通过TSL或XCHG指令来确保同一时刻只有一个CPU在对信号量进行操作。
使用TSL或XCHG指令来防止几个CPU同时访问一个信号量,这与生产者或者消费者使用忙等待来等待对方腾出或者填充缓冲区是完全不同的。信号量操作仅需几个毫秒,而生产者或消费者可能需要任意长的时间。
该解决方案使用了三个信号量:一个称为full
,用来记录充满的缓冲槽数目;一个称为empty
,记录空的缓冲槽数目;一个称为mutex
,用来确保生产者和消费者不会同时访问缓冲区。full
的初值为0,empty
的初值为缓冲区中槽的数目,mutex
的初值为1。供两个或多个进程使用的信号量,其初值为1,保证同时只有一个进程可以进入临界区,称作二元信号量。如果每个进程在进入临界区前都执行一个down
操作,并在刚刚退出时执行一个up
操作,就能够实现互斥。
信号量mutex
用于互斥,它用于保证任意时刻只有一个进程读写缓冲区和相关变量。互斥是避免混乱所必需的操作。
#define N 100 //缓冲区中的槽数目
typedef int semaphore; //信号量是一种特殊的整型数据
semaphore mutex = 1; //控制对临界区的访问
semaphore empty = N; //计数缓冲区的空槽数目
semaphore full = 0; //技术缓冲区的满槽数目
void produce(void)
{
int item;
while(TRUE) //TRUE是常量1
{
item = produce_item(); //产生放在缓冲区中的一些数据
down(&empty); //将空槽数目减1
down(&mutex); //进入临界区
insert_item(item); //将新数据项放到缓冲区中
up(&mutex); //离开临界区
up(&full); //将满槽的数目加1
}
}
void consumer(void)
{
int item;
while(TRUE)
{
down(&full); //将满槽数减1
down(&mutex); //进入临界区
item = remove_item(); //从缓冲区中取出数据项
up(&mutex); //离开临界区
up(&empty); //将空槽数目加1
consume_item(item); //处理数据项
}
}
信号量的另一种用途是用于实现同步。信号量full
和empty
用来保证某种事件的顺序发生或不发生。在本例中,它们保证当缓冲区满的时候生产者停止运行,以及当缓冲区空的时候消费者停止运行。这种用法与互斥是不同的。
互斥量
如果不需要信号量的计算能力,有时可以使用信号量的一个简化版本,称为互斥量。互斥量仅仅适用于管理共享资源或一小段代码。互斥量在实现用户空间线程包时非常有用。
互斥量是一个可以处于两态之一的变量:解锁和加锁。这样,只需要一个二进制位表示它,不过实际上,常常使用一个整型量,0表示解锁,而其它所有的值则表示加锁。互斥量使用两个过程。当一个线程(或进程)需要访问临界区时,他调用mutex_lock
。如果该互斥量当前是解锁的(即临界区可用),此调用成功,调用线程可以自由进入该临界区。
另一方面,如果该互斥量已经加锁,调用线程被阻塞,直到在临界区中的线程完成并调用mutex_unlock
。如果多个线程被阻塞在该互斥量上,将随机选择一个线程并允许它获得锁。
由于互斥量非常简单,所以如果有可用的TSL或XCHG指令,就可以很容易地在用户空间中实现它们。用于用户级线程包的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
的代码与enter_region
的代码很相似,但有一个关键的区别。当enter_region
进入临界区失败时,它始终重复测试锁(忙等待)。实际上,由于时钟超时的作用,会调度其他进程运行。这样迟早拥有锁的进程会进入运行并释放锁。
在用户线程中,情形有所不同,因为没有时钟停止运行时间过长的线程。结果是通过忙等待的方式来试图获得锁的线程将永远循环下去,决不会得到锁,因为这个运行的线程不会让其他线程运行从而释放锁。
以上就是enter_region
和mutex_lock
的差别所在。在后者取锁失败时,它调用thread_yield
将CPU放弃给另一个线程。这样,就没有忙等待。在该线程下次运行时,它再一次对锁进行测试。
由于thread_yield
只是在用户空间中对线程调度程序的一个调用,所以它的运行非常快捷。这样,mutex_lock
和mutex_unlock
都不需要任何内核调用。通过使用这些过程,用户线程完全可以实现在用户空间中的同步,这些过程仅仅需要少量的指令。
上面所叙述的互斥量系统是一套调用框架。对于软件来说,总是需要更多的特性,而同步原语也不例外。例如,有时线程包提供一个调用mutex_trylock
,这个调用或者获得锁或者返回失败码,但并不阻塞线程。这就给了调用线程一个灵活性,用于决定下一步做什么,是使用替代办法还是等待下去。
快速用户区互斥量futex
随着并行的增加,有效的同步和锁机制对性能而言非常重要。如果等待时间短的话,自旋锁会很快,但如果等待时间长,则会浪费CPU周期。如果有很多竞争,那么阻塞此进程,并仅当锁被释放的时候让内核解除阻塞会更加有效。然而,这却带来了相反的问题:它在竞争激烈的情况下效果不错,但如果一开始只有很小的竞争,那么不停地内核切换将花销很大。更糟的是,预测锁竞争的数量并不容易。
一个引人注目的致力于结合两者优点的解决方案是“futex
”,或者快速用户空间互斥。futex
是Linux的一个特性,它实现了基本的锁,但避免了陷入内核。一个futex
包含两个部分:一个内核服务和一个用户库。内核服务提供一个等待队列,它允许多个进程在一个锁上等待。它们将不会运行,除非内核明确地对它们解除阻塞。将一个进程放到等待队列需要(代价很大)的系统调用,应该避免这种情况。因此,没有竞争时,futex
完全在用户空间工作。特别地,这些进程共享通用的锁变量——一个对齐的32位整数锁的专业术语。假设锁初始值为1,即假设这意味着锁是释放状态。线程通过执行原子操作“减少并检验”来夺取锁。接下来,这个线程检查结果,看锁是否被释放。如果未处于被锁状态,那么一切顺利,我们的线程成功夺取该锁。然而,如果该锁被另一个线程持有,那么线程必须等待。这种情况下,futex
库不自旋,而是使用一个系统调用把这个线程放在内核的等待队列上。可以期望的是,切换到内核的开销已经是合乎情理的了,因为无论如何线程被阻塞了。当一个线程使用完该锁,它通过原子操作“增加并检验”来释放锁,并检查结果,看是否仍有进程阻塞在内核等待队列上。如果有,它会通知内核可以对等待队列里的一个或多个进程解除阻塞。如果没有锁竞争,内核则不需要参与其中。
pthread中的互斥量
pthread提供许多可以用来同步线程的函数。其基本机制是使用一个可以被锁定和解锁的互斥量来保护每个临界区。一个线程如果想要进入临界区,它首先尝试锁住相关的互斥量。如果互斥量没有加锁,那么这个线程可以立即进入,并且该互斥量被自动锁定以防其他线程进入。如果互斥量已经被加锁,则调用线程被阻塞,直到该互斥量被解锁。如果多个线程在等待同一个互斥量,当它被解锁时,这些等待的线程中只有一个被允许运行并将互斥量重新锁定。这些互斥锁不是强制性的,而是由程序员来保证线程正确地使用它们。
与互斥量相关的主要函数如下所示。就像所期待的那样,可以创建和撤销互斥量。实现它们的函数调用分别是pthread_mutex_init
与pthread_mutex_destroy
。也可以通过pthread_mutex_lock
给互斥量加锁,如果该互斥量已被加索时,则会阻塞调用者。还有一个调用可以用来尝试锁住一个互斥量,当互斥量已被加锁时会返回错误代码而不是阻塞调用者。这个调用就是pthread_mutex_trylock
。如果需要的话,该调用允许一个线程有效地忙等待。最后,pthread_mutex_unlock
用来给一个互斥量解锁,并在一个或多个线程等待它的情况下正确地释放一个线程。互斥量也可以有属性,但是这些属性只在某些特殊的场合下使用。
除互斥量之外,pthread
提供了另一种同步机制:条件变量。互斥量在允许或阻塞对临界区的访问上是很有用的。条件变量则允许线程由于一些未达到的条件而阻塞。绝大部分情况下这两种方法是一起使用的。
一些与互斥量相关的pthread
调用
线程调用 | 描述 |
---|---|
pthread_mutex_init | 创建一个互斥量 |
pthread_mutex_destroy | 撤销一个已存在的互斥量 |
pthread_mutex_lock | 获得一个锁或阻塞 |
pthread_mutex_trylock | 获得一个锁或失败 |
pthread_mutex_unlock | 释放一个锁 |
一些与条件变量相关的pthread
调用
线程调用 | 描述 |
---|---|
pthread_cond_init | 创建一个条件变量 |
pthread_cond_destroy | 撤销一个条件变量 |
pthread_cond_wait | 阻塞以等待一个信号 |
pthread_cond_signal | 向另一个线程发信号来唤醒它 |
pthread_cond_broadcast | 向多个线程发信号来让它们全部唤醒 |
再次考虑生产者-消费者问题:一个线程将产品放在一个缓冲区内,由另一个线程将它们取出。如果生产者发现缓冲区中没有空槽可以使用了,他不得不阻塞直到有一个空槽可以使用。生产者使用互斥量可以进行原子性检查,而不受其他线程干扰。但是当发现缓冲区已经满了以后,生产者需要一种方法来阻塞自己并在以后被唤醒。这便是条件变量做的事。
条件变量上的主要操作是pthread_cond_wait
和pthread_cond_signal
,前者阻塞调用线程直到另一其他线程向它发出信号。被阻塞的线程经常是在等待发信号的线程去做某些工作,释放某些资源或是进行其他的一些活动。只有完成后被阻塞的线程才可以继续运行。条件变量允许这种等待与阻塞原子性的运行。当有多个线程被阻塞并等待同一个信号时,可以使用pthread_cond_broadcast
调用。
条件变量与互斥量经常一起使用。这种模式用于让一个线程锁住一个互斥量,然后当它不能获得他期待的结果时等待一个条件变量。最后另一个线程会向它发出信号,使它可以继续执行。pthread_cond_wait
原子性地调用并解锁它持有的互斥量。由于这个原因,互斥量是参数之一。
值得指出的是,条件变量(不像信号量)不会存在内存中。如果将一个信号量传递给一个没有线程在等待的条件变量,那么这个信号就会丢失。程序员必须小心使用避免丢失信号。
#include <stdio.h>
#include <pthread.h>
#define MAX 100000000 //需要生产的数量
pthread_mutex_t the_mutex;
pthread_cond_t condc,condp;
int buffer = 0; //生产者消费者使用的缓冲区
void* produce(void* ptr) //生产数据
{
int i;
for(i = 1; i <= MAX; i++)
{
pthread_mutex_lock(&the_mutex); //互斥使用缓冲区
while(buffer != 0)
pthread_cond_wait(&condp, &the_mutex);
buffer = i; //将数据放入缓冲区
pthread_cond_signal(&condc); //唤醒消费者
pthread_mutex_unlock(&the_mutex);
}
pthread_exit(0);
}
void* consumer(void* ptr) //消费数据
{
int i;
for(i = 1; i <= MAX; i++)
{
pthread_mutex_lock(&the_mutex); //互斥使用缓冲区
while(buffer == 0)
pthread_cond_wait(&condc, &the_mutex);
buffer = 0; //从缓冲区中取走数据
pthread_cond_signal(&condp); //唤醒生产者
pthread_mutex_unlock(&the_mutex);
}
pthread_exit(0);
}
int main()
{
pthread_t pro,con;
pthread_mutex_init(&the_mutex, 0);
pthread_mutex_init(&condc, 0);
pthread_mutex_init(&condp, 0);
pthread_create(&con, 0, consumer, 0);
pthread_create(&pro, 0, producer, 0);
pthread_join(pro, 0);
pthread_join(con, 0);
pthread_cond_destroy(&condc);
pthread_cond_destroy(&condp);
pthread_mutex_destroy(&the_mutex);
}
管程
一个管程是一个由过程,变量及数据结构等组成的一个集合,它们组成一个一个特殊的模块或软件包。进程可在任何需要的时候调用管程中的过程,但他们不能在管程之外声明的过程中直接访问管程内的数据结构。
管程有一个很重要的特性,即任一时刻管程中只能有一个活跃进程,这一特性使管程能有效地完成互斥。管程是编程语言的组成部分,编译器知道它们的特殊性,因此可以采用与其他过程调用不同的方法来处理对管程的调用。典型的处理方法是:当一个进程调用管程过程时,该过程中的前几条指令将检查在管程中是否有其他的活跃进程。如果有,调用进程将被挂起,直到另一个进程离开管程将其唤醒。如果没有活跃进程在使用管程,则该调用进程可以进入。
进入管程时的互斥由编译器负责,但通常的做法是用一个互斥量或二元信号量。
尽管管程提供了一种实现互斥的简单途径,但这还不够,还需要一种办法使得进程在无法继续运行时被阻塞。在生产者-消费者问题中,很容易将针对缓冲区满和缓冲区空的测试放到管程过程中,但是生产者在发现缓冲区满的时候如何阻塞呢?
解决的方法是引入条件变量以及相关的两个操作:wait
和signal
。当一个管程过程发现它无法继续运行时,它会在某个条件变量上(如full
)执行wait
操作。该操作导致调用进程自身阻塞,并且还将另一个以前等在管程之外的进程调入管程。
另一个进程,比如消费者,可以唤醒正在睡眠的伙伴进程,这可以通过对其伙伴正在等待的一个条件变量执行signal
完成。为了避免管程中同时有两个活跃进程,我们需要一条规则来通知在signal
之后该怎么办?这里我们采用执行signal
的进程必须立即退出管程,即signal
语句只可能作为一个管程过程的最后一条语句。如果在一个条件变量上有若干进程正在等待,则对该条件变量执行signal
操作后,系统调度程序只能在其中选择一个使其恢复运行。
这里还有第三种方法,该方法让发信号者继续运行,并且只有在发信号者退出管程之后,才允许等待的进程开始运行。
条件变量不是计数器,条件变量也不能像信号量那样累计信号以便以后使用。所以,如果向一个条件变量发送信号,但是在该条件变量上并没有等待进程,则该信号会永远丢失。换句话说,wait
操作必须在signal
操作之前。
读者可能会觉得wait
和signal
操作看起来像前面提到的sleep
和wakeup
,而且已经看到后者存在严重的竞争条件。是的,它们确实很像,但是有个很关键的区别:sleep
和wakeup
之所以失败是因为当一个进程想睡眠时另一个进程试图去唤醒它。使用管程则不会发生这种情况。对管程过程的自动互斥保证了这一点:如果管程过程中的生产者发现缓冲区满,他将能够完成wait
操作而不用担心调度程序可能会在wait
完成之前切换到消费者。甚至,在wait
执行完成而且把生产者标志为不可运行之前,根本不会允许消费者进入管程。
通过临界区互斥的自动化,管程比信号量更容易保证并行编程的正确性。但管程也有缺点。我们之所以使用类Pascal和JAVA,而不像在本书中其他例子那样使用C语言,并不是没有原因的。正如前面提到过的,管程是一个编程语言概念,编译器必须要识别管程并用某种方式对其互斥做出安排。C,Pascal以及多数其他语言都没有管程。所以不能依靠编译器遵守互斥规则。
与管程和信号量有关的另一个问题是,这些机制都是设计用来解决访问公共内存的一个或多个CPU上的互斥问题的。通过将信号量放在共享内存中并用TSL或XCHG指令来保护它们,可以避免竞争。如果一个分布式系统具有多个CPU,并且每个CPU拥有自己的私有内存,它们通过一个局域网相连,那么这些原语将失效。这里的结论是:信号量太低级了,而管程在少数几种编程语言之外又无法使用,并且,这些原语均未提供机器间的信息交换方法,所以还需要其他的方法。
消息传递
消息传递,这种进程间通信的方法使用两条原语send
和receive
,它们像信号量而不是管程,是系统调用而不是语言成分。
例如
send(destination, &message);
receive(source, &message);
前一个调用向一个给定的目标发送一条消息,后一个调用从一个给定的源接受一条信息。如果没有消息,则接收者可能被阻塞,直到一条消息到达,或者,带着一个错误码立即返回。
消息传递系统的设计要点
消息有可能被网络丢失。为了防止信息丢失,发送方和接收方可以达成如下一致:一旦接受到信息,接收方马上回送一条特殊的确认消息。如果发送方在一段时间间隔内未收到确认,则重发消息。
现在考虑消息本身被正确接收,而返回给发送者的确认信息丢失的情况。发送者将重发信息,这样接收者将接收到两次相同的消息。对于接收者来说,如何区分新的消息和一条重发的老消息是非常重要的。通常采用在每条原始消息中嵌入一个连续的序号来解决此问题。如果接收者受到一条消息,它具有与前面一条消息一样的序号,就知道这条消息是重复的,可以忽略。
消息系统还需要解决进程命名的问题。在send
和receive
调用中所指定的进程必须是没有二义性的。身份验证也是一个问题。
用消息传递解决生产者-消费者问题
假设所有的消息都有同样的大小,并且在尚未接收到发出的消息时,由操作系统自动进行缓冲,在该解决方案中共使用N条消息,这就类似于一块共享内存缓冲区中的N个槽。消费者首先将N条空消息发送给生产者。当生产者向消费者传递一个数据项时,它取走一条空消息并送回一条填充了内容的消息。通过这种方式,系统中总的消息数保持不变,所以消息都可以存放在事先确定数量的内存中。
如果生产者的速度比消费者块,则所有的消息最终都将被填满,等待消费者,生产者将被阻塞,等待返回一条空消息。如果消费者速度块,则情况正好相反:所有的消息均为空,等待生产者来填充它们,消费者被阻塞,以等待一条填充过的消息。
消息传递方式可以有许多变体,下面介绍如何对消息进行编址。一种方法是为每个进程分配一个唯一的地址,让消息按进程的地址编址。另一种方法是引入一种新的数据结构,称作信箱。信箱是一个用来对一定数量的消息进行缓冲的地方,信箱中消息数量的设置方法也有多种,典型的方法是在信箱创建时确定消息的数量。当使用信箱时,在send
和receive
调用中的地址参数就是信箱的地址,而不是进程地址。当一个进程试图向一个满的信箱发消息时,它将被挂起,直到信箱内有消息被取走,而从而新消息腾出空间。
对于生产者——消费者问题,生产者和消费者均应该创建足够容纳N条信息的信箱。生产者向消费者信箱发送包含实际数据的信息,消费者则向生产者信箱发送空的消息。当使用信箱时,缓冲机制的作用是很清楚的:目标信箱容纳那些已被发送但尚未被目标进程接受的消息。
#define N 100 //缓冲区中的槽数目
void produce(void)
{
int item;
message m; //消息缓冲区
while(TRUE)
{
item = produce_item(); //产生放入缓冲区的一些数据
receive(consumer, &m); //等待消费者发送空缓冲区
build_message(&m, item); //建立一个待发送的消息
send(consumer,&m); //发送数据项给消费者
}
}
void consumer(void)
{
int item, i;
message m;
for(int i = 0; i < N; i++)
send(producer, &m); //发送N个空缓冲区
while(TRUE)
{
receive(producer, &m); //接受包含数据项的消息
item = extrace_item(&m); //将数据项从消息中提取出来
send(producer, &m); //将空缓冲区发送回生产者
consume_item(item); //处理数据项
}
}
使用信箱的另一种极端方法是彻底去校缓冲。采用这种方法时,如果send
和receive
之前执行,则发送进程被阻塞,直到receive
发生。在执行receive
时,消息可以直接从发送者复制到接收者,不用任何中间缓冲。类似的,如果先执行receive
,则接收者会被阻塞,直到send
发送。这种方案常被称为会合。与带有缓冲的消息方案相比,该方案实现起来会更容易一些,但却降低了灵活性。
屏障
最后一个同步机制是准备用于进程组而不是用于双进程的生产者——消费者类情形的。在有些应用中划分了若干阶段,并且规定,除非所有的进程都就绪准备着手下一个阶段,否则任何进程都不能进入下一个阶段。可以通过在每个阶段的结尾设置屏障来实现这种行为。当一个进程到达屏障时,它就会被屏障阻拦,直到所有进程都到达该屏障为止。屏障可用于一组进程同步,屏障的操作如图所示。
在图中我们可以看到有四个进程接近屏障,这意味着它们正在运算,但是还没有达到每个阶段的末尾。过了一会,第一个进程完成了所有需要在第一阶段进行的计算。它接着执行barrier原语,这通常是调用一个库过程。于是该进程被挂起。一会,第二个和第三个进程也完成了第一阶段的计算,也接着执行barrier原语这种情形如图所示。结果,当最后一个进程C到达屏障时,所有的进程就一起被释放。
避免锁:读——复制——更新
最快的锁是根本没有锁。问题在于在没有锁的情况下,我们是否允许对共享数据结构的并发读写进行访问。在通常情况下,答案显然是否定的。假设进程A正在对一个数字数组进行排序,而进程B正在计算其均值。因为A在数组中将数值前后来回移动,所以B可能多次遇到某些数值,而某些数值则根本没有遇到过。得到的结果可能是任意值,而它几乎肯定是错的。
然而,在某些情况下,我们可以允许写操作来更新数据数据,即便还有其他的进程正在使用它。窍门在于确保每个读操作要么读取旧的数据版本,要么读取新的数据版本,但绝不能是新旧数据的奇怪组合。举例说明,考虑图2-38中的树。读操作从根部到叶子遍历整个树。在图的上半部分,加入一个新的节点X。为了实现这一操作,我们要让这个节点在树中可见之前使它“恰好正确”:我们对结点X中的所有值进行初始化。包括它的子节点指针。然后通过原子写操作,使X成为A的子节点。所有的读操作都不会读到前后不一致的版本。在图的下半部分,我们接着移除B和D。首先,将A的左子节点指针指向C。所有原本在A中的读操作将会后续读到结点C,而永远不会读B和D。也就是说,它们将只会读到新版数据。同样,所有当前在B和D中的读操作将继续依照原始的数据结构指针并且读取旧版数据。所有操作均正确进行,我们不需要锁住任何东西。而不需要锁住数据就够就能移去B和D的主要原因就是读——复制——更新,将更新过程中的移除和再分配过程分离开来。