前两篇我们聊了 Mutex 和 WaitGroup,它们分别解决"互斥访问"和"等待一组任务完成"的问题。但并发编程中还有一类需求——等待某个条件满足后再继续执行。比如:队列满了,生产者要等;队列空了,消费者要等。这就是 sync.Cond(条件变量)要解决的问题。
一、为什么需要 Cond?
假设你要实现一个限定容量的队列:队列满时生产者阻塞,队列空时消费者阻塞。没有 Cond 的话,你可能会这样写:
// ❌ 轮询方案:浪费 CPU,响应慢
for len(queue) == maxSize {
time.Sleep(10 * time.Millisecond) // 空转等待
}
queue = append(queue, item)这种轮询方式有两个严重问题:
问题 1:CPU 空转 问题 2:响应延迟
CPU 条件满足
┌─┐ ┌─┐ ┌─┐ ┌─┐ ┌─┐ ──●──────────────┐
│?│ │?│ │?│ │?│ │?│ ← 反复空问 │ 等待下一次轮询
└─┘ └─┘ └─┘ └─┘ └─┘ ─────────────────●
全是无效检查 最多延迟 10msCond 的方案则是:条件不满足就阻塞休眠,条件满足后立即唤醒,零空转、零延迟。
二、Cond 的基本 API
Cond 初始化时需要关联一个 Locker(通常是 *sync.Mutex 或 *sync.RWMutex),用于保护条件状态:
var mu sync.Mutex
cond := sync.NewCond(&mu)Cond 只有三个方法:
┌────────────────────────────────────────────────────────────────────┐
│ sync.Cond │
├────────────────────────────────────────────────────────────────────┤
│ Wait() 释放锁 → 阻塞等待 → 被唤醒后重新获取锁 │
│ Signal() 唤醒一个等待的 goroutine │
│ Broadcast() 唤醒所有等待的 goroutine │
└────────────────────────────────────────────────────────────────────┘三者的协作关系:
等待者 goroutine 通知者 goroutine
│ │
│── cond.L.Lock() │
│ │
│── for !condition { │
│ cond.Wait() │
│ // 释放锁 → 休眠 │
│ // ... │
│ // 被唤醒 → 重新获取锁 │
│ } │
│ │── cond.L.Lock()
│ [条件满足,执行业务] │── 修改条件
│ │── cond.Signal() / Broadcast()
│── cond.L.Unlock() │── cond.L.Unlock()关键点:Wait() 不是"等待条件成立",而是"释放锁并休眠,被唤醒后重新获取锁"。条件判断必须由调用者自己在 for 循环中完成。
三、Wait 的执行流程
Wait() 的内部行为可以拆解为三步,理解这三步是正确使用 Cond 的关键:
cond.Wait() 内部执行流程:
┌─────────────────────────────────────────┐
│ 第一步:把自己加入等待队列 │
│ (在释放锁之前,保证不会丢信号) │
├─────────────────────────────────────────┤
│ 第二步:释放锁 (cond.L.Unlock()) │
│ 允许其他 goroutine 获取锁并修改 │
│ 条件 │
├─────────────────────────────────────────┤
│ 第三步:休眠,等待 Signal/Broadcast │
│ │
│ ......被唤醒...... │
│ │
│ 重新获取锁 (cond.L.Lock()) │
│ 回到 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() │
│ • 唤醒等待队列中的一个 goroutine │
│ • 适用于:只有一个等待者需要被唤醒的场景 │
│ • 例如:一对一的生产者-消费者 │
├──────────────────────────────────────────────────────────────────┤
│ Broadcast() │
│ • 唤醒等待队列中的所有 goroutine │
│ • 适用于:多个等待者可能需要检查条件的场景 │
│ • 例如:通知所有消费者"有数据了",或通知所有生产者"有空位了" │
└──────────────────────────────────────────────────────────────────┘一个实际的对比:
场景:3 个消费者等待数据,生产者放入 1 条数据
Signal(): Broadcast():
消费者 A ← 唤醒 ✅ 消费者 A ← 唤醒 ✅ (抢到数据)
消费者 B 继续休眠 消费者 B ← 唤醒 ✅ (条件不满足,继续 Wait)
消费者 C 继续休眠 消费者 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 被唤醒后,条件可能仍然不满足:
消费者 A 和 消费者 B 都在等待数据
生产者放入 1 条数据
│
│── Broadcast() ──→ 唤醒 A 和 B
│
消费者 A 先获取锁,取走数据
消费者 B 获取锁时,队列又空了!
│
└── 如果用 if,B 会在队列为空时继续执行 💀正确做法:始终用 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):
┌──────────────────────────────────────────────────────────────┐
│ sync.Cond 内部结构 │
├──────────────────────────────────────────────────────────────┤
│ │
│ L Locker │
│ ┌──────────────────────┐ │
│ │ 用户传入的 Mutex │ │
│ └──────────────────────┘ │
│ │
│ notify notifyList │
│ ┌──────────┬───────────┬───────────────────────────────┐ │
│ │ wait: 5 │ notify: 3 │ 等待者链表 (goroutine 队列) │ │
│ └──────────┴───────────┴───────────────────────────────┘ │
│ wait = 下一个等待者的编号(递增) │
│ notify = 已通知到的编号 │
│ │
│ 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。