第16讲 · 并发控制:信号量

2026 南京大学《操作系统原理》第十六讲 · 课堂实录重构

主讲 蒋炎岩(Yanyan Jiang)
视频 https://www.bilibili.com/video/BV1yQogB2Esf/
课程主页 https://jyywiki.cn/OS/2026/
本讲讲义 https://jyywiki.cn/OS/2026/lect16.md
讲义 / 幻灯片作者 蒋炎岩,https://jyywiki.cn/
讲义 / 幻灯片许可 CC BY-NC 4.0
图片来源 书中插图为课堂演示与讲义渲染图;演示类截图为视频画面

对应讲次:第16讲(南京大学 操作系统原理)

制作模型:deepseek-flash(coding-agent / pi;据 ~/.pi/agent/settings.json)

编者说明:本书由课堂录像的语音字幕重构而成,术语、章节骨架与参考链接已按课程官方讲义(lect16.md)校准。讲师的口语风格、类比与案例被尽量保留;编辑性的补充一律以「编者说明」或「编者拓展」标出。


引子:从「一个奇妙的想法」出发

(参考时间: 00:00)

上节课讲了同步:让异步的线程达到一个全局确定的状态(同步点),确立 happens-before。也给了条件变量这个万能模板:

lock(lk);
while (!cond) wait(cv, lk);
unlock(lk);
// 条件可能满足时:broadcast(cv);

这一讲,讲师要从零开始「发明」信号量——它比条件变量更「优雅」,代价是它只适合计数型资源。


一、发明信号量

1.1 一个奇妙的想法:用互斥锁实现同步

(参考时间: 00:03)

回忆计算图:$u \to v$ 表示 $v$ 的计算要在 $u$ 之后。要执行 $v$,得等所有前驱完成:

foreach (u -> v) while (!u_done) ;       // 或者
while (pred_done != n_pred) ;

一个聪明的想法:用互斥锁实现同步!

关键洞察:release 和 acquire 天然实现了 happens-before——在一个线程 release、在另一个线程 acquire,就把一份「凭证」跨线程传递下去了。

编者拓展|这其实是一个 UB hack:这个程序逻辑上正确,但互斥锁不允许这样做——pthread_mutex 要求 lock 和 unlock 在同一个线程(因为里面有 fast path 优化)。它是一个 undefined behavior。「今天我们会讲一个同步机制叫信号量,你就可以用它来做这件事。」

cgraph-mutex 运行:node0 → 1/2/3 → 4/5

📷 cgraph-mutex 运行:node0 → 1/2/3 → 4/5
回到原视频 (00:11:00)

cgraph-mutex:用互斥锁的「跨线程 release → acquire」实现计算图

📷 cgraph-mutex:用互斥锁的「跨线程 release → acquire」实现计算图
回到原视频 (00:05:00)

1.2 为什么不能有多把钥匙?→ 信号量

(参考时间: 00:11)

既然「release = 在桌上放钥匙、acquire = 拿走钥匙」能实现同步,那为什么桌上不能同时有多把钥匙?

现实中的类比:

编者说明:1970 年代 Dijkstra 发明了 信号量(semaphore)——一个带计数器 count 的互斥锁。P(荷兰语 Prolaag = try + decrease)对应 acquire,V(Verhoog = increase)对应 release。用条件变量写出来就非常干净:

void P(sem_t *sem) {          // acquire
    mutex_lock(&sem->lk);
    while (!(sem->count > 0)) {
        cond_wait(&sem->cv, &sem->lk);
    }
    sem->count--;             // 消耗一个 token
    mutex_unlock(&sem->lk);
}

void V(sem_t *sem) {          // release
    mutex_lock(&sem->lk);
    sem->count++;             // 创建一个 token
    cond_broadcast(&sem->cv);
    mutex_unlock(&sem->lk);
}

初始的 count 相当于执行了 count 次 release(桌上有几把钥匙 / 停车场有几个车位)。每个 P 都能配一个 happens-before 的 V。

停车场 / 游泳馆:计数型资源 → 信号量

📷 停车场 / 游泳馆:计数型资源 → 信号量
回到原视频 (00:14:00)

Semaphore API:P = 拿走一把钥匙,V = 放回一把钥匙

📷 Semaphore API:P = 拿走一把钥匙,V = 放回一把钥匙
回到原视频 (00:17:00)

编者拓展|信号量只管「入口」:P 通过意味着「我保证有一个车位」,但你具体停哪个车位,还需要别的机制和其他线程协商(比如再上一把互斥锁)。「P 就是停车场的闸门;你在停车场里干什么,它不负责任。」


