进程之间需要通信,而且最好使用一种结构良好的方式,不要使用中断。
竞争条件
在一些操作系统中,协作的进程可能共享一些彼此都能读写的公用存储区。
这个公用存储区可能在内存中(可能是在内核数据结构中),也可能是一个共享文件。
两个或多个进程读写某些共享数据,而最后的结果取决于进程运行的精确时序,称为竞争条件(race condition)。
调试包含有竞争条件的程序是一件很头疼的事。
大多数的测试运行结果都很好,但在极少数情况下会发生一些无法解释的奇怪现象。
临界区
怎样避免竞争条件?
实际上凡涉及共享内存,共享文件以及共享任何资源的情况下都会引发与前面类似的错误,要避免这种错误,关键是要找出某种途径来阻止多个进程同时读写共享的数据。
换言之,我们需要的是互斥,即以某种手段确保当一个进程在使用一个共享变量或文件时,其他进程不能做同样的操作。
一个进程的一部分时间做内部计算或另外一些不会引发竞争条件的操作。
在某些时候进程可能需要访问共享内存或共享文件,或执行另外一些会导致竞争的操作。
我们把对共享内存进行访问的程序片段称为临界区域(critical region)或临界区(critical section)。
如果我们能够适当的安排,使得两个进程不可能同时处于临界区中,就能避免竞争条件。
忙等待互斥
(1)屏蔽中断
在单处理器系统中,最简单的方法是使每个进程在刚刚进入临界区后立即屏蔽所有中断,并在就要离开之前再打开中断。
屏蔽中断后,时钟中断也被屏蔽。
CPU只有发生时钟中断或其他中断才会进行进程切换,这样,在屏蔽中断之后CPU将不会切换到其他进程。
屏蔽中断对于操作系统本身而言是一项很有用的技术,但对于用户进程则不是一种合适的通用互斥机制。
(2)锁变量
设想有一个共享(锁)变量,其初始值为0。
当一个进程想进入其临界区时,它首先测试这把锁。
如果该锁的值为0,则该进程将其设置为1并进入临界区。
若这把锁的值已经是1,则该进程将等待直到其值变为0。
但是,这种想法包含了疏漏,假设一个进程读出锁变量的值并发现它为0,而恰好在将其值设置为1之前,另一个进程被调度运行,将该锁变量设置为1。
当第一个进程再次能运行时,它同样也将该锁设置为1,则此时同时有两个进程进入临界区中。
(3)严格轮换法
整型变量turn,初始值为0,用于记录轮到哪个进程进入临界区,并检查或更新共享内存。
开始时,进程0检查turn,发现其值为0,于是进入临界区。
进程1也发现其值为0,所以在一个等待循环中不停的测试turn,看其值何时变成1。
连续测试一个变量知道某个值出现为止,称为忙等待(busy waiting)。
由于这种方式浪费CPU时间,所以通常应该避免。
只有在有理由认为等待时间是非常短的情形下,才使用忙等待。
用于忙等待的锁,称为自旋锁(spin lock)。
进程0离开临界区时,它将turn的值设置为1,以便允许进程1进入其临界区。
但是如果一个进程比另一个慢了很多的情况下,轮流进入临界区并不是一个好办法。
(4)Peterson算法
一开始,没有任何进程处于临界区中,现在进程0调用enter_resion。
它通过设置其数组元素和将turn置为0来标识它希望进入临界区。
由于进程1并不想进入临界区,所以enter_resion很快便返回。
如果进程1现在调用enter_region,进程1将在此处挂起知道interested[0]变成FALSE。
该事件只有在进程0调用leave_resion退出临界区时才会发生。
现在考虑两个进程几乎同时调用enter_resion的情况。
它们都将自己的进程号存入turn,但只有后被保存进去的进程号才有效,前一个因被重写而丢失。
(5)TSL指令
某些计算机中,特别是那些设计为多处理器的计算机,都有下面一条指令。
TSL RX, lock
称为测试并加锁(Test and Set Lock),它将一个内存字lock读到寄存器RX中,然后在该内存地址上存一个非零值。
读字和写字操作保证是不可分割的,即该指令结束之前其他处理器均不允许访问该内存字。
执行TSL指令的CPU将锁住内存总线,以禁止其他CPU在本指令结束之前访问内存。
着重说明一下,锁住内存总线不同于屏蔽中断。
屏蔽中断,然后在读内存字之后跟着写操作并不能阻止总线上的第二个处理器在读操作和写操作之间访问该内存字。
事实上,在处理器1上屏蔽中断对处理器2根本没有任何影响。
让处理器2远离内存直到处理器1完成的唯一方法就是锁住总线。
这需要一个特殊的硬件设施。
信号量
信号量,使用一个整型变量来累计唤醒次数,供以后使用。
信号量的取值可以为0(表示没有保存下来的唤醒操作)或者正值(表示有一个或多个唤醒操作)。
对一个信号量执行down操作,则是检查其值是否大于0。
若该值大于0,则将其值减1(即用掉一个保存的唤醒信号)并继续。
若该值为0,则进程将睡眠,而且此时down操作并未结束。
up操作对信号量的值增1。
检查数值,修改变量值以及可能发生的睡眠操作均作为一个单一的,不可分割的原子操作完成。
保证一旦一个信号量操作开始,则在该操作完成或阻塞之前,其他进程均不允许访问该信号量。
这种原子性对于解决同步问题和避免竞争条件是绝对必要的。
如果一个或多个进程在该信号量上睡眠,无法完成一个先前的down操作,则由系统选择其中的一个(如随机挑选)并允许该进程完成它的down操作。
于是,对一个有进程在其上睡眠的信号量执行一次up操作之后,该信号量的值仍旧是0,但在其上睡眠的进程却少了一个。
信号量的值增1和唤醒一个进程同样也是不可分割的。
为确保信号量能正确工作,最重要的是要采用一种不可分割的方式来实现它。
通常是将up和down作为系统调用实现,而且操作系统只需在执行以下操作时暂时屏蔽全部中断:测试信号量,更新信号量,以及在需要时使某个进程睡眠。
由于这些动作只需要几条指令,所以屏蔽中断不会带来什么副作用。
如果使用多个CPU,则每个信号量应由一个锁变量进行保护。
通过TSL或XCHG指令来确保同一时刻只有一个CPU在对信号量进行操作。
互斥量
如果不需要信号量的计数能力,有时可以使用信号量的一个简化版本,称为互斥量(mutex)。
互斥量仅仅适用于管理共享资源或一小段代码。
互斥量是一个可以处于两态之一的变量:解锁和加锁。
当一个进程需要访问临界区时,它调用nutex_lock。
如果该互斥量当前是解锁的(即临界区可用),此调用成功,调用进程可以自由进入该临界区。
另一方面,如果该互斥量已经加锁,调用进程被阻塞,知道临界区中的线程完成并调用mutex-unlock。
如果多个线程被阻塞在该互斥量上,将随机选择一个线程并允许它获得锁。
共享
在用户级线程包中,多个线程访问同一个互斥量是没有问题的,因为所有线程都在一个公共地址空间中操作。
但是诸如Peterson算法和信号量,都有一个未说明的前提,即这些多个进程至少应该访问一些共享内存,也许仅仅是一个字。
有两种方案。第一种,有些共享数据结构,如信号量,可以存放在内核中,并且只能通过系统调用来访问。
第二种,多数现代操作系统,提供一种方法,让进程与其他进程共享其部分地址空间。
在这种方法中,缓冲区和其他数据结构可以共享。
在最坏的情形下,如果没有可共享的途径,则可以使用共享文件。
如果两个或多个进程共享其全部或大部分地址空间时,进程和线程之间的差别就变得模糊起来,但无论怎样,两者的差别还是有的。
共享一个公共地址空间的两个进程仍旧有各自的打开文件,报警定时器以及其他一些单个进程的特性,而在单个进程中的线程,则共享进程全部特性。
另外,共享一个公共地址空间的多个线程绝不会拥有用户级线程的效率。
管程
使用信号量时要非常小心,一处很小的错误将导致很大的麻烦。
为了更易于编写正确的程序,Brinch Hansen和Hoare提出了一种高级同步原语,称为管程。
一个管程是一个由过程,变量及数据结构等组成的一个集合,它们组成一个特殊的模块或软件包。
进程可在任何需要的时候调用管程中的进程,但它们不能再管程之外声明的过程中直接访问管程内的数据结构。
管程有一个很重要的特性,即任一时刻管程中只能有一个活跃进程,这一特性使管程能有效的完成互斥。
管程是编程语言的组成部分,编译器知道它们的特殊性,因此可以采用与其他过程调用不同的方法来处理对管程的调用。
进入管程时的互斥由编译器负责,但通常的做法是用一个互斥量或二元信号量。
因为是由编译器而非程序员来安排互斥,所以出错的可能性要小得多。
在任一时刻,写管程的人无须关心编译器是如何实现互斥的。
他只需知道将所有的临界区转换成管程过程即可,绝不会有两个进程同时执行临界区中的代码。
消息传递
这种进程间通信的方法使用两条原语send和service,它们像信号量而不像管程,是系统调用而不是语言成分。
消息传递系统面临着许多信号量和管程所未涉及的问题和设计难点,特别是位于网络中不同机器上的通信进程的情况。
例如,消息有可能被网络丢失。为了防止消息丢失,发送方和接收方可以达成如下一致:
一旦接收到消息,接收方马上回送一条特殊的确认消息。
如果发送方在一段时间间隔内未收到确认,则重发消息。
现在考虑消息本身被正确接收,而返回给发送者的确认信息丢失的情况。
发送者将重发信息,这样接收者将收到两次相同的消息。
对于接收者来说,如何区分新的消息和一条重发的老消息是非常重要的。
通常采用在每条原始消息中嵌入一个连续的序号来解决此问题。
如果接收者收到一条消息,它具有与前面某一条消息一样的序号,就知道这条消息是重复的,可以忽略。
屏障
在有些应用中,划分了若干阶段。
并且规定,除非所有的进程都就绪准备着手下一个阶段,否则任何进程都不能进入下一个阶段。
可以通过在每个阶段的结果安置屏障(barrier)来实现这种行为。
当一个进程到达屏障时,它就被屏障阻拦,直到所有进程都到达该屏障为止。