前两篇我们聊了 Mutex 和 WaitGroup,它们分别解决"互斥访问"和"等待一组任务完成"的问题。但并发编程中还有一类需求——等待某个条件满足后再继续执行。比如:队列满了,生产者要等;队列空了,消费者要等。这就是 sync.Cond(条件变量)要解决的问题。
一、为什么需要 Cond?
假设你要实现一个限定容量的队列:队列满时生产者阻塞,队列空时消费者阻塞。没有 Cond 的话,你可能会这样写:
// ❌ 轮询方案:浪费 CPU,响应慢
for len(queue) == maxSize {
time.Sleep(10 * time.Millisecond) // 空转等待
}
queue = append(queue, item)这种轮询方式有两个严重问题:CPU 空转(反复空问都是无效检查)和响应延迟(最多延迟一个轮询周期才能感知条件成立)。
Cond 的方案则是:条件不满足就阻塞休眠,条件满足后立即唤醒,零空转、零延迟。
二、Cond 的基本 API
Cond 初始化时需要关联一个 Locker(通常是 *sync.Mutex 或 *sync.RWMutex),用于保护条件状态:
var mu sync.Mutex
cond := sync.NewCond(&mu)Cond 只有三个方法:
| 方法 | 说明 |
|---|---|
Wait() | 释放锁 → 阻塞等待 → 被唤醒后重新获取锁 |
Signal() | 唤醒一个等待的 goroutine |
Broadcast() | 唤醒所有等待的 goroutine |
三者的协作关系:
关键点:Wait() 不是"等待条件成立",而是"释放锁并休眠,被唤醒后重新获取锁"。条件判断必须由调用者自己在 for 循环中完成。
三、Wait 的执行流程
Wait() 的内部行为可以拆解为三步,理解这三步是正确使用 Cond 的关键:
在释放锁之前,保证不会丢信号] B --> C[第二步: 释放锁 L.Unlock
允许其他 goroutine 修改条件] C --> D[第三步: 休眠,等待 Signal/Broadcast] D --> E[被唤醒] E --> F[重新获取锁 L.Lock] F --> G[回到 for 循环检查条件]
为什么"先入队再释放锁"的顺序很重要?如果反过来——先释放锁,再入队——就会出现窗口期:通知者在你入队之前发了 Signal,你没收到,然后才入队休眠,永远等不到唤醒。
四、经典示例:有界阻塞队列
用 Cond 实现一个线程安全的有界队列,队列满时生产者阻塞,队列空时消费者阻塞:
package main
import (
"fmt"
"sync"
"time"
)
type BoundedQueue struct {
cond *sync.Cond
queue []int
maxSize int
}
func NewBoundedQueue(maxSize int) *BoundedQueue {
return &BoundedQueue{
cond: sync.NewCond(&sync.Mutex{}),
maxSize: maxSize,
}
}
func (q *BoundedQueue) Put(item int) {
q.cond.L.Lock()
defer q.cond.L.Unlock()
for len(q.queue) == q.maxSize { // ✅ 必须用 for,不能用 if
q.cond.Wait() // 队列满了,阻塞等待
}
q.queue = append(q.queue, item)
fmt.Printf("放入: %d, 队列长度: %d\n", item, len(q.queue))
q.cond.Broadcast() // 通知所有等待者(消费者可能在等)
}
func (q *BoundedQueue) Take() int {
q.cond.L.Lock()
defer q.cond.L.Unlock()
for len(q.queue) == 0 { // ✅ 必须用 for,不能用 if
q.cond.Wait() // 队列空了,阻塞等待
}
item := q.queue[0]
q.queue = q.queue[1:]
fmt.Printf("取出: %d, 队列长度: %d\n", item, len(q.queue))
q.cond.Broadcast() // 通知所有等待者(生产者可能在等)
return item
}
func main() {
q := NewBoundedQueue(3) // 容量为 3
// 启动 5 个生产者
for i := 0; i < 5; i++ {
go func(id int) {
for j := 0; j < 3; j++ {
q.Put(id*100 + j)
time.Sleep(50 * time.Millisecond)
}
}(i)
}
// 启动 3 个消费者
for i := 0; i < 3; i++ {
go func() {
for j := 0; j < 5; j++ {
q.Take()
time.Sleep(80 * time.Millisecond)
}
}()
}
time.Sleep(3 * time.Second)
}这个程序中,生产者和消费者通过 Cond 自动协调——队列满了生产者自动休眠,队列有空位了自动唤醒;队列空了消费者自动休眠,队列有数据了自动唤醒。
五、Signal vs Broadcast:怎么选?
Signal()
Broadcast()
一个实际的对比:
| 场景:3 个消费者等待数据,生产者放入 1 条数据 | Signal() | Broadcast() |
|---|---|---|
| 消费者 A | 唤醒 ✅ 抢到数据 | 唤醒 ✅ 抢到数据 |
| 消费者 B | 继续休眠 | 唤醒 → 条件不满足,继续 Wait |
| 消费者 C | 继续休眠 | 唤醒 → 条件不满足,继续 Wait |
经验法则:不确定用哪个的时候,用 Broadcast() 更安全。 Signal 只唤醒一个,如果那个 goroutine 因为某些原因没能消费成功(比如条件其实不匹配),其他等待者不会被唤醒,可能导致"信号丢失"。Broadcast 虽然会多唤醒一些 goroutine,但每个被唤醒的 goroutine 都会在 for 循环中重新检查条件,条件不满足的会重新 Wait,不会出错。
六、使用 Cond 最常踩的 4 个坑
坑 1:用 if 而不是 for 检查条件
这是最常见、也是最危险的错误:
// ❌ 用 if:可能在条件不满足时继续执行
cond.L.Lock()
if !condition {
cond.Wait()
}
// 到这里条件不一定为 true!
doSomething()
cond.L.Unlock()为什么 if 不行?因为存在 虚假唤醒(Spurious Wakeup)——goroutine 被唤醒后,条件可能仍然不满足:
正确做法:始终用 for 循环:
// ✅ 用 for:每次唤醒都重新检查条件
cond.L.Lock()
for !condition {
cond.Wait()
}
// 到这里条件一定为 true
doSomething()
cond.L.Unlock()坑 2:没持有锁就调用 Wait
Wait() 的第一步是释放锁。如果调用时没持有锁,行为是未定义的(通常直接 panic):
// ❌ 没有 Lock 就 Wait
cond.Wait() // panic: sync: unlock of unlocked mutex// ✅ 必须先获取锁
cond.L.Lock()
for !condition {
cond.Wait()
}
cond.L.Unlock()坑 3:Signal/Broadcast 后忘记释放锁
通知者调用 Signal 或 Broadcast 后,必须释放锁,否则被唤醒的 goroutine 无法重新获取锁,依然阻塞:
// ❌ 通知后没释放锁
cond.L.Lock()
condition = true
cond.Signal()
// 忘记 Unlock!被唤醒的 goroutine 卡在获取锁上 💀// ✅ 通知后释放锁
cond.L.Lock()
condition = true
cond.Signal()
cond.L.Unlock() // 被唤醒的 goroutine 才能拿到锁小技巧:Signal/Broadcast 可以在持有锁或不持有锁的时候调用,都是合法的。但推荐在修改条件和调用通知之间保持一致——先修改条件(持有锁),再通知,最后释放锁。
坑 4:复制 Cond
和 Mutex 一样,Cond 是值类型,复制后内部的等待队列和锁都会出问题:
// ❌ 复制 Cond
func process(c sync.Cond) { // 值传递,复制了 Cond
c.L.Lock()
c.Wait()
c.L.Unlock()
}
// ✅ 传指针
func process(c *sync.Cond) {
c.L.Lock()
c.Wait()
c.L.Unlock()
}Cond 内部有一个 noCopy 字段,go vet 可以检测到复制问题。
七、Cond 的实现原理
Cond 的实现比 Mutex 简单得多,核心是一个 通知列表(notifyList):
| 字段 | 类型 | 作用 |
|---|---|---|
L | Locker | 用户传入的 Mutex |
notify.wait | uint32 | 下一个等待者的编号(递增) |
notify.notify | uint32 | 已通知到的编号 |
notify 链表 | goroutine 队列 | 等待者列表 |
checker | copyChecker | go vet 用于检测复制 |
三个方法的实现逻辑:
Wait():
1. 获取一个自增的 ticket 编号,加入等待链表
2. 释放锁 (cond.L.Unlock())
3. 信号量休眠,直到自己的 ticket 被通知
4. 重新获取锁 (cond.L.Lock())
Signal():
notify 计数 + 1
唤醒等待链表中下一个未被通知的 goroutine
Broadcast():
notify 计数直接设为 wait 计数
唤醒等待链表中所有未被通知的 goroutine这个 ticket 机制保证了 Signal 是 FIFO 的——先等待的先被唤醒,不会饥饿。
八、Cond vs Channel:怎么选?
在 Go 中遇到等待/通知的场景,更地道的做法通常是用 Channel。那什么时候该用 Cond,什么时候用 Channel?
// 方案 A:Cond(等待任意条件)
cond.L.Lock()
for len(queue) == 0 {
cond.Wait()
}
item := queue[0]
queue = queue[1:]
cond.L.Unlock()
// 方案 B:Channel(天然的生产者-消费者)
ch := make(chan int, maxSize)
ch <- item // 满了自动阻塞
item := <-ch // 空了自动阻塞对比:
| 特性 | Cond | Channel |
|---|---|---|
| 等待单一条件 | ✅ | ✅ |
| 等待复合条件 | ✅ 任意布尔表达式 | ❌ 需要额外编排 |
| 唤醒所有等待者 | ✅ Broadcast | ❌ 需要 close 或多写 |
| 生产者-消费者 | ✅ 能做但繁琐 | ✅ 天然支持 |
| 超时控制 | ❌ 不支持 | ✅ select + timer |
| select 多路复用 | ❌ 不支持 | ✅ 天然支持 |
| 代码复杂度 | 较高(锁+循环) | 低 |
选择依据:
| 场景 | 推荐方案 |
|---|---|
| 简单的生产者-消费者 | Channel |
| 需要 select / 超时控制 | Channel |
| 需要广播唤醒所有等待者 | Cond(或 close channel,但只能用一次) |
| 等待复杂的复合条件 | Cond |
大多数场景下,Channel 是更好的选择。Cond 的优势在于:它可以等待任意复杂的条件表达式,并且 Broadcast 可以反复使用(Channel 的 close 只能用一次)。
九、实战场景:等待初始化完成
一个实际场景——多个 goroutine 需要等待某个服务初始化完成后才能开始工作:
package main
import (
"fmt"
"sync"
"time"
)
type Service struct {
cond *sync.Cond
ready bool
}
func NewService() *Service {
return &Service{
cond: sync.NewCond(&sync.Mutex{}),
}
}
// Init 模拟耗时初始化
func (s *Service) Init() {
fmt.Println("服务初始化中...")
time.Sleep(2 * time.Second) // 模拟耗时操作
s.cond.L.Lock()
s.ready = true
s.cond.Broadcast() // ✅ 唤醒所有等待的 worker
s.cond.L.Unlock()
fmt.Println("服务初始化完成")
}
// WaitReady 阻塞直到服务就绪
func (s *Service) WaitReady() {
s.cond.L.Lock()
for !s.ready {
s.cond.Wait()
}
s.cond.L.Unlock()
}
func main() {
svc := NewService()
// 启动 5 个 worker,都需要等服务就绪
var wg sync.WaitGroup
for i := 0; i < 5; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
fmt.Printf("Worker %d: 等待服务就绪...\n", id)
svc.WaitReady()
fmt.Printf("Worker %d: 开始工作\n", id)
}(i)
}
// 启动初始化
go svc.Init()
wg.Wait()
fmt.Println("所有 Worker 完成")
}提示:这个"等待初始化"的场景用
sync.Once+ Channel 也能实现,但如果初始化状态可能反复变化(比如服务可能降级后恢复),Cond 的 Broadcast 能反复使用,而 Channel close 只能用一次。
十、为什么 Cond 被认为"难以掌握"?
Go 社区有人提议将 Cond 从标准库移除(issue #21165),也有人说 Cond 是"唯一难以掌握的 Go 并发原语"。原因有几个:
- Wait 的语义反直觉——它不是"等到条件成立",而是"释放锁 + 休眠 + 重新获取锁",条件检查完全靠调用者
- 必须配合 for 循环——忘了就出 bug,而且不容易在测试中暴露
- 不支持超时和 select——Channel 天然支持
select多路复用和超时控制,Cond 做不到 - 容易和锁搞混——Wait 前后的锁状态切换容易让人迷糊
- 几乎总有 Channel 替代方案——大多数场景用 Channel 更简洁、更安全
但这不意味着 Cond 没有价值。在需要"等待复杂条件 + 反复广播唤醒"的场景下,Cond 仍然是最直接的工具。
十一、实战建议
- 始终用
for循环包裹Wait()——防止虚假唤醒导致的逻辑错误 - 优先考虑 Channel——大多数等待/通知场景,Channel 更简洁、更地道
- 不确定用 Signal 还是 Broadcast 时,用 Broadcast——更安全,只是多唤醒几个 goroutine
- 不要复制 Cond——和 Mutex 一样,传指针
- 用
go vet做静态检查——检测 Cond 复制问题
# 两个应该加入 CI 的命令(和前面的文章一样)
go vet ./...
go test -race ./...Cond 是 Go 并发原语中使用频率最低、但概念密度最高的一个。理解它的 Wait 三步曲(入队 → 释放锁 → 休眠 → 唤醒 → 获取锁),掌握 for 循环检查的模式,你就能在需要的时候正确地使用它——而在不需要的时候,果断选择 Channel。