Go sync.Cond 为什么必须在 Wait 前加锁与伪唤醒的防范
Go 语言的 sync.Cond 是一个强大的同步原语,用于在共享状态发生变化时通知等待的 goroutine。然而,它的使用比 mutex 和 channel 更容易出错。本文将直接剖析两个核心问题:为什么 Wait 方法调用前必须持有锁,以及如何应对“伪唤醒”。
理解 Wait 的工作流程与锁的必要性
调用 Wait 方法前必须持有与该 Cond 关联的互斥锁(sync.Mutex 或 sync.RWMutex),这不是一个可选的“最佳实践”,而是 Go 运行时强制的行为。
Wait 方法在内部执行的操作是一个原子性的复合动作,其步骤如下:
- 释放当前持有的互斥锁。
- 挂起当前的 goroutine,使其进入等待状态,等待被
Signal或Broadcast方法唤醒。 - 当被唤醒后,尝试重新获取之前释放的互斥锁。
- 返回调用者,此时锁已再次被当前 goroutine 持有。
这个原子操作是为了防止经典的“丢失唤醒”问题。如果在检查条件(例如队列是否为空)和进入等待之间没有锁的保护,可能会发生以下情况:
- Goroutine A 检查条件,发现条件不满足(队列空)。
- 在 Goroutine A 执行
Wait挂起之前,系统调度 Goroutine B。 - Goroutine B 修改了共享状态(生产一个数据到队列),然后发送唤醒信号。
- 信号发出时,Goroutine A 尚未进入等待状态,因此信号被丢失。
- Goroutine A 随后执行
Wait并挂起,但再也不会被唤醒,因为它错过了唯一的信号。
加锁 并在锁的保护下检查条件,确保了“检查条件”和“进入等待”之间共享状态不会被改变,从而保证了信号不会被错过。
识别与防范“伪唤醒”
“伪唤醒”是指一个正在等待的 goroutine 在没有 Signal 或 Broadcast 调用的情况下被操作系统或运行时唤醒。这是一种合法的、可能发生的行为,尤其在多核处理器环境下。
关键结论:你绝对不能假设 goroutine 被唤醒就意味着你所等待的条件已经变为 true。
因此,检查条件的逻辑必须放在一个循环里,而不是一个简单的 if 语句中。这是使用 sync.Cond 的铁律。
正确使用 sync.Cond 的代码模板
下面是一个生产者-消费者的完整示例,展示了如何正确使用 Wait、Signal 和 Broadcast。
package main
import (
"fmt"
"sync"
"time"
)
// SharedState 定义共享数据结构。
type SharedState struct {
mu sync.Mutex
cond *sync.Cond
queue []string
stopped bool
}
func NewSharedState() *SharedState {
ss := &SharedState{}
// 用 ss.mu 初始化条件变量。
ss.cond = sync.NewCond(&ss.mu)
return ss
}
// Produce 向队列添加一个数据项。
func (ss *SharedState) Produce(item string) {
ss.mu.Lock()
defer ss.mu.Unlock()
// 操作共享数据。
ss.queue = append(ss.queue, item)
fmt.Printf("Produced: %s, Queue: %v\n", item, ss.queue)
// 唤醒一个等待的消费者。
ss.cond.Signal()
}
// Consume 从队列取出一个数据项。如果没有数据则等待。
func (ss *SharedState) Consume() string {
ss.mu.Lock()
defer ss.mu.Unlock()
// 核心:必须在 for 循环中检查条件,以防伪唤醒。
// 条件:队列为空 且 生产未停止。
for len(ss.queue) == 0 && !ss.stopped {
// Wait 内部会自动释放 ss.mu 锁,并挂起当前 goroutine。
// 当被唤醒后,会自动重新获取锁,然后从这里继续执行。
ss.cond.Wait()
}
// 再次检查,因为可能因停止信号而被唤醒。
if len(ss.queue) == 0 {
return "" // 生产已停止且队列空
}
// 取出队首元素。
item := ss.queue[0]
ss.queue = ss.queue[1:]
fmt.Printf("Consumed: %s, Queue: %v\n", item, ss.queue)
return item
}
// Stop 通知所有消费者停止。
func (ss *SharedState) Stop() {
ss.mu.Lock()
defer ss.mu.Unlock()
ss.stopped = true
// 唤醒所有等待的消费者,让它们检查 stopped 标志。
ss.cond.Broadcast()
}
func main() {
ss := NewSharedState()
var wg sync.WaitGroup
// 启动多个消费者。
for i := 0; i < 3; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
for {
item := ss.Consume()
if item == "" {
fmt.Printf("Consumer %d exiting.\n", id)
return
}
// 模拟消费耗时。
time.Sleep(100 * time.Millisecond)
}
}(i)
}
// 生产数据。
for i := 0; i < 5; i++ {
ss.Produce(fmt.Sprintf("Item-%d", i))
time.Sleep(50 * time.Millisecond)
}
// 通知停止并等待消费者退出。
time.Sleep(200 * time.Millisecond)
ss.Stop()
wg.Wait()
fmt.Println("All consumers exited. Main finished.")
}
解析代码中的关键模式
-
初始化条件变量:必须使用
sync.NewCond(Locker)创建,其中Locker通常是*sync.Mutex或*sync.RWMutex。这建立了Cond与锁的绑定关系。 -
生产者
Produce方法:- 操作前加锁:
ss.mu.Lock()。 - 修改共享状态:
ss.queue = append(...)。 - 发送唤醒信号:
ss.cond.Signal()。Signal只唤醒一个等待的 goroutine。如果确定只有一个条件(如队列非空),并且只有一个消费者需要被唤醒,这是高效的。Broadcast会唤醒所有等待的 goroutine,适用于条件更复杂或需要清理所有等待者的场景(如Stop方法)。
- 操作前加锁:
-
消费者
Consume方法:- 加锁进入临界区:
ss.mu.Lock()。 - 在
for循环中检查条件:for len(ss.queue) == 0 && !ss.stopped。这是防范伪唤醒的核心。即使 goroutine 被唤醒,也会回到循环顶部重新验证条件是否真正满足。 - 调用
Wait:ss.cond.Wait()。在循环内部、条件不满足时调用。它会原子地释放锁并挂起。 - 条件满足后操作:循环退出,说明条件已满足(队列非空或生产已停止),此时安全地操作数据。
- 加锁进入临界区:
Signal 与 Broadcast 的选择
Signal:唤醒一个等待该条件变量的 goroutine。适用于等待者等待的都是同一个条件,且唤醒任意一个都能推进工作。例如,多个消费者等待同一个队列有数据,生产一个数据只需要一个消费者去处理。Broadcast:唤醒所有等待该条件变量的 goroutine。适用于条件发生了根本性变化,所有等待者都需要重新评估自己的状态,或者需要优雅地关闭(如上述Stop方法)。如果误用Signal来停止服务,可能只停止了一个消费者,导致其他消费者永远挂起。
何时使用 sync.Cond
虽然 Go 社区更推崇使用 channel 进行 goroutine 间通信,但 sync.Cond 在某些场景下更直接或高效:
- 等待复杂条件:条件涉及多个变量(如
len(queue) > 0 && capacity > 0),且这些变量在锁的保护下。使用channel传递这类复杂状态变化比较繁琐。 - 实现高级同步机制:如信号量(Semaphore)、读写锁的读者等待队列等底层并发原语。
- 高频唤醒单个等待者:
Signal比通过channel发送一个空消息的开销理论上更小,尽管在实践中差异通常可以忽略。
首要原则:如果问题能用一个 channel 清晰、安全地解决,优先使用 channel。当问题更贴近“等待一个由互斥锁保护的状态改变”这一模型时,考虑 sync.Cond。

暂无评论,快来抢沙发吧!