二、用信号量实现同步

2.1 互斥锁 = 信号量的特例

(参考时间: 00:22)

sem_t sem = SEM_INIT(1);   // 只有一把钥匙 / 只有一个车位的停车场
void lock()   { P(&sem); }  // 拿走钥匙
void unlock() { V(&sem); }  // 放回钥匙
`SEM_INIT(1)`:一个车位的停车场 = 互斥锁

📷 `SEM_INIT(1)`:一个车位的停车场 = 互斥锁
回到原视频 (00:22:00)

2.2 两种典型应用

(参考时间: 00:23)

(1)实现一次临时的 happens-before:A → B

(2)控制并发数 ≤ n(计数型资源)

2.3 线程 join 与计算图

(参考时间: 00:26)

线程 join:main 要等所有线程结束。

join 的信号量实现:每线程一张桌子,或用一张桌子「数数」

📷 join 的信号量实现:每线程一张桌子,或用一张桌子「数数」
回到原视频 (00:26:30)

推广到任意计算图:

join-sem:用计数型信号量等待正确数量的线程结束

📷 join-sem:用计数型信号量等待正确数量的线程结束
回到原视频 (00:30:00)

cgraph-sem:每个节点一个信号量,拿到和入边数量相同的钥匙才能计算

📷 cgraph-sem:每个节点一个信号量,拿到和入边数量相同的钥匙才能计算
回到原视频 (00:33:00)

编者说明:讲师坦言,在计算图这个例子上,信号量比条件变量更好用——「条件变量的『同步条件』是个逻辑表达式,某种程度可以验证;但信号量要借助拟人化的『桌子/钥匙/人』来理解。」在解决计数型资源的问题上,信号量是非常推荐的用法。


三、Killer Application:生产者-消费者

(参考时间: 00:34)

信号量最优雅的应用,是生产者-消费者——只要两个信号量:

sem_t empty = SEM_INIT(depth);   // 初始有 depth 个「空位」
sem_t fill  = SEM_INIT(0);       // 初始没有「数据」

void T_produce() { P(&empty); printf("("); V(&fill); }
void T_consume() { P(&fill);  printf(")"); V(&empty); }

「极为对称、极为优雅」:想象有两张桌子 empty 和 fill:

全局不变式:empty + fill + P_hold + C_hold = depth。通过 V → P 的配对,保证「生产 happens-before 消费」。

pc-sem:两个信号量 empty/fill,对称而优雅

📷 pc-sem:两个信号量 empty/fill,对称而优雅
回到原视频 (00:35:30)

运行结果:不同 buffer size 下括号深度受控

📷 运行结果:不同 buffer size 下括号深度受控
回到原视频 (00:43:30)

编者拓展|别忘了互斥:printf 是线程安全的,但你自己实现的数据结构还需要互斥。信号量给的是「入口」,缓冲区内部的并发访问仍要自己保护好。

对比条件变量版本:上节课我们用 while(!cond) wait + broadcast,代码更啰嗦;而信号量版一看就对称、优雅。所以「遇到生产者-消费者,完全可以 follow 这个 pattern 用信号量实现」。


四、信号量 vs 条件变量

4.1 单信号量表达不了「或」

(参考时间: 00:44)

回到上节课的「鱼」问题(三类线程打印 <、>、_,组成 <><_ / ><>_):同步的条件里有一个「或」——打印完 _ 后,可以打印 < 或 >。

4.2 哲学家吃饭问题(Dijkstra, 1960)

(参考时间: 00:48)

5 个哲学家(线程)围坐,吃饭需要同时拿到左手和右手的两把叉子。

条件变量版(背模板即可):同步条件 = avail[lhs] && avail[rhs],拿两把叉子 → assert + 置零 + 吃完放回 + broadcast。「期末考试 100% 会考,这就是通关密码。」

信号量版(naive):P(&sem[lhs]); P(&sem[rhs]); —— 看起来没问题,但当 5 个人同时举起左手叉子时,谁都在等右手,循环等待 → 死锁。

哲学家吃饭:naive 信号量版(各拿左手叉子 → 死锁)

📷 哲学家吃饭:naive 信号量版(各拿左手叉子 → 死锁)
回到原视频 (00:49:00)

运行日志:所有人拿了左手就卡住,系统陷入死锁

📷 运行日志:所有人拿了左手就卡住,系统陷入死锁
回到原视频 (00:58:30)

两种修复(但都不通用):

