OS--进程间通信详解(一)

OS–进程间通信详解(一)

一、进程间通信

进程是需要频繁的和其他进程进行交流的。

  • 例如,在一个shell管道中,第一个进程的输出必须传递给 第二个进程,这样沿着管道进行下去。

因此,进程之间如果需要通信的话,必须要使用一种良好的数据 结构以至于不能被中断。

下面我们会一起讨论有关进程间通信(Inter Process Communication, IPC)的问题。

  • 关于进程间的通信,这里有三个问题:

  • •上面提到了第一个问题,那就是一个进程如何传递消息给其他进程

  • •第二个问题是如何确保两个或多个线程之间不会相互干扰

例如,两个航空公司都试图为不同的顾 客抢购飞机上的最后一个座位。

  • •第三个问题是数据的先后顺序的问题,如果进程A产生数据并且进程B打印数据。则进程B打印 数据之前需要先等A产生数据后才能够进行打印。

需要注意的是,这三个问题中的后面两个问题同样也适用于线程

  • 第一个问题在线程间比较好解决,因为它们共享一个地址空间,它们具有相同的运行时环境

可以想象 你在用高级语言编写多线程代码的过程中,线程通信问题是不是比较容易解决?

另外两个问题也同样适用于线程,同样的问题可用同样的方法来解决。我们后面会慢慢讨论

1.竞态条件

  • 在一些操作系统中,协作的进程可能共享一些彼此都能读写的公共资源。公共资源可能在内存中也可能 在一个共享文件。
  • 为了讲清楚进程间是如何通信的,这里我们举一个例子:
  • 一个后台打印程序。
  • 当一个进程需要打印某个文件时,它会将文件名放在一个特殊的后台目录(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).
  • 调试竞态条件是一种非常困难的工作,因为绝大多数情况下程序运行良好,但在极少数的情况下会发生 一些无法解释的奇怪现象。

2.临界区

  • 不仅共享资源会造成竞态条件,事实上共享文件、共享内存也会造成竞态条件、那么该如何避免呢?
  • 或 许一句话可以概括说明:禁止一个或多个进程在同一时刻对共享资源(包括共享内存、共享文件等)进 行读写。
  • 换句话说,我们需要一种互斥(mutual exclusion)条件,这也就是说,如果一个进程在 某种方式下使用共享变量和文件的话,除该进程之外的其他进程就禁止做这种事(访问统一资源)

上 面问题的纠结点在于,在进程A对共享变量的使用未结束之前进程B就使用它。

在任何操作系统中, 为了实现互斥操作而选用适当的原语是一个主要的设计问题,接下来我们会着重探讨一下。

  • 避免竞争问题的条件可以用一种抽象的方式去描述。大部分时间,进程都会忙于内部计算和其他不会导 致竞争条件的计算。

  • 然而,有时候进程会访问共享内存或文件,或者做一些能够导致竞态条件的操作。

  • 我们把对共享内存进行访问的程序片段称作临界区域(critical region)或临界区(critical section)。

  • 如果我们能够正确的操作,使两个不同进程不可能同时处于临界区,就能避免竞争条件, 这也是从操作系统设计角度来进行的。

  • 尽管上面这种设计避免了竞争条件,但是不能确保并发线程同时访问共享数据的正确性和高效性。一个 好的解决方案,应该包含下面四种条件

  • 1.任何时候两个进程不能同时处于临界区

  • 2.不应对CPU的速度和数量做任何假设

  • 3.位于临界区外的进程不得阻塞其他进程

  • 4.不能使任何进程无限等待进入临界区
    在这里插入图片描述

  • 从抽象的角度来看,我们通常希望进程的行为如上图所示,在t1时刻,进程A进入临界区

  • 在t2的时 刻,进程B尝试进入临界区,因为此时进程A正在处于临界区中,所以进程B会阻塞直到t3时刻进 程A离开临界区,此时进程B能够允许进入临界区。

  • 最后,在t4时刻,进程B离开临界区,系统恢 复到没有进程的原始状态。

