| 本篇位置 | 并发原语的最后一块。第 21 篇开始讲它们怎么被用错 |
| 运行环境 | 容器里的 Linux,gcc |
一、有一件事,锁和条件变量都不好使
你的服务要连数据库。数据库那边说:最多同时给你 20 条连接,超了就拒绝。
你手上有第 17 篇的锁和第 19 篇的条件变量。用它们表达这句话试试:
- 用锁? 锁只能表达"同时一个"。你要的是"同时二十个"。
- 用条件变量? 可以——但你得自己维护一个"当前用了几条"的计数,自己加、自己减、自己在超了的时候
wait、自己在归还时signal。能写,但你会发现你在手工拼一个别人早就做好的东西。
而这个"别人早就做好的东西",还顺手把锁和条件变量各自的活儿也干了。
二、一个整数,两条规则
信号量(semaphore)是 Dijkstra 在 1960 年代提出的。它的定义短到可以写在一张便签上:
一个整数,加两个原子操作:
wait(也叫 P、sem_wait):把它减 1。如果结果小于 0,就把自己挂起。post(也叫 V、sem_post):把它加 1。如果有人在挂着,叫醒一个。
就这些。
⭐ 妙处在于这个整数的值本身就带着信息:
- 正数:还剩几个"许可"可以拿。
- 零:没有许可了,但也还没人在等。
- 负数:绝对值就是正在等待的线程数。
一个变量同时表达了"还有多少资源"和"有多少人在排队"。
打个比方
停车场入口那块车位计数牌。
- 牌子上写着
3,你开进去,牌子变成2。 - 牌子是
0了,下一辆车就得在门口停着等。 - 有车开出来,牌子加 1,门口等着的第一辆车放进去。
⭐ 这块牌子和第 17 篇那把挂锁的区别,只是"许可数量"这一个参数。挂锁是许可数为 1 的计数牌。 后面三节就是把这个参数拧到不同的值。
三、拧到 1:它是一把锁
初值设成 1:
sem_t m; sem_init(&m, 0, 1);
sem_wait(&m); /* 减到 0,进去了 */
counter++;
sem_post(&m); /* 加回 1,放行下一个 */
第二个线程来的时候,wait 把它减到 -1,小于 0,挂起。这就是第 17 篇那把互斥量。
这种用法叫二元信号量。
四、拧到 0:它是"等一件事发生"
初值设成 0:
sem_t done; sem_init(&done, 0, 0);
/* 线程甲 */ /* 线程乙 */
干活(); sem_wait(&done); /* 等甲干完 */
sem_post(&done); 接着干();
乙先到的话,wait 减到 -1,挂起。甲干完 post,加回 0,叫醒乙。
甲先到的话,post 把它加到 1,乙的 wait 减回 0,直接通过,不用等。
⭐ 两种到达顺序都对。 这就是信号量比条件变量省心的地方:它有记忆。
⚠️ 对比一下:条件变量没有记忆。如果 signal 发生在 wait 之前,那个信号就丢了,等待方会永远等下去(第 19 篇的"错失唤醒")。所以条件变量必须配一个显式的状态变量和一个 while 循环。
信号量把那个状态变量内置了——就是它自己那个整数。
五、拧到 N:它是限流器
这是信号量真正独一无二的用法:同时最多允许 N 个人进来。
锁做不到(只能 1 个),条件变量做起来很别扭(要自己数)。
sem_t gate; sem_init(&gate, 0, 3); /* 最多 3 个 */
sem_wait(&gate);
/* ... 最多三个线程能同时执行到这里 ... */
sem_post(&gate);
真的是这样吗?让程序自己数。下面每个线程进门时把一个原子计数器加 1,出门减 1,并记录历史最大值:
void *worker(void *a) {
if (USE_SEM) sem_wait(&gate);
int now = atomic_fetch_add(&inside, 1) + 1;
int old = atomic_load(&peak);
while (now > old && !atomic_compare_exchange_weak(&peak, &old, now)) ;
usleep(20000); /* 假装在干活 */
atomic_fetch_sub(&inside, 1);
if (USE_SEM) sem_post(&gate);
return NULL;
}
不加任何限制 20 个线程同时冲 实际最多有 20 个同时在里面
信号量(许可 3) 20 个线程同时冲 实际最多有 3 个同时在里面
二十个线程同时冲,同时在场的人数被死死压在 3。
⭐ 注意 peak 是用一次 CAS 循环更新的(第 17 篇那条指令)——统计代码本身也得是并发安全的,否则测出来的数不可信。这是"独立裁判"的一个小要求:你的证据不能自己有 bug。
这个用法在真实系统里到处都是:数据库连接池(最多 20 条连接)、下载并发限制、限制同时打开的文件数、给下游服务限流。
六、经典组合:生产者-消费者
第 19 篇用一把锁加两个条件变量写的生产者-消费者,用信号量写更短:
sem_t empty, full, mutex;
sem_init(&empty, 0, CAP); /* 还有几个空位 */
sem_init(&full, 0, 0); /* 有几件货 */
sem_init(&mutex, 0, 1); /* 保护缓冲区本身 */
/* 生产者 */ /* 消费者 */
sem_wait(&empty); sem_wait(&full);
sem_wait(&mutex); sem_wait(&mutex);
放进去(); 取出来();
sem_post(&mutex); sem_post(&mutex);
sem_post(&full); sem_post(&empty);
empty 和 full 直接就是"空位数"和"货物数",不需要单独维护一个 cnt 变量,也不需要 while 循环重查。
⚠️ 但那两行的顺序不能反。 如果写成先 wait(&mutex) 再 wait(&empty):
- 生产者拿到
mutex,发现满了,在empty上睡着——手里还攥着mutex。 - 消费者想来取货腾空位,但它拿不到
mutex。 - 两个人一起挂死。
⭐ 这就是下一篇的主题,而且它给出了一条能记一辈子的规矩:永远不要拿着一把锁去睡等另一个东西。
七、代价与取舍
信号量换来了什么: 一个原语覆盖了互斥、等待、限流三种需求。它有记忆,所以不会错失唤醒。限流这个用法它独一份。
它花了什么、放弃了什么:
⭐ 它的状态是一个整数,而一个整数表达不了"谁在等什么"。
回到那块计数牌:牌子上只有一个数字。 它说得清"还剩几个车位",也说得清"门口积了几辆车",但它说不清门口那几辆车各自在等什么。只要门口所有人等的是同一件事,这块牌子就够用;一旦不是,它就会把消息传给错的人。
这句话是信号量所有缺点的根源:
- 不知道谁在等。 上面那个生产者-消费者能工作,是因为我们用了两个独立的信号量把"等空位"和"等货物"分开了。如果混用一个,就会出现第 19 篇第五节那种"信号叫错了人"的挂死。
- 不知道等的条件是什么。 条件变量至少还有一个显式的
while (条件)写在那儿,读代码的人看得见等待条件。信号量的等待条件是隐含在那个整数的语义里的——它只存在于写代码那个人的脑子里。 post可以由任何人调用,包括从来没wait过的线程。这在"限流"里是特性,在"当锁用"时是灾难——一个线程可以解开另一个线程加的锁,而互斥量通常会拒绝这么做。
所以现代代码的实践是:
- 要互斥 → 用互斥量。意图明确,还能配死锁检测工具。
- 要等条件 → 用条件变量(配
while)。等待条件写在代码里,读得懂。 - 要限流 → 用信号量。这是它的主场。
⚠️ 换句话说:信号量能干所有事,但除了限流之外,它都不是最好的选择。 “一个工具顶三个"听起来是优点,实际上意味着看代码的人分辨不出你在干哪一件。
八、小结
- 信号量 = 一个整数 +
wait(减 1,负了就睡)+post(加 1,叫醒一个)。 - ⭐ 整数的值本身带信息:正数是剩余许可,负数的绝对值是等待人数。
- 初值 1 → 锁;初值 0 → 等一件事发生;初值 N → 限流器。
- ⭐ 信号量有记忆:
post早于wait也没关系,许可攒着。条件变量没有记忆,早发的信号会丢——这是它必须配while和显式状态变量的原因。 - 实测:20 个线程同时冲,许可 3 的信号量把同时在场人数压在 3。⭐ 注意统计代码自己也得是并发安全的——证据不能自己有 bug。
- 生产者-消费者用
empty/full/mutex三个信号量写起来最短。⚠️ 但wait(&mutex)必须在后——⭐ 永远不要拿着一把锁去睡等另一个东西。 - ⭐ 它的根本局限:状态只是一个整数,表达不了"谁在等什么”。所以实践中:互斥用互斥量、等条件用条件变量、限流才用信号量。
思考题
- 第四节说"两种到达顺序都对"。如果甲
post了两次而乙只wait一次,会怎样?这是 bug 还是特性? - 用信号量实现一个"读者-写者锁"(多个读者可以同时读,写者独占)。写出来之后检查一下:写者会不会饿死?(读写锁本身在第 18 篇第四节讲过,那里实测了 glibc 默认配置饿死写者的样子——你自己写的这个会是读者优先还是写者优先?)
- 第七节说
post可以由任何线程调用。举一个具体的场景,说明这个性质如何导致一个极难查的 bug。 - 停车场计数牌那个比方里,“信号量有记忆而条件变量没有"对应什么?(提示:想想牌子上的数字在没人排队时会怎样。)