Go 的 sync.Cond 实战:用条件变量做等待/通知,比忙轮询省 CPU
你写了个有界队列,消费者要「队列非空才取」。第一版你多半会写个 for 循环反复检查------CPU 直接跑满一个核。加个 time.Sleep 呢?延迟又上来了。这类「等某个条件成立再继续」的场景,标准库给了个专门的工具:sync.Cond。它冷门,但一旦遇上就是它最合适。这篇手把手教你用对它,以及那几个几乎人人踩的坑。
先看忙轮询有多糟
需求:一个容量固定的缓冲区,生产者往里放,消费者从里取;满了生产者等,空了消费者等。
go
type BadQueue struct {
mu sync.Mutex
items []int
cap int
}
func (q *BadQueue) Pop() int {
for {
q.mu.Lock()
if len(q.items) > 0 {
v := q.items[0]
q.items = q.items[1:]
q.mu.Unlock()
return v
}
q.mu.Unlock()
// 队列空,啥也没干就继续下一轮 ------ 空转烧 CPU
}
}
这段代码功能没错,但消费者在队列空时会疯狂加锁/解锁空转,一个 goroutine 就能吃满一核。你可能会想到 channel------没错,大多数场景 channel 才是 Go 的首选 。但当「等待的条件」比「有没有数据」更复杂(比如「队列长度低于某阈值」「多个条件同时满足」),或者你要一次唤醒全部等待者 时,sync.Cond 更直接。
sync.Cond 的三个方法
条件变量把「等待」这件事交给运行时调度,等待中的 goroutine 会被挂起、不占 CPU,直到被唤醒:
Wait():原子地 释放锁并挂起当前 goroutine;被唤醒时自动重新加锁再返回。Signal():唤醒一个等待者(唤醒谁不保证)。Broadcast():唤醒全部等待者。
创建时要绑定一把锁:c := sync.NewCond(&mu)。这把锁保护你在等待/判断时读写的共享状态。
正确写法:队列非空/非满都用条件变量
go
type Queue struct {
mu sync.Mutex
notEmpty *sync.Cond
notFull *sync.Cond
items []int
cap int
}
func NewQueue(cap int) *Queue {
q := &Queue{cap: cap}
// 两个条件变量共用同一把锁,保护同一份 items
q.notEmpty = sync.NewCond(&q.mu)
q.notFull = sync.NewCond(&q.mu)
return q
}
func (q *Queue) Push(v int) {
q.mu.Lock()
defer q.mu.Unlock()
for len(q.items) == q.cap { // 满了就等「非满」信号
q.notFull.Wait()
}
q.items = append(q.items, v)
q.notEmpty.Signal() // 放进去了,唤醒一个等着取的消费者
}
func (q *Queue) Pop() int {
q.mu.Lock()
defer q.mu.Unlock()
for len(q.items) == 0 { // 空了就等「非空」信号
q.notEmpty.Wait()
}
v := q.items[0]
q.items = q.items[1:]
q.notFull.Signal() // 取走一个,唤醒一个等着放的生产者
return v
}
空队列时,Pop 里的 Wait() 会让消费者 goroutine 挂起,完全不占 CPU ,直到 Push 调用 Signal() 把它叫醒。这就是和忙轮询的本质区别。
坑一:Wait 必须用 for,不能用 if
这是最经典的错误。很多人把上面的 for len(q.items) == 0 写成 if,偶发地就取到空队列 panic 了。
原因有两个:
- 虚假唤醒 / 抢跑 :
Signal唤醒你之后,Wait要重新抢那把锁才能返回。在你抢到锁之前,可能有另一个 消费者先抢到锁把数据取走了。等你终于返回时,队列又空了。用if只判断一次就往下走,直接读到空 slice。 for会在被唤醒后重新检查条件 ,不满足就继续Wait,这才安全。
go
// 错:被唤醒后不复查,并发下会取到空队列
if len(q.items) == 0 {
q.notEmpty.Wait()
}
// 对:循环复查条件,这是使用 Cond 的铁律
for len(q.items) == 0 {
q.notEmpty.Wait()
}
记死一句话:Cond 的 Wait 永远包在 for 里检查谓词。
坑二:调 Wait 前必须持有锁
Wait() 内部会先解锁再挂起。如果你没加锁就调 Wait,运行时会 panic:sync: unlock of unlocked mutex。所以模式永远是「先 Lock → for 里 Wait → 用完 Unlock」。上面例子用 defer q.mu.Unlock() 保证了这点。
坑三:Signal/Broidcast 怎么选,以及别忘了唤醒
- 只需让一个 等待者继续(比如队列多了一个元素,叫醒一个消费者就够)→
Signal(),更省。 - 状态变化让所有 等待者的条件都可能成立(比如「关闭队列,所有人都别等了」)→ 必须
Broadcast(),否则只醒一个,其余永远挂着。
关闭场景是重灾区,给个正确收尾:
go
func (q *Queue) Close() {
q.mu.Lock()
q.closed = true
q.mu.Unlock()
q.notEmpty.Broadcast() // 叫醒所有等待者,让它们看到 closed 后退出
q.notFull.Broadcast()
}
func (q *Queue) Pop() (int, bool) {
q.mu.Lock()
defer q.mu.Unlock()
for len(q.items) == 0 && !q.closed {
q.notEmpty.Wait()
}
if len(q.items) == 0 { // 只剩 closed 情况
return 0, false
}
v := q.items[0]
q.items = q.items[1:]
q.notFull.Signal()
return v, true
}
关闭时若用 Signal 只叫醒一个,其他消费者会永久阻塞(goroutine 泄漏)。关闭/广播状态变更一律 Broadcast。
什么时候还是该用 channel
别把 sync.Cond 当默认选择。判断标准:
- 就是「传数据 + 一对一/多对多收发」→ 用 channel ,更符合 Go 习惯,还能配
select做超时。 - 「多个 goroutine 等同一个复杂条件,条件成立要一次性叫醒全部 」,或者 channel 表达起来别扭(比如状态是一坨结构体字段的组合)→
sync.Cond更贴。
一个典型该用 Cond 的例子:一批 worker 都在等「配置加载完成」,加载完 Broadcast() 一次全放行------用 channel 得靠 close(channel) 技巧,而 Cond 语义更直白。
小结
- 「等条件成立再继续」别写忙轮询,
sync.Cond能让等待的 goroutine 挂起、不烧 CPU。 - 三个方法:
Wait(原子解锁+挂起,唤醒后重新加锁)、Signal(醒一个)、Broadcast(醒全部)。 - 三条铁律:Wait 必须包在 for 里复查条件 、调 Wait 前必须持锁 、状态终结/全员放行用 Broadcast。
- channel 仍是 Go 并发首选;只有「多等待者等复杂条件、需一次唤醒全部」时,Cond 才是更顺手的工具。
一句话记忆:Cond 是「挂起等通知」,for 复查 + 持锁 Wait + 该广播就广播,三样齐了它就稳。