3.忙等互斥

下面我们会继续探讨实现互斥的各种设计,在这些方案中,当一个进程正忙于更新其关键区域的共享内 存时,没有其他进程会进入其关键区域,也不会造成影响。

屏蔽中断

  • 单处理器系统上,最简单的解决方案是让每个进程在进入临界区后立即屏蔽所有中断,并在离开临 界区之前重新启用它们。
  • 屏蔽中断后,时钟中断也会被屏蔽。CPU只有发生时钟中断或其他中断时才 会进行进程切换。这样,在屏蔽中断后CPU不会切换到其他进程。
  • 所以,一旦某个进程屏蔽中断之 后,它就可以检查和修改共享内存,而不用担心其他进程介入访问共享数据。

这个方案可行吗?进程进入临界区域是由谁决定的呢?不是用户进程吗?

  • 进程进入临界区域后,用户 进程关闭中断,如果经过一段较长时间后进程没有离开,那么中断不就一直启用不了,结果会如何?
  • 可 能会造成整个系统的终止。而且如果是多处理器的话,屏蔽中断仅仅对执行disable指令的CPU有 效。其他CPU仍将继续运行,并可以访问共享内存
  • 另一方面,对内核来说,当它在执行更新变量或列表的几条指令期间将中断屏蔽是很方便的。
  • 例如,如 果多个进程处理就绪列表中的时候发生中断,则可能会发生竞态条件的出现。
  • 所以,屏蔽中断对于操作系统本身来说是一项很有用的技术,但是对于用户线程来说,屏蔽中断却不是一项通用的互斥机制。

锁变量

作为第二种尝试,可以寻找一种软件层面解决方案。

  • 考虑有单个共享的(锁)变量,初始为值为0。当 一个线程想要进入关键区域时,它首先会查看锁的值是否为0 ,如果锁的值是0,进程会把它设置为1 并让进程进入关键区域。如果锁的状态是1,进程会等待直到锁变量的值变为0(这里的说法有些不同,有的说初始值为1,代表锁资源可用。不管怎么说都是一个道理)。

因此,锁变量的值是 0则意味着没有线程进入关键区域。如果是1则意味着有进程在关键区域内

我们对上图修改后,如下 所示
在这里插入图片描述

  • 这种设计方式是否正确呢?是否存在批漏呢?
  • 假设一个进程读出锁变量的值并发现它为0,而恰好在它 将其设置为1之前,另一个进程调度运行,读出锁的变量为0 ,并将锁的变量设置为1。
  • 然后第一个线 程运行,把锁变量的值再次设置为1,此时,临界区域就会有两个进程在同时运行。
    在这里插入图片描述
  • 也许我们可以这么认为,在进入前检查一次,在要离开的关键区域再检查一次不就解决了吗?
  • 实际 上这种情况也是于事无补,因为在第二次检查期间其他线程仍有可能修改锁变量的值,
  • 换句话说,这种 set-before-check不是一种原子性操作,所以同样还会发生竞争条件。

严格轮询法

第三种互斥的方式先抛出来一段代码:
在这里插入图片描述

  • 在上面代码中,变量turn ,初始值为0 ,用于记录轮到那个进程进入临界区,并检查或更新共享内 存。
  • 开始时,进程0检查turn,发现其值为0 ,于是进入临界区
  • 进程1也发现其值为0 ,所以在一 个等待循环中不停的测试turn, 看其值何时变为1。
  • 连续检查一个变量直到某个值出现为止,这种方法 称为 忙等待(busywaiting)
  • 由于这种方式浪费CPU时间,所以这种方式通常应该要避免
  • 只有在 有理由认为等待时间是非常短的情况下,才能够使用忙等待。用于忙等待的锁,称为自旋锁 (spinlock)。
  • 进程0离开临界区时,它将turn的值设置为1,以便允许进程1进入其临界区。
  • 假设进程1很快便离 开了临界区,则此时两个进程都处于临界区之外,turn的值又被设置为0。
  • 现在进程0很快就执行完 了整个循环,它退出临界区,并将turn的值设置为1。此时,turn的值为1,两个进程都在其临界区外 执行。
  • 突然,进程0结束了非临界区的操作并返回到循环的开始。
  • 但是,这时它不能进入临界区,因为turn 的当前值为1,此时进程1还忙于非临界区的操作,进程0只能继续while循环,直到进程1把turn 的值改为0。
  • 这说明,在一个进程比另一个进程执行速度慢了很多的情况下,轮流进入临界区并不是一 个好的方法。
  • 这种情况违反了前面的叙述3,即位于临界区外的进程不得阻塞其他进程进程0被一个临界区外的 进程阻塞。由于违反了第三条,所以也不能作为一个好的方案。

