Go的sync.Cond——最易被忽视的同步机制

超级欧派课程 2024-11-02 16:52:58

在 Go 语言的众多同步原语中,sync.Cond​的使用频率明显低于sync.Mutex​​和sync.WaitGroup​​,在大多数项目和标准库中常常被其他同步机制所替代。然而,对于 Go 工程师而言,了解sync.Cond​​的工作原理至关重要,这样在阅读相关代码时才不会感到困惑。

一、什么是 sync.Cond?传统等待方法的不足

在 Go 语言中,当一个 goroutine 需要等待某个特定条件变为真时,例如共享数据发生变化,它可以选择“阻塞”,即暂停工作直到收到继续执行的信号。传统的忙等待(busy-waiting)方法,如使用循环或添加time.Sleep​​,虽然简单但效率低下,因为它们会不断占用 CPU 资源。

sync.Cond 的概念与优势

为了解决这一问题,Go 提供了sync.Cond​,这是一种更高效的 goroutine 协调工作机制。sync.Cond​,从学术角度讲,是一个“条件变量”,它允许 goroutine 在条件不满足时等待,并在条件变为真时被唤醒。

当一个 goroutine 正在等待某件事情发生(等待某个特定条件变为真)时,它可以调用Wait()​。另一个 goroutine,一旦知道条件可能满足,就可以调用Signal()​或Broadcast()​来唤醒正在等待的 goroutine,并让它们知道是时候继续执行了。

以下是sync.Cond​提供的基本接口:

// 挂起调用的 goroutine,直到条件满足func (c *Cond) Wait() {}// 唤醒一个正在等待的 goroutine,如果有等待的 goroutinefunc (c *Cond) Signal() {}// 唤醒所有正在等待的 goroutinefunc (c *Cond) Broadcast() {}

为了更直观地理解sync.Cond​​,让我们通过一个pokemon出现的例子来说明。

假设我们有一个pokemon列表,并希望当特定pokemon(如Pikachu)出现时,通知所有等待的 goroutine。

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() // 等待直到Pikachu出现 for pokemon!= "Pikachu" { cond.Wait() } println("Caught" + pokemon) pokemon = "" }() // 生产者 go func() { // 每 1 毫秒,一个随机pokemon出现 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 作为消费者等待Pikachu出现,另一个 goroutine 作为生产者随机选择pokemon并发送信号。当皮卡丘出现时,消费者被唤醒并捕获它。

尽管sync.Cond​提供了有效的等待/通知机制,但它也存在一些潜在问题。特别是在生产者发送信号和消费者实际检查条件之间可能存在时间差,这可能导致消费者错过正确的pokemon​。

通道 vs sync.Cond

在 Go 中,通道通常比sync.Cond​​更受欢迎,因为它们更简单且更符合 Go 的习惯用法。然而,在处理共享状态时,两者都有其用途。

通道:通过发送消息来传递数据或信号,但需要额外的互斥锁来保护共享状态。sync.Cond:结合了锁定和信号机制,提供了更集成的解决方案,但在某些情况下可能不如通道直观。

尽管通道在某些情况下更受欢迎,但sync.Cond​​仍有其独特优势:

精细控制:Signal()​​​​和Broadcast()​​​​允许更精细的控制,可以同时向一个或多个 goroutine 发送信号。可重用性:与一旦关闭就不能再使用的通道不同,sync.Cond​​​​可以多次调用Broadcast()​​​​。性能:通过将锁定和信号机制结合在一起,sync.Cond​​​​可能提供更好的性能。

为什么锁被嵌入在 sync.Cond 中?虽然理论上可以在sync.Cond​​​之外管理锁,但将其嵌入在条件变量中有助于减少人为错误。手动管理锁和条件变量很容易出错,因为需要在正确的时间锁定和解锁。sync.Cond​​​通过在一个包中结合锁定和信号,简化了这一过程,并减少了出错的可能性。

二、如何使用 sync.Cond?

