第15讲 同步原语II

以下根据附件《第15讲 同步原语II.md》整理。

第15讲:同步原语 II 学习指南

同步原语 II 的主线是:在互斥锁之外,操作系统还需要更丰富的同步机制来处理“条件等待”“有限资源”“读写分离”和“极端读多写少”的场景。


❗ 本节核心结论:
互斥锁只能解决“同一时刻谁能进入临界区”的问题;条件变量、信号量、读写锁和 RCU 分别进一步解决“什么时候能继续”“资源数量够不够”“读者能否并行”“读路径能否几乎无锁”的问题。

1. 本章知识地图

这张图展示了同步原语的升级路线:从最基础的互斥,逐渐走向条件等待、资源计数、读写区分和读路径极致优化。


2. 条件变量:让线程等到条件成立

2.1 条件变量解决什么问题?

互斥锁能保证临界区安全,但它不能优雅地处理“条件暂时不满足”的情况。

例如生产者消费者问题中:

1
2
while (empty_slot == 0)
; // 忙等

这段代码的问题是:

  • 线程明明不能继续执行,却一直占用 CPU。
  • 它只是反复检查条件,没有真正让出处理器。
  • 如果等待时间很长,会造成严重浪费。

条件变量的目的就是:

  • 条件不满足时,让线程阻塞。
  • 条件满足后,由其他线程唤醒它。
  • 避免无意义的忙等。

2.2 条件变量的基本接口

条件变量主要有两个操作:

1
2
cond_wait();
cond_signal();

它们的含义是:

操作 含义 典型角色
cond_wait() 条件不满足,当前线程进入等待 等待者
cond_signal() 条件可能满足,唤醒等待线程 唤醒者

在生产者消费者问题中:

  • 生产者发现缓冲区满了,就等待 empty_cond
  • 消费者取走一个元素后,产生空位,于是 cond_signal(&empty_cond)
  • 消费者发现缓冲区空了,就等待 filled_cond
  • 生产者放入一个元素后,产生可消费对象,于是 cond_signal(&filled_cond)

2.3 cond_wait() 为什么必须配合互斥锁?

条件变量不能单独使用。标准模式是:

1
2
3
4
5
6
7
8
9
lock(&mutex);

while (condition_not_satisfied) {
cond_wait(&cond, &mutex);
}

do_something();

unlock(&mutex);

cond_wait(&cond, &mutex) 通常要原子地完成两件事:

  • 把当前线程加入条件变量的等待队列。
  • 释放互斥锁并阻塞当前线程。

被唤醒后,它还要:

  • 重新获得互斥锁。
  • 再继续检查条件和执行后续代码。

关键点在于:释放锁和阻塞必须是原子的。如果不是原子的,可能出现:

  • 线程准备睡眠但还没睡眠时,另一个线程已经发送了 signal。
  • signal 被“错过”,等待线程之后睡下去再也没人唤醒。
  • 或者等待线程持锁睡眠,导致其他线程无法修改条件,形成死锁。

2.4 为什么等待条件要用 while,而不是 if

错误写法:

1
2
3
if (empty_slot == 0) {
cond_wait(&empty_cond, &mutex);
}

推荐写法:

1
2
3
while (empty_slot == 0) {
cond_wait(&empty_cond, &mutex);
}

原因是:线程被唤醒,不等于条件一定成立。

可能出现:

  • 多个线程同时被唤醒,但资源只够一个线程使用。
  • 线程被唤醒后没有立即运行,期间条件又被其他线程改掉。
  • 某些系统可能存在虚假唤醒。
  • cond_signal() 只表示“条件可能成立了”,不是保证条件仍然成立。

所以条件变量的核心范式是:

1
2
3
while (条件不满足) {
等待;
}

❗ 本节核心结论:
条件变量不是条件本身,而是等待队列。真正的条件仍然要由共享变量表达,并且必须在互斥锁保护下用 while 反复检查。

3. 信号量:把资源数量和等待机制合在一起

3.1 信号量是什么?

信号量可以理解为:

一个带同步能力的资源计数器。

它内部有一个计数值,用来表示当前可用资源数量。

例如生产者消费者问题中:

1
2
sem_t empty_slot;
sem_t filled_slot;

