信号量与条件变量:同步机制的经典工具
上一篇我们把互斥讲透了,但你很快会撞上互斥锁解决不了的一类问题。设想一个任务队列:消费者线程拿到锁、发现队列是空的——然后呢?它能做什么?继续持锁等?那生产者永远拿不到锁往里放东西,死局。放掉锁再重新抢、抢到再看一眼空不空?这叫忙等,CPU 全空转。
问题的本质是:互斥回答的是"能不能进",而这里需要回答的是"现在还不能做,那要等到什么时候、怎么等、谁来叫醒我"。这是一个全新的维度——同步,也就是协调多个执行流之间的先后顺序和等待唤醒。
这一篇继续先讲机制:先把信号量的 P/V 操作、条件变量的 wait/signal 在内部到底改了什么状态、线程怎么被挂起和唤醒讲清楚,再回头看它们各自适合解决哪一类同步问题。
一、信号量的内部结构:一个计数器加一个等待队列
信号量(semaphore)听起来抽象,但它的内部结构极简单,就两个东西:
1 | struct semaphore { |
value 的含义是"当前还剩多少个许可(permit)"。它支持两个原子操作,历史上叫 P 和 V(来自荷兰语),现在多叫 wait 和 signal,或 acquire 和 release:
1 | P / wait / acquire: |
这两个操作整体是原子的(内部用上一篇的硬件原子指令或自旋锁保护)。盯住 value 的变化,机制就清楚了:
P先把value减 1,相当于"我要拿一个许可"。减完如果value < 0,说明许可已经被别人拿光了,没有你的份,于是当前线程进等待队列、阻塞;V先把value加 1,相当于"我还回一个许可"。加完如果value <= 0,说明等待队列里还有线程在排队(负值的绝对值正好是排队人数),于是唤醒其中一个。
信号量的精髓就在
value的符号:正数表示"还有几个许可可拿",零表示"刚好用完",负数的绝对值表示"有几个线程正阻塞着等"。一个整数同时编码了资源余量和等待人数。
二、信号量的两种用法:计数 vs 二值
理解了机制,信号量的两副面孔就很自然了。
用法一:当资源计数器(计数信号量)。 把 value 初始化成"资源总数 N"。每个线程用资源前 P(拿一个,余量减一),用完 V(还一个,余量加一)。当 N 个资源都被占用,第 N+1 个线程 P 时 value 变负,自动阻塞,直到有人 V 归还。这天然就是连接池、限流器、令牌桶的模型。
用法二:当互斥锁(二值信号量)。 把 value 初始化成 1。第一个线程 P 后 value 变 0,进入临界区;第二个线程 P 时 value 变 -1,阻塞;持锁者 V 后唤醒它。这时信号量退化成了上一篇讲的互斥锁——这也印证了那句话:互斥是"许可数为 1"的特例,信号量是把它推广到了 N。
用法三:表达执行顺序(同步信号量)。 把 value 初始化成 0。如果要求"线程 B 的某段代码必须在线程 A 的某段代码之后执行",就让 B 在那段代码前 P(因为初值 0,B 一来就阻塞),让 A 完成后 V(唤醒 B)。这样就用信号量强行编排了顺序。
同一个信号量,初值设成 N 是资源池,设成 1 是互斥锁,设成 0 是顺序同步。机制只有一套,语义全靠初值和 P/V 的摆放位置决定——这正是它强大也容易用错的地方。
三、条件变量的内部机制:等待一个"条件"
信号量很万能,但用它表达"等待某个复杂条件成立"时会很别扭——因为它只有一个整数计数器,没法描述"队列非空且队头是高优先级任务"这种条件。于是有了条件变量(condition variable),它专门为"等待—唤醒"而生。
条件变量从不单独使用,它永远和一把互斥锁绑定。它提供三个操作:
1 | wait(cond, mutex): |
最精妙、也最容易被忽略的是 wait 里那个原子地"释放锁 + 阻塞"。为什么必须原子?设想如果分成两步:先释放锁、再阻塞。那么在这两步中间,另一个线程可能恰好抢到锁、改变了条件、并发出 signal——可此时本线程还没真正进入阻塞状态,这个 signal 就丢了,本线程随后阻塞下去再也无人唤醒。这叫丢失唤醒(lost wakeup)。把"释放锁"和"阻塞"做成原子,就堵住了这个缝隙。
四、条件变量的标准用法:为什么必须用 while
条件变量的使用有一个雷打不动的范式,几乎所有教材和标准库都强调:判断条件必须用 while 循环,绝不能用 if。
1 | // 等待方 |
为什么是 while 不是 if?因为线程"被唤醒"和"条件成立"是两回事,被唤醒后条件不一定还成立:
- 虚假唤醒(spurious wakeup):某些系统实现里,线程可能在没有任何人
signal的情况下被唤醒,这是底层允许的现象; - 唤醒后被抢先:A 被
signal唤醒,但它要重新抢mutex,在抢到之前,另一个线程 B 可能先抢到锁、把刚满足的条件又消耗掉了(比如队列里那个元素被 B 取走了)。等 A 拿到锁,条件其实又不成立了。
所以被唤醒后必须重新检查条件,不满足就接着 wait。while 正好做到这一点,if 只检查一次就往下走,会基于一个已经不成立的条件去操作,酿成 bug。
"用
while包住wait"不是可选的编码风格,而是条件变量正确性的硬性要求。记住这条,能避开并发编程里最隐蔽的一类错误。
五、信号量 vs 条件变量:机制差异决定适用场景
两者都能做"等待—唤醒",但内部机制的差异决定了它们各擅其长。
最核心的区别在于"有没有记忆":
- 信号量有记忆。
V操作会把value加 1,这个"许可"被记录下来了。即使此刻没有线程在等,将来某个线程P时也能拿到它、不阻塞。V永远不会丢失。 - 条件变量没有记忆。
signal如果发出时没有线程正在wait,这个信号就直接消失,不会被记住。将来才来wait的线程收不到它。
这个差异直接指导选型:
| 维度 | 信号量 | 条件变量 |
|---|---|---|
| 本质 | 带计数的许可管理 | 纯粹的等待/唤醒队列 |
| 是否记忆通知 | 是(V 累加到 value) | 否(无人等待则丢失) |
| 等待的依据 | 计数是否 > 0 | 任意复杂的布尔条件 |
| 是否需配互斥锁 | 不必(自带原子性) | 必须搭配 mutex |
| 最适合 | 资源计数、限流、简单顺序 | 复杂条件的等待协作 |
一句话决策:要管"还剩几个"用信号量;要等"某个状态成真"用条件变量。前者关注数量,后者关注条件。
六、用两者分别实现有限缓冲区
把抽象落地,看看同一个"有限缓冲区"(生产者消费者的核心)两种工具怎么写,机制差异一目了然。
信号量版——用三个信号量分别管"空位数"“数据数”“互斥”:
1 | empty = N // 空槽位数,初值=容量 |
注意 empty 和 full 这对信号量天然完成了"满了就挡住生产者、空了就挡住消费者"的同步,mutex 只负责保护缓冲区数据结构本身。三者分工清晰。
条件变量版——用一把锁加两个条件:
1 | 生产者: 消费者: |
两版都对。信号量版更紧凑,靠计数自动卡住边界;条件变量版更直白,“满了就在 notFull 上等、放完通知 notEmpty”,可读性更接近人的思维。工程上 Java 的 ArrayBlockingQueue 用的就是后者(ReentrantLock + 两个 Condition)。
七、为什么要设计出这两套工具
回到设计动机。互斥锁已经有了,为什么还要信号量和条件变量?
因为互斥锁只覆盖了并发问题的一半——资源的排他访问。但真实协作还需要另一半——等待与通知:一个线程的推进,要依赖另一个线程先把某个条件准备好。互斥锁对此无能为力,因为它没有"挂起到条件满足、再被精准唤醒"的能力,硬用互斥锁只能退化成忙等。
信号量和条件变量补上的,正是这个"高效等待 + 精准唤醒"的能力:
- 它们让等待的线程真正睡下去、让出 CPU,而不是空转;
- 它们让通知的线程能精准唤醒正在等这个条件的线程,而不是让所有线程一起醒来瞎抢(这也是
signal唤一个、broadcast唤全部的区别由来)。
互斥把并发从"会出错"推进到"不出错",信号量和条件变量再把它从"不出错"推进到"高效有序地协作"。这是同步机制演进的主线。
八、和工程实践 / 后端开发的联系
这两套工具是几乎所有后端并发组件的底层零件:
- 连接池 / 限流器:数据库连接池的"最多 N 个连接"、信号量限流(如 Hystrix、Resilience4j 的 semaphore 隔离)就是计数信号量的直接应用——
value初始化成池大小,借连接P、还连接V。 - 阻塞队列 / 任务队列:线程池背后的
BlockingQueue,消费者在队列空时wait、生产者入队后signal,正是条件变量范式。 - 看懂语言标准库:理解了机制,你再看 Java 的
Semaphore、Condition,Go 的sync.Cond,POSIX 的pthread_cond_t,会发现它们就是同一套机制换了层皮。 - 排查"线程全堵住":线上服务"假死"、所有工作线程卡住,常见原因就是等待条件设计错了——比如该
broadcast却只signal、或者通知发在了wait之前导致丢失。jstack看到一堆线程WAITING (parking)时,就该往这个方向查。
学习这一部分最容易踩的坑
1. 用 if 而不是 while 判断条件
这是条件变量头号陷阱。被唤醒不等于条件成立——可能虚假唤醒,也可能被其他线程抢先消耗了条件。必须用 while 循环重新检查,if 会让线程基于过期条件往下走。
2. 把条件变量当成"条件"本身
条件变量不存储任何条件状态,它只是个等待队列。真正的条件(队列空不空、资源够不够)是你自己维护的共享变量。条件变量只负责"在它上面睡、被它唤醒",判断逻辑得你自己写。
3. 忽视信号量"有记忆"而条件变量"没记忆"
把条件变量当信号量用,最容易栽在丢失通知上:signal 发出时没人在等,信号就没了。如果你的逻辑依赖"通知一定不丢",那该用信号量(V 会累加进 value 不丢失),而不是裸条件变量。
4. signal 和 broadcast 不分
只有一个等待者、或唤醒一个就够时用 signal;当条件变化可能让多个等待者都能继续、或不确定该唤醒哪个时,要用 broadcast。该 broadcast 却用了 signal,会导致本可推进的线程一直睡着。
总结
信号量和条件变量把并发控制从"互斥访问"推进到了"有序协作",补上了互斥锁缺失的"等待与唤醒"维度。核心要点:
- 信号量内部是"计数器 + 等待队列",
value的正负同时编码了资源余量和排队人数; - 信号量一套机制三种用法:初值 N 是资源池、初值 1 是互斥锁、初值 0 是顺序同步;
- 条件变量必须和互斥锁绑定,
wait的"释放锁 + 阻塞"必须原子,否则会丢失唤醒; - 条件变量判断条件必须用
while,以应对虚假唤醒和唤醒后被抢先; - 二者最本质的差异是"信号量有记忆、条件变量没记忆",这决定了选型;
- 工程上连接池/限流用信号量,阻塞队列/复杂条件协作用条件变量。
当你能根据"是数量问题还是条件问题"自然地选出工具,并条件反射地用 while 包住 wait,这部分就真正内化了。下一篇我们会看到,把这些零件封装进一个更安全的模块,就是管程。
参考资源:
- 《操作系统导论》(Operating Systems: Three Easy Pieces)信号量与条件变量章节
- 《现代操作系统》(Andrew S. Tanenbaum)
- OSTEP - Semaphores
- 《Java 并发编程实战》(Brian Goetz)