关键问题与本章定位

如何使用信号量?怎样用信号量代替锁和条件变量?信号量的定义是什么?什么是二值信号量?用锁和条件变量构造信号量是否简单?反过来,用信号量构造锁和条件变量呢?

原文:How can we use semaphores instead of locks and condition variables? What is the definition of a semaphore? What is a binary semaphore? Is it straightforward to build a semaphore out of locks and condition variables? To build locks and condition variables out of semaphores?

前两章我们知道:解决各种并发问题既需要锁(lock),也需要条件变量(condition variable)。Dijkstra 和同事们很早就意识到了这一点,于是发明了信号量(semaphore)——一个想"包打天下"的同步原语:同一个东西,初值设成 1 就是锁,设成 0 就能表达"等某件事发生"。

本章先给出信号量的精确定义,然后用它重做一遍熟悉的问题(锁、join、生产者/消费者),再看几个经典问题(读写锁、哲学家就餐、线程节流),最后用锁 + 条件变量亲手实现一个信号量。本章中"拿着一把锁去等另一样东西"引发的死锁,将在下一章《常见并发问题》里被系统地分析。

核心概念速览

信号量(semaphore)

一个带整数值的对象,只能用 sem_wait() 和 sem_post() 两个操作来改变它;使用前必须用 sem_init() 设定初值。

sem_wait()(P / down)

先把值减 1;如果减完是负数,调用者就睡眠等待。

sem_post()(V / up)

先把值加 1;如果有线程在等,唤醒其中一个。它从不等待。

负值 = 等待者数

值为负时,其绝对值恰好等于正在等待的线程个数(Dijkstra 定义下的不变量)。

二值信号量(binary semaphore)

初值为 1、只在"持有/未持有"两态间使用的信号量——就是一把锁。

初值的含义

初值 = 初始化后"愿意立刻送出去的资源数":锁是 1,排序是 0,空槽数是 MAX,节流是 K。

有界缓冲区

empty / full 两个信号量计数空槽和满槽;mutex 只包住 put/get,否则会死锁。

Zemaphore

用一把锁 + 一个条件变量 + 一个整数实现的信号量;值永远不小于 0,与 Linux 实现一致。

信号量的定义

信号量是一个带整数值的对象。因为初值决定了它的行为,所以用它之前必须先初始化(Figure 31.1):

#include <semaphore.h>
sem_t s;
sem_init(&s, 0, 1);   // 第 3 个参数:初值 1;第 2 个参数 0:在同一进程的线程之间共享

初始化之后,只能用两个函数操作它(Figure 31.2)。先不管它们内部怎么实现(显然需要小心处理并发),先假设这两个操作都是原子的:

int sem_wait(sem_t *s) {
    decrement the value of semaphore s by one     // 先减 1
    wait if value of semaphore s is negative      // 减完是负数就睡
}

int sem_post(sem_t *s) {
    increment the value of semaphore s by one     // 先加 1
    if there are one or more threads waiting, wake one   // 有人在等就唤醒一个
}
sem_t s value -2 等待队列 T1 T2 值为 -2 ⇔ 恰好 2 个线程在等 sem_wait(&s) value = value − 1 value < 0 ? 是 入队,睡眠(让出 CPU) 否 返回,继续执行 被 post 唤醒后 → sem_post(&s) value = value + 1 队列非空 ? 是 唤醒队首一个线程(→就绪) 返回(从不等待) 否
信号量 = 一个整数 + 一个等待队列。wait 可能睡眠,post 永远不会;两者都先改值、再判断。

关于这两个接口,原书强调三点:

  1. sem_wait() 要么立即返回(调用时值 ≥ 1),要么让调用者挂起,等待之后的某次 post。多个线程可以同时卡在 sem_wait() 里排队。
  2. sem_post() 从不等待任何条件。它只是把值加 1,如果有线程在等就唤醒一个。
  3. 值为负时,其绝对值等于等待线程数。使用者通常看不到这个值,但记住这个不变量有助于理解信号量如何运作。

Dijkstra 最早把 sem_wait() 叫作 P(),sem_post() 叫作 V(),来自荷兰语。起初 P 来自 passering(通过)、V 来自 vrijgave(释放);后来 Dijkstra 又说 P 来自 prolaag(probeer "尝试" + verlaag "减少"),V 来自 verhoog("增加")。也有人叫它们 down 和 up。

二值信号量:把信号量当锁用

