本篇位置 并发原语的最后一块。第 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);

emptyfull 直接就是"空位数"和"货物数",不需要单独维护一个 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) 必须在后——⭐ 永远不要拿着一把锁去睡等另一个东西。
  • ⭐ 它的根本局限:状态只是一个整数,表达不了"谁在等什么”。所以实践中:互斥用互斥量、等条件用条件变量、限流才用信号量

思考题

  1. 第四节说"两种到达顺序都对"。如果甲 post 了两次而乙只 wait 一次,会怎样?这是 bug 还是特性?
  2. 用信号量实现一个"读者-写者锁"(多个读者可以同时读,写者独占)。写出来之后检查一下:写者会不会饿死?(读写锁本身在第 18 篇第四节讲过,那里实测了 glibc 默认配置饿死写者的样子——你自己写的这个会是读者优先还是写者优先?)
  3. 第七节说 post 可以由任何线程调用。举一个具体的场景,说明这个性质如何导致一个极难查的 bug。
  4. 停车场计数牌那个比方里,“信号量有记忆而条件变量没有"对应什么?(提示:想想牌子上的数字在没人排队时会怎样。)

延伸