第15讲 同步原语II

第15讲 同步原语II
agsd以下根据附件《第15讲 同步原语II.md》整理。
第15讲:同步原语 II 学习指南
同步原语 II 的主线是:在互斥锁之外,操作系统还需要更丰富的同步机制来处理“条件等待”“有限资源”“读写分离”和“极端读多写少”的场景。
❗ 本节核心结论:
互斥锁只能解决“同一时刻谁能进入临界区”的问题;条件变量、信号量、读写锁和 RCU 分别进一步解决“什么时候能继续”“资源数量够不够”“读者能否并行”“读路径能否几乎无锁”的问题。
1. 本章知识地图
flowchart TD
A[互斥锁 / 自旋锁] --> B[条件变量]
B --> C[信号量]
C --> D[读写锁]
D --> E[RCU]
A --> A1[只解决临界区互斥]
B --> B1[条件不满足时阻塞等待]
C --> C1[把资源数量和等待唤醒结合]
D --> D1[读读并行,读写互斥]
E --> E1[读者无锁直读,写者复制更新]
这张图展示了同步原语的升级路线:从最基础的互斥,逐渐走向条件等待、资源计数、读写区分和读路径极致优化。
2. 条件变量:让线程等到条件成立
2.1 条件变量解决什么问题?
互斥锁能保证临界区安全,但它不能优雅地处理“条件暂时不满足”的情况。
例如生产者消费者问题中:
1 | while (empty_slot == 0) |
这段代码的问题是:
- 线程明明不能继续执行,却一直占用 CPU。
- 它只是反复检查条件,没有真正让出处理器。
- 如果等待时间很长,会造成严重浪费。
条件变量的目的就是:
- 条件不满足时,让线程阻塞。
- 条件满足后,由其他线程唤醒它。
- 避免无意义的忙等。
2.2 条件变量的基本接口
条件变量主要有两个操作:
1 | cond_wait(); |
它们的含义是:
| 操作 | 含义 | 典型角色 |
|---|---|---|
cond_wait() |
条件不满足,当前线程进入等待 | 等待者 |
cond_signal() |
条件可能满足,唤醒等待线程 | 唤醒者 |
在生产者消费者问题中:
- 生产者发现缓冲区满了,就等待
empty_cond。 - 消费者取走一个元素后,产生空位,于是
cond_signal(&empty_cond)。 - 消费者发现缓冲区空了,就等待
filled_cond。 - 生产者放入一个元素后,产生可消费对象,于是
cond_signal(&filled_cond)。
2.3 cond_wait() 为什么必须配合互斥锁?
条件变量不能单独使用。标准模式是:
1 | lock(&mutex); |
cond_wait(&cond, &mutex) 通常要原子地完成两件事:
- 把当前线程加入条件变量的等待队列。
- 释放互斥锁并阻塞当前线程。
被唤醒后,它还要:
- 重新获得互斥锁。
- 再继续检查条件和执行后续代码。
关键点在于:释放锁和阻塞必须是原子的。如果不是原子的,可能出现:
- 线程准备睡眠但还没睡眠时,另一个线程已经发送了 signal。
- signal 被“错过”,等待线程之后睡下去再也没人唤醒。
- 或者等待线程持锁睡眠,导致其他线程无法修改条件,形成死锁。
2.4 为什么等待条件要用 while,而不是 if?
错误写法:
1 | if (empty_slot == 0) { |
推荐写法:
1 | while (empty_slot == 0) { |
原因是:线程被唤醒,不等于条件一定成立。
可能出现:
- 多个线程同时被唤醒,但资源只够一个线程使用。
- 线程被唤醒后没有立即运行,期间条件又被其他线程改掉。
- 某些系统可能存在虚假唤醒。
cond_signal()只表示“条件可能成立了”,不是保证条件仍然成立。
所以条件变量的核心范式是:
1 | while (条件不满足) { |
❗ 本节核心结论:
条件变量不是条件本身,而是等待队列。真正的条件仍然要由共享变量表达,并且必须在互斥锁保护下用 while 反复检查。
3. 信号量:把资源数量和等待机制合在一起
3.1 信号量是什么?
信号量可以理解为:
一个带同步能力的资源计数器。
它内部有一个计数值,用来表示当前可用资源数量。
例如生产者消费者问题中:
1 | sem_t empty_slot; |
它们分别表示:
| 信号量 | 含义 |
|---|---|
empty_slot |
当前还有多少空槽位 |
filled_slot |
当前有多少可消费对象 |
3.2 P / V 原语怎么理解?
信号量有两个经典操作:
| 原语 | 常见名称 | 直观含义 |
|---|---|---|
| P 操作 | wait() |
申请一个资源 |
| V 操作 | signal() |
释放或产生一个资源 |
可以记成:
1 | wait(S); // 申请资源 |
生产者逻辑:
1 | wait(&empty_slot); |
含义是:
- 先申请一个空槽位。
- 把消息放入缓冲区。
- 通知系统多了一个可消费对象。
消费者逻辑:
1 | wait(&filled_slot); |
含义是:
- 先申请一个已有消息。
- 从缓冲区取走消息。
- 通知系统多了一个空槽位。
3.3 信号量和条件变量的区别
条件变量的问题是:条件变量和真实条件是分离的。
例如:
1 | int empty_slot = 5; |
这里:
empty_slot才是真正的资源数量。empty_cond只是一个等待队列。- 二者的对应关系靠程序员维护。
信号量则把二者合并:
- 计数值表示资源数量。
- 当资源不足时自动阻塞。
- 当资源增加时自动唤醒等待线程。
| 对比点 | 条件变量 | 信号量 |
|---|---|---|
| 本质 | 等待队列 / 通知机制 | 资源计数器 + 等待机制 |
| 是否记录资源数量 | 不记录 | 记录 |
| 是否需要额外条件变量 | 需要共享变量表达条件 | 信号量值本身表达条件 |
| 典型问题 | 缓冲区空/满等待 | 空槽位、连接池、令牌数 |
| 易错点 | 忘记用 while 检查条件 |
把信号量当普通整数 |
3.4 信号量不是普通整数
信号量看起来像整数,但不能把它当成普通变量:
1 | S--; |
这是错误直觉。
真正的信号量要求:
wait()内部修改计数值必须是同步安全的。signal()内部修改计数值也必须是同步安全的。- 多个线程同时申请或释放资源时不能发生数据竞争。
- 必要时线程要进入等待队列,而不是单纯修改整数。
因此信号量通常可以用:
- 互斥锁
- 条件变量
- 内部计数值
- 等待队列
共同实现。
❗ 本节核心结论:
信号量适合表达“有限数量资源”。它不是普通整数,而是把资源计数、互斥保护和等待唤醒组合在一起的同步原语。
4. 读写锁:读者之间不必互斥
4.1 为什么需要读写锁?
互斥锁过于保守。
如果多个线程只是读取共享数据:
1 | read_data(data); |
它们之间不会修改数据,也不会互相破坏结果。普通互斥锁却会让它们排队执行,降低并行性。
读写锁的基本规则是:
| 组合 | 是否允许并行 | 原因 |
|---|---|---|
| 读者 + 读者 | 允许 | 读操作不修改数据 |
| 读者 + 写者 | 不允许 | 写操作可能改变读者看到的数据 |
| 写者 + 写者 | 不允许 | 多个写者会互相覆盖或破坏状态 |
所以读写锁的目标是:
- 保证写操作安全。
- 尽量放开读操作之间的并行。
- 提高读多写少场景下的吞吐量。
4.2 读写锁的基本使用方式
读者使用读锁:
1 | void reader(void) { |
写者使用写锁:
1 | void writer(void) { |
关键不是“函数名不同”,而是进入规则不同:
- 读锁可以被多个读者同时持有。
- 写锁必须独占。
- 只要有写者进入,读者不能同时进入。
- 只要有读者存在,写者通常不能进入。
5. 读写锁的偏向性:吞吐量与公平性的取舍
5.1 问题场景
读写锁会遇到一个典型问题:
1 | t0:已有读者在临界区 |
此时问题是:
新读者能不能直接进入?
不同答案会形成不同偏向策略。
5.2 偏向读者
偏向读者的策略是:
- 即使有写者等待,新读者也可以继续进入。
- 读者吞吐量更高。
- 但写者可能长期等不到机会。
典型实现思路:
1 | struct rwlock { |
核心逻辑:
reader_cnt记录当前读者数量。- 第一个读者进入时获取
writer_lock,阻止写者进入。 - 后续读者只增加
reader_cnt,不需要再抢写者锁。 - 最后一个读者离开时释放
writer_lock,写者才有机会进入。
关键代码含义:
1 | if (reader_cnt == 1) { |
表示:
- 第一个读者负责挡住写者。
1 | if (reader_cnt == 0) { |
表示:
- 最后一个读者负责放行写者。
5.3 偏向写者
偏向写者的策略是:
- 一旦有写者等待,后续新读者不能继续进入。
- 写者更不容易饥饿。
- 但读者并行性会被更早限制。
典型实现中可以引入:
1 | volatile bool has_writer; |
它表示:
- 当前是否已经有写者等待或进入。
- 如果
has_writer == TRUE,新读者需要等待。 - 这样可以防止新读者源源不断进入,导致写者永远拿不到锁。
5.4 偏向读者 vs 偏向写者
| 对比点 | 偏向读者 | 偏向写者 |
|---|---|---|
| 有写者等待时,新读者能否进入 | 能 | 不能 |
| 优点 | 读吞吐量高 | 写者更公平 |
| 缺点 | 写者可能饥饿 | 读者可能更早阻塞 |
| 更关注 | 性能 / 吞吐量 | 公平性 / 避免写者饥饿 |
❗ 本节核心结论:
读写锁不等于“读者永远优先”。它必须选择偏向策略:偏向读者提高吞吐量,偏向写者减少写者饥饿。
6. RCU:读多写少场景下的极致优化
6.1 RCU 是什么?
RCU 全称是:
1 | Read-Copy-Update |
中文可以理解为:
读者直接读,写者复制后更新。
它的基本思想是:
- 读者不通过传统锁阻塞。
- 写者不直接修改旧对象。
- 写者复制旧对象,在副本上修改。
- 修改完成后,原子发布新指针。
- 等旧读者全部退出后,再释放旧对象。
flowchart LR
A[旧对象 A] --> B[写者复制 A 得到 A']
B --> C[在 A' 上修改]
C --> D[发布新指针 gp -> A']
D --> E[新读者读取 A']
A --> F[旧读者继续读 A]
F --> G[宽限期结束后释放 A]
这张图强调 RCU 的核心不是“阻止读者进入”,而是允许新旧版本在短时间内共存。
6.2 RCU 为什么适合读多写少?
读写锁虽然允许多个读者并行,但读者仍然要执行读锁逻辑:
1 | lock_reader(&lock); |
在读特别多、写特别少的场景中,哪怕读锁很轻,也可能成为热点开销。
RCU 的目标是:
- 让读路径尽可能短。
- 让读者几乎不需要传统锁。
- 把复杂性转移给写者。
- 用复制和延迟回收换取读路径性能。
适合场景:
- 读远多于写。
- 读路径非常关键。
- 数据通过指针间接访问。
- 可以接受旧版本短暂存在。
- 可以接受写者复制和延迟释放成本。
6.3 RCU 更新流程
假设共享指针 gp 指向旧对象 A:
1 | gp -> A |
写者更新时,不直接修改 A,而是:
1 | p = rcu_dereference(gp); |
这个流程可以拆成:
- 读取旧指针
p。 - 分配新对象
q。 - 把旧对象内容复制到新对象。
- 在新对象上完成修改。
- 用
rcu_assign_pointer()发布新版本。 - 新读者开始看到新对象。
- 旧读者仍可能持有旧对象。
- 调用
synchronize_rcu()等待旧读者退出。 - 确认安全后释放旧对象。
6.4 什么是宽限期 Grace Period?
宽限期指的是:
写者发布新版本之后,等待所有可能正在读取旧版本的读者退出 RCU 读临界区的时间。
为什么需要宽限期?
因为发布新指针后,系统中可能同时存在:
| 读者类型 | 看到的对象 |
|---|---|
| 旧读者 | 旧对象 A |
| 新读者 | 新对象 A’ |
如果写者发布新指针后立刻:
1 | kfree(A); |
那么旧读者可能还在访问 A,这会导致 use-after-free。
所以 RCU 的安全性依赖:
1 | synchronize_rcu(); |
它保证:
- 在它返回时,旧读者已经离开 RCU 读临界区。
- 旧对象可以安全释放。
6.5 RCU 读者的约束
RCU 读者通常写成:
1 | rcu_read_lock(); |
需要注意:
- 必须用
rcu_dereference()读取共享指针。 - 它防止编译器或 CPU 重排破坏指针发布顺序。
- RCU 读临界区内通常不能睡眠或调度。
- 否则宽限期可能被无限拉长。
- RCU 不等于写者不用锁。
- 多个写者之间仍然需要额外互斥,例如
spin_lock()。
6.6 synchronize_rcu() 不能替代写者锁
sync_rcu() 或 synchronize_rcu() 只解决一个问题:
什么时候可以安全释放旧对象?
它不解决:
- 多个写者同时修改同一结构。
- 多个写者同时发布新版本。
- 写者之间的更新顺序。
- 写者之间的数据竞争。
所以多个写者仍然可能需要:
1 | spin_lock(&list_lock); |
❗ 本节核心结论:
RCU 不是“更快的读写锁”,而是另一种更新策略:读者允许读旧版本或新版本,写者负责复制、发布和延迟回收。
7. 五类同步原语总对比
| 同步原语 | 主要解决的问题 | 核心思想 | 典型场景 | 易错点 |
|---|---|---|---|---|
| 互斥锁 | 临界区互斥 | 同一时刻只允许一个线程进入 | 修改共享变量 | 只解决互斥,不解决条件等待 |
| 条件变量 | 条件不满足时避免忙等 | 等待条件,由其他线程唤醒 | 缓冲区空/满 | 条件变量不是条件本身 |
| 信号量 | 有限资源数量管理 | 资源计数 + 阻塞唤醒 | 空槽位、连接池、令牌 | 不能当普通整数 |
| 读写锁 | 区分读者和写者 | 读读并行,读写互斥 | 读多写少共享数据 | 偏向策略影响饥饿问题 |
| RCU | 极端优化读多写少 | 读者无锁读,写者复制更新 | 内核读路径、共享指针结构 | 延迟回收不等于写者互斥 |
8. 最容易混淆的点
8.1 条件变量不是条件本身
empty_cond 不是:
1 | empty_slot != 0 |
empty_cond 只是等待队列。真正的条件是共享变量表达的状态。
8.2 cond_signal() 通常不是等待者自己调用
等待者调用:
1 | cond_wait(); |
唤醒者调用:
1 | cond_signal(); |
这和互斥锁不同。互斥锁通常是同一个线程:
1 | lock(); |
而条件变量的等待和唤醒通常发生在不同线程。
8.3 条件变量必须配合锁
因为共享条件变量需要保护,例如:
1 | empty_slot |
如果没有锁,检查条件、修改条件、进入等待之间都可能发生竞态。
8.4 信号量不是普通整数
信号量虽然有计数值,但它的 wait() 和 signal() 必须是原子安全的同步操作。
8.5 读写锁不是固定偏向读者
读写锁可以偏向读者,也可以偏向写者。具体策略决定了:
- 系统更重视读吞吐量。
- 还是更重视写者公平性。
8.6 RCU 不负责写者之间的互斥
RCU 负责的是:
- 读者如何安全看到旧版本或新版本。
- 旧对象何时可以安全释放。
它不负责:
- 多个写者如何排队。
- 多个写者如何避免覆盖彼此更新。
9. 自测题
- 为什么
while (empty_slot == 0);会浪费 CPU? cond_wait()为什么必须同时完成“释放锁 + 阻塞”?- 为什么条件变量等待时要用
while,而不是if? - 条件变量和互斥锁分别解决什么问题?
- 为什么说条件变量不是条件本身?
- 信号量中的 P 操作和 V 操作分别表示什么?
- 为什么信号量适合表达有限资源数量?
- 为什么信号量不能被当成普通整数?
- 读写锁为什么允许多个读者同时进入?
- 读写锁为什么不允许读者和写者同时进入?
- 偏向读者的读写锁为什么可能导致写者饥饿?
- 偏向写者的读写锁如何避免写者长期等待?
- RCU 为什么不直接修改旧对象?
- RCU 的宽限期到底在等待什么?
- 为什么发布新指针后不能立刻释放旧对象?
- 为什么 RCU 读临界区中通常不能睡眠?
synchronize_rcu()为什么不能替代写者之间的互斥锁?
10. 最终总结
同步原语 II 的核心不是再讲一种“新的锁”,而是展示操作系统如何针对不同并发问题设计不同抽象:
- ❗ 条件变量解决“条件不满足时如何睡眠等待”。
- ❗ 信号量解决“有限资源数量如何同步管理”。
- ❗ 读写锁解决“读操作之间是否必须互斥”。
- ❗ RCU解决“读远多于写时,如何让读路径尽可能轻”。
最终应形成的理解是:
同步原语不是越高级越好,而是要匹配具体并发模式。互斥锁适合保护临界区,条件变量适合等待条件,信号量适合管理资源数量,读写锁适合读多写少,RCU 适合极端读多写少且能接受复制更新与延迟回收的场景。