Peterson 解法

  • 荷兰数学家T.Dekker通过将锁变量与警告变量相结合,最早提出了一个不需要严格轮换的软件互斥算 法,

  • 后来,G.L.Peterson发现了一种简单很多的互斥算法,它的算法如下:
    在这里插入图片描述

  • 那么上面讨论的是顺序进入的情况,现在来考虑一种两个进程同时调用enter_region的情况。

  • 它们 都将自己的进程存入turn,但只有最后保存进去的进程号才有效,前一个进程的进程号因为重写而丢 失。

  • 假如进程1是最后存入的,则turn为1。

  • 当两个进程都运行到while的时候,进程0将不会 循环并进入临界区,而进程1将会无限循环且不会进入临界区,直到进程0退出位置。

TSL指令

现在来看一种需要硬件帮助的方案。一些计算机,特别是那些设计为多处理器的计算机,都会有下面这 条指令

TSL RX,LOCK
  • 称为测试并加锁(test and set lock),它将一个内存字lock读到寄存器RX中,然后在该内存 地址上存储一个非零值。
  • 读写指令能保证是一体的,不可分割的,一同执行的。在这个指令结束之前其 他处理器均不允许访问内存。执行TSL指令的CPU将会锁住内存总线,用来禁止其他CPU在这个指 令结束之前访问内存。
  • 很重要的一点是锁住内存总线和禁用中断不一样。禁用中断并不能保证一个处理器在读写操作之间另一 个处理器对内存的读写。
  • 也就是说,在处理器1上屏蔽中断对处理器2没有影响。让处理器2远离内 存直到处理器1完成读写的最好的方式就是锁住总线。这需要一个特殊的硬件(基本上,一根总线就可 以确保总线由锁住它的处理器使用,而其他的处理器不能使用)
  • 为了使用TSL指令,要使用一个共享变量lock来协调对共享内存的访问。当lock为0时,任何进程 都可以使用TSL指令将其设置为1,并读写共享内存。当操作结束时,进程使用move指令将lock 的值重新设置为0

这条指令如何防止两个进程同时进入临界区呢?下面是解决方案
在这里插入图片描述

  • 我们可以看到这个解决方案的思想和Peterson的思想很相似。
  • 假设存在如下共4指令的汇编语言程 序。
  • 第一条指令将lock原来的值复制到寄存器中并将lock设置为1 ,随后这个原来的值和0做对 比。
  • 如果它不是零,说明之前已经被加过锁,则程序返回到开始并再次测试。
  • 经过一段时间后(可长可 短),该值变为0 (当前处于临界区中的进程退出临界区时),于是过程返回,此时已加锁。
  • 要清除这 个锁也比较简单,程序只需要将0存入lock即可,不需要特殊的同步指令。
  • 现在有了一种很明确的做法,那就是进程在进入临界区之前会先调用enter_region ,判断是否进行 循环,如果lock的值是1 ,进行无限循环,如果lock是0,不进入循环并进入临界区。
  • 在进程从临界 区返回时它调用leave_region ,这会把lock设置为0。与基于临界区问题的所有解法一样,进程 必须在正确的时间调用enter_region和leave_region ,解法才能奏效。
  • 还有一个可以替换TSL的指令是XCHG ,它原子性的交换了两个位置的内容,

