波波技术栈
article

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)。