前两篇我们聊了 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

三者的协作关系:

sequenceDiagram participant W as 等待者 participant C as Cond participant N as 通知者 W->>C: L.Lock() loop for !condition W->>C: Wait() 释放锁+休眠 end N->>C: L.Lock() N->>C: 修改条件 N->>C: Signal/Broadcast N->>C: L.Unlock() C-->>W: 唤醒+重新获取锁 Note over W: 执行业务 W->>C: L.Unlock()

关键点Wait() 不是"等待条件成立",而是"释放锁并休眠,被唤醒后重新获取锁"。条件判断必须由调用者自己在 for 循环中完成。

三、Wait 的执行流程

Wait() 的内部行为可以拆解为三步,理解这三步是正确使用 Cond 的关键:

graph TD A[调用 cond.Wait] --> B[第一步: 把自己加入等待队列
在释放锁之前,保证不会丢信号] 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()

唤醒等待队列中的一个 goroutine。适用于"只有一个等待者需要被唤醒"的场景,例如一对一的生产者-消费者。
📢

Broadcast()

唤醒等待队列中的所有 goroutine。适用于"多个等待者可能需要检查条件"的场景,例如通知所有消费者"有数据了"。

一个实际的对比:

场景: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 被唤醒后,条件可能仍然不满足:

sequenceDiagram participant A as 消费者 A participant B as 消费者 B participant Q as 队列 participant P as 生产者 Note over A,B: 都在等待数据 P->>Q: 放入 1 条数据 P->>A: Broadcast P->>B: Broadcast A->>Q: 获取锁,取走数据 B->>Q: 获取锁时队列又空了 Note over B: 💀 用 if 会在队列为空时继续执行

正确做法:始终用 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)

字段类型作用
LLocker用户传入的 Mutex
notify.waituint32下一个等待者的编号(递增)
notify.notifyuint32已通知到的编号
notify 链表goroutine 队列等待者列表
checkercopyCheckergo 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 // 空了自动阻塞

对比:

特性CondChannel
等待单一条件
等待复合条件✅ 任意布尔表达式❌ 需要额外编排
唤醒所有等待者✅ 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 并发原语"。原因有几个:

  1. Wait 的语义反直觉——它不是"等到条件成立",而是"释放锁 + 休眠 + 重新获取锁",条件检查完全靠调用者
  2. 必须配合 for 循环——忘了就出 bug,而且不容易在测试中暴露
  3. 不支持超时和 select——Channel 天然支持 select 多路复用和超时控制,Cond 做不到
  4. 容易和锁搞混——Wait 前后的锁状态切换容易让人迷糊
  5. 几乎总有 Channel 替代方案——大多数场景用 Channel 更简洁、更安全

但这不意味着 Cond 没有价值。在需要"等待复杂条件 + 反复广播唤醒"的场景下,Cond 仍然是最直接的工具。

十一、实战建议

  1. 始终用 for 循环包裹 Wait()——防止虚假唤醒导致的逻辑错误
  2. 优先考虑 Channel——大多数等待/通知场景,Channel 更简洁、更地道
  3. 不确定用 Signal 还是 Broadcast 时,用 Broadcast——更安全,只是多唤醒几个 goroutine
  4. 不要复制 Cond——和 Mutex 一样,传指针
  5. go vet 做静态检查——检测 Cond 复制问题
# 两个应该加入 CI 的命令(和前面的文章一样)
go vet ./...
go test -race ./...

Cond 是 Go 并发原语中使用频率最低、但概念密度最高的一个。理解它的 Wait 三步曲(入队 → 释放锁 → 休眠 → 唤醒 → 获取锁),掌握 for 循环检查的模式,你就能在需要的时候正确地使用它——而在不需要的时候,果断选择 Channel。