它们分别表示:

信号量 含义
empty_slot 当前还有多少空槽位
filled_slot 当前有多少可消费对象

3.2 P / V 原语怎么理解?

信号量有两个经典操作:

原语 常见名称 直观含义
P 操作 wait() 申请一个资源
V 操作 signal() 释放或产生一个资源

可以记成:

1
2
wait(S);    // 申请资源
signal(S); // 释放资源

生产者逻辑:

1
2
3
wait(&empty_slot);
buffer_add_safe(new_msg);
signal(&filled_slot);

含义是:

  • 先申请一个空槽位。
  • 把消息放入缓冲区。
  • 通知系统多了一个可消费对象。

消费者逻辑:

1
2
3
wait(&filled_slot);
cur_msg = buffer_remove_safe();
signal(&empty_slot);

含义是:

  • 先申请一个已有消息。
  • 从缓冲区取走消息。
  • 通知系统多了一个空槽位。

3.3 信号量和条件变量的区别

条件变量的问题是:条件变量和真实条件是分离的。

例如:

1
2
int empty_slot = 5;
struct cond empty_cond;

这里:

  • empty_slot 才是真正的资源数量。
  • empty_cond 只是一个等待队列。
  • 二者的对应关系靠程序员维护。

信号量则把二者合并:

  • 计数值表示资源数量。
  • 当资源不足时自动阻塞。
  • 当资源增加时自动唤醒等待线程。
对比点 条件变量 信号量
本质 等待队列 / 通知机制 资源计数器 + 等待机制
是否记录资源数量 不记录 记录
是否需要额外条件变量 需要共享变量表达条件 信号量值本身表达条件
典型问题 缓冲区空/满等待 空槽位、连接池、令牌数
易错点 忘记用 while 检查条件 把信号量当普通整数

3.4 信号量不是普通整数

信号量看起来像整数,但不能把它当成普通变量:

1
2
S--;
S++;

这是错误直觉。

真正的信号量要求:

  • wait() 内部修改计数值必须是同步安全的。
  • signal() 内部修改计数值也必须是同步安全的。
  • 多个线程同时申请或释放资源时不能发生数据竞争。
  • 必要时线程要进入等待队列,而不是单纯修改整数。

因此信号量通常可以用:

  • 互斥锁
  • 条件变量
  • 内部计数值
  • 等待队列

共同实现。


❗ 本节核心结论:
信号量适合表达“有限数量资源”。它不是普通整数,而是把资源计数、互斥保护和等待唤醒组合在一起的同步原语。

4. 读写锁:读者之间不必互斥

4.1 为什么需要读写锁?

互斥锁过于保守。

如果多个线程只是读取共享数据:

1
read_data(data);

它们之间不会修改数据,也不会互相破坏结果。普通互斥锁却会让它们排队执行,降低并行性。

读写锁的基本规则是:

组合 是否允许并行 原因
读者 + 读者 允许 读操作不修改数据
读者 + 写者 不允许 写操作可能改变读者看到的数据
写者 + 写者 不允许 多个写者会互相覆盖或破坏状态

所以读写锁的目标是:

  • 保证写操作安全。
  • 尽量放开读操作之间的并行。
  • 提高读多写少场景下的吞吐量。

4.2 读写锁的基本使用方式

读者使用读锁:

1
2
3
4
5
void reader(void) {
lock_reader(&lock);
read_data(data);
unlock_reader(&lock);
}

写者使用写锁:

1
2
3
4
5
void writer(void) {
lock_writer(&lock);
update_data(data);
unlock_writer(&lock);
}

关键不是“函数名不同”,而是进入规则不同:

  • 读锁可以被多个读者同时持有。
  • 写锁必须独占。
  • 只要有写者进入,读者不能同时进入。
  • 只要有读者存在,写者通常不能进入。

5. 读写锁的偏向性:吞吐量与公平性的取舍

5.1 问题场景

读写锁会遇到一个典型问题:

1
2
3
t0:已有读者在临界区
t1:写者开始等待
t2:新的读者到来

此时问题是:

新读者能不能直接进入?

不同答案会形成不同偏向策略。


5.2 偏向读者

