在日常开发中,我们经常会遇到这样的场景:多个goroutine需要等待某个条件成立才能继续执行。比如,等待缓存加载完成、等待资源就绪或等待特定信号。面对这种需求,你的第一反应是什么?是使用忙轮询不断检查条件,还是使用channel进行通信?

其实,Go语言提供了一个更加优雅的解决方案:sync.Cond

什么是sync.Cond?

sync.Cond是Go语言标准库中提供的条件变量,它允许一组goroutine在某个条件不满足时进入等待状态,直到其他goroutine通知它们条件已经改变。条件变量与互斥锁(sync.Mutex或sync.RWMutex)配合使用,可以有效协调多个goroutine的执行顺序。

简单来说,sync.Cond提供了三个核心方法:

  • Wait():使当前goroutine释放锁并进入等待状态,直到被唤醒
  • Signal():唤醒一个等待的goroutine
  • Broadcast():唤醒所有等待的goroutine

为什么需要sync.Cond?

忙轮询的陷阱

我们先来看一个"反面教材"——忙轮询(Busy Waiting):

// 不推荐的方式:忙轮询
for !condition {
    // 空转,浪费CPU
}
// 条件满足后继续执行

这种方式最大的问题是CPU资源浪费。goroutine会不断地检查条件是否满足,在条件未满足期间,它会占用大量的CPU时间,就像"CPU烧开水"一样,效率极低。

Channel的局限性

虽然channel是Go语言并发编程的核心组件,但在某些场景下并不是最优选择。当需要广播通知或多个goroutine等待同一条件时,使用channel会变得复杂且低效。

sync.Cond的优势

相比之下,sync.Cond在特定场景下具有明显优势:

  • 零CPU浪费:等待的goroutine会真正挂起,不消耗CPU资源
  • 即时响应:条件满足时,goroutine会被立即唤醒
  • 灵活唤醒:可以选择唤醒一个(Signal)或所有(Broadcast)等待者

sync.Cond的核心使用模式

使用sync.Cond有一个固定的模式,遵循这个模式可以避免常见的陷阱:

// 等待方
cond.L.Lock()
for !condition {
    cond.Wait() // Wait()内部会释放锁,并在唤醒后重新获取锁
}
// 此时条件已满足,可以安全地操作共享资源
cond.L.Unlock()

// 通知方
cond.L.Lock()
condition = true // 修改共享状态
cond.Signal()    // 或 cond.Broadcast()
cond.L.Unlock()

关键要点

  1. Wait()前必须持有锁:调用Wait()前必须已经获取了cond.L对应的锁
  2. 使用for循环检查条件:必须使用for而不是if来判断条件,防止虚假唤醒
  3. 先修改状态再通知:在调用Signal()或Broadcast()前,确保已经修改了共享状态

实战案例:智能短信发送系统

让我们通过一个真实的案例来理解sync.Cond的应用价值。

假设我们有一个短信发送系统,需要支持暂停和恢复功能:当系统负载过高或出现问题时,可以立即暂停所有发送任务,等问题解决后再恢复发送。

方案1:忙轮询(不推荐)

// worker代码片段
for {
    if paused {
        continue // 疯狂循环,CPU飙升
    }
    // 发送短信...
}

这种方式会导致CPU使用率飙升,严重影响系统性能。

方案2:使用sync.Cond(推荐)

type SMSManager struct {
    mu     sync.Mutex
    cond   *sync.Cond
    paused bool
}

func NewSMSManager() *SMSManager {
    sm := &SMSManager{}
    sm.cond = sync.NewCond(&sm.mu)
    return sm
}

func (sm *SMSManager) Pause() {
    sm.mu.Lock()
    sm.paused = true
    sm.mu.Unlock()
}

func (sm *SMSManager) Resume() {
    sm.mu.Lock()
    sm.paused = false
    sm.cond.Broadcast() // 唤醒所有等待的goroutine
    sm.mu.Unlock()
}

func (sm *SMSManager) Worker(id int) {
    for {
        sm.mu.Lock()
        for sm.paused {
            sm.cond.Wait() // 等待时释放锁,唤醒时重新获取
        }
        sm.mu.Unlock()

        // 发送短信...
    }
}

在这种实现中,当系统暂停时,所有worker会优雅地等待,不消耗CPU资源;当调用Resume()时,所有worker会立即恢复工作,响应零延迟。

更多应用场景

生产者-消费者模型

sync.Cond非常适合实现生产者-消费者模式,特别是当需要限制缓冲区大小时:

type BoundedQueue struct {
    items   []interface{}
    cond    *sync.Cond
    maxSize int
}

func (q *BoundedQueue) Put(item interface{}) {
    q.cond.L.Lock()
    for len(q.items) >= q.maxSize {
        q.cond.Wait() // 队列已满,等待
    }
    q.items = append(q.items, item)
    q.cond.Signal() // 唤醒一个消费者
    q.cond.L.Unlock()
}

func (q *BoundedQueue) Get() interface{} {
    q.cond.L.Lock()
    for len(q.items) == 0 {
        q.cond.Wait() // 队列为空,等待
    }
    item := q.items[0]
    q.items = q.items[1:]
    q.cond.Signal() // 唤醒一个生产者
    q.cond.L.Unlock()
    return item
}

这种方式比使用channel更加灵活,可以轻松实现有界队列复杂的调度策略

资源池管理

当管理数据库连接、工作线程等资源时,sync.Cond可以帮助我们在资源不足时让请求等待,直到有资源可用:

type ResourcePool struct {
    resources chan interface{}
    cond      *sync.Cond
    maxSize   int
}

func (p *ResourcePool) Get() interface{} {
    p.cond.L.Lock()
    for len(p.resources) == 0 {
        p.cond.Wait() // 等待资源可用
    }
    resource := <-p.resources
    p.cond.L.Unlock()
    return resource
}

sync.Cond与Channel的对比

那么,在什么情况下应该选择sync.Cond而不是channel呢?下面这个表格可以帮助你决策:

场景 推荐方式 理由
单个生产者和单个消费者 Channel 代码更简洁,语义更清晰
多个消费者等待同一个条件 sync.Cond 更高效,避免创建多个channel
需要广播通知多个goroutine sync.Cond 直接使用Broadcast(),避免复杂channel操作
状态驱动的事件等待 sync.Cond 更灵活,适合复杂的条件判断

简单来说:当需要传递数据时,优先选择channel;当只需要通知状态变化时,sync.Cond可能更合适

写在最后

sync.Cond是Go语言并发工具箱中一个强大但容易被忽视的工具。它特别适合处理基于状态的等待和通知场景,能够在不消耗CPU资源的情况下实现goroutine的精确协调。