进程是须要频繁的和其余进程进行交流的。例如,在一个 shell 管道中,第一个进程的输出必须传递给第二个进程,这样沿着管道进行下去。所以,进程之间若是须要通讯的话,必需要使用一种良好的数据结构以致于不能被中断。下面咱们会一块儿讨论有关 进程间通讯(Inter Process Communication, IPC)
的问题。html
关于进程间的通讯,这里有三个问题java
须要注意的是,这三个问题中的后面两个问题一样也适用于线程程序员
第一个问题在线程间比较好解决,由于它们共享一个地址空间,它们具备相同的运行时环境,能够想象你在用高级语言编写多线程代码的过程当中,线程通讯问题是否是比较容易解决?算法
另外两个问题也一样适用于线程,一样的问题可用一样的方法来解决。咱们后面会慢慢讨论这三个问题,你如今脑子中大体有个印象便可。shell
在一些操做系统中,协做的进程可能共享一些彼此都能读写的公共资源。公共资源可能在内存中也可能在一个共享文件。为了讲清楚进程间是如何通讯的,这里咱们举一个例子:一个后台打印程序。当一个进程须要打印某个文件时,它会将文件名放在一个特殊的后台目录(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)。调试竞态条件是一种很是困难的工做,由于绝大多数状况下程序运行良好,但在极少数的状况下会发生一些没法解释的奇怪现象。不幸的是,多核增加带来的这种问题使得竞态条件愈来愈广泛。
不只共享资源会形成竞态条件,事实上共享文件、共享内存也会形成竞态条件、那么该如何避免呢?或许一句话能够归纳说明:禁止一个或多个进程在同一时刻对共享资源(包括共享内存、共享文件等)进行读写。换句话说,咱们须要一种 互斥(mutual exclusion)
条件,这也就是说,若是一个进程在某种方式下使用共享变量和文件的话,除该进程以外的其余进程就禁止作这种事(访问统一资源)。上面问题的纠结点在于,在进程 A 对共享变量的使用未结束以前进程 B 就使用它。在任何操做系统中,为了实现互斥操做而选用适当的原语是一个主要的设计问题,接下来咱们会着重探讨一下。
避免竞争问题的条件能够用一种抽象的方式去描述。大部分时间,进程都会忙于内部计算和其余不会致使竞争条件的计算。然而,有时候进程会访问共享内存或文件,或者作一些可以致使竞态条件的操做。咱们把对共享内存进行访问的程序片断称做 临界区域(critical region)
或 临界区(critical section)
。若是咱们可以正确的操做,使两个不一样进程不可能同时处于临界区,就能避免竞争条件,这也是从操做系统设计角度来进行的。
尽管上面这种设计避免了竞争条件,可是不能确保并发线程同时访问共享数据的正确性和高效性。一个好的解决方案,应该包含下面四种条件
从抽象的角度来看,咱们一般但愿进程的行为如上图所示,在 t1 时刻,进程 A 进入临界区,在 t2 的时刻,进程 B 尝试进入临界区,由于此时进程 A 正在处于临界区中,因此进程 B 会阻塞直到 t3 时刻进程 A 离开临界区,此时进程 B 可以容许进入临界区。最后,在 t4 时刻,进程 B 离开临界区,系统恢复到没有进程的原始状态。
下面咱们会继续探讨实现互斥的各类设计,在这些方案中,当一个进程正忙于更新其关键区域的共享内存时,没有其余进程会进入其关键区域,也不会形成影响。
在单处理器系统上,最简单的解决方案是让每一个进程在进入临界区后当即屏蔽全部中断
,并在离开临界区以前从新启用它们。屏蔽中断后,时钟中断也会被屏蔽。CPU 只有发生时钟中断或其余中断时才会进行进程切换。这样,在屏蔽中断后 CPU 不会切换到其余进程。因此,一旦某个进程屏蔽中断以后,它就能够检查和修改共享内存,而不用担忧其余进程介入访问共享数据。
这个方案可行吗?进程进入临界区域是由谁决定的呢?不是用户进程吗?当进程进入临界区域后,用户进程关闭中断,若是通过一段较长时间后进程没有离开,那么中断不就一直启用不了,结果会如何?可能会形成整个系统的终止。并且若是是多处理器的话,屏蔽中断仅仅对执行 disable
指令的 CPU 有效。其余 CPU 仍将继续运行,并能够访问共享内存。
另外一方面,对内核来讲,当它在执行更新变量或列表的几条指令期间将中断屏蔽是很方便的。例如,若是多个进程处理就绪列表中的时候发生中断,则可能会发生竞态条件的出现。因此,屏蔽中断对于操做系统自己来讲是一项颇有用的技术,可是对于用户线程来讲,屏蔽中断却不是一项通用的互斥机制。
做为第二种尝试,能够寻找一种软件层面解决方案。考虑有单个共享的(锁)变量,初始为值为 0 。当一个线程想要进入关键区域时,它首先会查看锁的值是否为 0 ,若是锁的值是 0 ,进程会把它设置为 1 并让进程进入关键区域。若是锁的状态是 1,进程会等待直到锁变量的值变为 0 。所以,锁变量的值是 0 则意味着没有线程进入关键区域。若是是 1 则意味着有进程在关键区域内。咱们对上图修改后,以下所示
这种设计方式是否正确呢?是否存在纰漏呢?假设一个进程读出锁变量的值并发现它为 0 ,而刚好在它将其设置为 1 以前,另外一个进程调度运行,读出锁的变量为0 ,并将锁的变量设置为 1 。而后第一个线程运行,把锁变量的值再次设置为 1,此时,临界区域就会有两个进程在同时运行。
也许有的读者能够这么认为,在进入前检查一次,在要离开的关键区域再检查一次不就解决了吗?实际上这种状况也是于事无补,由于在第二次检查期间其余线程仍有可能修改锁变量的值,换句话说,这种 set-before-check
不是一种 原子性
操做,因此一样还会发生竞争条件。
第三种互斥的方式先抛出来一段代码,这里的程序是用 C 语言编写,之因此采用 C 是由于操做系统广泛是用 C 来编写的(偶尔会用 C++),而基本不会使用 Java 、Modula3 或 Pascal 这样的语言,Java 中的 native 关键字底层也是 C 或 C++ 编写的源码。对于编写操做系统而言,须要使用 C 语言这种强大、高效、可预知和有特性的语言,而对于 Java ,它是不可预知的,由于它在关键时刻会用完存储器,而在不合适的时候会调用垃圾回收机制回收内存。在 C 语言中,这种状况不会发生,C 语言中不会主动调用垃圾回收回收内存。有关 C 、C++ 、Java 和其余四种语言的比较能够参考 连接
进程 0 的代码
while(TRUE){ while(turn != 0){ /* 进入关键区域 */ critical_region(); turn = 1; /* 离开关键区域 */ noncritical_region(); } }
进程 1 的代码
while(TRUE){ while(turn != 1){ critical_region(); turn = 0; noncritical_region(); } }
在上面代码中,变量 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 被一个临界区外的进程阻塞。因为违反了第三条,因此也不能做为一个好的方案。
荷兰数学家 T.Dekker 经过将锁变量与警告变量相结合,最先提出了一个不须要严格轮换的软件互斥算法,关于 Dekker 的算法,参考 连接
后来, G.L.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 很快便返回。若是进程如今调用 enter_region,进程 1 将在此处挂起直到 interested[0]
变为 FALSE,这种状况只有在进程 0 调用 leave_region
退出临界区时才会发生。
那么上面讨论的是顺序进入的状况,如今来考虑一种两个进程同时调用 enter_region
的状况。它们都将本身的进程存入 turn,但只有最后保存进去的进程号才有效,前一个进程的进程号由于重写而丢失。假如进程 1 是最后存入的,则 turn 为 1 。当两个进程都运行到 while
的时候,进程 0 将不会循环并进入临界区,而进程 1 将会无限循环且不会进入临界区,直到进程 0 退出位置。
如今来看一种须要硬件帮助的方案。一些计算机,特别是那些设计为多处理器的计算机,都会有下面这条指令
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 。
这条指令如何防止两个进程同时进入临界区呢?下面是解决方案
enter_region: TSL REGISTER,LOCK | 复制锁到寄存器并将锁设为1 CMP REGISTER,#0 | 锁是 0 吗? JNE enter_region | 若不是零,说明锁已被设置,因此循环 RET | 返回调用者,进入临界区 leave_region: MOVE LOCK,#0 | 在锁中存入 0 RET | 返回调用者
咱们能够看到这个解决方案的思想和 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
,它原子性的交换了两个位置的内容,例如,一个寄存器与一个内存字,代码以下
enter_region: MOVE REGISTER,#1 | 把 1 放在内存器中 XCHG REGISTER,LOCK | 交换寄存器和锁变量的内容 CMP REGISTER,#0 | 锁是 0 吗? JNE enter_region | 若不是 0 ,锁已被设置,进行循环 RET | 返回调用者,进入临界区 leave_region: MOVE LOCK,#0 | 在锁中存入 0 RET | 返回调用者
XCHG 的本质上与 TSL 的解决办法同样。全部的 Intel x86 CPU 在底层同步中使用 XCHG 指令。
上面解法中的 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 调用有一个参数,即要唤醒的进程。还有一种方式是 wakeup 和 sleep 都有一个参数,即 sleep 和 wakeup 须要匹配的内存地址。
做为这些私有原语的例子,让咱们考虑生产者-消费者(producer-consumer)
问题,也称做 有界缓冲区(bounded-buffer)
问题。两个进程共享一个公共的固定大小的缓冲区。其中一个是生产者(producer)
,将信息放入缓冲区, 另外一个是消费者(consumer)
,会从缓冲区中取出。也能够把这个问题通常化为 m 个生产者和 n 个消费者的问题,可是咱们这里只讨论一个生产者和一个消费者的状况,这样能够简化实现方案。
若是缓冲队列已满,那么当生产者仍想要将数据写入缓冲区的时候,会出现问题。它的解决办法是让生产者睡眠,也就是阻塞生产者。等到消费者从缓冲区中取出一个或多个数据项时再唤醒它。一样的,当消费者试图从缓冲区中取数据,可是发现缓冲区为空时,消费者也会睡眠,阻塞。直到生产者向其中放入一个新的数据。
这个逻辑听起来比较简单,并且这种方式也须要一种称做 监听
的变量,这个变量用于监视缓冲区的数据,咱们暂定为 count,若是缓冲区最多存放 N 个数据项,生产者会每次判断 count 是否达到 N,不然生产者向缓冲区放入一个数据项并增量 count 的值。消费者的逻辑也很类似:首先测试 count 的值是否为 0 ,若是为 0 则消费者睡眠、阻塞,不然会从缓冲区取出数据并使 count 数量递减。每一个进程也会检查检查是否其余线程是否应该被唤醒,若是应该被唤醒,那么就唤醒该线程。下面是生产者消费者的代码
#define N 100 /* 缓冲区 slot 槽的数量 */ int count = 0 /* 缓冲区数据的数量 */ // 生产者 void producer(void){ int item; while(TRUE){ /* 无限循环 */ item = produce_item() /* 生成下一项数据 */ if(count == N){ sleep(); /* 若是缓存区是满的,就会阻塞 */ } insert_item(item); /* 把当前数据放在缓冲区中 */ count = count + 1; /* 增长缓冲区 count 的数量 */ if(count == 1){ wakeup(consumer); /* 缓冲区是否为空? */ } } } // 消费者 void consumer(void){ int item; while(TRUE){ /* 无限循环 */ if(count == 0){ /* 若是缓冲区是空的,就会进行阻塞 */ sleep(); } item = remove_item(); /* 从缓冲区中取出一个数据 */ count = count - 1 /* 将缓冲区的 count 数量减一 */ if(count == N - 1){ /* 缓冲区满嘛? */ wakeup(producer); } consumer_item(item); /* 打印数据项 */ } }
为了在 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 ,则该位清除,而进程仍然保持清醒。
然而,当进程数量有许多的时候,这时你能够说经过增长唤醒等待位的数量来唤醒等待位,因而就有了 二、四、六、8 个唤醒等待位,可是并无从根本上解决问题。
信号量是 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 问题,代码以下
#define N 100 /* 定义缓冲区槽的数量 */ typedef int semaphore; /* 信号量是一种特殊的 int */ semaphore mutex = 1; /* 控制关键区域的访问 */ semaphore empty = N; /* 统计 buffer 空槽的数量 */ semaphore full = 0; /* 统计 buffer 满槽的数量 */ void producer(void){ int item; while(TRUE){ /* TRUE 的常量是 1 */ item = producer_item(); /* 产生放在缓冲区的一些数据 */ down(&empty); /* 将空槽数量减 1 */ down(&mutex); /* 进入关键区域 */ insert_item(item); /* 把数据放入缓冲区中 */ up(&mutex); /* 离开临界区 */ up(&full); /* 将 buffer 满槽数量 + 1 */ } } void consumer(void){ int item; while(TRUE){ /* 无限循环 */ down(&full); /* 缓存区满槽数量 - 1 */ down(&mutex); /* 进入缓冲区 */ item = remove_item(); /* 从缓冲区取出数据 */ up(&mutex); /* 离开临界区 */ up(&empty); /* 将空槽数目 + 1 */ consume_item(item); /* 处理数据 */ } }
为了确保信号量能正确工做,最重要的是要采用一种不可分割的方式来实现它。一般是将 up 和 down 做为系统调用来实现。并且操做系统只需在执行如下操做时暂时屏蔽所有中断:检查信号量、更新、必要时使进程睡眠。因为这些操做仅须要很是少的指令,所以中断不会形成影响。若是使用多个 CPU,那么信号量应该被锁进行保护。使用 TSL 或者 XCHG 指令用来确保同一时刻只有一个 CPU 对信号量进行操做。
使用 TSL 或者 XCHG 来防止几个 CPU 同时访问一个信号量,与生产者或消费者使用忙等待来等待其余腾出或填充缓冲区是彻底不同的。前者的操做仅须要几个毫秒,而生产者或消费者可能须要任意长的时间。
上面这个解决方案使用了三种信号量:一个称为 full,用来记录充满的缓冲槽数目;一个称为 empty,记录空的缓冲槽数目;一个称为 mutex,用来确保生产者和消费者不会同时进入缓冲区。Full
被初始化为 0 ,empty 初始化为缓冲区中插槽数,mutex 初始化为 1。信号量初始化为 1 而且由两个或多个进程使用,以确保它们中同时只有一个能够进入关键区域的信号被称为 二进制信号量(binary semaphores)
。若是每一个进程都在进入关键区域以前执行 down 操做,而在离开关键区域以后执行 up 操做,则能够确保相互互斥。
如今咱们有了一个好的进程间原语的保证。而后咱们再来看一下中断的顺序保证
硬件压入堆栈程序计数器等
硬件从中断向量装入新的程序计数器
汇编语言过程保存寄存器的值
汇编语言过程设置新的堆栈
C 中断服务器运行(典型的读和缓存写入)
调度器决定下面哪一个程序先运行
C 过程返回至汇编代码
汇编语言过程开始运行新的当前进程
在使用信号量
的系统中,隐藏中断的天然方法是让每一个 I/O 设备都配备一个信号量,该信号量最初设置为0。在 I/O 设备启动后,中断处理程序马上对相关联的信号执行一个 down
操做,因而进程当即被阻塞。当中断进入时,中断处理程序随后对相关的信号量执行一个 up
操做,可以使已经阻止的进程恢复运行。在上面的中断处理步骤中,其中的第 5 步 C 中断服务器运行
就是中断处理程序在信号量上执行的一个 up 操做,因此在第 6 步中,操做系统可以执行设备驱动程序。固然,若是有几个进程已经处于就绪状态,调度程序可能会选择接下来运行一个更重要的进程,咱们会在后面讨论调度的算法。
上面的代码其实是经过两种不一样的方式来使用信号量的,而这两种信号量之间的区别也是很重要的。mutex
信号量用于互斥。它用于确保任意时刻只有一个进程可以对缓冲区和相关变量进行读写。互斥是用于避免进程混乱所必须的一种操做。
另一个信号量是关于同步(synchronization)
的。full
和 empty
信号量用于确保事件的发生或者不发生。在这个事例中,它们确保了缓冲区满时生产者中止运行;缓冲区为空时消费者中止运行。这两个信号量的使用与 mutex 不一样。
若是不须要信号量的计数能力时,能够使用信号量的一个简单版本,称为 mutex(互斥量)
。互斥量的优点就在于在一些共享资源和一段代码中保持互斥。因为互斥的实现既简单又有效,这使得互斥量在实现用户空间线程包时很是有用。
互斥量是一个处于两种状态之一的共享变量:解锁(unlocked)
和 加锁(locked)
。这样,只须要一个二进制位来表示它,不过通常状况下,一般会用一个 整形(integer)
来表示。0 表示解锁,其余全部的值表示加锁,比 1 大的值表示加锁的次数。
mutex 使用两个过程,当一个线程(或者进程)须要访问关键区域时,会调用 mutex_lock
进行加锁。若是互斥锁当前处于解锁状态(表示关键区域可用),则调用成功,而且调用线程能够自由进入关键区域。
另外一方面,若是 mutex 互斥量已经锁定的话,调用线程会阻塞直到关键区域内的线程执行完毕而且调用了 mutex_unlock
。若是多个线程在 mutex 互斥量上阻塞,将随机选择一个线程并容许它得到锁。
因为 mutex 互斥量很是简单,因此只要有 TSL 或者是 XCHG 指令,就能够很容易地在用户空间实现它们。用于用户级线程包的 mutex_lock
和 mutex_unlock
代码以下,XCHG 的本质也同样。
mutex_lock: TSL REGISTER,MUTEX | 将互斥信号量复制到寄存器,并将互斥信号量置为1 CMP REGISTER,#0 | 互斥信号量是 0 吗? JZE ok | 若是互斥信号量为0,它被解锁,因此返回 CALL thread_yield | 互斥信号正在使用;调度其余线程 JMP mutex_lock | 再试一次 ok: RET | 返回调用者,进入临界区 mutex_unlcok: MOVE MUTEX,#0 | 将 mutex 置为 0 RET | 返回调用者
mutex_lock 的代码和上面 enter_region 的代码很类似,咱们能够对比着看一下
上面代码最大的区别你看出来了吗?
根据上面咱们对 TSL 的分析,咱们知道,若是 TSL 判断没有进入临界区的进程会进行无限循环获取锁,而在 TSL 的处理中,若是 mutex 正在使用,那么就调度其余线程进行处理。因此上面最大的区别其实就是在判断 mutex/TSL 以后的处理。
在(用户)线程中,状况有所不一样,由于没有时钟来中止运行时间过长的线程。结果是经过忙等待的方式来试图得到锁的线程将永远循环下去,决不会获得锁,由于这个运行的线程不会让其余线程运行从而释放锁,其余线程根本没有得到锁的机会。在后者获取锁失败时,它会调用 thread_yield
将 CPU 放弃给另一个线程。结果就不会进行忙等待。在该线程下次运行时,它再一次对锁进行测试。
上面就是 enter_region 和 mutex_lock 的差异所在。因为 thread_yield 仅仅是一个用户空间的线程调度,因此它的运行很是快捷。这样,mutex_lock
和 mutex_unlock
都不须要任何内核调用。经过使用这些过程,用户线程彻底能够实如今用户空间中的同步,这个过程仅仅须要少许的同步。
咱们上面描述的互斥量实际上是一套调用框架中的指令。从软件角度来讲,老是须要更多的特性和同步原语。例如,有时线程包提供一个调用 mutex_trylock
,这个调用尝试获取锁或者返回错误码,可是不会进行加锁操做。这就给了调用线程一个灵活性,以决定下一步作什么,是使用替代方法仍是等候下去。
随着并行的增长,有效的同步(synchronization)
和锁定(locking)
对于性能来讲是很是重要的。若是进程等待时间很短,那么自旋锁(Spin lock)
是很是有效;可是若是等待时间比较长,那么这会浪费 CPU 周期。若是进程不少,那么阻塞此进程,并仅当锁被释放的时候让内核解除阻塞是更有效的方式。不幸的是,这种方式也会致使另外的问题:它能够在进程竞争频繁的时候运行良好,可是在竞争不是很激烈的状况下内核切换的消耗会很是大,并且更困难的是,预测锁的竞争数量更不容易。
有一种有趣的解决方案是把二者的优势结合起来,提出一种新的思想,称为 futex
,或者是 快速用户空间互斥(fast user space mutex)
,是否是听起来颇有意思?
futex 是 Linux
中的特性实现了基本的锁定(很像是互斥锁)并且避免了陷入内核中,由于内核的切换的开销很是大,这样作能够大大提升性能。futex 由两部分组成:内核服务和用户库。内核服务提供了了一个 等待队列(wait queue)
容许多个进程在锁上排队等待。除非内核明确的对他们解除阻塞,不然它们不会运行。
对于一个进程来讲,把它放到等待队列须要昂贵的系统调用,这种方式应该被避免。在没有竞争的状况下,futex 能够直接在用户空间中工做。这些进程共享一个 32 位整数(integer)
做为公共锁变量。假设锁的初始化为 1,咱们认为这时锁已经被释放了。线程经过执行原子性的操做减小并测试(decrement and test)
来抢占锁。decrement and set 是 Linux 中的原子功能,由包裹在 C 函数中的内联汇编组成,并在头文件中进行定义。下一步,线程会检查结果来查看锁是否已经被释放。若是锁如今不是锁定状态,那么恰好咱们的线程能够成功抢占该锁。然而,若是锁被其余线程持有,抢占锁的线程不得不等待。在这种状况下,futex 库不会自旋
,可是会使用一个系统调用来把线程放在内核中的等待队列中。这样一来,切换到内核的开销已是合情合理的了,由于线程能够在任什么时候候阻塞。当线程完成了锁的工做时,它会使用原子性的 增长并测试(increment and test)
释放锁,并检查结果以查看内核等待队列上是否仍阻止任何进程。若是有的话,它会通知内核能够对等待队列中的一个或多个进程解除阻塞。若是没有锁竞争,内核则不须要参与竞争。
Pthreads 提供了一些功能用来同步线程。最基本的机制是使用互斥量变量,能够锁定和解锁,用来保护每一个关键区域。但愿进入关键区域的线程首先要尝试获取 mutex。若是 mutex 没有加锁,线程可以立刻进入而且互斥量可以自动锁定,从而阻止其余线程进入。若是 mutex 已经加锁,调用线程会阻塞,直到 mutex 解锁。若是多个线程在相同的互斥量上等待,当互斥量解锁时,只有一个线程可以进入而且从新加锁。这些锁并非必须的,程序员须要正确使用它们。
下面是与互斥量有关的函数调用
向咱们想象中的同样,mutex 可以被建立和销毁,扮演这两个角色的分别是 Phread_mutex_init
和 Pthread_mutex_destroy
。mutex 也能够经过 Pthread_mutex_lock
来进行加锁,若是互斥量已经加锁,则会阻塞调用者。还有一个调用Pthread_mutex_trylock
用来尝试对线程加锁,当 mutex 已经被加锁时,会返回一个错误代码而不是阻塞调用者。这个调用容许线程有效的进行忙等。最后,Pthread_mutex_unlock
会对 mutex 解锁而且释放一个正在等待的线程。
除了互斥量之外,Pthreads
还提供了第二种同步机制: 条件变量(condition variables)
。mutex 能够很好的容许或阻止对关键区域的访问。条件变量容许线程因为未知足某些条件而阻塞。绝大多数状况下这两种方法是一块儿使用的。下面咱们进一步来研究线程、互斥量、条件变量之间的关联。
下面再来从新认识一下生产者和消费者问题:一个线程将东西放在一个缓冲区内,由另外一个线程将它们取出。若是生产者发现缓冲区没有空槽能够使用了,生产者线程会阻塞起来直到有一个线程能够使用。生产者使用 mutex 来进行原子性检查从而不受其余线程干扰。可是当发现缓冲区已经满了之后,生产者须要一种方法来阻塞本身并在之后被唤醒。这即是条件变量作的工做。
下面是一些与条件变量有关的最重要的 pthread 调用
上表中给出了一些调用用来建立和销毁条件变量。条件变量上的主要属性是 Pthread_cond_wait
和 Pthread_cond_signal
。前者阻塞调用线程,直到其余线程发出信号为止(使用后者调用)。阻塞的线程一般须要等待唤醒的信号以此来释放资源或者执行某些其余活动。只有这样阻塞的线程才能继续工做。条件变量容许等待与阻塞原子性的进程。Pthread_cond_broadcast
用来唤醒多个阻塞的、须要等待信号唤醒的线程。
须要注意的是,条件变量(不像是信号量)不会存在于内存中。若是将一个信号量传递给一个没有线程等待的条件变量,那么这个信号就会丢失,这个须要注意
下面是一个使用互斥量和条件变量的例子
#include <stdio.h> #include <pthread.h> #define MAX 1000000000 /* 须要生产的数量 */ pthread_mutex_t the_mutex; pthread_cond_t condc,condp; /* 使用信号量 */ int buffer = 0; void *producer(void *ptr){ /* 生产数据 */ int i; for(int i = 0;i <= MAX;i++){ pthread_mutex_lock(&the_mutex); /* 缓冲区独占访问,也就是使用 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(int i = 0;i <= MAX;i++){ pthread_mutex_lock(&the_mutex); /* 缓冲区独占访问,也就是使用 mutex 获取锁 */ while(buffer == 0){ pthread_cond_wait(&condc,&the_mutex); } buffer = 0; /* 把他们从缓冲区中取出 */ pthread_cond_signal(&condp); /* 唤醒生产者 */ pthread_mutex_unlock(&the_mutex); /* 释放缓冲区 */ } pthread_exit(0); }
为了可以编写更加准确无误的程序,Brinch Hansen 和 Hoare 提出了一个更高级的同步原语叫作 管程(monitor)
。他们两我的的提案略有不一样,经过下面的描述你就能够知道。管程是程序、变量和数据结构等组成的一个集合,它们组成一个特殊的模块或者包。进程能够在任何须要的时候调用管程中的程序,可是它们不能从管程外部访问数据结构和程序。下面展现了一种抽象的,相似 Pascal 语言展现的简洁的管程。不能用 C 语言进行描述,由于管程是语言概念而 C 语言并不支持管程。
monitor example integer i; condition c; procedure producer(); . . . end; procedure consumer(); . end; end monitor;
管程有一个很重要的特性,即在任什么时候候管程中只能有一个活跃的进程,这一特性使管程可以很方便的实现互斥操做。管程是编程语言的特性,因此编译器知道它们的特殊性,所以能够采用与其余过程调用不一样的方法来处理对管程的调用。一般状况下,当进程调用管程中的程序时,该程序的前几条指令会检查管程中是否有其余活跃的进程。若是有的话,调用进程将被挂起,直到另外一个进程离开管程才将其唤醒。若是没有活跃进程在使用管程,那么该调用进程才能够进入。
进入管程中的互斥由编译器负责,可是一种通用作法是使用 互斥量(mutex)
和 二进制信号量(binary semaphore)
。因为编译器而不是程序员在操做,所以出错的概率会大大下降。在任什么时候候,编写管程的程序员都无需关心编译器是如何处理的。他只须要知道将全部的临界区转换成为管程过程便可。毫不会有两个进程同时执行临界区中的代码。
即便管程提供了一种简单的方式来实现互斥,但在咱们看来,这还不够。由于咱们还须要一种在进程没法执行被阻塞。在生产者-消费者问题中,很容易将针对缓冲区满和缓冲区空的测试放在管程程序中,可是生产者在发现缓冲区满的时候该如何阻塞呢?
解决的办法是引入条件变量(condition variables)
以及相关的两个操做 wait
和 signal
。当一个管程程序发现它不能运行时(例如,生产者发现缓冲区已满),它会在某个条件变量(如 full)上执行 wait
操做。这个操做形成调用进程阻塞,而且还将另外一个之前等在管程以外的进程调入管程。在前面的 pthread 中咱们已经探讨过条件变量的实现细节了。另外一个进程,好比消费者能够经过执行 signal
来唤醒阻塞的调用进程。
Brinch Hansen 和 Hoare 在对进程唤醒上有所不一样,Hoare 建议让新唤醒的进程继续运行;而挂起另外的进程。而 Brinch Hansen 建议让执行 signal 的进程必须退出管程,这里咱们采用 Brinch Hansen 的建议,由于它在概念上更简单,而且更容易实现。
若是在一个条件变量上有若干进程都在等待,则在对该条件执行 signal 操做后,系统调度程序只能选择其中一个进程恢复运行。
顺便提一下,这里还有上面两位教授没有提出的第三种方式,它的理论是让执行 signal 的进程继续运行,等待这个进程退出管程时,其余进程才能进入管程。
条件变量不是计数器。条件变量也不能像信号量那样积累信号以便之后使用。因此,若是向一个条件变量发送信号,可是该条件变量上没有等待进程,那么信号将会丢失。也就是说,wait 操做必须在 signal 以前执行。
下面是一个使用 Pascal
语言经过管程实现的生产者-消费者问题的解法
monitor ProducerConsumer condition full,empty; integer count; procedure insert(item:integer); begin if count = N then wait(full); insert_item(item); count := count + 1; if count = 1 then signal(empty); end; function remove:integer; begin if count = 0 then wait(empty); remove = remove_item; count := count - 1; if count = N - 1 then signal(full); end; count := 0; end monitor; procedure producer; begin while true do begin item = produce_item; ProducerConsumer.insert(item); end end; procedure consumer; begin while true do begin item = ProducerConsumer.remove; consume_item(item); end end;
读者可能以为 wait 和 signal 操做看起来像是前面提到的 sleep 和 wakeup ,并且后者存在严重的竞争条件。它们确实很像,可是有个关键的区别:sleep 和 wakeup 之因此会失败是由于当一个进程想睡眠时,另外一个进程试图去唤醒它。使用管程则不会发生这种状况。管程程序的自动互斥保证了这一点,若是管程过程当中的生产者发现缓冲区已满,它将可以完成 wait 操做而不用担忧调度程序可能会在 wait 完成以前切换到消费者。甚至,在 wait 执行完成而且把生产者标志为不可运行以前,是不会容许消费者进入管程的。
尽管类 Pascal 是一种想象的语言,但仍是有一些真正的编程语言支持,好比 Java (终于轮到大 Java 出场了),Java 是可以支持管程的,它是一种 面向对象
的语言,支持用户级线程,还容许将方法划分为类。只要将关键字 synchronized
关键字加到方法中便可。Java 可以保证一旦某个线程执行该方法,就不容许其余线程执行该对象中的任何 synchronized 方法。没有关键字 synchronized ,就不能保证没有交叉执行。
下面是 Java 使用管程解决的生产者-消费者问题
public class ProducerConsumer { static final int N = 100; // 定义缓冲区大小的长度 static Producer p = new Producer(); // 初始化一个新的生产者线程 static Consumer c = new Consumer(); // 初始化一个新的消费者线程 static Our_monitor mon = new Our_monitor(); // 初始化一个管程 static class Producer extends Thread{ public void run(){ // run 包含了线程代码 int item; while(true){ // 生产者循环 item = produce_item(); mon.insert(item); } } private int produce_item(){...} // 生产代码 } static class consumer extends Thread { public void run( ) { // run 包含了线程代码 int item; while(true){ item = mon.remove(); consume_item(item); } } private int produce_item(){...} // 消费代码 } static class Our_monitor { // 这是管程 private int buffer[] = new int[N]; private int count = 0,lo = 0,hi = 0; // 计数器和索引 private synchronized void insert(int val){ if(count == N){ go_to_sleep(); // 若是缓冲区是满的,则进入休眠 } buffer[hi] = val; // 向缓冲区插入内容 hi = (hi + 1) % N; // 找到下一个槽的为止 count = count + 1; // 缓冲区中的数目自增 1 if(count == 1){ notify(); // 若是消费者睡眠,则唤醒 } } private synchronized void remove(int val){ int val; if(count == 0){ go_to_sleep(); // 缓冲区是空的,进入休眠 } val = buffer[lo]; // 从缓冲区取出数据 lo = (lo + 1) % N; // 设置待取出数据项的槽 count = count - 1; // 缓冲区中的数据项数目减 1 if(count = N - 1){ notify(); // 若是生产者睡眠,唤醒它 } return val; } private void go_to_sleep() { try{ wait( ); }catch(Interr uptedExceptionexc) {}; } } }
上面的代码中主要设计四个类,外部类(outer class)
ProducerConsumer 建立并启动两个线程,p 和 c。第二个类和第三个类 Producer
和 Consumer
分别包含生产者和消费者代码。最后,Our_monitor
是管程,它有两个同步线程,用于在共享缓冲区中插入和取出数据。
在前面的全部例子中,生产者和消费者线程在功能上与它们是相同的。生产者有一个无限循环,该无限循环产生数据并将数据放入公共缓冲区中;消费者也有一个等价的无限循环,该无限循环用于从缓冲区取出数据并完成一系列工做。
程序中比较回味无穷的就是 Our_monitor
了,它包含缓冲区、管理变量以及两个同步方法。当生产者在 insert 内活动时,它保证消费者不能在 remove 方法中运行,从而保证更新变量以及缓冲区的安全性,而且不用担忧竞争条件。变量 count 记录在缓冲区中数据的数量。变量 lo
是缓冲区槽的序号,指出将要取出的下一个数据项。相似地,hi
是缓冲区中下一个要放入的数据项序号。容许 lo = hi,含义是在缓冲区中有 0 个或 N 个数据。
Java 中的同步方法与其余经典管程有本质差异:Java 没有内嵌的条件变量。然而,Java 提供了 wait 和 notify 分别与 sleep 和 wakeup 等价。
经过临界区自动的互斥,管程比信号量更容易保证并行编程的正确性。可是管程也有缺点,咱们前面说到过管程是一个编程语言的概念,编译器必需要识别管程并用某种方式对其互斥做出保证。C、Pascal 以及大多数其余编程语言都没有管程,因此不能依靠编译器来遵照互斥规则。
与管程和信号量有关的另外一个问题是,这些机制都是设计用来解决访问共享内存的一个或多个 CPU 上的互斥问题的。经过将信号量放在共享内存中并用 TSL
或 XCHG
指令来保护它们,能够避免竞争。可是若是是在分布式系统中,可能同时具备多个 CPU 的状况,而且每一个 CPU 都有本身的私有内存呢,它们经过网络相连,那么这些原语将会失效。由于信号量过低级了,而管程在少数几种编程语言以外没法使用,因此还须要其余方法。
上面提到的其余方法就是 消息传递(messaage passing)
。这种进程间通讯的方法使用两个原语 send
和 receive
,它们像信号量而不像管程,是系统调用而不是语言级别。示例以下
send(destination, &message); receive(source, &message);
send 方法用于向一个给定的目标发送一条消息,receive 从一个给定的源接受一条消息。若是没有消息,接受者可能被阻塞,直到接受一条消息或者带着错误码返回。
消息传递系统如今面临着许多信号量和管程所未涉及的问题和设计难点,尤为对那些在网络中不一样机器上的通讯情况。例如,消息有可能被网络丢失。为了防止消息丢失,发送方和接收方能够达成一致:一旦接受到消息后,接收方立刻回送一条特殊的 确认(acknowledgement)
消息。若是发送方在一段时间间隔内未收到确认,则重发消息。
如今考虑消息自己被正确接收,而返回给发送着的确认消息丢失的状况。发送者将重发消息,这样接受者将收到两次相同的消息。
对于接收者来讲,如何区分新的消息和一条重发的老消息是很是重要的。一般采用在每条原始消息中嵌入一个连续的序号来解决此问题。若是接受者收到一条消息,它具备与前面某一条消息同样的序号,就知道这条消息是重复的,能够忽略。
消息系统还必须处理如何命名进程的问题,以便在发送或接收调用中清晰的指明进程。身份验证(authentication)
也是一个问题,好比客户端怎么知道它是在与一个真正的文件服务器通讯,从发送方到接收方的信息有可能被中间人所篡改。
如今咱们考虑如何使用消息传递来解决生产者-消费者问题,而不是共享缓存。下面是一种解决方式
#define N 100 /* buffer 中槽的数量 */ void producer(void){ int item; message m; /* buffer 中槽的数量 */ 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++){ /* 循环N次 */ send(producer,&m); /* 发送N个缓冲区 */ } while(TRUE){ receive(producer,&m); /* 接受包含数据的消息 */ item = extract_item(&m); /* 将数据从消息中提取出来 */ send(producer,&m); /* 将空缓冲区发送回生产者 */ consume_item(item); /* 处理数据 */ } }
假设全部的消息都有相同的大小,而且在还没有接受到发出的消息时,由操做系统自动进行缓冲。在该解决方案中共使用 N 条消息,这就相似于一块共享内存缓冲区的 N 个槽。消费者首先将 N 条空消息发送给生产者。当生产者向消费者传递一个数据项时,它取走一条空消息并返回一条填充了内容的消息。经过这种方式,系统中总的消息数量保持不变,因此消息均可以存放在事先肯定数量的内存中。
若是生产者的速度要比消费者快,则全部的消息最终都将被填满,等待消费者,生产者将被阻塞,等待返回一条空消息。若是消费者速度快,那么状况将正相反:全部的消息均为空,等待生产者来填充,消费者将被阻塞,以等待一条填充过的消息。
消息传递的方式有许多变体,下面先介绍如何对消息进行 编址
。
信箱(mailbox)
,信箱是一个用来对必定的数据进行缓冲的数据结构,信箱中消息的设置方法也有多种,典型的方法是在信箱建立时肯定消息的数量。在使用信箱时,在 send 和 receive 调用的地址参数就是信箱的地址,而不是进程的地址。当一个进程试图向一个满的信箱发送消息时,它将被挂起,直到信箱中有消息被取走,从而为新的消息腾出地址空间。最后一个同步机制是准备用于进程组而不是进程间的生产者-消费者状况的。在某些应用中划分了若干阶段,而且规定,除非全部的进程都就绪准备着手下一个阶段,不然任何进程都不能进入下一个阶段,能够经过在每一个阶段的结尾安装一个 屏障(barrier)
来实现这种行为。当一个进程到达屏障时,它会被屏障所拦截,直到全部的屏障都到达为止。屏障可用于一组进程同步,以下图所示
在上图中咱们能够看到,有四个进程接近屏障,这意味着每一个进程都在进行运算,可是尚未到达每一个阶段的结尾。过了一段时间后,A、B、D 三个进程都到达了屏障,各自的进程被挂起,但此时还不能进入下一个阶段呢,由于进程 B 尚未执行完毕。结果,当最后一个 C 到达屏障后,这个进程组才可以进入下一个阶段。
最快的锁是根本没有锁。问题在于没有锁的状况下,咱们是否容许对共享数据结构的并发读写进行访问。答案固然是不能够。假设进程 A 正在对一个数字数组进行排序,而进程 B 正在计算其平均值,而此时你进行 A 的移动,会致使 B 会屡次读到重复值,而某些值根本没有遇到过。
然而,在某些状况下,咱们能够容许写操做来更新数据结构,即使还有其余的进程正在使用。窍门在于确保每一个读操做要么读取旧的版本,要么读取新的版本,例以下面的树
上面的树中,读操做从根部到叶子遍历整个树。加入一个新节点 X 后,为了实现这一操做,咱们要让这个节点在树中可见以前使它"刚好正确":咱们对节点 X 中的全部值进行初始化,包括它的子节点指针。而后经过原子写操做,使 X 称为 A 的子节点。全部的读操做都不会读到先后不一致的版本
在上面的图中,咱们接着移除 B 和 D。首先,将 A 的左子节点指针指向 C 。全部本来在 A 中的读操做将会后续读到节点 C ,而永远不会读到 B 和 D。也就是说,它们将只会读取到新版数据。一样,全部当前在 B 和 D 中的读操做将继续按照原始的数据结构指针而且读取旧版数据。全部操做均能正确运行,咱们不须要锁住任何东西。而不须要锁住数据就可以移除 B 和 D 的主要缘由就是 读-复制-更新(Ready-Copy-Update,RCU)
,将更新过程当中的移除和再分配过程分离开。
文章参考:
《现代操做系统》
《Modern Operating System》forth edition
https://www.encyclopedia.com/computing/news-wires-white-papers-and-books/interactive-systems
https://j00ru.vexillium.org/syscalls/nt/32/
https://www.bottomupcs.com/process_hierarchy.xhtml
https://en.wikipedia.org/wiki/Runtime_system
https://en.wikipedia.org/wiki/Execution_model