第一个用法我们已经很熟悉了:用 sem_wait()/sem_post() 包住临界区(Figure 31.3)。关键问题是:初值 X 应该是多少?先想一想再往下看。

sem_t m;
sem_init(&m, 0, X);   // X 应该是多少?

sem_wait(&m);
// 临界区
sem_post(&m);

答案是 1。第一个线程调用 sem_wait() 把值从 1 减到 0,0 不是负数,于是直接返回、进入临界区;如果这时第二个线程也来 sem_wait(),它把值减到 -1,只能睡眠。第一个线程离开时 sem_post() 把值加回 0 并唤醒第二个线程;第二个线程用完后再 post,值回到 1。

1 0 -1 -2 锁空闲 被持有,无人等待 被持有,1 个在等 被持有,2 个在等 sem_wait ← 减 1 sem_post → 加 1 锁只有"持有 / 未持有"两种状态,所以这种用法叫二值信号量(binary semaphore)
初值为 1 的信号量当锁用时,每个取值的含义。

原书 Figure 31.5 给出了两个线程争锁的完整轨迹:T0 进入临界区后被中断,T1 调用 sem_wait() 把值减到 -1 并睡眠;T0 回来后 post,值变回 0 并唤醒 T1;T1 从 sem_wait() 返回、执行临界区、post,值回到 1。在下方"信号量执行器"里选择 Figure 31.5 预设,可以逐行复现这张表。注意一个细节:T1 被唤醒后从 sem_wait() 返回时不会再减一次——它的那次减 1 在睡下去之前就做完了。

如果只把信号量当二值锁用,其实可以实现得比通用信号量更简单。

信号量用于排序:实现 join

信号量也可以用来给事件排序:一个线程等某件事发生,另一个线程让这件事发生后发信号——这和上一章用条件变量做的事一样。经典例子是父线程等待子线程结束(Figure 31.6):

sem_t s;

void *child(void *arg) {
    printf("child\n");
    sem_post(&s);        // 发信号:子线程完成了
    return NULL;
}

int main(int argc, char *argv[]) {
    sem_init(&s, 0, X);  // X 应该是多少?
    printf("parent: begin\n");
    pthread_t c;
    Pthread_create(&c, NULL, child, NULL);
    sem_wait(&s);        // 在这里等子线程
    printf("parent: end\n");
    return 0;
}

我们希望输出总是 parent: begin → child → parent: end。答案是 X = 0。要考虑两种情况:

情况 1:父线程先 sem_wait() 情况 2:子线程先跑完 父子 create wait: 0→-1 睡眠 post: -1→0 唤醒父 返回 父子 create post: 0→1 无人可唤醒 wait: 1→0 直接返回 值为 0 → 父线程减到 -1,只能睡着等子线程 post 子线程的 post 把"完成"记在值里(变成 1) 与条件变量不同:没有人等时 post 也不会"丢",它被信号量的值记住了
原书 Figure 31.7(情况 1)与 Figure 31.8(情况 2)的示意。两种交错下 child 都一定先于 parent: end。

情况 1:子线程已创建但还没运行,父线程先调用 sem_wait()。我们希望父线程等待,唯一的办法是值不大于 0——所以初值是 0。父线程把值减到 -1 后睡眠;子线程运行后 sem_post() 把值加到 0 并唤醒父线程。情况 2:子线程在父线程调用 sem_wait() 之前就跑完了,post 把值从 0 加到 1;之后父线程 sem_wait() 把 1 减到 0,不需要等待就返回。两种情况都达到了目的。

怎样设置信号量的初值?Perry Kivolowitz 给了一个简单的思考方式:初值 = 初始化之后你愿意立刻送出去的资源个数。锁:初始化后愿意让它立刻被"拿走"一次,所以是 1;排序:一开始没有东西可给,只有子线程完成后才"产生"这个资源,所以是 0。

用法初值"一开始能送出去的资源"
锁(二值信号量)1一把锁,可以立刻被拿走
排序 / join0还没有——事件发生后才产生
有界缓冲区的 emptyMAXMAX 个空槽
有界缓冲区的 full0一开始没有数据
线程节流K同时允许 K 个线程进入
模拟器

信号量执行器:锁与 join 的逐行轨迹

每个 sem_wait/sem_post 拆成 4 个微步骤(与原书轨迹表粒度一致)。先载入原书预设逐步对照表格;任意时刻都可以点"让 Tx 执行一步"自己调度,走出不同的交错。观察:值、等待队列、每个线程的状态(运行/就绪/睡眠)。

