管程与条件变量
引言:为何需要超越信号量?
知识回顾:信号量(Semaphore)通过 down()
(P操作)和 up()
(V操作)为我们提供了强大的并发控制能力。然而,直接使用信号量来编写正确的并发程序是一项极具挑战性的任务。
- 【重点】 信号量的核心困境:
- 棘手的逻辑 🤔:正确地对多个信号量的
down()
操作进行排序,以避免死锁,是非常困难的。 - 代码的脆弱性 💔:
up()
和down()
操作在代码中是分离的,程序员的任何疏忽(如忘记up()
)都可能导致死锁或互斥失效。 - 目标模糊 🎯:信号量机制本身并未清晰地区分其用于互斥还是同步。
- 棘手的逻辑 🤔:正确地对多个信号量的
正因为这些困难,计算机科学家们开始寻求一种更高级、更不易出错的抽象机制来管理并发。这便引出了我们的主角——管程(Monitor)。
第一章:管程(Monitor)—— 更高级的同步抽象
1.1 🧐 什么是管程?
【重点】 管程是一种高级的同步原语,它将共享资源及其上的操作封装在一个编程结构中,并自动保证了对这些资源的互斥访问。
1.1.1 核心思想:封装与互斥
管程的设计借鉴了面向对象编程的思想,其核心在于两点:
封装 (Encapsulation) 📦:
- 管程将共享数据(如缓冲区)以及所有能访问这些数据的过程(或方法)打包在一起。
- 外部线程只能通过调用管程提供的入口方法来访问内部的共享数据,无法直接触及。
互斥 (Mutual Exclusion) 🛡️:
- 这是管程最强大的特性。编译器会自动为管程的所有入口方法添加“锁”机制。
- 当一个线程调用管程的任何入口方法时,它必须首先获取管程的内部互斥锁。
- 这就从根本上消除了程序员手动加锁、解锁时可能犯的错误。
1.1.2 管程的基本结构
一个管程可以被想象成一个特殊的“房间”,里面保护着共享数据。线程需要通过唯一的入口进入,并且房间里一次只能有一个线程。 ![[Pic/Pasted image 20251015093136.png]] [图 1.1:管程示例结构图,源于原文第4页]
1.2 ✍️ 使用管程解决生产者-消费者问题(初次尝试)
让我们尝试用一个简化的管程来解决经典的生产者-消费者问题。注意,这里的 sleep()
和 wakeup()
还是操作系统提供的基本原语,尚未引入条件变量。
monitor ProducerConsumer {
// 共享数据
private buffer, in, out;
// 入口方法 send
public send(message msg) {
while (in - out == N) { // 如果缓冲区满
sleep(); // 发送者休眠
}
buffer[in % N] = msg;
in = in + 1;
if (in == out + 1) { // 如果之前缓冲区是空的
wakeup(receiverThread); // 唤醒消费者
}
}
// 入口方法 receive
public message receive() {
while (in == out) { // 如果缓冲区空
sleep(); // 消费者休眠
}
msg = buffer[out % N];
out = out + 1;
if (in - out == N - 1) { // 如果之前缓冲区是满的
wakeup(senderThread); // 唤醒生产者
}
return msg;
}
}
1.2.1 🚨 问题与陷阱:在管程内休眠
上述实现存在一个致命缺陷。
【核心考点】 考虑当缓冲区已满时,生产者线程调用 send
方法:
- 生产者线程进入管程,获得互斥锁。
- 它发现缓冲区已满,于是调用
sleep()
使自己进入休眠状态。 - 问题来了:由于生产者在管程内部休眠了,它并没有释放管程的互斥锁!
- 后果:消费者线程即使想进入管程消费数据,也无法获取锁,因此它永远无法进入管程去唤醒沉睡的生产者。这就造成了永久的阻塞。
这个问题的根源在于,我们混淆了两种不同的并发需求:互斥(一次一个线程访问)和同步(线程间等待某个条件成立)。管程本身只解决了互斥,我们需要一个新工具来处理同步。
第二章:条件变量(Condition Variable)—— 实现线程的同步与协作
2.1 💡 引入条件变量
为了解决在管程内部等待的问题,我们引入了一个与管程紧密配合的新机制:条件变量 (Condition Variable)。
【重点】 条件变量不是锁,它是一个让线程能够原子地释放锁并进入等待状态的队列。它专门用于解决同步问题。
这样,其他线程就可以进入管程,修改共享数据,当条件满足时,再通过该条件变量唤醒等待的线程。
2.1.1 定义与核心操作
每个条件变量通常支持三种原子操作:
condition.wait()
😴:- 释放管程的互斥锁。
- 将当前线程阻塞,并放入该条件变量的等待队列中。
- 当被唤醒后,它会重新尝试获取管程的互斥锁,获取成功后才能继续执行。
condition.signal()
📣:- 从该条件变量的等待队列中,唤醒一个正在等待的线程(如果有的话)。
- 被唤醒的线程会从
wait()
的阻塞状态中返回,并尝试重新获取锁。 - 发出信号的线程并不会立即释放锁。
condition.broadcast()
📢:- 从该条件变量的等待队列中,唤醒所有正在等待的线程。
- 这在多个线程可能都满足条件的情况下非常有用。
2.2 ✅ 使用管程与条件变量重解生产者-消费者问题
现在,我们可以用这个强大的工具来完美地解决生产者-消费者问题了。
【核心考点】
monitor ProducerConsumer {
// 共享数据
private buffer, in, out;
// 两个条件变量
Condition full; // 用于表示缓冲区已满
Condition empty; // 用于表示缓冲区为空
// 入口方法 send
public send(message msg) {
if (in - out == N) { // 如果缓冲区满
full.wait(); // 等待在 full 条件上 (原子地释放锁并阻塞)
}
buffer[in % N] = msg;
in = in + 1;
empty.signal(); // 唤醒一个可能在 empty 上等待的消费者
}
// 入口方法 receive
public message receive() {
if (in == out) { // 如果缓冲区空
empty.wait(); // 等待在 empty 条件上 (原子地释放锁并阻塞)
}
msg = buffer[out % N];
out = out + 1;
full.signal(); // 唤醒一个可能在 full 上等待的生产者
return msg;
}
}
代码解析:
- 当生产者发现缓冲区满了,它调用
full.wait()
。此时它会释放管程锁,允许消费者进入。 - 当消费者取走一个数据后,缓冲区不再是满的,它调用
full.signal()
,这会唤醒在full
上等待的生产者。 - 消费者的逻辑与此对称。
第三章:管程的语义之争 —— Hoare vs. Mesa
3.1 🤔 一个悬而未决的问题:signal
之后谁先运行?
我们刚才的解决方案看似完美,但忽略了一个微妙的细节。假设线程 A (生产者) 在管程中执行了 empty.signal()
,唤醒了正在等待的线程 B (消费者)。
此时管程内有两个都准备好运行的线程:A 和 B。但管程的互斥原则决定了只有一个能运行。那么,谁继续运行,谁等待呢?
这个问题引出了管程的两种不同设计语义。
3.2 👨🏫 Hoare 语义 (强语义)
由管程的发明者 Tony Hoare 提出。
- 规则:发出
signal()
的线程 (A) 立即将管程的控制权和锁交给被唤醒的线程 (B),而自己则进入等待状态,直到 B 离开管程或再次等待。 - 保证:这种语义保证了当被唤醒的线程 (B) 恢复执行时,它等待的那个条件肯定是真的。因为从 A 发出信号到 B 开始运行,中间没有其他线程可以进入管程来改变这个条件。
- 编码风格:由于有此强保证,我们在
wait()
返回后,可以使用if
语句来检查条件。
[图 3.1:Hoare 语义流程图,源于原文第23页]
3.3 🛠️ MESA 语义 (弱语义)
在 Xerox PARC 的 Mesa 语言中首次实现,也是现代编程语言(如 Java, C#)普遍采用的语义。
- 规则:发出
signal()
的线程 (A) 继续持有锁并执行。被唤醒的线程 (B) 只是从等待队列被移动到就绪队列,它必须和其他新来的线程一样,重新竞争管程的锁。 - 保证:Mesa 语义的唯一保证是线程被唤醒了。它不保证当线程 B 最终获得锁并恢复执行时,它等待的条件仍然成立。因为在 A 释放锁和 B 获得锁之间,可能有其他线程(比如另一个生产者)抢先进入管程,再次把缓冲区填满了。
- 编码风格:由于没有强保证,我们在
wait()
返回后,必须使用while
循环来重新检查条件是否满足。
[图 3.2:MESA 语义流程图,源于原文第25页]
3.4 🚨 【核心考点】if
vs while
:两种语义下的代码差异
Mesa 语义下的生产者-消费者代码必须做出修改:
// MESA 语义版本
monitor ProducerConsumer {
...
// 入口方法 send
public send(message msg) {
// 必须使用 while 循环重新检查条件
while (in - out == N) {
full.wait();
}
...
}
// 入口方法 receive
public message receive() {
// 必须使用 while 循环重新检查条件
while (in == out) {
empty.wait();
}
...
}
}
3.5 ☕ Java 中的管程【了解】
Java 语言内置了管程的概念,但实现方式略有不同:
- 任何 Java 对象都可以作为管程。使用
synchronized
关键字标记的方法或代码块即为管程的入口。 - 每个对象只有一个内部锁和一个等待队列。
Object.wait()
、Object.notify()
和Object.notifyAll()
分别对应wait()
、signal()
和broadcast()
操作。- Java 采用的是 MESA 语义。
[图 3.3:Java 风格管程示意图,源于原文第33页]
第四章:高级同步问题与实践
4.1 📚 读者-写者问题
【重点】 这是一个经典的并发问题,其约束条件如下:
- 共享资源:一块可以被多个线程读或写的数据区。
- 两类线程:
- 读者 (Reader):只读取数据。
- 写者 (Writer):修改数据。
- 约束规则:
- 多个读者可以同时读取资源。
- 写者必须独占资源(写的时候不能有其他读者或写者)。
根据读者和写者被饿死的可能性,该问题分为读者优先、写者优先和读写公平三种策略。
4.1.1 读者优先策略的实现
- 策略:只要有一个读者正在读,就允许后续的读者进入,写者必须等待所有读者都离开。这种策略可能导致写者被“饿死”。
- 实现思路(使用信号量):
wlock
:一个互斥信号量,用于写者之间以及第一个读者与写者之间的互斥。readers
:一个计数器,记录当前正在读取的读者数量。rlock
:一个互斥信号量,用于保护对readers
计数器的修改。- 关键逻辑:
- 第一个进入的读者需要获取
wlock
,从而阻止写者。 - 最后一个离开的读者需要释放
wlock
,从而允许等待的写者进入。
- 第一个进入的读者需要获取
第五章:并发程序中的常见错误与修复
【核心考点】 编写并发程序极易出错。常见的错误可以归为三类:
5.1 💥 原子性错误 (Atomicity Bugs)
- 定义:本应作为一个原子操作执行的代码序列,被其他线程中断,导致状态不一致。
- 例子:c如果 T1 在
// 线程 T1 if (shared > 23) { // 在这里,T1 可能被中断 printf("Value: %d\n", shared); } // 线程 T2 shared = 12;
if
判断后、printf
之前被中断,T2 修改了shared
的值,那么 T1 恢复后打印出的值就会是 12,这与if
的判断条件相矛盾。 - 修复:使用锁(如互斥信号量
mutex
)来保护访问共享资源的临界区,确保检查和使用的操作是原子的。
5.2 ➡️ 违反顺序错误 (Order Violation Bugs)
- 定义:代码执行的顺序与程序员预期的逻辑顺序不一致。
- 例子:c如果线程2在线程1完成
// 线程 1 mThread = CreateThread(...); mThread->State = READY; // 线程 2 mState = mThread->State;
mThread
的初始化之前就执行了,那么mThread
可能是一个空指针,导致程序崩溃。 - 修复:使用条件变量来确保某个动作(如初始化)在另一个动作(如使用)发生之前已经完成。线程1完成初始化后
signal
,线程2在执行前wait
。
5.3 ⛓️ 死锁 (Deadlock)
- 定义:两个或多个线程相互等待对方持有的资源,导致所有线程都无法继续执行。
- 经典例子(不一致的加锁顺序):c如果线程1获得了
// 线程 1 lock(L1); lock(L2); // 线程 2 lock(L2); lock(L1);
L1
,同时线程2获得了L2
,那么它们将永远相互等待对方的锁。 - 修复:确保所有线程都以相同(全局)的顺序来请求锁。
总结
- 同步原语的演进 🚀:我们从困难且易错的信号量,演进到了更高级、更安全的管程和条件变量。
- 各有所长 🛠️:自旋锁、信号量、条件变量(配合管程)都能实现同步,但适用场景和性能各不相同。
- 自旋锁:适用于锁持有时间极短的场景。
- 信号量:强大的底层工具,但使用复杂。
- 管程/条件变量:构建复杂同步逻辑的首选,代码更清晰、安全。
- 并发编程的挑战 🧗:同步问题是并发编程的核心挑战,违反原子性、违反顺序和死锁是必须时刻警惕的三大陷阱。