在 Go 并发编程中,sync.Cond​​ 是一种用于协调 goroutine 之间操作的机制。其核心功能包括 Wait()​​、Signal()​​ 和 Broadcast()​​ 方法,它们共同实现了基于条件的等待和通知。

基本使用模式

当你使用 sync.Cond​​ 时,通常的做法是首先锁定一个互斥锁(sync.Mutex​​),然后调用 Wait()​​ 方法来暂停当前 goroutine 的执行,直到某个条件被满足。条件满足后,其他 goroutine 会通过 Signal()​​ 或 Broadcast()​​ 方法来唤醒等待的 goroutine。

这里是一个典型的模式:

cond := sync.NewCond(&sync.Mutex{}) // 消费者 goroutine go func() { cond.L.Lock() defer cond.L.Unlock() for conditionNotMet() { cond.Wait() } // 处理条件满足后的逻辑 }()

在这个例子中,conditionNotMet()​​ 是一个检查条件的函数。如果条件不满足,Wait()​​ 方法会使 goroutine 进入等待状态,并自动释放互斥锁。当条件满足时(通过 Signal()​​ 或 Broadcast()​​ 唤醒),goroutine 会重新获取锁并继续执行。

Cond.Wait() 的细节

​Wait()​​ 方法背后有几个关键步骤:

添加到等待列表:goroutine 被添加到等待此条件的列表中。释放锁:Wait()​​​ 自动释放互斥锁,允许其他 goroutine 获取锁并继续执行。挂起 goroutine:goroutine 被挂起,直到被 Signal()​​​ 或 Broadcast()​​​ 唤醒。重新获取锁:被唤醒后,goroutine 重新获取互斥锁。

由于 Wait()​​ 方法只保证在唤醒时重新获取锁,并不保证条件已经满足(可能有其他 goroutine 在唤醒后修改了条件),因此通常需要在 Wait()​​ 调用后使用循环来重新检查条件。