生产者/消费者(有界缓冲区)问题

上一章用条件变量解决过这个问题;现在只用信号量来做。先写好 put 和 get(Figure 31.9),注意这里 put/get 本身不带任何同步:

int buffer[MAX];
int fill = 0;
int use  = 0;

void put(int value) {
    buffer[fill] = value;       // Line F1
    fill = (fill + 1) % MAX;    // Line F2
}

int get() {
    int tmp = buffer[use];      // Line G1
    use = (use + 1) % MAX;      // Line G2
    return tmp;
}

第一次尝试:empty 和 full

引入两个信号量:empty 表示空槽数,full 表示满槽数(Figure 31.10)。生产者先等一个空槽再放数据,然后宣布多了一个满槽;消费者对称。

sem_t empty;
sem_t full;

void *producer(void *arg) {
    for (int i = 0; i < loops; i++) {
        sem_wait(&empty);       // Line P1
        put(i);                 // Line P2
        sem_post(&full);        // Line P3
    }
}

void *consumer(void *arg) {
    int tmp = 0;
    while (tmp != -1) {
        sem_wait(&full);        // Line C1
        tmp = get();            // Line C2
        sem_post(&empty);       // Line C3
        printf("%d\n", tmp);
    }
}

int main(int argc, char *argv[]) {
    // ...
    sem_init(&empty, 0, MAX);   // MAX 个空槽
    sem_init(&full, 0, 0);      // 0 个满槽
    // ...
}
buffer[MAX],MAX = 6(环形使用) 7 8 9 ▲ use ▲ fill 消费者从这取 → ← 生产者往这放 full = 3消费者 sem_wait(&full) 拿一个 empty = 3生产者 sem_wait(&empty) 拿一个
empty + full 始终等于 MAX(除去正在 put/get 中途的那一点时间)。两个信号量本质上是"空槽计数器"和"满槽计数器"。

先看 MAX = 1、一个生产者一个消费者。假设消费者先运行:它在 C1 调用 sem_wait(&full),full 初值 0,被减到 -1,消费者睡眠。然后生产者运行:P1 把 empty 从 1 减到 0,通过;P2 放入数据;P3 把 full 从 -1 加到 0 并唤醒消费者。之后如果生产者继续跑,它在 P1 把 empty 减到 -1 而睡眠;如果换消费者跑,它从 C1 返回、取走数据。两种情况都正确。多个生产者、多个消费者(MAX = 1)也仍然正确。

MAX > 1 时的竞争:数据被覆盖

现在让 MAX = 10,并且有多个生产者。问题出在 put() 和 get() 上:设生产者 Pa 先运行,在 F1 往 buffer[0] 写数据,还没来得及执行 F2 把 fill 加到 1 就被中断;生产者 Pb 运行,它在 F1 也往 buffer[0] 写——Pa 的数据被覆盖、永远丢失了。原因是忘了互斥:填缓冲区和移动 fill 下标是一个临界区。

加锁,但放错了位置:死锁

用一个二值信号量 mutex 把整个 put/get 部分包起来(Figure 31.11,NEW LINE 标出的是新加的两行):

void *producer(void *arg) {
    for (int i = 0; i < loops; i++) {
        sem_wait(&mutex);       // Line P0 (NEW LINE)
        sem_wait(&empty);       // Line P1
        put(i);                 // Line P2
        sem_post(&full);        // Line P3
        sem_post(&mutex);       // Line P4 (NEW LINE)
    }
}

void *consumer(void *arg) {
    for (int i = 0; i < loops; i++) {
        sem_wait(&mutex);       // Line C0 (NEW LINE)
        sem_wait(&full);        // Line C1
        int tmp = get();        // Line C2
        sem_post(&empty);       // Line C3
        sem_post(&mutex);       // Line C4 (NEW LINE)
        printf("%d\n", tmp);
    }
}

看起来对,但会死锁(deadlock)。设一个生产者一个消费者,消费者先运行:它在 C0 拿到 mutex,然后在 C1 对 full 调用 sem_wait()——没有数据,于是睡眠。关键是:它睡着的时候仍然持有 mutex。接着生产者运行,它本来可以放入数据并唤醒消费者,可它第一件事是在 P0 等 mutex——mutex 已被占用,生产者也睡着了。