例如,一个寄存器与一 个内存字,代码如下:
在这里插入图片描述

4.睡眠与唤醒

上面解法中的Peterson 、TSL和XCHG解法都是正确的,但是它们都有忙等待的缺点。

  • 这些解法的本 质上都是一样的,先检查是否能够进入临界区,若不允许,则该进程将原地等待,直到允许为止。
  • 这种方式不但浪费了 CPU时间,而且还可能引起意想不到的结果
  • 考虑一台计算机上有两个进程,这 两个进程具有不同的优先级,H是属于优先级比较高的进程,L是属于优先级比较低的进程。
  • 进程 调度的规则是不论何时只要H进程处于就绪态H就开始运行。
  • 在某一时刻,L处于临界区中,此时H变为就绪态,准备运行(例如,一条I/O操作结束)。
  • 现在H要开始忙等,但由于当H就绪时L就不会被调度,L从来不会有机会离开关键区域,所以H会变成死循环,有时将这种情况称为优先级反转问题 (priority inversion
    problem)
  • 现在让我们看一下进程间的通信原语,这些原语在不允许它们进入关键区域之前会阻塞而不是浪费CPU时间,最简单的是sleep和wakeup
  • Sleep是一个能够造成调用者阻塞的系统调用,也就是 说,这个系统调用会暂停直到其他进程唤醒它。
  • wakeup调用有一个参数,即要唤醒的进程。还有一种 方式是wake叩和sleep都有一个参数,即sleep和wakeup需要匹配的内存地址。

生产者-消费者问题

  • 作为这些私有原语的例子,让我们考虑生产者-消费者(producer-consumer)问题,也称作 有界缓冲区(bounded-buffer)问题。
  • 两个进程共享一个公共的固定大小的缓冲区。其中一个是生产者 (producer),将信息放入缓冲区,另一个是消费者(consumer),会从缓冲区中取出。
  • 也可以把这 个问题一般化为m个生产者和n个消费者的问题,但是我们这里只讨论一个生产者和一个消费者的情况,这样可以简化实现方案。
  • 如果缓冲队列已满,那么当生产者仍想要将数据写入缓冲区的时候,会出现问题。它的解决办法是让生 产者睡眠,也就是阻塞生产者。等到消费者从缓冲区中取出一个或多个数据项时再唤醒它。
  • 同样的,当 消费者试图从缓冲区中取数据,但是发现缓冲区为空时,消费者也会睡眠,阻塞。直到生产者向其中放 入一个新的数据。
  • 这个逻辑听起来比较简单,而且这种方式也需要一种称作 监听 的变量,这个变量用于监视缓冲区的 数据,我们暂定为count
  • 如果缓冲区最多存放N个数据项,生产者会每次判断count是否达到N, 否则生产者向缓冲区放入一个数据项并增量count的值。
  • 消费者的逻辑也很相似:首先测试count的 值是否为0 ,如果为0则消费者睡眠、阻塞,否则会从缓冲区取出数据并使count数量递减。
  • 每个进 程也会检查检查是否其他线程是否应该被唤醒,如果应该被唤醒,那么就唤醒该线程。

