三亩地 三亩地SAN MU DI · CODE DIARY
ARTICLE DETAIL

日记详情

真实记录编程学习的某一天,欢迎挑你感兴趣的翻一翻。

Go 的 sync.Cond 实战:用条件变量做等待/通知,比忙轮询省 CPU

Go 的 sync.Cond 实战:用条件变量做等待/通知,比忙轮询省 CPU

Go 的 sync.Cond 实战:用条件变量做等待/通知,比忙轮询省 CPU

你写了个有界队列,消费者要「队列非空才取」。第一版你多半会写个for循环反复检查——CPU 直接跑满一个核。加个time.Sleep呢?延迟又上来了。这类「等某个条件成立再继续」的场景,标准库给了个专门的工具:sync.Cond。它冷门,但一旦遇上就是它最合适。这篇手把手教你用对它,以及那几个几乎人人踩的坑。

先看忙轮询有多糟

需求:一个容量固定的缓冲区,生产者往里放,消费者从里取;满了生产者等,空了消费者等。

typeBadQueuestruct{mu sync.Mutex items[]intcapint}func(q*BadQueue)Pop()int{for{q.mu.Lock()iflen(q.items)>0{v:=q.items[0]q.items=q.items[1:]q.mu.Unlock()returnv}q.mu.Unlock()// 队列空,啥也没干就继续下一轮 —— 空转烧 CPU}}

这段代码功能没错,但消费者在队列空时会疯狂加锁/解锁空转,一个 goroutine 就能吃满一核。你可能会想到 channel——没错,大多数场景 channel 才是 Go 的首选。但当「等待的条件」比「有没有数据」更复杂(比如「队列长度低于某阈值」「多个条件同时满足」),或者你要一次唤醒全部等待者时,sync.Cond更直接。

sync.Cond 的三个方法

条件变量把「等待」这件事交给运行时调度,等待中的 goroutine 会被挂起、不占 CPU,直到被唤醒:

  • Wait():原子地释放锁并挂起当前 goroutine;被唤醒时自动重新加锁再返回。
  • Signal():唤醒一个等待者(唤醒谁不保证)。
  • Broadcast():唤醒全部等待者。

创建时要绑定一把锁:c := sync.NewCond(&mu)。这把锁保护你在等待/判断时读写的共享状态。

正确写法:队列非空/非满都用条件变量

typeQueuestruct{mu sync.Mutex notEmpty*sync.Cond notFull*sync.Cond items[]intcapint}funcNewQueue(capint)*Queue{q:=&Queue{cap:cap}// 两个条件变量共用同一把锁,保护同一份 itemsq.notEmpty=sync.NewCond(&q.mu)q.notFull=sync.NewCond(&q.mu)returnq}func(q*Queue)Push(vint){q.mu.Lock()deferq.mu.Unlock()forlen(q.items)==q.cap{// 满了就等「非满」信号q.notFull.Wait()}q.items=append(q.items,v)q.notEmpty.Signal()// 放进去了,唤醒一个等着取的消费者}func(q*Queue)Pop()int{q.mu.Lock()deferq.mu.Unlock()forlen(q.items)==0{// 空了就等「非空」信号q.notEmpty.Wait()}v:=q.items[0]q.items=q.items[1:]q.notFull.Signal()// 取走一个,唤醒一个等着放的生产者returnv}

空队列时,Pop里的Wait()会让消费者 goroutine 挂起,完全不占 CPU,直到Push调用Signal()把它叫醒。这就是和忙轮询的本质区别。

坑一:Wait 必须用 for,不能用 if

这是最经典的错误。很多人把上面的for len(q.items) == 0写成if,偶发地就取到空队列 panic 了。

原因有两个:

  1. 虚假唤醒 / 抢跑:Signal唤醒你之后,Wait要重新抢那把锁才能返回。在你抢到锁之前,可能有另一个消费者先抢到锁把数据取走了。等你终于返回时,队列又空了。用if只判断一次就往下走,直接读到空 slice。
  2. for会在被唤醒后重新检查条件,不满足就继续Wait,这才安全。
// 错:被唤醒后不复查,并发下会取到空队列iflen(q.items)==0{q.notEmpty.Wait()}// 对:循环复查条件,这是使用 Cond 的铁律forlen(q.items)==0{q.notEmpty.Wait()}

记死一句话:Cond 的 Wait 永远包在 for 里检查谓词

坑二:调 Wait 前必须持有锁

Wait()内部会先解锁再挂起。如果你没加锁就调Wait,运行时会 panic:sync: unlock of unlocked mutex。所以模式永远是「先LockforWait→ 用完Unlock」。上面例子用defer q.mu.Unlock()保证了这点。

坑三:Signal/Broidcast 怎么选,以及别忘了唤醒

  • 只需让一个等待者继续(比如队列多了一个元素,叫醒一个消费者就够)→Signal(),更省。
  • 状态变化让所有等待者的条件都可能成立(比如「关闭队列,所有人都别等了」)→ 必须Broadcast(),否则只醒一个,其余永远挂着。

关闭场景是重灾区,给个正确收尾:

func(q*Queue)Close(){q.mu.Lock()q.closed=trueq.mu.Unlock()q.notEmpty.Broadcast()// 叫醒所有等待者,让它们看到 closed 后退出q.notFull.Broadcast()}func(q*Queue)Pop()(int,bool){q.mu.Lock()deferq.mu.Unlock()forlen(q.items)==0&&!q.closed{q.notEmpty.Wait()}iflen(q.items)==0{// 只剩 closed 情况return0,false}v:=q.items[0]q.items=q.items[1:]q.notFull.Signal()returnv,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 + 该广播就广播,三样齐了它就稳。

← 返回列表