消费者睡在 C1: wait(full) 生产者睡在 P0: wait(mutex) mutex full 持有 想要 等待 只有它能 post
一个简单的环:消费者持有 mutex、等 full;唯一能 post full 的生产者在等 mutex。两者互相等待,谁也走不动。

正确的解法:缩小锁的范围

只需把 mutex 的获取和释放挪到只包住临界区(put/get),full/empty 的等待和通知留在外面(Figure 31.12):

void *producer(void *arg) {
    for (int i = 0; i < loops; i++) {
        sem_wait(&empty);       // Line P1
        sem_wait(&mutex);       // Line P1.5 (lock)
        put(i);                 // Line P2
        sem_post(&mutex);       // Line P2.5 (unlock)
        sem_post(&full);        // Line P3
    }
}

void *consumer(void *arg) {
    for (int i = 0; i < loops; i++) {
        sem_wait(&full);        // Line C1
        sem_wait(&mutex);       // Line C1.5 (lock)
        int tmp = get();        // Line C2
        sem_post(&mutex);       // Line C2.5 (unlock)
        sem_post(&empty);       // Line C3
        printf("%d\n", tmp);
    }
}

现在任何线程都不会拿着 mutex 去睡:它只在确定有空槽/有数据之后才进锁,锁内的 put/get 很短、不会阻塞。这就是常用的有界缓冲区模式——原书说"现在弄懂它,以后会用上",面试和期末考试尤其爱考。(原书脚注:为了模块化,把 mutex 的获取/释放放进 put() 和 get() 里面可能更自然。)

模拟器

信号量版生产者/消费者:逐行交错执行

选择版本(正确 Figure 31.12 / 错误 Figure 31.11 / 无 mutex Figure 31.10)、缓冲区大小和线程数,然后点"运行 Xx 一步"亲自当调度器,或用随机调度。put/get 被拆成 F1/F2、G1/G2 两行,这样才能看到无锁时的覆盖。观察:三个信号量的值和等待队列、谁持有 mutex、死锁时的等待环。
buffer:

读写锁

另一个经典问题来自这样的需求:不同的数据结构访问需要不同的锁。比如一个并发链表,插入会修改链表(需要传统的临界区),而查找只是读——只要保证没有插入在进行,多个查找完全可以同时进行。支持这种操作的锁叫读写锁(reader-writer lock)(Figure 31.13):

typedef struct _rwlock_t {
    sem_t lock;       // 二值信号量(普通锁),保护 readers
    sem_t writelock;  // 允许 1 个写者 或 多个读者
    int   readers;    // 临界区里有几个读者
} rwlock_t;

void rwlock_init(rwlock_t *rw) {
    rw->readers = 0;
    sem_init(&rw->lock, 0, 1);
    sem_init(&rw->writelock, 0, 1);
}

void rwlock_acquire_readlock(rwlock_t *rw) {
    sem_wait(&rw->lock);
    rw->readers++;
    if (rw->readers == 1)          // 第一个读者负责拿 writelock
        sem_wait(&rw->writelock);
    sem_post(&rw->lock);
}

void rwlock_release_readlock(rwlock_t *rw) {
    sem_wait(&rw->lock);
    rw->readers--;
    if (rw->readers == 0)          // 最后一个读者负责放掉它
        sem_post(&rw->writelock);
    sem_post(&rw->lock);
}

void rwlock_acquire_writelock(rwlock_t *rw) { sem_wait(&rw->writelock); }
void rwlock_release_writelock(rwlock_t *rw) { sem_post(&rw->writelock); }

写者很简单:直接用 writelock 保证只有一个写者进入。读者更有意思:先拿 lock,把 readers 加 1;如果自己是第一个读者,就顺带替整个"读者群"拿走 writelock,然后释放 lock。之后的读者只需 readers++ 就能进去;而写者必须等到所有读者都离开——最后一个离开的读者 post writelock。

临界区(链表) R0 R1 R2 R3 readers = 4;R0 进来时替大家拿走了 writelock W sem_wait(writelock)睡着等…… R4 新读者直接进
只要读者络绎不绝、readers 从不降到 0,写者就永远等不到 writelock——读者饿死了写者。

这个实现能用,但不公平:读者很容易饿死(starve)写者。更复杂的方案可以解决它——提示:一旦有写者在等,就不再让新读者进入。另外要注意,读写锁通常开销更大(尤其是复杂实现),和简单快速的锁相比,未必真能提速。