下面是生产者消 费者的代码:
在这里插入图片描述

  • 为了在C语言中描述像是sleep和wakeup的系统调用,我们将以库函数调用的形式来表示。
  • 它 们不是C标准库的一部分,但可以在实际具有这些系统调用的任何系统上使用。代码中未实现的 insert_item和remove_item用来记录将数据项放入缓冲区和从缓冲区取出数据等。
  • 现在让我们回到生产者-消费者问题上来,上面代码中会产生竞争条件,因为count这个变量是暴露在 大众视野下的。
  • 有可能出现下面这种情况:缓冲区为空,此时消费者刚好读取count的值发现它为0 。此时调度程序决定暂停消费者并启动运行生产者
  • 生产者生产了一条数据并把它放在缓冲区中,然后 增加count的值,并注意到它的值是1。
  • 由于count为0,消费者必须处于睡眠状态,因此生产者调 用wakeup来唤醒消费者。但是,消费者此时在逻辑上并没有睡眠,所以wakeup信号会丢失。
  • 当消 费者下次启动后,它会查看之前读取的count值,发现它的值是0 ,然后在此进行睡眠。不久之后生 产者会填满整个缓冲区,在这之后会阻塞,这样一来两个进程将永远睡眠下去
  • 引起上面问题的 本质是唤醒尚未进行睡眠状态的进程会导致唤醒丢失
  • 如果它没有丢失,则一切都很 正常。一种快速 解决上面问题的方式是增加一个唤醒等待位(wakeup waiting bit) 。
  • 当一个 wakeup信号发送给仍在清醒的进程后,该位置为1。之后,当进程尝试睡眠的时候,如果唤醒等待位 为1 ,则该位清除,而进程仍然保持清醒
  • 然而,当进程数量有许多的时候,这时你可以说通过增加唤醒等待位的数量来唤醒等待位,于是就有了2、4、6、8个唤醒等待位,但是并没有从根本上解决问题。

5.信号量

  • 信号量是E.W.Dijkstra在1965年提出的一种方法,它使用一个整形变量来累计唤醒次数,以供之后使 用。
  • 在他的观点中,有一个新的变量类型称作**信号量(semaphore) ,—个信号量的取值可以是0 ,或 任意正数。0表示的是不需要任何唤醒,任意的正数表示的就是唤醒次数。**
  • Dijkstra提出了信号量有两个操作,现在通常使用down和up (分别可以用sleep和wakeup来表 示)。
  • down这个指令的操作会检查值是否大于0。如果大于0 ,则将其值减1 ;若该值为0 ,则进 程将睡眠,而且此时down操作将会继续执行
  • 检查数值、修改变量值以及可能发生的睡眠操作均为一 个单一的、不可分割的原子操作(atomic action)完成。
  • 这会保证一旦信号量操作开始,没有其他 的进程能够访问信号量,直到操作完成或者阻塞。这种原子性对于解决同步问题和避免竞争绝对必不可 少。

原子性操作指的是在计算机科学的许多其他领域中,一组相关操作全部执行而没有中断或根本不 执行。

  • up操作会使信号量的值+ 1。如果一个或者多个进程在信号量上睡眠,无法完成一个先前的down操 作,则由系统选择其中一个并允许该程完成down操作
  • 因此,对一个进程在其上睡眠的信号量执行一 次up操作之后,该信号量的值仍然是0 ,但在其上睡眠的进程却少了一个。
  • 信号量的值增1和唤醒一 个进程同样也是不可分割的。不会有某个进程因执行up而阻塞,正如在前面的模型中不会有进程因执 行wakeup而阻塞是一样的道理

用信号量解决生产者消费者丢失的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。

  • 在I/O设备启动后,中断处理程序立刻对相关联的信号执行一个down操作,于是进程立即被 阻塞。
  • 当中断进入时,中断处理程序随后对相关的信号量执行一个up操作,能够使已经阻止的进程恢 复运行。
  • 在上面的中断处理步骤中,其中的第5步C中断服务器运行 就是中断处理程序在信号量上 执行的一个up操作,所以在第6步中,操作系统能够执行设备驱动程序
  • 当然,如果有几个进程已经 处于就绪状态,调度程序可能会选择接下来运行一个更重要的进程,我们会在后面讨论调度的算法。
  • 上面的代码实际上是通过两种不同的方式来使用信号量的,而这两种信号量之间的区别也是很重要 的。
  • mutex信号量用于互斥。它用于确保任意时刻只有一个进程能够对缓冲区和相关变量进行读写。互斥是用于避免进程混乱所必须的一种操作。
  • 另外一个信号量是关于同步(synchronization)的。full和empty信号量用于确保事件的发生 或者不发生。在这个事例中,它们确保了缓冲区满时生产者停止运行;缓冲区为空时消费者停止运行。 这两个信号量的使用与mutex不同。

猜你喜欢

转载自blog.csdn.net/wolfGuiDao/article/details/107736448