Go 条件变量:sync.Cond 与等待通知
Go 语言的 sync 包提供了多种同步原语,其中 sync.Cond 是一个用于协调 goroutine 之间“等待-通知”行为的条件变量。它常用于解决多个 goroutine 需要等待某个共享状态发生变化后再继续执行的问题。
何时使用 sync.Cond?
不要在能用 channel 解决问题时强行使用 sync.Cond。但当你遇到以下情况时,sync.Cond 是更合适的选择:
- 多个 goroutine 同时等待同一个条件成立(例如缓冲区非空、任务队列有新任务)。
- 条件的变化由另一个 goroutine 主动触发通知。
- 不希望为每个等待者创建单独的 channel,避免资源浪费或逻辑复杂。
sync.Cond 的核心机制是:goroutine 调用 Wait() 进入阻塞状态,直到另一个 goroutine 调用 Signal() 或 Broadcast() 唤醒它。
创建 sync.Cond
构造一个 sync.Cond 实例需要传入一个 *sync.Locker(通常是 *sync.Mutex 或 *sync.RWMutex):
var mu sync.Mutex
cond := sync.NewCond(&mu)
这个锁的作用是:保护共享状态,确保在检查条件和进入等待之间不会被其他 goroutine 干扰。
基本使用模式
使用 sync.Cond 的标准流程如下:
- 获取锁(调用
Lock())。 - 检查条件是否满足。如果不满足,调用
Wait()。 Wait()内部会自动释放锁,使其他 goroutine 有机会修改状态。- 当被唤醒后,
Wait()重新获取锁,然后返回。 - 再次检查条件(因为可能被虚假唤醒),决定是否继续执行。
- 释放锁(调用
Unlock())。
下面是一个典型示例:一个生产者不断向队列添加任务,多个消费者等待任务到来。
package main
import (
"fmt"
"sync"
"time"
)
func main() {
var mu sync.Mutex
cond := sync.NewCond(&mu)
queue := make([]int, 0)
// 启动 3 个消费者
for i := 0; i < 3; i++ {
go func(id int) {
for {
mu.Lock()
// 循环检查条件:队列为空则等待
for len(queue) == 0 {
cond.Wait()
}
// 取出第一个任务
task := queue[0]
queue = queue[1:]
mu.Unlock()
fmt.Printf("Consumer %d processing task %d\n", id, task)
time.Sleep(100 * time.Millisecond) // 模拟处理时间
}
}(i)
}
// 生产者:每秒添加一个任务
for i := 1; ; i++ {
mu.Lock()
queue = append(queue, i)
mu.Unlock()
cond.Signal() // 唤醒一个等待的消费者
time.Sleep(time.Second)
}
}
注意几个关键点:
- 必须用 for 循环检查条件,而不是 if。因为
Wait()可能被虚假唤醒(spurious wakeup)。 - Signal() 只唤醒一个等待者;如果想唤醒所有等待者,使用
Broadcast()。 - 所有对共享状态(如
queue)的访问都必须在持有锁的情况下进行。
Signal() vs Broadcast()
| 方法 | 行为 | 适用场景 |
|---|---|---|
Signal() |
唤醒一个正在 Wait() 的 goroutine |
通常用于“一个任务唤醒一个消费者”的场景 |
Broadcast() |
唤醒所有正在 Wait() 的 goroutine |
用于“状态全局变化”,所有等待者都需要重新评估条件 |
例如,在缓存失效场景中,当缓存被清空后,所有等待缓存更新的 goroutine 都应被唤醒,此时应使用 Broadcast()。
常见错误与陷阱
-
忘记在循环中检查条件
使用if而不是for检查条件,可能导致 goroutine 在虚假唤醒后错误地继续执行。 -
未持有锁就调用 Wait()/Signal()/Broadcast()
这些方法必须在持有与Cond关联的锁时调用,否则会导致 panic。 -
在 Wait() 返回后不重新验证条件
即使被唤醒,条件也可能不再成立(例如被其他 goroutine 抢先消费),因此必须再次检查。 -
滥用 Broadcast() 导致性能下降
唤醒所有等待者会引发“惊群效应”(thundering herd),不必要的 goroutine 被唤醒后又立即进入等待,浪费 CPU。
与 channel 的对比
虽然 channel 也能实现类似功能,但各有优劣:
- channel 更适合“一对一”或“多对一”的消息传递。
- sync.Cond 更适合“多对多”的状态等待,尤其是当等待条件复杂或状态变化频繁时。
- 使用 channel 模拟多个消费者等待同一条件,通常需要额外的 goroutine 转发消息,增加复杂度。
例如,若用 channel 实现上述消费者模型,可能需要一个广播 channel 或 fan-out 模式,而 sync.Cond 直接在共享状态上操作,逻辑更清晰。
性能注意事项
sync.Cond的内部实现基于runtime_Semacquire和runtime_Semrelease,底层使用 futex(Linux)等高效同步机制。- 但在高并发场景下,频繁调用
Broadcast()可能导致大量 goroutine 竞争锁,反而降低性能。 - 如果等待者数量固定且较少,优先考虑 channel;如果等待者动态增减或数量庞大,
sync.Cond更合适。
实战:实现一个简单的 Barrier
Barrier 是一种同步原语,要求 N 个 goroutine 都到达某一点后才能继续执行。我们可以用 sync.Cond 实现:
type Barrier struct {
mu sync.Mutex
cond *sync.Cond
count int
waiting int
}
func NewBarrier(n int) *Barrier {
b := &Barrier{count: n}
b.cond = sync.NewCond(&b.mu)
return b
}
func (b *Barrier) Wait() {
b.mu.Lock()
b.waiting++
if b.waiting < b.count {
b.cond.Wait()
} else {
b.cond.Broadcast()
}
b.mu.Unlock()
}
使用方式:
barrier := NewBarrier(3)
for i := 0; i < 3; i++ {
go func(id int) {
fmt.Printf("Goroutine %d ready\n", id)
barrier.Wait()
fmt.Printf("Goroutine %d proceeding\n", id)
}(i)
}
此实现展示了 sync.Cond 如何协调多个 goroutine 的同步点。
始终确保:在调用 Wait() 前持有锁,在 Wait() 返回后重新验证条件,并在适当时候调用 Signal() 或 Broadcast()。正确使用 sync.Cond 能让你在复杂的并发控制中游刃有余。

暂无评论,快来抢沙发吧!