Go语言中sync.Cond介绍使用完全教程
sync.Cond 是 Go 标准库提供的条件变量,用于实现多 Goroutine 间的等待-通知机制,避免忙轮询带来的 CPU 浪费。本文详细介绍了其核心方法(Wait、Signal、Broadcast)与固定使用模式,通过生产者-消费者队列示例演示具体用法,并与 Channel 方案进行对比,帮助读者根据场景选择更合适的同步方式。
1. 什么是 sync.Cond?
sync.Cond 是 Go 标准库 sync 包中的条件变量(Condition Variable),用于在多个 Goroutine 之间协调对共享资源的访问。
它的核心作用是:让一个或多个 Goroutine 在某个条件不满足时进入等待状态(阻塞),直到其他 Goroutine 改变了这个条件并通过 Signal 或 Broadcast 将其唤醒。
简单说:
Cond实现了“等待-通知”机制,避免了低效的忙轮询。
2. 为什么需要条件变量?
假设多个消费者从一个共享队列中取数据,如果队列为空,消费者该怎么办?
- 忙轮询(Busy Waiting):不断循环检查
len(queue)==0,会持续占用 CPU,浪费资源。 - 固定休眠(Sleep):每次检查后休眠一会儿,但响应延迟增大,且休眠时间难以权衡。
- 条件变量(
sync.Cond):队列为空时,消费者主动挂起(释放 CPU),直到生产者添加数据后立即唤醒,兼顾低延迟和低资源消耗。
3. sync.Cond 的核心方法
| 方法 | 说明 |
|---|---|
func NewCond(l Locker) *Cond |
创建一个条件变量,需传入一个互斥锁(*Mutex 或 *RWMutex)。 |
func (c *Cond) Wait() |
等待。当前 Goroutine 会原子地释放锁并挂起,直到被唤醒。唤醒后会自动重新获取锁。调用前必须持有锁。 |
func (c *Cond) Signal() |
唤醒一个等待的 Goroutine(如果有多个,随机选择一个)。 |
func (c *Cond) Broadcast() |
唤醒所有等待的 Goroutine。 |
其中 Cond 结构体中的 L 字段暴露出来,方便调用锁操作,例如 cond.L.Lock()。
4. 使用模式(三步走)
使用 sync.Cond 必须遵循固定的模式,否则容易死锁或出现竞态。
等待方(消费者)模式
cond.L.Lock() // 1. 持有锁
for !condition() { // 2. 循环检查条件(防止虚假唤醒)
cond.Wait() // 3. 等待通知(内部会释放锁并挂起)
}
// 4. 条件满足,执行操作(此时已经重新持有锁)
...
cond.L.Unlock() // 5. 解锁
通知方(生产者)模式
cond.L.Lock() // 1. 持有锁
// 2. 修改共享状态,使条件变为 true
changeState()
// 3. 发送通知(Signal 或 Broadcast)
cond.Signal() // 或 cond.Broadcast()
cond.L.Unlock() // 4. 解锁
关键点:通知方可以在解锁前或解锁后发送通知,但习惯上在解锁前发送,确保唤醒后的等待方能立即竞争锁。
5. 完整示例:生产者-消费者队列
下面是一个使用 sync.Cond 实现的简单队列,一个生产者生成数据,多个消费者消费数据。
package main
import (
"fmt"
"sync"
"time"
)
func main() {
var mu sync.Mutex
cond := sync.NewCond(&mu)
queue := make([]int, 0, 10)
// 启动一个消费者
go consumer(cond, &queue, "C1")
go consumer(cond, &queue, "C2")
// 启动生产者
producer(cond, &queue)
}
func producer(cond *sync.Cond, queue *[]int) {
for i := 0; i < 10; i++ {
cond.L.Lock()
*queue = append(*queue, i)
fmt.Printf("Produced: %d\n", i)
cond.Signal() // 唤醒一个消费者
cond.L.Unlock()
time.Sleep(200 * time.Millisecond)
}
}
func consumer(cond *sync.Cond, queue *[]int, name string) {
for {
cond.L.Lock()
for len(*queue) == 0 { // 队列为空则等待
fmt.Printf("%s waiting...\n", name)
cond.Wait()
}
// 取出第一个元素
item := (*queue)[0]
*queue = (*queue)[1:]
fmt.Printf("%s consumed: %d\n", name, item)
cond.L.Unlock()
time.Sleep(100 * time.Millisecond) // 模拟处理耗时
}
}
输出示例(顺序可能变化):
Produced: 0
C1 waiting...
C2 waiting...
C1 consumed: 0
Produced: 1
C2 consumed: 1
...
6. sync.Cond vs select(Channel)
两者都能实现等待通知,但适用场景不同:
| 特性 | sync.Cond |
select + Channel |
|---|---|---|
| 同步模型 | 共享内存 + 互斥锁 | 通信(CSP) |
| 唤醒模式 | 支持Signal(单播)和 Broadcast(广播) |
只能通过 Channel 单播(需要循环发消息实现广播) |
| 条件检查 | 显式使用for 循环检查任意复杂条件 |
隐式触发(收到数据即满足条件) |
| 超时/取消 | 需手动实现(如用time.After + Broadcast) |
原生支持select 的 time.After / context.Done() |
| 多路复用 | 无法同时等待多个条件(只能等待一个变量状态) | 可以同时监听多个 Channel(任务、退出、超时) |
| 性能开销 | 需加锁,有锁竞争开销 | Channel 本身并发安全,无显式锁 |
| 使用难度 | 容易出错(忘记加锁、死锁) | 更安全,推荐优先使用 |
选型建议:
- 需要广播唤醒多个等待者(如连接池释放)、或条件涉及多个变量组合判断时,用
sync.Cond。 - 大多数场景(尤其是单播、超时控制、多事件选择)优先使用
select+ Channel,更符合 Go 的哲学。
7. 重要注意事项
7.1 必须持有锁再调用 Wait()
Wait()内部会先释放锁,再挂起,唤醒后重新获取锁。如果调用前未持有锁,会直接 panic。- 正确做法:调用
cond.L.Lock()后再cond.Wait()。
7.2 使用 for 循环,而不是 if 检查条件
- 原因:虚假唤醒(Spurious Wakeup)。操作系统或 Go 运行时可能在没有收到通知的情况下唤醒等待的 Goroutine。
- 使用
for可以在唤醒后重新检查条件,不满足则继续等待,避免错误执行后续逻辑。
// 错误写法(可能 panic)
if len(queue) == 0 {
cond.Wait()
}
item := queue[0] // 被虚假唤醒时 queue 可能仍为空,导致 index out of range
7.3 先修改共享状态,再发送通知
- 通知只表示“状态可能已经改变”,等待方必须自己重新检查条件。所以要确保在发送通知前,共享变量已经被正确修改。
7.4 避免死锁
- 确保等待方和通知方使用同一把锁(
NewCond传入的锁)。 - 通知方在发送通知后必须解锁,否则等待方被唤醒后无法获取锁,从而阻塞。
7.5 Broadcast 的使用场景
- 当条件发生重大变化,需要唤醒所有等待者重新竞争时使用(如资源池扩容、系统重置)。
- 滥用
Broadcast可能导致“惊群效应”,大量 Goroutine 同时唤醒竞争锁,应谨慎使用。
8. 高级技巧:带超时的等待
sync.Cond 本身不支持超时,但可以通过 time.AfterFunc 配合 Broadcast 实现:
func WaitWithTimeout(cond *sync.Cond, timeout time.Duration, condition func() bool) bool {
timer := time.AfterFunc(timeout, func() {
cond.L.Lock()
cond.Broadcast() // 唤醒所有等待者
cond.L.Unlock()
})
defer timer.Stop()
cond.L.Lock()
defer cond.L.Unlock()
for !condition() {
cond.Wait()
}
return true // 条件满足
}
注意:这种方法会唤醒所有等待者,且可能产生竞争,需在条件中判断是否因超时而退出。
9. 实际应用场景
- 连接池:当池中没有空闲连接时,请求线程等待,归还连接时唤醒一个等待者。
- 任务队列:多个 Worker 等待任务,生产者投递任务后唤醒 Worker。
- 单次初始化:使用
Broadcast通知所有等待初始化完成的 Goroutine。 - 流量控制:根据系统负载动态调整消费者数量,通过条件变量协调。
10. 总结
sync.Cond是 Go 中实现“条件等待”的基础同步原语,用于共享内存并发模型。- 使用三要素:锁 + for循环 + Wait()。
- 与
select+ Channel 相比,Cond更灵活(广播、复杂条件),但更底层、更易出错。 - 现代 Go 编程优先考虑 Channel,仅在需要广播或复杂状态判断时使用
Cond。
学习建议:在实际项目中,先尝试用 Channel 解决问题,若发现需要“广播唤醒”或“多个变量组合条件”,再考虑引入
sync.Cond。同时,务必为并发代码编写充分的竞态检测测试(go test -race)。