sync.Cond 条件变量详解:Wait 为什么要先加锁?Signal 与 Broadcast 的区别与底层实现
引言:Mutex 管不住的「等一等」
前面我们讲过 Mutex 与 RWMutex(第 10 期)、atomic 原子操作(第 15 期)、sync.Map(第 32 期)。但有一种并发场景它们都管不好:一个 goroutine 想等「某个条件成立」再往下走。
典型例子是生产者-消费者:消费者发现缓冲区空了,不能一直霸占着锁轮询(那是自旋,烧 CPU),它需要「挂起自己,等生产者放入数据后叫醒我」。sync.Cond 就是为这个而生的——条件变量 = 一把锁 + 一个等待队列。它是 sync 包四大原语(Mutex / RWMutex / Once / Cond)中面试最容易被忽略、但一旦问起来最能区分功底的一个。
一、三个方法,一句话各是什么
对照 go1.26.0 的 src/sync/cond.go(本文源码细节均已对照官方源码核实):
| 方法 | 语义 | 唤醒谁 |
|---|---|---|
Wait() |
原子地「解锁 → 挂起 → 被唤醒后重新加锁」 | ——(是睡觉的那个) |
Signal() |
唤醒等待队列中一个 goroutine | 队首 |
Broadcast() |
唤醒等待队列中所有 goroutine | 全部 |
Signal 和 Broadcast 调用时允许不加锁(官方注释:allowed but not required),但生产代码里建议持锁调用,保证「改条件」和「发通知」之间的 happens-before 关系,这也是 第 27 期内存模型 的实际应用场景。
二、Wait 为什么必须先持锁?
这是第一高频追问。看 Wait() 的源码实现:
func (c *Cond) Wait() {
c.checker.check()
t := runtime_notifyListAdd(&c.notify) // 1. 登记:拿一个排队号
c.L.Unlock() // 2. 释放锁
runtime_notifyListWait(&c.notify, t) // 3. 挂起
c.L.Lock() // 4. 被唤醒后重新抢锁
}
关键在于第 1、2 步的原子性。试想消费者这样写(错误示范):
// 错误:先解锁再判断是否该睡
c.L.Unlock()
if len(buf) == 0 {
c.Wait() // 噩梦开始
}
解锁和挂起之间有个窗口期:生产者可能恰好在这时放入数据并调用 Signal()——可等待队列里根本没有人,通知直接丢失(missed wakeup)。等消费者执行到 Wait() 时,数据明明已经在缓冲区里,它却永远睡过去了。而 Wait() 把「登记进队列」和「释放锁」绑在一起,且调用者必须先持锁、检查条件,才能杜绝这个窗口。锁在这里不是保护数据的,是保证「检查条件」与「进入等待」这两件事不可分割的。
三、为什么用 for 循环而不是 if?
官方注释给出的标准范式直接就是循环:
c.L.Lock()
for !condition() { // 注意是 for,不是 if
c.Wait()
}
// 条件成立,干活
c.L.Unlock()
Go 的 Wait() 注释明确说:不会无故返回,只能被 Signal/Broadcast 唤醒。那循环防的是什么?防的是唤醒后到重新拿到锁之间条件又被改掉。经典场景:两个消费者都被 Broadcast 唤醒去抢一个空位,后抢到锁的那个发现条件又不成立了。用 if 只检查一次就会带着不成立的条件往下跑;用 for 则每次醒来都重新验货。这是面试写代码题的扣分重灾区。
四、手写题:有界队列(完整可运行)
这几乎就是 sync.Cond 的标准面试题——用 Mutex + Cond 实现一个容量有限、并发安全的队列:
package main
import (
"errors"
"fmt"
"sync"
)
type BoundedQueue struct {
mu sync.Mutex
condGet *sync.Cond // 队列空时让消费者等
condPut *sync.Cond // 队列满时让生产者等
buf []int
cap int
}
func NewBoundedQueue(cap int) *BoundedQueue {
q := &BoundedQueue{cap: cap}
q.condGet = sync.NewCond(&q.mu)
q.condPut = sync.NewCond(&q.mu)
return q
}
func (q *BoundedQueue) Put(v int) error {
q.mu.Lock()
defer q.mu.Unlock()
for len(q.buf) == q.cap { // 满则睡,等消费者腾出位置
q.condPut.Wait()
}
q.buf = append(q.buf, v)
q.condGet.Signal() // 队列有货了,叫醒一个消费者
return nil
}
func (q *BoundedQueue) Get() (int, error) {
q.mu.Lock()
defer q.mu.Unlock()
for len(q.buf) == 0 { // 空则睡,等生产者放货
q.condGet.Wait()
}
v := q.buf[0]
q.buf = q.buf[1:]
q.condPut.Signal() // 腾出位置了,叫醒一个生产者
return v, nil
}
func main() {
q := NewBoundedQueue(2)
var wg sync.WaitGroup
for i := 0; i < 3; i++ { // 3 个生产者
wg.Add(1)
go func(id int) {
defer wg.Done()
for j := 0; j < 4; j++ {
q.Put(id*10 + j)
}
}(i)
}
total := 0
for k := 0; k < 12; k++ { // 主 goroutine 消费 12 个
v, err := q.Get()
if err != nil {
panic(err)
}
total += v
}
wg.Wait()
fmt.Println("total =", total)
}
注意两个细节:Signal 只叫醒一个等待者就够了(队列状态的变化恰好只惠及一人);而 Broadcast 的场景是「一个条件变化可能让所有等待者都该重新检查」,比如「关闭」信号。如果你把上面 condPut.Signal() 换成 Broadcast,功能也对,只是白白叫醒一堆还会接着睡的人,浪费调度。
五、sync.Cond vs Channel:什么时候用哪个
这是收尾必问题。多数情况下 channel 更地道(也符合 Go 的 share memory by communicating 哲学),Cond 只留两个杀手锏场景:
| 场景 | 推荐 | 原因 |
|---|---|---|
| 传数据 + 同步 | channel | 一等公民,select 支持、天然队列 |
| 多 goroutine 等同一个「状态变化」 | sync.Cond | Broadcast 一次唤醒全部,channel 要广播得逐个发或关 channel |
| 需要条件与共享状态强绑定(如上面队列题) | sync.Cond | 锁保护状态,Cond 保护等待,语义成套 |
| 超时等待 | channel + select + time.After |
Cond 的 Wait 不支持超时(没有 WaitTimeout),只能另起 goroutine 调 Signal 补救 |
顺带一提:标准库的 RWMutex 读锁排队、WaitGroup 都不能直接替代 Cond;写业务代码时如果发现自己要「广播唤醒一批等待者」,先想想 channel close 能不能解决,解决不了再上 Cond。
六、面试速答清单
- Cond 是什么? 锁 + 等待队列;
Wait原子地解锁挂起、醒后重新加锁。 - Wait 为什么持锁? 保证「检查条件」和「进入等待队列」不可分割,否则通知会丢失(missed wakeup)。
- 为什么 for 不是 if? 唤醒到抢到锁之间条件可能被别的 goroutine 改回去,醒来必须重新检查。
- Signal 和 Broadcast 区别? 唤醒一个 vs 全部;调用时都允许不持锁,但建议持锁保证顺序。
- 底层是什么? 运行时
notifyList(ticket 排队号机制),runtime_notifyListAdd/Wait/NotifyOne/NotifyAll。 - 和 channel 怎么选? 传数据用 channel;广播唤醒一批等待者、条件与共享状态绑定时用 Cond。
版本说明
- 本文涉及的标准库 API(
sync.NewCond、Wait/Signal/Broadcast语义、notifyList运行时钩子)对照go1.26.0的src/sync/cond.go与src/sync/runtime.go核实(2026-10-31 核对,源码:github.com/golang/go,tag go1.26.0)。 sync.Cond语义自 Go 1.0 起稳定,无版本差异;本文示例仅使用标准库,不依赖第三方模块。
本文是「Go语言面试题」系列第 33 篇。相关阅读:《Go Mutex 与 RWMutex 的区别》、《什么时候用 atomic 比 Mutex 更合适?》、《Go Channel 底层实现全解析》、《死锁排查指南》。全系列目录见 《Go语言面试题总目录》。
- 《Go 程序设计语言》(Go 语言圣经,机械工业出版社) —— Go 官方团队出品,公认入门第一书
- 《Go 语言实战》(异步图书,人民邮电出版社) —— 实战导向,面试前巩固工程手感
- 《Go 语言编程之旅:一起用 Go 做项目》(博文视点,电子工业出版社) —— 项目驱动入门,从零写出完整应用
- Keychron K1Max 矮轴机械键盘(Mac/Win 双系统,QMK/VIA 开源改键) —— 轻薄矮轴 + 开源固件,程序员桌面常客
- 罗技 MX Master 3S 无线鼠标(Flow 跨屏,静音电磁滚轮) —— 人体工学设计,写代码一整天手不酸
以上链接含京东联盟推广代码;通过链接购买本站可能获得少量佣金,不影响你的购买价格。