进程是需要频繁的和其他进程进行交流的。
- 例如,在一个shell管道中,第一个进程的输出必须传递给 第二个进程,这样沿着管道进行下去。
因此,进程之间如果需要通信的话,必须要使用一种良好的数据 结构以至于不能被中断。
下面我们会一起讨论有关进程间通信(Inter Process Communication, IPC)的问题。
关于进程间的通信,这里有三个问题:
•上面提到了第一个问题,那就是一个进程如何传递消息给其他进程
。
•第二个问题是如何确保两个或多个线程之间不会相互干扰
。
例如,两个航空公司都试图为不同的顾 客抢购飞机上的最后一个座位。
是数据的先后顺序的问题
,如果进程A产生数据并且进程B打印数据。则进程B打印 数据之前需要先等A产生数据后才能够进行打印。需要注意的是,这三个问题中的后面两个问题同样也适用于线程
可以想象 你在用高级语言编写多线程代码的过程中,线程通信问题是不是比较容易解决?
另外两个问题也同样适用于线程,同样的问题可用同样的方法来解决。我们后面会慢慢讨论
协作的进程可能共享一些彼此都能读写的公共资源
。公共资源可能在内存中也可能 在一个共享文件。
- 为了讲清楚进程间是如何通信的,这里我们举一个例子:
- 一个后台打印程序。
- 当一个进程需要打印某个文件时,它会将文件名放在一个特殊的后台目录(spooler directory)中。
- 另一个 进程打印后台进程(printer daemon)会定期的检查是否需要文件被打印,如果有的话,就打印并将 该文件名从目录下删除。
- 假设我们的后台目录有非常多的槽位(slot),编号依次为0, 1, 2,…,每个槽位存放一个文件名。
- 同时假设有两个共享变量:out ,指向下一个需要打印的文件;in,指向目录中下个空闲的槽位。
- 可以把这两个文件保存在一个所有进程都能访问的文件中,该文件的长度为两个字。
在某一时刻,0至3号槽位空,4号至6号槽位被占用。- 在同一时刻,进程A和进程B都决定将一个文件排队打印,情 况如下
- 墨菲法则(Murphy)中说过,任何可能出错的地方终将出错,这句话生效时,可能发生如下情况。
- 进程A读到in的值为7,将7存在一个局部变量next_free_slot中。
- 此时发生一次时钟中断, CPU认为进程A已经运行了足够长的时间,决定切换到进程B。
- 进程B也读取in的值,发现是7, 然后进程B将7写入到自己的局部变量next_free_slot中,在这一时刻两个进程都认为下一个可 用槽位是7。
- 进程B现在继续运行,它会将打印文件名写入到slot 7中,然后把in的指针更改为8 ,然后进程B 离开去做其他的事情
- 现在进程A开始恢复运行,由于进程A通过检查next_free_slot也发现slot 7的槽位是空的,于 是将打印文件名存入slot 7中,然后把in的值更新为8
- 由于slot 7这个槽位中已经有进程B写入的 值,所以进程A的打印文件名会把进程B的文件覆盖,由于打印机内部是无法发现是哪个进程更新 的,它的功能比较局限,所以这时候进程B永远无法打印输出
两个或多个线程同 时对一共享数据进行修改,从而影响程序运行的正确性时,这种就被称为竞态条件(race condition).
禁止一个或多个进程在同一时刻对共享资源(包括共享内存、共享文件等)进 行读写。
如果一个进程在 某种方式下使用共享变量和文件的话,除该进程之外的其他进程就禁止做这种事(访问统一资源)
。上 面问题的纠结点在于,在进程A对共享变量的使用未结束之前进程B就使用它。
在任何操作系统中, 为了实现互斥操作而选用适当的原语是一个主要的设计问题,接下来我们会着重探讨一下。
避免竞争问题的条件可以用一种抽象的方式去描述。大部分时间,进程都会忙于内部计算和其他不会导 致竞争条件的计算。
然而,有时候进程会访问共享内存或文件,或者做一些能够导致竞态条件的操作。
我们把对共享内存进行访问的程序片段称作临界区域(critical region)或临界区(critical section)。
如果我们能够正确的操作,使两个不同进程不可能同时处于临界区,就能避免竞争条件, 这也是从操作系统设计角度来进行的。
尽管上面这种设计避免了竞争条件,但是不能确保并发线程同时访问共享数据的正确性和高效性
。一个 好的解决方案,应该包含下面四种条件
1.任何时候两个进程不能同时处于临界区
2.不应对CPU的速度和数量做任何假设
3.位于临界区外的进程不得阻塞其他进程
从抽象的角度来看,我们通常希望进程的行为如上图所示,在t1时刻,进程A进入临界区
在t2的时 刻,进程B尝试进入临界区,因为此时进程A正在处于临界区中,所以进程B会阻塞直到t3时刻进 程A离开临界区,此时进程B能够允许进入临界区。
最后,在t4时刻,进程B离开临界区,系统恢 复到没有进程的原始状态。
下面我们会继续探讨实现互斥的各种设计,在这些方案中,当一个进程正忙于更新其关键区域的共享内 存时,没有其他进程会进入其关键区域,也不会造成影响。
单处理器系统上,最简单的解决方案是让每个进程在进入临界区后立即屏蔽所有中断,并在离开临 界区之前重新启用
它们。屏蔽中断后,时钟中断也会被屏蔽。CPU只有发生时钟中断或其他中断时才 会进行进程切换。这样,在屏蔽中断后CPU不会切换到其他进程。
这个方案可行吗?进程进入临界区域是由谁决定的呢?不是用户进程吗?
进程进入临界区域后,用户 进程关闭中断,如果经过一段较长时间后进程没有离开,那么中断不就一直启用不了
,结果会如何?对内核来说,当它在执行更新变量或列表的几条指令期间将中断屏蔽是很方便的。
- 例如,
如 果多个进程处理就绪列表中的时候发生中断,则可能会发生竞态条件的出现。
- 所以,
屏蔽中断对于操作系统本身来说是一项很有用的技术,但是对于用户线程来说,屏蔽中断却不是一项通用的互斥机制。
作为第二种尝试,可以寻找一种软件层面解决方案。
有单个共享的(锁)变量,初始为值为0
。当 一个线程想要进入关键区域时,它首先会查看锁的值是否为0 ,如果锁的值是0,进程会把它设置为1 并让进程进入关键区域。如果锁的状态是1,进程会等待直到锁变量的值变为0
(这里的说法有些不同,有的说初始值为1,代表锁资源可用。不管怎么说都是一个道理)。因此,锁变量的值是 0则意味着没有线程进入关键区域。如果是1则意味着有进程在关键区域内
。
然后第一个线 程运行,把锁变量的值再次设置为1,此时,临界区域就会有两个进程在同时运行。
不是一种原子性操作,所以同样还会发生竞争条件。
进程0检查turn,发现其值为0 ,于是进入临界区
。忙等待
(busywaiting)。这种方式浪费CPU时间,所以这种方式通常应该要避免
。有理由认为等待时间是非常短的情况下,才能够使用忙等待。用于忙等待的锁,称为自旋锁 (spinlock)。
位于临界区外的进程不得阻塞其他进程
,进程0被一个临界区外的 进程阻塞。由于违反了第三条,所以也不能作为一个好的方案。荷兰数学家T.Dekker通过将锁变量与警告变量相结合,最早提出了一个不需要严格轮换的软件互斥算 法,
那么上面讨论的是顺序进入的情况,现在来考虑一种两个进程同时调用enter_region的情况。
它们 都将自己的进程存入turn,但只有最后保存进去的进程号才有效,前一个进程的进程号因为重写而丢 失。
假如进程1是最后存入的,则turn为1。
当两个进程都运行到while的时候,进程0将不会 循环并进入临界区,而进程1将会无限循环且不会进入临界区,直到进程0退出位置。
现在来看一种需要硬件帮助的方案。一些计算机,特别是那些设计为多处理器的计算机,都会有下面这 条指令
TSL RX,LOCK
称为测试并加锁
(test and set lock),它将一个内存字lock读到寄存器RX中,然后在该内存 地址上存储一个非零值。读写指令能保证是一体的,不可分割的,一同执行
的。在这个指令结束之前其 他处理器均不允许访问内存
。执行TSL指令的CPU将会锁住内存总线,用来禁止其他CPU在这个指 令结束之前访问内存。锁住内存总线和禁用中断不一样。禁用中断并不能保证一个处理器在读写操作之间另一 个处理器对内存的读写。
为了使用TSL指令,要使用一个共享变量lock来协调对共享内存的访问。当lock为0时,任何进程 都可以使用TSL指令将其设置为1,并读写共享内存。当操作结束时,进程使用move指令将lock 的值重新设置为0
。在进程从临界 区返回时它调用leave_region ,这会把lock设置为0。与基于临界区问题的所有解法一样,进程 必须在正确的时间调用enter_region和leave_region ,解法才能奏效。
上面解法中的Peterson 、TSL和XCHG解法都是正确的,但是它们都有忙等待的缺点。
这些解法的本 质上都是一样的,先检查是否能够进入临界区,若不允许,则该进程将原地等待,直到允许为止。
这种方式不但浪费了 CPU时间,而且还可能引起意想不到的结果
。
- 考虑一台计算机上有两个进程,这 两个进程具有不同的优先级,H是属于优先级比较高的进程,L是属于优先级比较低的进程。
- 进程 调度的规则是不论何时只要H进程处于就绪态H就开始运行。
- 在某一时刻,L处于临界区中,此时H变为就绪态,准备运行(例如,一条I/O操作结束)。
- 现在H要开始忙等,但由于当H就绪时L就不会被调度,L从来不会有机会离开关键区域,所以H会变成死循环,有时
将这种情况称为优先级反转问题
(priority inversion
problem)
sleep和wakeup
。Sleep是一个能够造成调用者阻塞的系统调用,也就是 说,这个系统调用会暂停直到其他进程唤醒它。
公共的固定大小的缓冲区
。其中一个是生产者
(producer),将信息放入缓冲区,另一个是消费者
(consumer),会从缓冲区中取出。消费者的逻辑也很相似:首先测试count的 值是否为0 ,如果为0则消费者睡眠、阻塞,否则会从缓冲区取出数据并使count数量递减。
上面代码中会产生竞争条件,因为count这个变量是暴露在 大众视野下的。
决定暂停消费者并启动运行生产者
。生产者生产了一条数据并把它放在缓冲区中,然后 增加count的值,并注意到它的值是1。
当消 费者下次启动后,它会查看之前读取的count值,发现它的值是0 ,然后在此进行睡眠。不久之后生 产者会填满整个缓冲区,在这之后会阻塞,这样一来两个进程将永远睡眠下去
。本质是唤醒尚未进行睡眠状态的进程会导致唤醒丢失
。解决上面问题的方式是增加一个唤醒等待位(wakeup waiting bit) 。
信号量(semaphore) ,—个信号量的取值可以是0 ,或 任意正数。0表示的是不需要任何唤醒,任意的正数表示的就是唤醒次数。
**down这个指令的操作会检查值是否大于0。如果大于0 ,则将其值减1 ;若该值为0 ,则进 程将睡眠,而且此时down操作将会继续执行
。原子性操作指的是在计算机科学的许多其他领域中,一组相关操作全部执行而没有中断或根本不 执行。
up操作会使信号量的值+ 1。如果一个或者多个进程在信号量上睡眠,无法完成一个先前的down操 作,则由系统选择其中一个并允许该程完成down操作
。信号量的值增1和唤醒一 个进程同样也是不可分割的。不会有某个进程因执行up而阻塞,正如在前面的模型中不会有进程因执 行wakeup而阻塞是一样的道理
。为了确保信号量能正确工作,最重要的是要采用一种不可分割的方式来实现它。通常是将up和down 作为系统调用来实现。
而且操作系统只需在执行以下操作时暂时屏蔽全部中断:检查信号量、更新、必 要时使进程睡眠
。由于这些操作仅需要非常少的指令,因此中断不会造成影响。
如果使用多个CPU, 那么信号量应该被锁进行保护。使用TSL或者XCHG指令用来确保同一时刻只有一个CPU对信号量 进行操作。
使用TSL或者XCHG来防止几个CPU同时访问一个信号量,与生产者或消费者使用忙等待来等待其 他腾出或填充缓冲区是完全不一样的。前者的操作仅需要几个毫秒,而生产者或消费者可能需要任意长 的时间。
上面这个解决方案使用了三种信号量:一个称为full,用来记录充满的缓冲槽数目;一个称为empty, 记录空的缓冲槽数目;一个称为mutex,用来确保生产者和消费者不会同时进入缓冲区
。
Full被初 始化为0 , empty初始化为缓冲区中插槽数,mutex初始化为1。信号量初始化为1并且由两个或多 个进程使用,以确保它们中同时只有一个可以进入关键区域的信号被称为 二进制信号量(binary semaphores)
如果每个进程都在进入关键区域之前执行down操作,而在离开关键区域之后执行up 操作,则可以确保相互互斥。
现在我们有了一个好的进程间原语的保证。然后我们再来看一下中断的顺序保证:
1.硬件压入堆栈程序计数器
等
2.硬件从中断向量装入新的程序计数器
3.汇编语言过程保存寄存器的值
4.汇编语言过程设置新的堆栈
5.C中断服务器运行(典型的读和缓存写入)
6.调度器决定下面哪个程序先运行
7.C过程返回至汇编代码
8.汇编语言过程开始运行新的当前进程
在使用信号量的系统中,隐藏中断的自然方法是让每个I/O设备都配备一个信号量,该信号量最初设 置为0。
当中断进入时,中断处理程序随后对相关的信号量执行一个up操作,能够使已经阻止的进程恢 复运行。
mutex信号量用于互斥
。它用于确保任意时刻只有一个进程能够对缓冲区和相关变量进行读写。互斥是用于避免进程混乱所必须的一种操作。另外一个信号量是关于同步(synchronization)的。full和empty信号量用于确保事件的发生 或者不发生。在这个事例中,它们确保了缓冲区满时生产者停止运行;缓冲区为空时消费者停止运行。 这两个信号量的使用与mutex不同。