修复一:table 信号量限制最多 N-1 人上桌

📷 修复一:table 信号量限制最多 N-1 人上桌
回到原视频 (01:00:30)

编者拓展|为什么说「这不成功」:这两种修复的正确性「不写在程序里」——它依赖一个额外的证明/说明(「从桌上赶走一个人」是通过一个巧妙的不变式保证的)。Systems 要的是 absolutely correct 的方案:如果你用一个数值型资源表达不了同步条件,就要果断放弃信号量。

更麻烦的是「加强版生产者-消费者」:再加 close() / open() 两种线程,信号量就很难表达「not closing」这种二选一的语义;而条件变量只要在全局状态里加一个变量即可。

4.3 用信号量实现条件变量:surprisingly tricky

(参考时间: 01:06)

反过来,用信号量实现条件变量非常棘手(有一篇 2003 年的 report 专门讲 Implementing CVs)。一个「看起来符合逻辑」的实现:

void wait(cond_t *cv, mutex_t *mutex) {
    cv->nwait++;
    mutex_unlock(mutex);
    P(&cv->sleep);      // ⚠️ 这里出问题
    mutex_lock(mutex);
}
void broadcast(cond_t *cv) {
    mutex_lock(&cv->lock);
    for (int i = 0; i < cv->nwait; i++) V(&cv->sleep);
    cv->nwait = 0;
    mutex_unlock(&cv->lock);
}

问题:唤醒丢失(lost wakeup)——nwait++ 和 P 之间不原子:一个「早就 nwait++ 但还没 P」的线程抢走了一次唤醒,导致后面少唤醒一个线程,系统就错了。

编者拓展|release-wait 必须是一个原子操作:这一点无法用普通信号量解决,只能求助于操作系统——实际实现靠的是 futex。「并发就是很绕——你唯一的办法就是试过所有的可能性(NP 完全),既然有『不需要试所有可能性』的条件变量这种万能的同步方法,那你为什么不先用它呢?」

用信号量实现条件变量:nwait++ 与 P 之间不原子 → 唤醒丢失

📷 用信号量实现条件变量:nwait++ 与 P 之间不原子 → 唤醒丢失
回到原视频 (01:13:00)


五、把任何同步问题变成生产者-消费者:waiter

(参考时间: 01:17)

还有一种通用的方法:引入一个调度线程(waiter / 服务员)。

void T_philosopher(int tid) {
    while (1) {
        send_message_to_waiter();     // 生产者:往消息队列放球
        P(&can_proceed[tid]);         // 消费者:等服务员答复
    }
}
把任何同步问题变成生产者-消费者:由 waiter 串行管理全局状态

📷 把任何同步问题变成生产者-消费者:由 waiter 串行管理全局状态
回到原视频 (01:19:00)

编者说明:这是「你不需要让每个线程自行 ADD hack 地 self-organize」的另一种思路——引入一个集中式的管理者,把指数多的调度可能性,压缩成一个串行、可见、可验证的逻辑。


总结


附:官方参考与延伸阅读

本讲的讲义见课程主页,以下链接按书中出现顺序整理:

主题 链接
使用互斥锁实现计算图(cgraph-mutex) https://jyywiki.cn/OS/demos/concurrency/cgraph-mutex
使用信号量实现线程 join(join-sem) https://jyywiki.cn/OS/demos/concurrency/join-sem
使用信号量实现计算图(cgraph-sem) https://jyywiki.cn/OS/demos/concurrency/cgraph-sem
使用信号量实现生产者-消费者(pc-sem) https://jyywiki.cn/OS/demos/concurrency/pc-sem
哲学家吃饭问题(philosophers) https://jyywiki.cn/OS/demos/concurrency/philosophers
用信号量实现条件变量(Birrell, 2003) http://birrell.org/andrew/papers/ImplementingCVs.pdf
OSTEP:Operating Systems: Three Easy Pieces(第 31 章) https://pages.cs.wisc.edu/~remzi/OSTEP/
sem_overview(7) / sem_wait(3) 手册 https://man7.org/linux/man-pages/man7/sem_overview.7.html
futex(7) 手册 https://man7.org/linux/man-pages/man7/futex.7.html

阅读材料:教科书 Operating Systems: Three Easy Pieces 第 31 章(Semaphores)。

版权说明:本讲讲义与幻灯片作者为蒋炎岩,采用 Creative Commons BY-NC 4.0 许可,来源于 https://jyywiki.cn/OS/2026/。视频画面与字幕版权归原作者所有。本书为课堂内容的学习性重构。