OS–进程间通信详解(二)
文章目录
一、进程间通信
1.互斥量
如果不需要信号量的计数能力时,可以使用信号量的一个简单版本,称为mutex(互斥量)。
-
互斥量的 优势就在于在一些共享资源和一段代码中保持互斥。由于互斥的实现既简单又有效,这使得互斥量在实 现用户空间线程包时非常有用
。 - 互斥量是一个处于两种状态之一的共享变量:
解锁(unlocked)和加锁(locked)
。 - 这样,只需要一 个二进制位来表示它,不过一般情况下,通常会用一个 整形(integer)来表示。0表示解锁,其他所 有的值表示加锁,比1大的值表示加锁的次数。
- mutex使用两个过程,当一个线程(或者进程)需要访问关键区域时,会调用mutex_lock进行加 锁。如果互斥锁当前处于解锁状态(表示关键区域可用),则调用成功,并且调用线程可以自由进入关 键区域。
-
另一方面,如果mutex互斥量已经锁定的话,调用线程会阻塞直到关键区域内的线程执行完毕并且调 用了 mutex_unlock。如果多个线程在mutex互斥量上阻塞,将随机选择一个线程并允许它获得 锁。
- 由于mutex互斥量非常简单,所以只要有TSL或者是XCHG指令,就可以很容易地在用户空间实现它 们。
- 用于用户级线程包的mutex_lock和mutex_unlock代码如下,XCHG的本质也一样。
mutex_lock的代码和上面enter_region的代码很相似,我们可以对比着看一下:
- 上面代码最大的区别你看出来了吗?
- •根据上面我们对TSL的分析,我们知道,
如果TSL判断没有进入临界区的进程会进行无限循环获 取锁,而在TSL的处理中,如果mutex正在使用,那么就调度其他线程进行处理
。所以上面最大 的区别其实就是在判断mutexTTSL之后的处理。 - •在(
用户)线程中
,情况有所不同,因为没有时钟来停止运行时间过长的线程。结果是通过忙等待 的方式来试图获得锁的线程将永远循环下去,决不会得到锁
,因为这个运行的线程不会让其他线程运行从而释放锁,其他线程根本没有获得锁的机会。在后者获取锁失败时,它会调用 thread-yield将CPU放弃给另外一个线程。结果就不会进行忙等待。 - 在该线程下次运行时,它再一次对锁进行测试。
- 上面就是enter_region和mutexjock的差别所在。由于
thread_yield仅仅是一个用户空间的线程调 度
,所以它的运行非常快捷。 - 这样,
mutex_lock和mutex_unlock都不需要任何内核调用。通过 使用这些过程,用户线程完全可以实现在用户空间中的同步,这个过程仅仅需要少量的同步
。 - 我们上面描述的互斥量其实是一套调用框架中的指令。从软件角度来说,总是需要更多的特性和同步原 语。
- 例如,有时线程包提供一个调用mutex_trylock ,这个调用尝试获取锁或者返回错误码,但是 不会进行加锁操作。这就给了调用线程一个灵活性,以决定下一步做什么,是使用替代方法还是等候下 去。
Futexes
随着并行的增加,有效的同步(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中的互斥量
- 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_cond_wait和 Pthread_cond_signal
前者阻塞调用线程,直到其他线程发出信号
为止(使用后者调用)。阻塞 的线程通常需要等待唤醒的信号以此来释放资源或者执行某些其他活动。只有这样阻塞的线程才能继续 工作。条件变量允许等待与阻塞原子性的进程- Pthread_cond_broadcast
用来唤醒多个阻塞的、 需要等待信号唤醒的线程
。 - 需要注意的是,
条件变量(不像是信号量)不会存在于内存中
。如果将一个信号量传递给一个没有线程等待的条件变量,那么这个信号就会丢失,这个需要注意
下面是一个使用互斥量和条件变量的例子:
2.管程
为了能够编写更加准确无误的程序,Brinch Hansen和Hoare提出了一个更局级的同步原语叫做 管程 (monitor)
他们两个人的提案略有不同,通过下面的描述你就可以知道。
-
管程是程序、变量和数据 结构等组成的一个集合,它们组成一个特殊的模块或者包。进程可以在任何需要的时候调用管程中的程序,但是它们不能从管程外部访问数据结构和程序
。
下面展示了一种抽象的,类似Pascal语言展示的 简洁的管程。不能用C语言进行描述,因为管程是语言概念而C语言并不支持管程。
-
管程
有一个很重要的特性,即在任何时候管程中只能有一个活跃的进程
,这一特性使管程能够很方便的 实现互斥操作。 - 管程是编程语言的特性,所以编译器知道它们的特殊性,因此可以采用与其他过程调用 不同的方法来处理对管程的调用。
-
通常情况下,当进程调用管程中的程序时,该程序的前几条指令会检 查管程中是否有其他活跃的进程。如果有的话,调用进程将被挂起,直到另一个进程离开管程才将其唤 醒。如果没有活跃进程在使用管程,那么该调用进程才可以进入。
- 进入管程中的互斥由编译器负责,但是一种通用做法是使用 互斥量(mutex)和 二进制信号量 (binary semaphore) ,由于编译器而不是程序员在操作,因此出错的几率会大大降低。
- 在任何时 候,编写管程的程序员都无需关心编译器是如何处理的。他只需要知道将所有的临界区转换成为管程过 程即可。
绝不会有两个进程同时执行临界区中的代码
。 - 即使管程提供了一种简单的方式来实现互斥,但在我们看来,这还不够。因为我们还需要一种在进程无 法执行被阻塞。
在生产者-消费者问题中,很容易将针对缓冲区满和缓冲区空的测试放在管程程序中, 但是生产者在发现缓冲区满的时候该如何阻塞呢?
-
解决的办法
是引入条件变量(condition variables)以及相关的两个操作wait和signal
-
一个管程程序发现它不能运行时(例如,生产者发现缓冲区已满),它会在某个条件变量(如full)上执行wait操作。这个操作造成调用进程阻塞,并且还将另一个以前等在管程之外的进程调入管程。
-
在前面的pthread中我们已经探讨过条件变量的实现细节了。另一个进程,比如消费者可以通过执行 signal来唤醒阻塞的调用进程。
-
Brinch Hansen和Hoare在对进程唤醒上有所不同,
Hoare建议让新唤醒的进程继续运行;而挂 起另外的进程。
-
而
Brinch Hansen建议让执行signal的进程必须退出管程
,这里我们采用Brinch Hansen的建议,因为它在概念上更简单,并且更容易实现。 -
如果在一个条件变量上有若干进程都在等待,则在对该条件执行signal操作后,系统调度程序只能选择 其中一个进程恢复运行。
-
顺便提一下,这里还有第三种方式,它的理论是让执行signal的进程继续运 行,等待这个进程退出管程时,其他进程才能进入管程。
-
条件变量不是计数器
。条件变量也不能像信号量那样积累信号以便以后使用。所以,如果向一个条件变 量发送信号,但是该条件变量上没有等待进程,那么信号将会丢失。也就是说,wait操作必须在 signal之前执行
。
-
我们可能觉得wait和signal操作看起来像是前面提到的sleep和wakeup ,而且后者存在严重的竞争 条件。
-
它们确实很像,但是有个关键的区别:
sleep和wakeup之所以会失败是因为当一个进程想睡眠 时,另一个进程试图去唤醒它
。 -
使用管程则不会发生这种情况。管程程序的自动互斥保证了这一点,如 果管程过程中的生产者发现缓冲区已满,它将能够完成wait操作而不用担心调度程序可能会在wait完 成之前切换到消费者。甚至,在wait执行完成并且把生产者标志为不可运行之前,是不会允许消费者 进入管程的。
-
尽管类Pascal是一种想象的语言,但还是有一些真正的编程语言支持,比如Java,Java是能够支持管程的,它是一种 面向对象的语言,支持用户级线程,还允许将方法划分 为类。
-
只要将关键字synchronized关键字加到方法中即可。Java能够保证一旦某个线程执行该方 法,就不允许其他线程执行该对象中的任何synchronized方法。没有关键字synchronized ,就不能保证没有交叉执行。
下面是Java使用管程解决的生产者-消费者问题:
- 上面的代码中主要设计四个类,外部类(outer class) Producerconsumer创建并启动两个线程,p 和c
- 第二个类和第三个类Producer和Consumer分别包含生产者和消费者代码。最
后,Our_monitor是管程,它有两个同步线程,用于在共享缓冲区中插入和取出数据。
在前面的所有例子中,生产者和消费者线程在功能上与它们是相同的。
生产者有一个无限循环,该无限 循环产生数据并将数据放入公共缓冲区中;消费者也有一个等价的无限循环,该无限循环用于从缓冲区 取出数据并完成一系列工作。
- 程序中比较耐人寻味的就是Our_monitor了,它包含缓冲区、管理变量以及两个同步方法。
当生产 者在insert内活动时,它保证消费者不能在remove方法中运行,从而保证更新变量以及缓冲区的安全 性,并且不用担心竞争条件。
- 变量count记录在缓冲区中数据的数量。变量1。是缓冲区槽的序号, 指出将要取出的下一个数据项。
- 类似地,hi是缓冲区中下一个要放入的数据项序号。允许lo = hi,含义是在缓冲区中有0个或N个数据。
- Java中的
同步方法与其他经典管程有本质差别:Java没有内嵌的条件变量。然而,Java提供了 wait 和notify分别与sleep和wakeup等价。
通过临界区自动的互斥,管程比信号量更容易保证并行编程的正确性。
- 但是
管程也有缺点
,我们前面说 到过管程是一个编程语言的概念,编译器必须要识别管程并用某种方式对其互斥作出保证
。C、Pascal 以及大多数其他编程语言都没有管程,所以不能依靠编译器来遵守互斥规则。 - 与管程和信号量有关的另一个问题是,这些机制都是设计用来解决访问共享内存的一个或多个CPU上 的互斥问题的。通过将信号量放在共享内存中并用TSL或XCHG指令来保护它们,可以避免竞争。
- 但是
如果是在分布式系统中,可能同时具有多个CPU的情况,并且每个CPU都有自己的私有内存 昵,它们通过网络相连,那么这些原语将会失效。因为信号量太低级了,而管程在少数几种编程语言之 外无法使用,所以还需要其他方法
。
3.消息传递
上面提到的其他方法就是 消息传递(messaage passing)
- 这种进程间通信的方法使用
两个原语 send和receive ,它们像信号量而不像管程,是系统调用而不是语言级别
。
示例如下
- send方法用于向一个给定的目标发送一条消息,receive从一个给定的源接受一条消息。
如果没有消 息,接受者可能被阻塞,直到接受一条消息或者带着错误码返回。
消息传递系统的设计要点
消息传递系统现在面临着许多信号量和管程所未涉及的问题和设计难点,尤其对那些在网络中不同机器 上的通信状况。
- 例如,消息有可能被网络丢失。为了防止消息丢失,发送方和接收方可以达成一致:
- 一旦接受到消息后,
接收方马上回送一条特殊的确认(acknowledgement)消息
- 如果发送方在一段时 间间隔内未收到确认,则重发消息。
现在考虑消息本身被正确接收,而返回给发送着的确认消息丢失的情况。发送者将重发消息,这样接受 者将收到两次相同的消息。
对于接收者来说,如何区分新的消息和一条重发的老消息是非常重要的。
-
通常采用
在每条原始消息中嵌 入一个连续的序号
来解决此问题。 -
如果接受者收到一条消息,它具有与前面某一条消息一样的序号,就 知道这条消息是重复的,可以忽略
。 -
消息系统还必须处理如何命名进程的问题,以便在发送或接收调用中清晰的指明进程。
-
身份验证 (authentication)也是一个问题,比如客户端怎么知道它是在与一个真正的文件服务器通信,从发 送方到接收方的信息有可能被中间人所篡改。
用消息传递解决生产者•消费者问题
现在我们考虑如何使用消息传递来解决生产者-消费者问题,而不是共享缓存。
下面是一种解决方式
- 假设所有的消息都有相同的大小,并且在尚未接受到发出的消息时,
由操作系统自动进行缓冲。
- 在该解 决方案中共使用N条消息,这就类似于一块共享内存缓冲区的N个槽。消费者首先将N条空消息发送 给生产者。
- 当生产者向消费者传递一个数据项时,它取走一条空消息并返回一条填充了内容的消息。
- 通 过这种方式,系统中总的消息数量保持不变,所以消息都可以存放在事先确定数量的内存中。
- 如果生产者的速度要比消费者快,则所有的消息最终都将被填满,等待消费者,生产者将被阻塞,等待 返回一条空消息。
如果消费者速度快,那么情况将正相反:所有的消息均为空,等待生产者来填充,消 费者将被阻塞,以等待一条填充过的消息。
消息传递的方式有许多变体,下面先介绍如何对消息进行编址
:
- •—种方法是
为每个进程分配一个唯一的地址,让消息按进程的地址编址
。 - •
另一种方式是引入一个新的数据结构,称为 信箱(mailbox),信箱是一个用来对一定的数据进行 缓冲的数据结构,信箱中消息的设置方法也有多种,典型的方法是在信箱创建时确定消息的数量
。
在使用信箱时,在send和receive调用的地址参数就是信箱的地址,而不是进程的地址。
当一个 进程试图向一个满的信箱发送消息时,它将被挂起,直到信箱中有消息被取走,从而为新的消息腾 出地址空间。
4.屏障
最后一个同步机制是准备用于进程组而不是进程间的生产者-消费者情况的。
- 在某些应用中划分了若干 阶段,并且规定,
除非所有的进程都就绪准备着手下一个阶段,否则任何进程都不能进入下一个阶段
, 可以通过在每个阶段的结尾安装一个屏障(barrier)来实现这种行为。 当一个进程到达屏障时,它 会被屏障所拦截,直到所有的屏障都到达为止
。屏障可用于一组进程同步
如下图所示
- 在上图中我们可以看到,有四个进程接近屏障,这意味着每个进程都在进行运算,但是还没有到达每个 阶段的结尾。
- 过了一段时间后,A、B、D三个进程都到达了屏障,各自的进程被挂起,但此时还不能进 入下一个阶段昵,因为进程B还没有执行完毕。
- 结果,当最后一个C到达屏障后,这个进程组才能够 进入下一个阶段。
5.避免锁:读-复制-更新
最快的锁是根本没有锁。
问题在于没有锁的情况下,我们是否允许对共享数据结构的并发读写进行访 问。
- 答案
当然是不可以
。
- 假设进程A正在对一个数字数组进行排序,而进程B正在计算其平均值
- 而 此时你进行A的移动,会导致B会多次读到重复值,而某些值根本没有遇到过。
- 然而,在某些情况下,我们可以允许写操作来更新数据结构,即便还有其他的进程正在使用。
- 窍门在于
确保每个读操作要么读取旧的版本,要么读取新的版本
例如下面的树
- 上面的树中,
读操作从根部到叶子遍历整个树
。 - 加入一个新节点X后,为了实现这一操作,我们要让这 个节点在树中可见之前使它”恰好正确”:我们对节点X中的所有值进行初始化,包括它的子节点指针。
- 然后通过原子写操作,使X称为A的子节点。
所有的读操作都不会读到前后不一致的版本
- 在上面的图中,我们接着移除B和D
- 首先,将A的左子节点指针指向C。所有原本在A中的读操作 将会后续读到节点C,而永远不会读到B和D
也就是说,它们将只会读取到新版数据。同样,所有 当前在B和D中的读操作将继续按照原始的数据结构指针并且读取旧版数据。
- 所有操作均能正确运 行,我们不需要锁住任何东西。而不需要锁住数据就能够移除B和D的主要原因就是
读-复制-更新 (Ready-Copy-Update,RCU),将更新过程中的移除和再分配过程分离开
。