永远不要低估"简单笨办法"。加锁时,简单的自旋锁有时效果最好,因为它容易实现而且快。读写锁听起来很酷,但复杂往往意味着慢。Mark Hill 的博士论文研究 CPU 缓存设计,发现简单的直接映射缓存比花哨的组相联设计更好,他总结为 "Big and dumb is better"——所以这条建议叫 Hill 定律。

模拟器

读写锁时间线:读者如何饿死写者

读者每隔 gap 个时间单位到达一个,每人读 rd 个单位;写者在 tw 时刻到达,要写 wd 个单位。"原书实现"下,只要读者的读区间彼此重叠,写者就一直等;切换到"写者优先"(写者在等时新读者排队)对比写者等待时间。
读写等待

哲学家就餐问题

Dijkstra 提出并解决的最著名的并发问题之一是哲学家就餐问题(dining philosophers)。它有趣、也有点智力挑战,实用价值不高——但名气太大,面试可能会问到。

设定:5 位哲学家围坐一桌,每两人之间放一把叉子(共 5 把)。哲学家时而思考(不需要叉子),时而吃饭;吃饭需要左右两把叉子。每位哲学家 p(0~4)的循环是:

while (1) {
    think();
    get_forks(p);
    eat();
    put_forks(p);
}

挑战是写出 get_forks() 和 put_forks(),使得没有死锁、没有哲学家饿死、并发度尽量高(尽可能多的人同时吃)。按 Downey 的写法,先定义两个辅助函数和 5 个信号量(每把叉子一个,初值 1):

int left(int p)  { return p; }
int right(int p) { return (p + 1) % 5; }   // p=4 时右叉是 fork 0
sem_t forks[5];

错误的解法

void get_forks(int p) {
    sem_wait(&forks[left(p)]);
    sem_wait(&forks[right(p)]);
}

void put_forks(int p) {
    sem_post(&forks[left(p)]);
    sem_post(&forks[right(p)]);
}

直觉很简单:先"锁住"左叉,再锁住右叉,吃完都放下。可惜简单在这里意味着错误:如果每位哲学家都在任何人拿右叉之前拿起了左叉——P0 拿 f0、P1 拿 f1、P2 拿 f2、P3 拿 f3、P4 拿 f4——所有叉子都被拿走,每个人都攥着一把叉子等另一把,永远等下去:死锁。

解法:打破依赖

最简单的办法是让至少一位哲学家换个顺序拿叉子——Dijkstra 本人就是这样解决的。让编号最大的 P4 先拿右叉再拿左叉(Figure 31.16),put_forks() 不变:

void get_forks(int p) {
    if (p == 4) {
        sem_wait(&forks[right(p)]);
        sem_wait(&forks[left(p)]);
    } else {
        sem_wait(&forks[left(p)]);
        sem_wait(&forks[right(p)]);
    }
}

因为最后一位哲学家先拿右叉,就不可能出现"每人拿一把、都在等下一把"的局面——等待的环被打破了。想一想为什么:P4 的右叉是 f0,它和 P0 抢的是同一把"第一把叉子";谁没抢到,谁手里就是空的,不会占着叉子等人。下一章会把这个思想总结为"破坏循环等待(circular wait)"。

模拟器

哲学家就餐:死锁与"P4 反序"

每位哲学家的一步 = think → 拿第一把叉 → 拿第二把叉 → eat → 放下两把叉。点"Px 走一步"手动调度,或随机调度(可复现的种子)。先载入"原书死锁调度",再切换到修复版用同样的调度对比。

