Skip to content

Commit 250df7e

Browse files
committed
Working on ch8-4
1 parent 04a617b commit 250df7e

File tree

1 file changed

+72
-12
lines changed

1 file changed

+72
-12
lines changed

source/chapter8/4condition-variable.rst

+72-12
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,6 @@
4040
.. code-block:: rust
4141
:linenos:
4242
43-
static mut A: usize = 0;
44-
4543
unsafe fn first() -> ! {
4644
mutex_lock(MUTEX_ID);
4745
A = 1;
@@ -77,6 +75,8 @@
7775
7876
在这种实现中,我们对忙等循环中的每一次对 ``A`` 的读取独立加锁。这样的话,当 ``second`` 线程发现 ``first`` 还没有对 ``A`` 进行修改的时候,就可以先将锁释放让 ``first`` 可以进行修改。这种实现是正确的,但是基于忙等会浪费大量 CPU 资源和产生不必要的上下文切换。于是,我们可以利用基于阻塞机制的信号量进一步进行改造:
7977

78+
.. _link-condsync-sem:
79+
8080
.. code-block:: rust
8181
:linenos:
8282
:emphasize-lines: 6,16
@@ -140,14 +140,48 @@
140140
141141
.. 这里需要解决的两个关键问题: **如何等待一个条件?** 和 **在条件为真时如何向等待线程发出信号** 。我们的计算机科学家给出了 **管程(Monitor)** 和 **条件变量(Condition Variables)** 这种巧妙的方法。接下来,我们就会深入讲解条件变量的设计与实现。
142142
143-
条件变量的基本思路
143+
管程与条件变量
144144
-------------------------------------------
145145

146-
.. note::
146+
我们再回顾一下我们需要解决的一类同步互斥问题:首先,线程间共享一些资源,于是必须使用互斥锁对这些资源进行保护,确保同一时间最多只有一个线程在资源的临界区内;其次,我们还希望能够高效且灵活地支持线程间的条件同步。这应该基于阻塞机制实现:即线程在条件未满足时将自身阻塞,之后另一个线程执行到了某阶段之后,发现条件已经满足,于是将之前阻塞的线程唤醒。 :ref:`刚刚 <link-condsync-sem>` ,我们用信号量与互斥锁的组合解决了这一问题,但是这并不是一种通用的解决方案,而是有局限性的:
147147

148-
Brinch Hansen(1973)和Hoare(1974)结合操作系统和Concurrent Pascal编程语言,提出了一种高级同步原语,称为管程(monitor)。一个管程是一个由过程(procedures,Pascal语言的术语,即函数)、共享变量及数据结构等组成的一个集合。线程可以调用管程中的过程,但线程不能在管程之外声明的过程中直接访问管程内的数据结构。
148+
- 信号量本质上是一个整数,它不足以描述所有类型的等待条件/事件;
149+
- 在使用信号量的时候需要特别小心。比如,up 和 down 操作必须配对使用。而且在和互斥锁组合使用的时候需要注意操作顺序,不然容易导致死锁。
150+
151+
.. _term-monitor:
152+
153+
针对这种情况,Brinch Hansen(1973)和 Hoare(1974)结合操作系统和 Concurrent Pascal 编程语言,提出了一种高级同步原语,称为 **管程** (Monitor)。管程是一个由过程(Procedures,是 Pascal 语言中的术语,等同于我们今天所说的函数)、共享变量及数据结构等组成的一个集合,体现了面向对象思想。编程语言负责提供管程的底层机制,程序员则可以根据需求设计自己的管程,包括自定义管程中的过程和共享资源。在管程帮助下,线程可以更加方便、安全、高效地进行协作:线程只需调用管程中的过程即可,过程会对管程中线程间的共享资源进行操作。需要注意的是,管程中的共享资源不允许直接访问,而是只能通过管程中的过程间接访问,这是在编程语言层面对共享资源的一种保护,与 C++/Java 等语言中类的私有成员类似。
154+
155+
下面这段代码是 `使用 Concurrent Pascal 语言编写的管程示例的一部分 <https://en.wikipedia.org/wiki/Concurrent_Pascal#Example>`_ :
156+
157+
.. code-block:: pascal
158+
:linenos:
159+
160+
type
161+
buffer = Monitor
162+
{ 管程数据成员定义 }
163+
var
164+
{ 共享资源 }
165+
saved: Integer;
166+
full : Boolean;
167+
{ 条件变量 }
168+
fullq, emptyq: Queue;
169+
170+
{ 管程过程定义 }
171+
procedure entry put(item: Integer);
172+
begin
173+
if full then
174+
{ 条件不满足,阻塞当前线程 }
175+
delay(fullq);
176+
saved := item;
177+
full := true;
178+
{ 条件已经满足,唤醒其他线程 }
179+
continue(emptyq);
180+
end;
149181
182+
.. .. note::
150183

184+
Brinch Hansen(1973)和Hoare(1974)结合操作系统和Concurrent Pascal编程语言,提出了一种高级同步原语,称为管程(monitor)。一个管程是一个由过程(procedures,Pascal语言的术语,即函数)、共享变量及数据结构等组成的一个集合。线程可以调用管程中的过程,但线程不能在管程之外声明的过程中直接访问管程内的数据结构。
151185

152186
.. code-block:: pascal
153187
:linenos:
@@ -165,16 +199,38 @@
165199
end;
166200
end monitor
167201

202+
那么,管程是如何满足互斥访问和条件同步这两个要求的呢?
203+
204+
- **互斥访问** :区别于 Pascal 语言中的一般过程,管程中的过程使用 ``entry`` 关键字(见第 12 行)描述。编程语言保证同一时刻最多只有一个活跃线程在执行管程中的过程,这保证了线程并发调用管程过程的时候能保证管程中共享资源的互斥访问。管程是编程语言的组成部分,编译器知道其特殊性,因此可以采用与其他过程调用不同的方法来处理对管程的调用,比如编译器可以在管程中的每个过程的入口/出口处自动加上互斥锁的获取/释放操作。这一过程对程序员是透明的,降低了程序员的心智负担,也避免了程序员误用互斥锁而出错。
205+
- **条件同步** :管程还支持线程间的条件同步机制,它也是基于阻塞等待的,因而也分成阻塞和唤醒两部分。对于阻塞而言,第 14 行发现条件不满足,当前线程需要等待,于是在第 16 行阻塞当前线程;对于唤醒而言,第 17~18 行的执行满足了某些条件,随后在第 20 行唤醒等待该条件的线程(如果存在)。
206+
207+
.. _term-condition-variable:
208+
209+
在上面的代码片段中,阻塞和唤醒操作分别叫做 ``delay`` 和 ``continue`` (分别在第 16 和 20 行),它们都是在数据类型 ``Queue`` 上进行的。这里的 ``Queue`` 本质上是一个阻塞队列: ``delay`` 会将当前线程阻塞并加入到该阻塞队列中;而 ``continue`` 会从该阻塞队列中移除一个线程并将其唤醒。今天我们通常将这个 ``Queue`` 称为 **条件变量** (Condition Variable) ,而将条件变量的阻塞和唤醒操作分别叫做 ``wait`` 和 ``signal`` 。
210+
211+
一个管程中可以有多个不同的条件变量,每个条件变量代表多线程并发执行中需要等待的一种特定的条件,并保存所有阻塞等待该条件的线程。注意条件变量与管程过程自带的互斥锁是如何交互的:当调用条件变量的 ``wait`` 操作阻塞当前线程的时候,注意到该操作是在管程过程中,因此此时当前线程是持有锁的。经验告诉我们 **不要在持有锁的情况下陷入阻塞** ,因此在陷入阻塞状态之前当前线程必须先释放锁;当被阻塞的线程被其他线程使用 ``signal`` 操作唤醒之后,需要重新获取到锁才能继续执行,不然的话就无法保证管程过程的互斥访问。因此,站在线程的视角,必须持有锁才能调用条件变量的 ``wait`` 操作阻塞自身,且 ``wait`` 的功能按顺序分成下述多个阶段,由编程语言保证其原子性:
212+
213+
- 释放锁;
214+
- 阻塞当前线程;
215+
- 当前线程被唤醒之后,重新获取到锁。
216+
- ``wait`` 返回,当前线程成功向下执行。
217+
218+
由于互斥锁的存在, ``signal`` 操作也不只是简单的唤醒操作。当线程 :math:`T_1` 在执行过程(位于管程过程中)中发现某条件满足准备唤醒线程 :math:`T_2` 的时候,如果直接让线程 :math:`T_2` 继续执行(位于管程过程中),就会违背管程过程的互斥访问要求。因此,问题的关键是,在 :math:`T_1` 唤醒 :math:`T_2` 的时候, :math:`T_1` 如何处理它正持有的锁。具体来说,根据相关线程的优先级顺序,唤醒操作有这几种语义:
219+
220+
- Hoare 语义:优先级 :math:`T_2>T_1>其他线程` 。也就是说,当 :math:`T_1` 发现条件满足之后,立即通过 ``signal`` 唤醒 :math:`T_2` 并 **将锁转交** 给 :math:`T_2` ,这样 :math:`T_2` 就能立即继续执行,而 :math:`T_1` 则暂停执行并进入一个 *紧急等待队列* 。当 :math:`T_2` 退出管程过程后会将锁交回给紧急等待队列中的 :math:`T_1` ,从而 :math:`T_1` 可以继续执行。
221+
- Hansen 语义:优先级 :math:`T_1>T_2>其他线程` 。即 :math:`T_1` 发现条件满足之后,先继续执行,直到退出管程之前再使用 ``signal`` 唤醒并 **将锁转交** 给 :math:`T_2` ,于是 :math:`T_2` 可以继续执行。注意在 Hansen 语义下, ``signal`` 必须位于管程过程末尾。
222+
- Mesa 语义:优先级 :math:`T_1>T_2=其他线程` 。即 :math:`T_1` 发现条件满足之后,就可以使用 ``signal`` 唤醒 :math:`T_2` ,但是并 **不会将锁转交** 给 :math:`T_2` 。这意味着在 :math:`T_1` 退出管程过程释放锁之后, :math:`T_2` 还需要和其他线程竞争,直到抢到锁之后才能继续执行。
223+
168224

169-
管程有一个很重要的特性,即任一时刻只能有一个活跃线程调用管程中过程,这一特性使线程在调用执行管程中过程时能保证互斥,这样线程就可以放心地访问共享变量。管程是编程语言的组成部分,编译器知道其特殊性,因此可以采用与其他过程调用不同的方法来处理对管程的调用,比如编译器可以在管程中的每个过程的入口/出口处加上互斥锁的加锁/释放锁的操作。因为是由编译器而非程序员来生成互斥锁相关的代码,所以出错的可能性要小。
225+
.. 管程有一个很重要的特性,即任一时刻只能有一个活跃线程调用管程中过程,这一特性使线程在调用执行管程中过程时能保证互斥,这样线程就可以放心地访问共享变量。管程是编程语言的组成部分,编译器知道其特殊性,因此可以采用与其他过程调用不同的方法来处理对管程的调用,比如编译器可以在管程中的每个过程的入口/出口处加上互斥锁的加锁/释放锁的操作。因为是由编译器而非程序员来生成互斥锁相关的代码,所以出错的可能性要小。
170226
171-
管程虽然借助编译器提供了一种实现互斥的简便途径,但这还不够,还需要一种线程间的沟通机制。首先是等待机制:由于线程在调用管程中某个过程时,发现某个条件不满足,那就在无法继续运行而被阻塞。这里需要注意的是:在阻塞之前,操作系统需要把进入管程的过程入口处的互斥锁给释放掉,这样才能让其他线程有机会调用管程的过程。
227+
.. 管程虽然借助编译器提供了一种实现互斥的简便途径,但这还不够,还需要一种线程间的沟通机制。首先是等待机制:由于线程在调用管程中某个过程时,发现某个条件不满足,那就在无法继续运行而被阻塞。这里需要注意的是:在阻塞之前,操作系统需要把进入管程的过程入口处的互斥锁给释放掉,这样才能让其他线程有机会调用管程的过程。
172228
173-
其次是唤醒机制:另外一个线程可以在调用管程的过程中,把某个条件设置为真,并且还需要有一种机制及时唤醒等待条件为真的阻塞线程。这里需要注意的是:唤醒线程(本身执行位置在管程的过程中)如果把阻塞线程(其执行位置还在管程的过程中)唤醒了,那么需要避免两个活跃的线程都在管程中导致互斥被破坏的情况。为了避免管程中同时有两个活跃线程,我们需要一定的规则来约定线程发出唤醒操作的行为。目前有三种典型的规则方案:
229+
.. 其次是唤醒机制:另外一个线程可以在调用管程的过程中,把某个条件设置为真,并且还需要有一种机制及时唤醒等待条件为真的阻塞线程。这里需要注意的是:唤醒线程(本身执行位置在管程的过程中)如果把阻塞线程(其执行位置还在管程的过程中)唤醒了,那么需要避免两个活跃的线程都在管程中导致互斥被破坏的情况。为了避免管程中同时有两个活跃线程,我们需要一定的规则来约定线程发出唤醒操作的行为。目前有三种典型的规则方案:
174230
175-
- Hoare语义:线程发出唤醒操作后,马上阻塞自己,让新被唤醒的线程运行。注:此时唤醒线程的执行位置还在管程中。
176-
- Hansen语义:是执行唤醒操作的线程必须立即退出管程,即唤醒操作只可能作为一个管程过程的最后一条语句。注:此时唤醒线程的执行位置离开了管程。
177-
- Mesa语义:唤醒线程在发出行唤醒操作后继续运行,并且只有它退出管程之后,才允许等待的线程开始运行。注:此时唤醒线程的执行位置还在管程中。
231+
.. - Hoare语义:线程发出唤醒操作后,马上阻塞自己,让新被唤醒的线程运行。注:此时唤醒线程的执行位置还在管程中。
232+
.. - Hansen语义:是执行唤醒操作的线程必须立即退出管程,即唤醒操作只可能作为一个管程过程的最后一条语句。注:此时唤醒线程的执行位置离开了管程。
233+
.. - Mesa语义:唤醒线程在发出行唤醒操作后继续运行,并且只有它退出管程之后,才允许等待的线程开始运行。注:此时唤醒线程的执行位置还在管程中。
178234
179235
一般开发者会采纳Brinch Hansen的建议,因为它在概念上更简单,并且更容易实现。这种沟通机制的具体实现就是 **条件变量** 和对应的操作:wait和signal。线程使用条件变量来等待一个条件变成真。条件变量其实是一个线程等待队列,当条件不满足时,线程通过执行条件变量的wait操作就可以把自己加入到等待队列中,睡眠等待(waiting)该条件。另外某个线程,当它改变条件为真后,就可以通过条件变量的signal操作来唤醒一个或者多个等待的线程(通过在该条件上发信号),让它们继续执行。
180236

@@ -353,5 +409,9 @@
353409
- 第33行,实现wait操作,释放mutex互斥锁,将把当前线程放入条件变量的等待队列,设置当前线程为挂起状态并选择新线程执行。在恢复执行后,再加上mutex互斥锁。
354410

355411

412+
参考文献
413+
--------------------------------------------------------------
356414

357-
Hansen, Per Brinch (1993). "Monitors and concurrent Pascal: a personal history". HOPL-II: The second ACM SIGPLAN conference on History of programming languages. History of Programming Languages. New York, NY, USA: ACM. pp. 1–35. doi:10.1145/155360.155361. ISBN 0-89791-570-4.
415+
- Hansen, Per Brinch (1993). "Monitors and concurrent Pascal: a personal history". HOPL-II: The second ACM SIGPLAN conference on History of programming languages. History of Programming Languages. New York, NY, USA: ACM. pp. 1–35. doi:10.1145/155360.155361. ISBN 0-89791-570-4.
416+
- `Monitor, Wikipedia <https://en.wikipedia.org/wiki/Monitor_(synchronization)>`_
417+
- `Concurrent Pascal, Wikipedia <https://en.wikipedia.org/wiki/Concurrent_Pascal>`

0 commit comments

Comments
 (0)