前言
本篇为笔者翻译 VictoriaMetrics 公司博客 《handling concurrency in Go》系列的第三篇,主要介绍了sync.Cond及其底层相关原理。
sync.Cond
在 Go 语言中,sync.Cond 是一种同步原语,但相比于其类似的工具,比如 sync.Mutex 或 sync.WaitGroup,它的使用并不那么普遍。在大多数项目中,甚至在标准库中,你很少能看到它的身影,因为通常会使用其他同步机制来替代它。
话虽如此,作为一名 Go 工程师,你一定不希望在阅读使用 sync.Cond 的代码时一头雾水,毕竟它也是标准库的一部分。
因此,这篇讨论将帮助你弥补这一知识空白,更重要的是,它还会让你对其实际工作原理有更清晰的理解。
sync.Cond 是啥?
让我们来逐步了解一下 sync.Cond 是什么。
当一个 goroutine 需要等待某些特定的事情发生,比如共享数据的变化,它可以“阻塞”,意味着它会暂停执行,直到得到继续执行的信号。最基本的做法是使用一个循环,可能还会加上 time.Sleep 来防止 CPU 因忙等待(busy-waiting)而过度使用。
这可能看起来像这样:
// 等待直到条件为真
for !condition {
}
或者:
// 等待直到条件为真
for !condition {
time.Sleep(100 * time.Millisecond)
}
这种做法并不是特别高效,因为即使条件没有发生变化,循环仍然会在后台运行,消耗 CPU 资源。
这时,sync.Cond 就派上用场了,它提供了一种更好的方式来协调 goroutines 的工作。技术上讲,如果你来自学术背景,它是一种“条件变量”(condition variable)。
当一个 goroutine 等待某个条件发生时(即等待某个条件变为真),它可以调用 Wait() 方法。
另一个 goroutine 在知道条件可能满足时,可以调用 Signal() 或 Broadcast() 来唤醒等待的 goroutine,并通知它们可以继续执行了。
sync.Cond 提供的基本接口如下:
// 暂停调用的 goroutine,直到条件满足
func (c *Cond) Wait()
// 唤醒一个等待的 goroutine(如果有的话)
func (c *Cond) Signal()
// 唤醒所有等待的 goroutine
func (c *Cond) Broadcast()
(上图 sync.Cond 概览)
让我们来看一个简单的伪代码示例。这次,我们使用宝可梦(Pokémon)作为主题,假设我们在等待一个特定的宝可梦出现,并希望在它出现时通知其他 goroutines。
var pokemonList = []string{"Pikachu", "Charmander", "Squirtle", "Bulbasaur", "Jigglypuff"}
var cond = sync.NewCond(&sync.Mutex{})
var pokemon = ""
func main() {
// 消费者
go func() {
cond.L.Lock()
defer cond.L.Unlock()
// 等待直到皮卡丘出现
for pokemon != "Pikachu" {
cond.Wait()
}
println("Caught " + pokemon)
pokemon = ""
}()
// 生产者
go func() {
// 每毫秒一个随机宝可梦出现
for i := 0; i < 100; i++ {
time.Sleep(time.Millisecond)
cond.L.Lock()
pokemon = pokemonList[rand.Intn(len(pokemonList))]
cond.L.Unlock()
cond.Signal()
}
}()
time.Sleep(100 * time.Millisecond) // 懒等待
}
输出:
Caught Pikachu
在这个示例中,一个 goroutine 等待皮卡丘的出现,而另一个 goroutine(生产者)从列表中随机选择一个宝可梦,并在每次出现新宝可梦时向消费者发送信号。
当生产者发送信号时,消费者会被唤醒并检查是否是正确的宝可梦。如果是,就捕获这个宝可梦;如果不是,消费者会继续等待下一个宝可梦的出现。
Caught Pikachu
在这个示例中,一个 goroutine 等待皮卡丘的出现,而另一个 goroutine(生产者)从列表中随机选择一个宝可梦,并在每次出现新宝可梦时向消费者发送信号。
当生产者发送信号时,消费者会被唤醒并检查是否是正确的宝可梦。如果是,就捕获这个宝可梦;如果不是,消费者会继续等待下一个宝可梦的出现。
这个模式存在一个潜在问题:生产者发送信号到消费者实际被唤醒之间可能存在时间差。此时,宝可梦的状态可能已经发生变化,因为消费者可能会因为某些原因(比如 1 毫秒内没有醒来)错过了信号。换句话说,sync.Cond 基本上是在说:“嘿,发生了变化!醒来检查一下,但如果你晚了,可能状态又变了。”
如果消费者醒得太晚,宝可梦可能已经逃跑,消费者将再次进入等待状态。
“我貌似可以使用 channel 来发送宝可梦名称或仅用信号通知其他 goroutine?”
当然。事实上,Go 中通常更推荐使用 channels,而不是 sync.Cond,因为 channels 更简单、符合 Go 的惯用法,并且大多数开发者比较熟悉。
在上述示例中,你完全可以通过 channel 传递宝可梦名称,或者使用一个空的 struct{} 来仅发送信号,而不传递数据。但我们的问题不仅仅是通过 channel 传递消息,更重要的是如何处理共享状态。
我们的示例相对简单,但如果多个 goroutines 正在访问共享的 pokemon 变量,让我们看看如果使用 channel 会发生什么情况:
- 如果我们使用 channel 发送宝可梦名称,我们仍然需要一个互斥锁来保护共享的
pokemon变量。 - 如果仅使用 channel 发送信号,仍然需要互斥锁来管理对共享状态的访问。
- 如果我们在生产者中检查皮卡丘并通过 channel 发送它,我们还需要一个互斥锁。而且这样做会违反关注点分离原则,因为生产者承担了实际上属于消费者的逻辑。
因此,当多个 goroutines 修改共享数据时,仍然需要使用互斥锁来保护数据。通常,在这些情况下,开发者会结合使用 channel 和互斥锁,以确保正确的同步和数据安全。
“好吧,我也可以使用channel来广播通知吗?”
这是个很好的问题!你确实可以使用 channel 模拟广播信号,通过简单地关闭它 (close(ch)) 来通知所有等待的 goroutines。当你关闭一个 channel 时,所有从该 channel 接收数据的 goroutines 都会被通知。然而需要注意的是,关闭的 channel 无法再被使用,一旦关闭,它就永远关闭了。
顺便提一下,其实在 Go 2 中有关于删除 sync.Cond 的讨论。
"那 sync.Cond 有啥用?"
sync.Cond 在某些情况下,比 channels 更适用。
- 频道(Channel) :你可以通过发送一个值向一个 goroutine 发送信号,或者通过关闭 channel 来通知所有 goroutine,但两者不能同时做。
sync.Cond则提供了更细粒度的控制,你可以通过调用Signal()唤醒一个 goroutine,或者调用Broadcast()唤醒所有 goroutines。 - 广播机制:你可以多次调用
Broadcast()来唤醒所有等待的 goroutines,而一旦 channel 被关闭,你就不能再发送信号,关闭一个已经关闭的 channel 会触发 panic。 - 数据保护:channel 本身并不提供对共享数据的保护。你需要使用互斥锁来保护共享数据。而
sync.Cond提供了一种集成的方式,结合了锁和信号传递(并且通常表现出更好的性能)。
"为什么 sync.Cond 内嵌了锁? "
理论上,条件变量(如 sync.Cond)的信号机制不必依赖于锁才能生效。你可以让使用者自己在外部管理锁,这似乎可以带来更多灵活性。然而,这并不是技术上的限制,而是为了避免人为错误。
手动管理锁容易出错,因为这种模式并不直观。在调用 Wait() 之前,你必须先解锁 mutex,而当 goroutine 被唤醒后,还需要重新锁定它。这一过程可能让人觉得很繁琐,且容易出现错误,比如忘记在正确的时机解锁或上锁。
为什么这种模式看起来有点不对劲呢?
通常,调用 cond.Wait() 的 goroutine 需要在一个循环中检查一些共享的状态,像这样:
for !checkSomeSharedState() {
cond.Wait()
}
嵌入 Lock 的 sync.Cond 帮助我们处理了锁/解锁的过程,使代码更简洁,错误率更低。我们稍后会详细讨论这个模式。
如何使用?
如果你仔细看之前的例子,你会注意到消费者部分始终会在调用 Wait() 前锁定互斥锁,并在条件满足后解锁它。
此外,我们将等待条件包裹在一个循环中,这里是回顾代码:
// 消费者
go func() {
cond.L.Lock()
defer cond.L.Unlock()
// 等待直到皮卡丘出现
for pokemon != "Pikachu" {
cond.Wait()
}
println("Caught " + pokemon)
}()
cond.Wait() 的工作原理
当我们调用 Wait() 时,当前 goroutine 会等待,直到某个条件满足。下面是它在后台发生的事情:
- 当前 goroutine 会被添加到等待列表中,与其他等待相同条件的 goroutines 一起。
- 所有这些 goroutines 都会被阻塞,直到通过
Signal()或Broadcast()唤醒。 - 关键点:在调用
Wait()之前,mutex 必须已经被锁定,因为Wait()做了一个重要的事情,它会自动释放锁(调用Unlock()),然后将 goroutine 放入等待状态。这样,其他 goroutines 就能在原始 goroutine 等待时抓住锁并执行任务。 - 当等待的 goroutine 被唤醒时(通过
Signal()或Broadcast()),它不会立即恢复工作。首先,它必须重新获取锁(调用Lock())。
(上图 cond.Wait()方法)
这是 Wait() 在 Go 中的实现:
func (c *Cond) Wait() {
// 检查 Cond 是否已被复制
c.checker.check()
// 获取票据号码
t := runtime_notifyListAdd(&c.notify)
// 解锁互斥锁
c.L.Unlock()
// 暂停 goroutine,直到被唤醒
runtime_notifyListWait(&c.notify, t)
// 重新锁定互斥锁
c.L.Lock()
}
从中可以提炼出 4 个主要要点:
sync.Cond有一个checker,防止Cond实例被复制;如果复制了,会 panic。- 调用
cond.Wait()会立即解锁互斥锁,因此必须在调用Wait()前先锁定互斥锁,否则会 panic。 - 被唤醒后,
cond.Wait()会重新锁定互斥锁,这意味着你需要在使用共享数据后解锁它。 - 大多数
sync.Cond的功能是在 Go 运行时通过一个内部数据结构notifyList实现的,使用基于票据的通知系统。
为了避免常见错误,使用 sync.Cond.Wait() 时通常会遵循以下模式:
c.L.Lock()
for !condition() {
c.Wait()
}
// ... 使用条件 ...
c.L.Unlock()
(上图 sync.Cond 典型用法)
“为什么不直接使用
c.Wait()而不加循环?”
当 Wait() 返回时,我们不能立即假设我们等待的条件已经满足。在 goroutine 被唤醒时,其他 goroutines 可能已经修改了共享的状态,条件可能不再成立。所以,为了正确处理这种情况,我们总是要在循环中使用 Wait()。
这也正是我们在宝可梦例子中提到的延迟问题。
这个循环通过不断测试条件来确保只有在条件为真时,goroutine 才会继续执行。
Cond.Signal() 和 Cond.Broadcast()
-
Signal() :用于唤醒一个当前在条件变量上等待的 goroutine。如果没有 goroutine 正在等待,
Signal()不会做任何事情。Signal()会唤醒队列中的第一个 goroutine,通常是先被唤醒的那个。如果你启动了多个 goroutines,Signal()会唤醒第一个等待的 goroutine。
示例:
func main() {
cond := sync.NewCond(&sync.Mutex{})
for i := range 10 {
go func(i int) {
cond.L.Lock()
defer cond.L.Unlock()
cond.Wait()
fmt.Println(i)
}(i)
time.Sleep(time.Millisecond)
}
time.Sleep(100 * time.Millisecond) // 等待 goroutines 准备好
cond.Signal()
time.Sleep(100 * time.Millisecond) // 等待 goroutines 被唤醒
}
输出:
0
cond.Broadcast()
cond.Broadcast() 用于唤醒所有等待的 goroutines,并将它们从等待队列中移除。当你调用 Broadcast() 时,它会标记所有的等待 goroutines 为已准备好运行,但它们并不会立即执行,而是根据 Go 调度器的算法决定何时执行。
示例:
func main() {
cond := sync.NewCond(&sync.Mutex{})
for i := range 10 {
go func(i int) {
cond.L.Lock()
defer cond.L.Unlock()
cond.Wait()
fmt.Println(i)
}(i)
}
time.Sleep(100 * time.Millisecond) // 等待 goroutines 准备好
cond.Broadcast()
time.Sleep(100 * time.Millisecond) // 等待 goroutines 被唤醒
}
输出:
8
6
3
2
4
5
1
0
9
7
在这个例子中,所有 goroutines 都在 100 毫秒内被唤醒,但它们被唤醒的顺序没有固定,这由 Go 调度器决定,可能是不可预测的。
内部机制
在我们所有关于 Go 的博客文章中,我们喜欢加入一个关于底层工作原理的部分。了解设计选择背后的原因以及它们试图解决的实际问题总是很有帮助。
复制检查器 (Copy Checker)
在 sync 包中的复制检查器 (copyChecker) 被设计用来检测 Cond 对象是否在首次使用之后被复制。“首次使用”可以是调用任意公开方法,例如 Wait()、Signal() 或 Broadcast()。
如果 Cond 在第一次使用之后被复制,程序会触发 panic,显示错误信息:“sync.Cond is copied”。
你可能在 sync.WaitGroup 或 sync.Pool 中看过类似的机制,它们通过使用 noCopy 字段来防止复制,但在这些情况下,复制仅被避免,而不会导致 panic。
copyChecker 实际上是一个 uintptr 类型,它是一个整数,表示内存地址。其工作原理如下:
- 在第一次使用
sync.Cond时,copyChecker会存储它自己的内存地址,也就是指向cond.copyChecker对象的地址。 - 如果该对象被复制,复制的
copyChecker内存地址会发生变化(因为新副本位于内存的不同位置),但copyChecker本身存储的uintptr地址不会改变。 - 检查很简单:比较内存地址。如果它们不同,程序就会 panic。
尽管这逻辑很简单,但如果你不熟悉 Go 的原子操作和 unsafe 包,可能会觉得实现有些复杂。
// copyChecker 用来检测对象是否被复制。
type copyChecker uintptr
func (c *copyChecker) check() {
if uintptr(*c) != uintptr(unsafe.Pointer(c)) &&
!atomic.CompareAndSwapUintptr((*uintptr)(c), 0, uintptr(unsafe.Pointer(c))) &&
uintptr(*c) != uintptr(unsafe.Pointer(c)) {
panic("sync.Cond is copied")
}
}
详细解析:
-
第一个检查,
uintptr(*c) != uintptr(unsafe.Pointer(c)),用于检查内存地址是否发生变化。如果变化,说明对象已被复制。需要注意的是,如果这是copyChecker第一次使用,值为 0,因为它还未初始化。 -
第二个检查,
!atomic.CompareAndSwapUintptr((*uintptr)(c), 0, uintptr(unsafe.Pointer(c))),使用了比较并交换(CAS)操作来处理初始化和检查:- 如果 CAS 成功,说明
copyChecker刚刚被初始化,尚未复制。 - 如果 CAS 失败,说明
copyChecker已初始化,此时需要进行最后的检查。
- 如果 CAS 成功,说明
-
最后一个检查(和第一个一样),
uintptr(*c) != uintptr(unsafe.Pointer(c)),确保对象没有被复制。
"为什么需要额外的检查?"
第三个检查的原因是前两个检查并不是原子的。在初始化期间,可能会存在竞态条件。
(上图: 初始化时的竞态)
如果这是第一次使用 copyChecker,那么它还没有被初始化,其值将为零。在这种情况下,即使对象没有被复制,只是没有被初始化,检查也会错误地通过。
译者注: 上图流程为:
时刻1 g1 -> 初始化,条件1通过
时刻2 g0 -> 初始化,条件1通过
时刻3 g1 -> 未初始化完成,CAS成功,交换值,结合!运算符返回false,退出
时刻4 g0 -> 由于时刻3,判定为已初始化,此时需要进行最后的检查
时刻5 g0 -> 没有发生复制,函数退出
notifyList - 基于票据的通知列表
除了锁和复制检查机制外,sync.Cond 还有一个重要组成部分 —— notifyList。
type Cond struct {
noCopy noCopy
L Locker
notify notifyList
checker copyChecker
}
type notifyList struct {
wait uint32
notify uint32
lock uintptr
head unsafe.Pointer
tail unsafe.Pointer
}
notifyList 在 sync 包和 runtime 包中有所不同,但它们共享相同的内存布局和功能。为了更好地理解其工作原理,我们需要查看 runtime 包中的实现:
type notifyList struct {
wait atomic.Uint32
notify uint32
lock mutex
head *sudog
tail *sudog
}
如果你看到 head 和 tail,你可能会猜到这是一种链表结构,而你猜的没错。它是一个由 sudog(“pseudo-goroutine”的缩写)组成的链表,sudog 代表的是等待同步事件的 goroutine,比如等待接收或发送数据的 channel,或者等待条件变量。
(上图 notifyList 数据结构)
head 和 tail 是指向链表中第一个和最后一个 goroutine 的指针。同时,wait 和 notify 字段充当“票号”,并不断递增,每个数字表示在等待 goroutine 队列中的位置。
wait:这个数字代表下一个将被分配给等待 goroutine 的票号。notify:这个数字跟踪下一个应该被通知或唤醒的票号。
这就是 notifyList 的核心思想,接下来我们将它们结合起来,看看它是如何工作的。
notifyListAdd()
当一个 goroutine 准备等待通知时,它首先调用 notifyListAdd() 获取自己的“票号”。
func (c *Cond) Wait() {
c.checker.check()
// 获取票号
t := runtime_notifyListAdd(&c.notify)
c.L.Unlock()
// 将 goroutine 添加到列表并挂起
runtime_notifyListWait(&c.notify, t)
c.L.Lock()
}
func notifyListAdd(l *notifyList) uint32 {
return l.wait.Add(1) - 1
}
票号分配是通过一个原子计数器来处理的。所以,当一个 goroutine 调用 notifyListAdd() 时,该计数器会递增,goroutine 会得到下一个可用的票号。
每个 goroutine 都会得到一个唯一的票号,这个过程无需加锁。这意味着多个 goroutine 可以同时请求票号,而不需要相互等待。
例如,如果当前的票号计数器是 5,接下来调用 notifyListAdd() 的 goroutine 会得到票号 5,接着 wait 计数器会增加到 6,为下一个排队的 goroutine 准备好票号。wait 字段始终指向下一个将要发放的票号。
但是问题来了。
由于多个 goroutine 可以同时获取票号,所以它们在调用 notifyListAdd() 和实际进入 notifyListWait() 之间存在一个小的时间差。即使票号是按顺序发放的,goroutine 被添加到链表的顺序也不一定是 1, 2, 3。它可能是 3, 2, 1,或者 2, 1, 3,具体顺序取决于时机。
(上图: 入队顺序的不确定的)
在获取到票号后,goroutine 的下一步是等待通知。这是在 goroutine 调用 notifyListWait(t) 时发生的,其中 t 是它刚刚获得的票号。
func notifyListWait(l *notifyList, t uint32) {
lockWithRank(&l.lock, lockRankNotifyList)
// 如果该票号已经被通知,则立即返回
if less(t, l.notify) {
unlock(&l.lock)
return
}
// 将自己加入队列
s := acquireSudog()
...
if l.tail == nil {
l.head = s
} else {
l.tail.next = s
}
l.tail = s
goparkunlock(&l.lock, waitReasonSyncCondWait, traceBlockCondWait, 3)
...
releaseSudog(s)
}
在进行其他操作之前,goroutine 会检查它的票号是否已经被通知。
它将自己的票号 t 与当前的通知号 l.notify 进行比较。如果通知号已经超过了 goroutine 的票号,说明它已经轮到它了,这时它不需要等待,可以直接去访问共享资源并开始工作。
这个快速检查非常重要,尤其是在我们深入了解 Signal() 和 Broadcast() 的工作原理时。因为,如果 goroutine 的票号尚未被通知,它就会将自己添加到等待列表中,然后“休眠”(或称为“停车”),直到收到通知。
这一机制有效地避免了 goroutine 在被通知前的无谓等待,并确保了 goroutine 按照票号顺序被唤醒。
notifyListNotifyOne()
当需要通知等待的 goroutines 时,系统会从尚未通知的最小票号开始,这由 l.notify 跟踪。
func notifyListNotifyOne(l *notifyList) {
// 快速路径:如果没有新的等待者,则无需操作
if l.wait.Load() == atomic.Load(&l.notify) {
return
}
lockWithRank(&l.lock, lockRankNotifyList)
// 在加锁后再次检查是否有需要处理的内容
t := l.notify
if t == l.wait.Load() {
unlock(&l.lock)
return
}
// 移动到下一个需要通知的票号
atomic.Store(&l.notify, t+1)
// 在链表中找到具有匹配票号的 goroutine
for p, s := (*sudog)(nil), l.head; s != nil; p, s = s, s.next {
if s.ticket == t {
// 找到了持有该票号的 goroutine
n := s.next
if p != nil {
p.next = n
} else {
l.head = n
}
if n == nil {
l.tail = p
}
unlock(&l.lock)
s.next = nil
readyWithTime(s, 4) // 标记该 goroutine 为就绪
return
}
}
unlock(&l.lock)
}
还记得我们提到过票号顺序不一定有保障吗?
可能会出现 goroutine 的票号是 2、1、3,但通知号始终是按顺序递增的。因此,当系统准备唤醒一个 goroutine 时,它会遍历链表,查找持有下一个票号(例如 1)的 goroutine。一旦找到,它会将该 goroutine 从链表中移除,并标记为可运行状态。
有趣的是,有时会出现一个时序问题。假设一个 goroutine 已经拿到了票号,但在函数运行时,它还未被加入等待的 goroutine 列表中。
那会发生什么呢?比如,调用顺序可能是:notifyListAdd() -> notifyListNotifyOne() -> notifyListWait()。
在这种情况下,函数会扫描链表,但找不到具有匹配票号的 goroutine。不过不用担心,当 goroutine 最终调用 notifyListWait() 时,这种情况会被处理。
(上图: 有3号票据的协程还不在链表中)
还记得之前提到的那个关键检查吗?就是在 notifyListWait() 函数中的这一段:if less(t, l.notify) { ... }。
这个检查非常重要,因为它允许持有票号小于当前 l.notify 值的 goroutine 意识到:“嘿,我的顺序已经过去了,可以直接执行了。”在这种情况下,goroutine 会跳过等待,直接访问共享资源。
因此,即使 goroutine 还没有进入链表,只要它持有一个有效的票号,也仍然能够被通知。这正是这个设计如此流畅的原因,每个 goroutine 都可以立刻拿到票号,而不需要等待其他 goroutine 或等着自己被添加到链表中。这种方式让一切都在不必要的阻塞中保持高效运转。
notifyListNotifyAll()
现在来谈谈最后的部分,即 Broadcast() 或 notifyListNotifyAll()。与 notifyListNotifyOne() 相比,这部分要简单得多:
func notifyListNotifyAll(l *notifyList) {
// 快速路径:如果没有新的等待者,则直接返回。
if l.wait.Load() == atomic.Load(&l.notify) {
return
}
lockWithRank(&l.lock, lockRankNotifyList)
s := l.head
l.head = nil
l.tail = nil
atomic.Store(&l.notify, l.wait.Load())
unlock(&l.lock)
// 唤醒链表中的所有等待者。
for s != nil {
next := s.next
s.next = nil
readyWithTime(s, 4)
s = next
}
}
代码很简单,相信你已经明白其核心了。基本上,Broadcast() 会遍历整个等待 goroutine 的链表,将它们全部标记为“就绪”,并清空链表。
最后,为这篇文章总结一下一个重要的提醒:要正确实现这一机制非常困难,sync.Cond 很容易被误用,从而引入一些复杂且难以调试的问题。在技术层面讲解之后,我建议大家可以关注这个提案: 作为工程优化的下一步探索。