类似的"著名问题"还有吸烟者问题(cigarette smoker's problem)、睡眠理发师问题(sleeping barber problem)等,它们大多是练习并发思维的借口。Downey 的《The Little Book of Semaphores》收录了大量这类题目。

线程节流

信号量还有一个简单用途:程序员怎样防止"太多"线程同时做某件事、把系统拖垮?答案:定一个"太多"的阈值,然后用信号量限制同时执行那段代码的线程数。这叫节流(throttling),是一种准入控制(admission control)。

具体例子:你创建了几百个线程并行处理一个问题,但代码中某一段(称为内存密集区)每个线程都要申请一大块内存。如果所有线程同时进入这一段,内存申请总量超过物理内存,机器就开始抖动(thrashing)——不停地换页,整个计算慢如蜗牛。只要把信号量初值设为"允许同时进入的最大线程数",并在这段代码前后放 sem_wait() 和 sem_post(),就能自然地限流:

sem_t gate;
sem_init(&gate, 0, K);     // 最多 K 个线程同时在内存密集区

void *worker(void *arg) {
    ...                    // 普通计算
    sem_wait(&gate);       // 进门拿一个名额
    memory_intensive_region();
    sem_post(&gate);       // 出门还回名额
    ...
}
在 sem_wait(&gate) 睡着排队 gate 内存密集区(最多 K = 3) 3 × 8 MB = 24 MB 不超过物理内存
信号量值 = 剩余名额。第 K+1 个线程在门口睡着,直到有人出来 post。
模拟器

线程节流:K 选多大?

N 个线程先做一小段普通计算,再进入内存密集区(每个占 mem 单位内存,工作量 work)。区内内存总量超过物理内存 cap 时发生抖动,每个线程的进度只有平时的 1/4。改变 K,看同时在区内的线程数和总完成时间。
普通计算在 gate 等待区内(正常)区内(抖动)完成

如何实现信号量:Zemaphore

最后,用底层同步原语——锁和条件变量——构造我们自己的信号量,叫作……(敲鼓)……Zemaphore(Figure 31.17):

typedef struct __Zem_t {
    int value;
    pthread_cond_t cond;
    pthread_mutex_t lock;
} Zem_t;

// 只能由一个线程调用
void Zem_init(Zem_t *s, int value) {
    s->value = value;
    Cond_init(&s->cond);
    Mutex_init(&s->lock);
}

void Zem_wait(Zem_t *s) {
    Mutex_lock(&s->lock);
    while (s->value <= 0)             // 没有名额就等(while,不是 if!)
        Cond_wait(&s->cond, &s->lock);
    s->value--;                       // 有名额才减
    Mutex_unlock(&s->lock);
}

void Zem_post(Zem_t *s) {
    Mutex_lock(&s->lock);
    s->value++;
    Cond_signal(&s->cond);
    Mutex_unlock(&s->lock);
}

只用了一把锁、一个条件变量,再加一个记录信号量值的状态变量。和 Dijkstra 定义的"纯"信号量有一个细微差别:Zemaphore 不维护"值为负时等于等待者数"这个不变量——它的值永远不会小于 0(先判断、有名额才减)。这种做法更容易实现,也和当前 Linux 的实现一致。

Dijkstra:先减再判断 Zemaphore:先判断再减 value-3 等待者T0 T1 T2 value0 cond 上等待T0 T1 T2 值的绝对值 = 等待者数 值 ≥ 0;等待者数要去看条件变量的队列
同样是"初值 0、三个线程 wait",两种实现的内部状态不同,但对使用者表现相同:三个线程都在等。

反过来,用信号量构造条件变量要难得多。一些经验丰富的并发程序员在 Windows 环境下尝试过,结果冒出了许多不同的 bug(Birrell 的文章记录了这段经历)。你可以自己试试,想想为什么它比看上去难——提示:条件变量的 signal 在没人等时什么都不做,而信号量的 post 会被"记住"。

泛化在系统设计里很有用:一个好想法稍加扩展就能解决更大一类问题。但 Lampson 警告:"不要泛化;泛化通常是错的"(Don't generalize; generalizations are generally wrong)。可以把信号量看作锁和条件变量的泛化——但真的需要这种泛化吗?考虑到在信号量之上实现条件变量有多难,这个泛化也许没有你以为的那么"通用"。

模拟器

Dijkstra 信号量 vs Zemaphore:同一串操作,两种内部状态

输入一串操作:W0 = T0 调用 wait,P3 = T3 调用 post,R0 = 被唤醒的 T0 真正获得 CPU(Zemaphore 里它要重新检查 while 条件)。观察 Zemaphore 的值从不为负;"插队"预设展示 Mesa 语义下为什么必须用 while。

先预测再验证

在"信号量执行器"里载入"3 个线程排队等锁"。先预测:值最低会降到多少?那一刻等待队列里有谁?T0 释放后,是 T1 还是 T2 先进入临界区?
看答案
最低 -2,队列是 [T1, T2](值的绝对值 = 等待者数)。T0 post 时值从 -2 变为 -1,唤醒队首的 T1;T1 用完后 post,值变 0,唤醒 T2;T2 post 后值回到 1。本模拟器的等待队列是先进先出的,真实系统里唤醒哪个线程取决于实现。
在执行器里载入 Figure 31.8(子线程先跑完),在第 2 行(切到 Child 之前)改为自己调度:让 Parent 先执行几步。会不会出现 parent: end 先于 child?再想想:如果初值误设为 1 呢?(可用 C 实验 ./fork_join 1 验证。)
看答案
初值 0 时不会:无论怎么交错,父线程要么睡在 sem_wait()(值 -1)等子线程 post,要么在子线程 post 之后才 wait(值 1→0)。初值为 1 时,父线程 sem_wait() 把 1 减到 0 直接返回,不等子线程——实验里会看到 parent: end 出现在 child 之前。
生产者/消费者模拟器,选"mutex 在外层(Fig 31.11)",MAX=1、各 1 个线程。先预测:是不是每一种调度都会死锁?如果不是,什么样的调度能侥幸跑完?然后用不同种子"随机调度到底"验证。
看答案
不是每次都死锁。死锁需要"拿着 mutex 去睡":消费者拿到 mutex 时缓冲区是空的(睡在 full 上),或生产者拿到 mutex 时缓冲区是满的(睡在 empty 上)。如果运气好,每次拿到 mutex 的一方恰好都能继续,就能跑完。本页逻辑在 node 中随机跑 3000 次(1 生产者 1 消费者、MAX=1、各 5 个),约 60% 死锁;正确版 3000 次无一死锁。这正是并发 bug 的可怕之处:它可能只是"偶尔"发生。
哲学家模拟器切到"P4 先右后左",载入原书死锁调度。先预测:这串调度走完之后,谁能吃上饭?最多能有几位哲学家同时吃?
看答案
P4 第一步去拿 f0,而 f0 已被 P0 拿走,于是 P4 两手空空地睡着;f4 就空出来了。P3 拿到 f3 后能再拿到 f4,于是 P3 可以吃。5 把叉子、每人要 2 把,所以最多同时 2 人吃饭(例如 P0 和 P2)。
读写锁模拟器默认参数下(gap=2、rd=5、写者 t=3 到达),先预测写者要等多久?把 gap 改成 6(大于 rd)呢?再切到"写者优先"。
看答案
默认参数下读区间首尾相接地重叠,readers 从不归零,写者从 t=3 一直等到最后一个读者在 t=19 离开,等了 16 个单位。gap=6 时每个读者读完下一个才来,写者只需等当前那个读者读完(t=5 开始写)。写者优先时,写者到达后新读者排队,写者在已有读者读完后(t=7)就能写,只等 4 个单位。
节流模拟器默认参数(8 个线程、每个占 2、物理内存 8),先预测哪个 K 总时间最短?点"比较所有 K"验证。
看答案
K=4 最短:4 × 2 = 8 恰好不超过物理内存,并发度又最高。K 更小则浪费了并行度;K ≥ 5 时区内内存超出,发生抖动,所有人都变慢,K=8(相当于不节流)反而最慢。

动手实验

五个小程序,都只用 POSIX 信号量:fork_join(join)、pc(生产者/消费者,-b 为错误版)、dining(哲学家就餐,带死锁检测)、barrier(屏障)、throttle(节流)。详见 labs/31-semaphores/README.md。

cd labs/31-semaphores && make
./fork_join; ./fork_join 1
./pc -p 2 -c 3 -m 10 -l 100000
./pc -b
./dining broken; ./dining fixed
./barrier 5
./throttle 20 3; ./throttle 20 0

示例输出(在 WSL2 / gcc 14 上实际运行所得,你的机器上数字会不同):

$ ./fork_join
parent: begin (sem 初值 = 0)
child
parent: end
$ ./fork_join 1
parent: begin (sem 初值 = 1)
parent: end
child

$ ./pc -p 2 -c 3 -m 10 -l 100000
正确版:2 个生产者,3 个消费者,MAX=10,每个生产者 100000 个
   消费者 C0 取走 64225 个
   消费者 C1 取走 76677 个
   消费者 C2 取走 59098 个
生产 200000 个 (校验和 109999900000),消费 200000 个 (校验和 109999900000) → 一致 ✓
$ ./pc -b
错误(mutex 在外层)版:1 个生产者,1 个消费者,MAX=1,每个生产者 10000 个
!! 2 秒没有进展:检测到死锁。各线程状态:
   生产者 P0: 等 mutex
   消费者 C0: 持有 mutex 并等 full
   已完成 put/get 次数 = 0

$ ./dining broken
错误版(全部先左后右):5 位哲学家,每人吃 1000 次
!! 2 秒内没有人吃上饭:检测到死锁(已吃 6 顿)
   P0 拿着 f0,等待 f1
   P1 拿着 f1,等待 f2
   P2 拿着 f2,等待 f3
   P3 拿着 f3,等待 f4
   P4 拿着 f4,等待 f0
   等待链:P0→f1(P1持有)→f2(P2持有)→…→f0(P0持有),形成环
$ ./dining fixed
修复版(P4 先右后左):5 位哲学家,每人吃 1000 次
全部吃完,用时 3.21 秒;每人吃的次数: P0=1000 P1=1000 P2=1000 P3=1000 P4=1000

$ ./barrier 5
线程 0: P1
线程 2: P1
线程 1: P1
线程 4: P1
线程 3: P1
线程 3:     P2 (此时已完成 P1 的线程数 = 5)
线程 2:     P2 (此时已完成 P1 的线程数 = 5)
线程 0:     P2 (此时已完成 P1 的线程数 = 5)
线程 1:     P2 (此时已完成 P1 的线程数 = 5)
线程 4:     P2 (此时已完成 P1 的线程数 = 5)
✓ 所有 P2 都发生在全部 P1 之后

$ ./throttle 20 3; ./throttle 20 0
20 个线程,K=3:内存密集区里同时最多有 3 个线程(峰值约 24 MB)
20 个线程,K=0(不节流):内存密集区里同时最多有 20 个线程(峰值约 160 MB)

要观察什么、为什么:

自测题

按 Dijkstra 的定义,信号量的值为 -3 意味着什么?
sem_wait 先减后判断,每个睡下去的线程都让值多减了 1,所以负值的绝对值恰好等于等待者数。注意这一不变量对 Zemaphore / Linux 实现不成立(它们的值不会小于 0)。
用信号量实现"父线程等待子线程结束"(join),信号量初值应该是多少?
初值 0:一开始"没有东西可给"。父线程先 wait 时减到 -1 而睡眠;子线程先 post 时值变 1,父线程再 wait 直接通过。初值 1 会让父线程根本不等。
MAX = 1,只用 empty/full 两个信号量、不加 mutex(Figure 31.10),多个生产者和多个消费者,会出问题吗?
empty 初值 1,至多一个生产者能通过 P1,且只有消费者 get 完 post empty 后下一个生产者才能进来;full 同理。原书说这种情况"仍然能工作"。竞争只在 MAX > 1 时出现:多个生产者可同时通过 P1,在 F1 覆盖彼此的数据。
Figure 31.11 把 mutex 放在 sem_wait(&full) 外面,为什么会死锁?
睡在 sem_wait 上的线程不会释放它已持有的其他信号量。"拿着锁去等别的东西"形成了等待环。解决方法是把 mutex 缩小到只包住 put/get。
关于原书 Figure 31.13 的读写锁,哪个说法正确?
只有第一个读者拿 writelock、最后一个读者释放它,所以读者可以"接力"一直占着。原书也提醒:读写锁开销更大,未必比简单的锁快(Hill 定律)。
哲学家就餐问题中,让 P4 先拿右叉再拿左叉,本质上消除了什么?
P4 仍然会拿着一把叉子等另一把(持有并等待还在),但获取顺序被改了,等待关系无法闭合成环。它不保证公平(饥饿问题另说)。下一章会系统讲死锁的四个条件。
Zemaphore(Figure 31.17)和 Dijkstra 定义的信号量有什么区别?
Zem_wait 先 while 判断 value <= 0 再减,所以值始终 ≥ 0。这更容易实现,也与当前 Linux 实现一致。
做线程节流时,信号量初值应该设为什么?
初值 = "一开始愿意送出去的资源数" = 名额数 K。初值 1 退化为锁(完全串行),初值为线程总数则等于不节流。
没有任何线程在等待时,分别调用 sem_post() 和 pthread_cond_signal(),会怎样?
这正是 join 用信号量不需要额外状态变量(done)的原因——值本身就是状态。条件变量没有记忆,所以必须配合状态变量使用,这也是用信号量实现条件变量很难的原因之一。

常见误区与小结

本章小结

与后续章节的联系:本章多次出现"拿着一个资源去等另一个资源"导致的死锁(Figure 31.11、哲学家就餐)。下一章《常见并发问题》将给出死锁的四个必要条件以及预防、避免、检测的方法;再下一章《基于事件的并发》则换一种思路:干脆不用线程和锁。