前两篇我们聊了 Mutex 和 WaitGroup,它们分别解决"互斥访问"和"等待一组任务完成"的问题。但并发编程中还有一类需求——等待某个条件满足后再继续执行。比如:队列满了,生产者要等;队列空了,消费者要等。这就是 sync.Cond(条件变量)要解决的问题。

一、为什么需要 Cond?

假设你要实现一个限定容量的队列:队列满时生产者阻塞,队列空时消费者阻塞。没有 Cond 的话,你可能会这样写:

// ❌ 轮询方案:浪费 CPU,响应慢
for len(queue) == maxSize {
    time.Sleep(10 * time.Millisecond) // 空转等待
}
queue = append(queue, item)

这种轮询方式有两个严重问题:

问题 1:CPU 空转                     问题 2:响应延迟

  CPU                                 条件满足
  ┌─┐ ┌─┐ ┌─┐ ┌─┐ ┌─┐                ──●──────────────┐
  │?│ │?│ │?│ │?│ │?│ ← 反复空问                       │ 等待下一次轮询
  └─┘ └─┘ └─┘ └─┘ └─┘                 ─────────────────●
    全是无效检查                              最多延迟 10ms

Cond 的方案则是:条件不满足就阻塞休眠,条件满足后立即唤醒,零空转、零延迟。

二、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 // 空了自动阻塞

对比:

特性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。