偏向读者的策略是:

  • 即使有写者等待,新读者也可以继续进入。
  • 读者吞吐量更高。
  • 但写者可能长期等不到机会。

典型实现思路:

1
2
3
4
5
struct rwlock {
int reader_cnt;
struct lock reader_lock;
struct lock writer_lock;
};

核心逻辑:

  • reader_cnt 记录当前读者数量。
  • 第一个读者进入时获取 writer_lock,阻止写者进入。
  • 后续读者只增加 reader_cnt,不需要再抢写者锁。
  • 最后一个读者离开时释放 writer_lock,写者才有机会进入。

关键代码含义:

1
2
3
if (reader_cnt == 1) {
lock(&writer_lock);
}

表示:

  • 第一个读者负责挡住写者。
1
2
3
if (reader_cnt == 0) {
unlock(&writer_lock);
}

表示:

  • 最后一个读者负责放行写者。

5.3 偏向写者

偏向写者的策略是:

  • 一旦有写者等待,后续新读者不能继续进入。
  • 写者更不容易饥饿。
  • 但读者并行性会被更早限制。

典型实现中可以引入:

1
volatile bool has_writer;

它表示:

  • 当前是否已经有写者等待或进入。
  • 如果 has_writer == TRUE,新读者需要等待。
  • 这样可以防止新读者源源不断进入,导致写者永远拿不到锁。

5.4 偏向读者 vs 偏向写者

对比点 偏向读者 偏向写者
有写者等待时,新读者能否进入 不能
优点 读吞吐量高 写者更公平
缺点 写者可能饥饿 读者可能更早阻塞
更关注 性能 / 吞吐量 公平性 / 避免写者饥饿

❗ 本节核心结论:
读写锁不等于“读者永远优先”。它必须选择偏向策略:偏向读者提高吞吐量,偏向写者减少写者饥饿。

6. RCU:读多写少场景下的极致优化

6.1 RCU 是什么?

RCU 全称是:

1
Read-Copy-Update

中文可以理解为:

读者直接读,写者复制后更新。

它的基本思想是:

  • 读者不通过传统锁阻塞。
  • 写者不直接修改旧对象。
  • 写者复制旧对象,在副本上修改。
  • 修改完成后,原子发布新指针。
  • 等旧读者全部退出后,再释放旧对象。

这张图强调 RCU 的核心不是“阻止读者进入”,而是允许新旧版本在短时间内共存。


6.2 RCU 为什么适合读多写少?

读写锁虽然允许多个读者并行,但读者仍然要执行读锁逻辑:

1
2
3
lock_reader(&lock);
read_data(data);
unlock_reader(&lock);

在读特别多、写特别少的场景中,哪怕读锁很轻,也可能成为热点开销。

RCU 的目标是:

  • 让读路径尽可能短。
  • 让读者几乎不需要传统锁。
  • 把复杂性转移给写者。
  • 用复制和延迟回收换取读路径性能。

适合场景:

  • 读远多于写。
  • 读路径非常关键。
  • 数据通过指针间接访问。
  • 可以接受旧版本短暂存在。
  • 可以接受写者复制和延迟释放成本。

6.3 RCU 更新流程

假设共享指针 gp 指向旧对象 A

1
gp -> A

写者更新时,不直接修改 A,而是:

1
2
3
4
5
6
7
8
9
10
11
p = rcu_dereference(gp);

q = kmalloc(...);
*q = *p;
q->v = newv;

rcu_assign_pointer(gp, q);

synchronize_rcu();

kfree(p);

这个流程可以拆成:

  1. 读取旧指针 p
  2. 分配新对象 q
  3. 把旧对象内容复制到新对象。
  4. 在新对象上完成修改。
  5. rcu_assign_pointer() 发布新版本。
  6. 新读者开始看到新对象。
  7. 旧读者仍可能持有旧对象。
  8. 调用 synchronize_rcu() 等待旧读者退出。
  9. 确认安全后释放旧对象。

6.4 什么是宽限期 Grace Period?

宽限期指的是:

写者发布新版本之后,等待所有可能正在读取旧版本的读者退出 RCU 读临界区的时间。

为什么需要宽限期?

因为发布新指针后,系统中可能同时存在:

读者类型 看到的对象
旧读者 旧对象 A
新读者 新对象 A’

如果写者发布新指针后立刻:

1
kfree(A);

那么旧读者可能还在访问 A,这会导致 use-after-free。

所以 RCU 的安全性依赖:

1
synchronize_rcu();

它保证:

  • 在它返回时,旧读者已经离开 RCU 读临界区。
  • 旧对象可以安全释放。

6.5 RCU 读者的约束

RCU 读者通常写成:

1
2
3
4
5
6
rcu_read_lock();

p = rcu_dereference(gp);
do_something(p->data);

rcu_read_unlock();

需要注意:

  • 必须用 rcu_dereference() 读取共享指针。
  • 它防止编译器或 CPU 重排破坏指针发布顺序。
  • RCU 读临界区内通常不能睡眠或调度。
  • 否则宽限期可能被无限拉长。
  • RCU 不等于写者不用锁。
  • 多个写者之间仍然需要额外互斥,例如 spin_lock()

6.6 synchronize_rcu() 不能替代写者锁

sync_rcu()synchronize_rcu() 只解决一个问题:

什么时候可以安全释放旧对象?

它不解决:

  • 多个写者同时修改同一结构。
  • 多个写者同时发布新版本。
  • 写者之间的更新顺序。
  • 写者之间的数据竞争。

所以多个写者仍然可能需要:

1
2
3
4
5
spin_lock(&list_lock);

/* update */

spin_unlock(&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
2
3
lock();
...
unlock();

而条件变量的等待和唤醒通常发生在不同线程。


8.3 条件变量必须配合锁

因为共享条件变量需要保护,例如:

1
2
empty_slot
filled_slot

如果没有锁,检查条件、修改条件、进入等待之间都可能发生竞态。


8.4 信号量不是普通整数

信号量虽然有计数值,但它的 wait()signal() 必须是原子安全的同步操作。


8.5 读写锁不是固定偏向读者

读写锁可以偏向读者,也可以偏向写者。具体策略决定了:

  • 系统更重视读吞吐量。
  • 还是更重视写者公平性。

8.6 RCU 不负责写者之间的互斥

RCU 负责的是:

  • 读者如何安全看到旧版本或新版本。
  • 旧对象何时可以安全释放。

它不负责:

  • 多个写者如何排队。
  • 多个写者如何避免覆盖彼此更新。

9. 自测题

  1. 为什么 while (empty_slot == 0); 会浪费 CPU?
  2. cond_wait() 为什么必须同时完成“释放锁 + 阻塞”?
  3. 为什么条件变量等待时要用 while,而不是 if
  4. 条件变量和互斥锁分别解决什么问题?
  5. 为什么说条件变量不是条件本身?
  6. 信号量中的 P 操作和 V 操作分别表示什么?
  7. 为什么信号量适合表达有限资源数量?
  8. 为什么信号量不能被当成普通整数?
  9. 读写锁为什么允许多个读者同时进入?
  10. 读写锁为什么不允许读者和写者同时进入?
  11. 偏向读者的读写锁为什么可能导致写者饥饿?
  12. 偏向写者的读写锁如何避免写者长期等待?
  13. RCU 为什么不直接修改旧对象?
  14. RCU 的宽限期到底在等待什么?
  15. 为什么发布新指针后不能立刻释放旧对象?
  16. 为什么 RCU 读临界区中通常不能睡眠?
  17. synchronize_rcu() 为什么不能替代写者之间的互斥锁?

10. 最终总结

同步原语 II 的核心不是再讲一种“新的锁”,而是展示操作系统如何针对不同并发问题设计不同抽象:

  • ❗ 条件变量解决“条件不满足时如何睡眠等待”。
  • ❗ 信号量解决“有限资源数量如何同步管理”。
  • ❗ 读写锁解决“读操作之间是否必须互斥”。
  • ❗ RCU解决“读远多于写时,如何让读路径尽可能轻”。

最终应形成的理解是:

同步原语不是越高级越好,而是要匹配具体并发模式。互斥锁适合保护临界区,条件变量适合等待条件,信号量适合管理资源数量,读写锁适合读多写少,RCU 适合极端读多写少且能接受复制更新与延迟回收的场景。