在日常开发中,我们经常会遇到这样的场景:多个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()
关键要点:
- Wait()前必须持有锁:调用Wait()前必须已经获取了cond.L对应的锁
- 使用for循环检查条件:必须使用for而不是if来判断条件,防止虚假唤醒
- 先修改状态再通知:在调用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的精确协调。