南京大学 操作系统 (JYY) 学习笔记:并发控制与同步的魔法 (Synchronization)
写在前面:这是本系列的第十五篇。
在上一讲中,借助硬件原子指令和操作系统的帮助,我们终于驯服了并发这头野兽,实现了高效的互斥锁(Mutex/Spinlock)。互斥锁能够确保保护的代码块按照某种未知的顺序原子地执行。
但是,互斥并不总是能满足并发线程协作的需求。在很多场景下,我们不仅需要原子性,还需要控制代码块执行的先后次序(确定性)。今天,我们将学习并发控制的核心武器:同步 (Synchronization),以及并发编程领域的“万能钥匙”——条件变量。

互斥的局限性:好消息与坏消息
好消息!!!
我们终于可以实现绝对正确的 1 + 1 了!使用自旋锁 (spin_lock) 或互斥锁 (mutex_lock),我们把并发的指令序列强行变成了串行:
lock(&lock);
sum++;
// 任意代码
unlock(&lock);
坏消息…
互斥似乎还不够。
- 互斥实现了原子性: 保证了要么执行 $ A \rightarrow B $,要么执行 $ B \rightarrow A $,两者绝对不会交织重叠。
- 但互斥没有给我们确定性: 我们无法控制到底是谁先谁后!
- 我们的目标是:让共享内存的线程像齿轮一样精准咬合,按照我们预定的顺序协同工作。
同步与条件变量 (Synchronization)
什么是同步?
维基百科:“两个或两个以上随时间变化的量在变化过程中保持一定的相对关系。”
DeepSeek:“多个事件、进程或系统在时间上协调一致,确保按预定顺序或同时执行。”
在程序世界里,我们希望控制事件发生的先后顺序(比如 $ A \rightarrow B \rightarrow C $)。互斥锁只能确保 A、B、C 分开执行,但做不到顺序控制。我们希望在多线程的混沌中,建立起受我们绝对控制的 “happens-before” 关系。
现实世界的同步类比
场景 1:合唱团 (避免越跑越偏)
- 每个乐手都是一个“线程”。
- 事件:指挥发出节拍 $ \rightarrow $ 乐手演奏节拍。
- 如果没有严格的同步,某个乐手演奏得太长,整个乐队就会陷入混乱。
void T_player() {
while (!end) {
wait_next_beat(); // 同步点:等待指挥
play_next_beat();
}
}
场景 2:不见不散 (会合点)
- 线程 A:第一个人到达。
- 线程 B:第二个人到达。
- 只有当 A 和 B 都到达后,才能共同触发事件 XXX(类似线程的
join)。 - 核心逻辑: 一定有一瞬间,A 和 B 都完成了到达动作,且 XXX 还没有开始。这个确定的状态就是同步点。同步,就是将发散的并发程序状态“收束”到一起。
void T_A() {
arrive_at_activity_center();
while (!both_A_and_B_are_here()) ; // 不见不散,在此死等
xxx();
}
问问 OS?引入条件变量 (Condition Variables)
我们在用户态写 while (!can_proceed) ; 会导致 CPU 疯狂空转(Busy Waiting,忙等待),不仅浪费算力,在单核系统上甚至可能引发死锁。
能不能让操作系统帮我们等待?
于是,计算机科学家“发明”了条件变量 (Condition Variables) 机制:
wait: 当条件不满足时,直接让出 CPU 进入睡眠等待。signal** /broadcast😗* 当条件满足时,唤醒正在等待的线程。
C++11 中的条件变量与 Lambda 表达式
现代语言把底层的 API 封装得极其优雅。比如在 C++ 中,我们可以把等待的条件直接写进 Lambda 表达式里:
std::mutex mtx; // 互斥锁
std::condition_variable cv; // 条件变量
void T_player() {
std::unique_lock lk(mtx); // 必须先获取锁!
cv.wait(lk,
[]{ return can_proceed; } // 这行代码简直就是自然语言!
);
// 线程运行到这,意味着 can_proceed 成立,且成功拿回了互斥锁 lk
cv.notify_all(); // 通知所有等待该条件的线程
lk.unlock(); // 手动释放锁
}
极客铁律: 使用条件变量之前,必须、必须、必须带上一把互斥锁!否则连编译都过不去。不要碰瓷并发编程,老老实实使用绝对正确的范式。
条件变量的正确打开方式:生产者-消费者问题
学废你就赢了!
99% 的实际并发问题都可以用“生产者-消费者”模型来解决!
- Producer (生产者): 生产数据。如果缓冲区满了,就睡觉等待;如果有空位,放入数据,并叫醒消费者。
- Consumer (消费者): 消费数据。如果缓冲区空了,就睡觉等待;如果有数据,取走数据,并叫醒生产者。
一个极其简化的等价描述:打印括号
假设:
- 生产 = 打印左括号
( - 消费 = 打印右括号
)
规则:
- 不能输出错误的括号序列(右括号不能比左括号多,不能出现
()))。 - 括号嵌套的深度不超过
n(缓冲区的最大容量。如果 n=3,连续出现 4 个((((就是错的)。
终极抄代码模板
使用条件变量,永远按照这个固定范式来写。想清楚程序继续执行的条件是什么:
// 生产者的条件:深度 d < n 可以生产
void produce() {
mutex_lock(🔒);
while (!(depth < n)) { // 条件不满足,把锁交出去并睡觉
cond_wait(&cv, 🔒);
}
assert(depth < n); // 醒来并且拿到锁,条件必然满足
depth++;
printf("("); // 执行生产操作
cond_broadcast(&cv); // 唤醒所有人
mutex_unlock(🔒); // 释放锁
}
🚨 极客避坑指南:小心 Signal 的“虚假唤醒”
原笔记中提到,如果把 cond_broadcast(&cv) 换成 cond_signal(&cv) 会非常危险。
为什么“看起来正确”其实很致命?
cond_signal只会随机唤醒一个正在睡觉的线程。- 如果一个生产者生产完后,刚好唤醒了另一个生产者(此时缓冲区已满),被唤醒的生产者一看条件不满足,继续睡。而真正该被唤醒的消费者却还在死睡!最终导致全局死锁。
Kimi 给出的正解:
永远使用两个条件变量!
cv_producer:供生产者等待(等待不满)。cv_consumer:供消费者等待(等待不空)。
- 生产者生产完后,只
signal消费者;消费者消费完后,只signal生产者。这样既高效又绝对安全!
条件变量:万能的同步法
假设有三种线程分别打印 <、>、_,要求最终屏幕只出现 <><_ 和 ><>_ 的组合。
这看起来像大脑体操,但使用条件变量,你只需要死板地回答三个问题:
- 打印
<的条件是什么? - 打印
>的条件是什么? - 打印
_的条件是什么?
然后套用上面的while(!cond) wait()模板,一切迎刃而解!
从同步走向并行:实现并发计算图 (DAG)
操作系统课程为什么要讲这个?因为掌握了同步,你就掌握了压榨现代多核 CPU 的终极钥匙!
理解你的计算任务:计算图模型 G(V,E)
任何复杂的并行计算,都可以被抽象为一张有向无环图 (DAG, Directed Acyclic Graph)。
- 节点 (V): 代表一个独立的计算任务。
- 边 (u, v) $ \in $ E: 表示节点 v 的计算必须等待节点 u 产生的值。这就是一个绝对的 happens-before 关系!
经典例子:
- Longest Common Subsequence (最长公共子序列): 经典的动态规划矩阵,可以沿着对角线一层层并行计算。
- 电路模拟: 逻辑门的输出作为下一个逻辑门的输入。
- 深度神经网络 (DNN): 神经网络的前向传播和反向传播,天生就是一张庞大的并行计算图!
同步:如何用代码实现任意计算图?
方案 1:为每个计算节点设置一个线程和条件变量
void T_u() { // 节点 u 的逻辑
... // 执行 u 的核心计算
// 通知下游的 v 节点:我已经算完了
mutex_lock(v->lock);
v->num_done++;
cond_signal(v->cv);
mutex_unlock(v->lock);
}
void T_v() { // 节点 v 的逻辑
// 先等待所有的前置节点算完
mutex_lock(v->lock);
while (!(v->num_done == v->num_predecessors)) {
cond_wait(v->cv, v->lock);
}
mutex_unlock(v->lock);
... // 执行 v 的核心计算
}
方案 2:工业级的任务调度器 (Task Scheduler)
为图中的每个节点单独开一个线程太浪费了。在真实工程中,我们会实现一个线程池 + 调度器。
- 一个生产者 (
scheduler) 不断解析 DAG 图,把准备就绪的任务塞进队列。 - 若干个消费者 (
workers) 循环读取任务并执行。这其实就是现代计算引擎(如 TensorFlow/PyTorch 执行图)的最底层原型!
总结
Take-away messages:
同步的本质,是线程需要等待某件它所预期的事件发生。而事件的发生,总是可以用某种条件(例如深度 depth 满足要求,或者是前置节点计算完毕)来精确表达。
正因如此,计算机系统的先驱们设计了条件变量 (Condition Variables),将“条件检查”和“临界区休眠”完美地绑定在一起,从而在宏观上掌控了多线程宇宙的混沌,使得并发程序的执行变得可控、可靠且高效。
掌握了条件变量与生产者-消费者模型,你就真正跨过了并发编程的最难门槛。
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐


所有评论(0)