Cond.Signal() 和 Cond.Broadcast()Signal():唤醒一个等待此条件的 goroutine。如果有多个 goroutine 在等待,它会唤醒第一个。例如:func main() { cond := sync.NewCond(&sync.Mutex{}) for i := 0; i < 10; i++ { go func(i int) { cond.L.Lock() defer cond.L.Unlock() cond.Wait() fmt.Println(i) }(i) } time.Sleep(100 * time.Millisecond) // 等待 goroutine 准备好 cond.Signal() // 唤醒一个 goroutine time.Sleep(100 * time.Millisecond) // 等待 goroutine 被唤醒 } // 输出可能是一个数字,例如 0Broadcast():唤醒所有等待此条件的 goroutine。例如:func main() { cond := sync.NewCond(&sync.Mutex{}) for i := 0; i < 10; i++ { go func(i int) { cond.L.Lock() defer cond.L.Unlock() cond.Wait() fmt.Println(i) }(i) } time.Sleep(100 * time.Millisecond) // 等待 goroutine 准备好 cond.Broadcast() // 唤醒所有 goroutine time.Sleep(100 * time.Millisecond) // 等待 goroutine 被唤醒 } // 输出是 0 到 9 的随机顺序

这两个方法通常用于在条件发生变化时通知等待的 goroutine。在调用 Signal()​​ 或 Broadcast()​​ 之前,通常不需要(但也不是绝对不能)锁定互斥锁,但在修改共享数据时通常需要这样做。

三、sync.Cond 的内部工作原理

在我们探讨Go语言的sync​包时,了解事物在底层的工作原理总是非常有益的。这不仅有助于我们理解设计选择背后的原因,还能让我们更好地解决可能遇到的问题。本文将深入探讨sync.Cond​的内部机制,特别是复制检查器和基于编号的通知列表。

复制检查器(copyChecker​)

​sync.Cond​中的复制检查器旨在检测对象在首次使用后是否被复制。如果Cond​对象在首次调用如Wait()​、Signal()​或Broadcast()​等公共方法后被复制,程序将引发panic,错误信息为“sync.Cond is copied”。

这个机制通过一个uintptr​类型的copyChecker​字段实现。uintptr​可以存储内存地址,因此copyChecker​实际上是一个指向自身的反向指针。其工作原理如下:

在首次使用sync.Cond​后,copyChecker​存储它自己的内存地址。如果对象被复制,copyChecker​的内存地址(&cond.copyChecker​)会改变,但copyChecker​持有的uintptr​值不会改变。

检查复制是否发生的逻辑很简单:比较内存地址。如果它们不同,就会引发panic。

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") } }

上述代码包含两次几乎相同的检查,这是为了确保在初始化期间的竞争条件下也能正确检测复制。第一次检查用于查看内存地址是否发生了变化,但如果这是首次使用复制检查器且尚未初始化,其值将为0。第二次检查使用原子比较并交换(CAS)操作来处理初始化和检查:

如果CAS操作成功,表示复制检查器刚刚被初始化,对象尚未被复制。如果CAS操作失败,表示复制检查器已经被初始化,此时需要进行最后一次检查以确保对象没有被复制。

进行第三次检查的原因是第一次和第二次检查不是原子操作,存在初始化期间的竞争条件。

基于编号的通知列表(notifyList​)

​sync.Cond​的另一个重要部分是notifyList​,它用于管理等待通知的goroutine。

type Cond struct { noCopy noCopy L Locker notify notifyList checker copyChecker } type notifyList struct { wait uint32 notify uint32 head unsafe.Pointer tail unsafe.Pointer }

虽然sync​包中的notifyList​与运行时包中的版本具有相同的名称和内存布局,但它们服务于不同的目的。要真正理解其工作原理,我们需要参考运行时包中的版本:

type notifyList struct { wait atomic.Uint32 notify uint32 lock mutex head *sudog tail *sudog }

​notifyList​是一个sudog​链表,sudog​代表一个等待同步事件的goroutine。head​和tail​分别指向链表中第一个和最后一个goroutine的指针。

编号与等待机制 notifyListAdd()

​wait​和notify​字段充当“编号”,每个编号代表等待goroutine队列中的一个位置。

​wait​:代表将要分配给下一个等待的goroutine的编号。​notify​:跟踪下一个应该被通知或唤醒的编号。

当一个goroutine准备等待通知时,它首先调用notifyListAdd()​获取编号,然后调用notifyListWait()​将自己添加到等待列表中并挂起。

func (c *Cond) Wait() { c.checker.check() t := runtime_notifyListAdd(&c.notify) c.L.Unlock() runtime_notifyListWait(&c.notify, t) c.L.Lock() } func notifyListAdd(l *notifyList) uint32 { return l.wait.Add(1) - 1 }

编号的分配由原子计数器处理,确保多个goroutine可以同时请求编号而无需相互等待。然而,由于存在时间间隔,goroutine添加到链表中的顺序并不保证与编号顺序一致。

在notifyListWait()​中,goroutine首先检查其编号是否已经被通知。如果已经被通知,则直接返回;否则,将自己添加到等待列表中并进入睡眠状态。

通知机制 notifyListNotifyOne()

当需要通知等待的goroutine时,系统从尚未被通知的最小编号开始,这由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)}

​notifyListNotifyOne()​遍历链表,寻找持有下一个编号的goroutine,将其从列表中删除并标记为就绪。如果存在时间问题导致goroutine尚未被添加到列表中,它将在调用notifyListWait()​时通过重要检查意识到自己的轮次已经过去,并继续执行。

广播机制 notifyListNotifyAll()

​Broadcast()​或notifyListNotifyAll()​则遍历整个等待goroutine的列表,将它们全部标记为就绪,并清空列表。

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 }}

​sync.Cond​的内部机制涉及复制检查和基于编号的通知列表。复制检查器通过比较内存地址来检测对象是否被复制,而通知列表则使用sudog​链表和编号机制来管理等待和通知的goroutine。正确使用sync.Cond​非常困难,很容易引发难以调试的问题,因此在实际开发中需要格外小心。

0 阅读:0

超级欧派课程

简介:感谢大家的关注