前言
go的标准库有个存在感特别低的工具 sync.Cond, 我们一般称为条件变量(cond是condition的缩写). 在我的职业生涯中基本没见过有人使用过这个东西. 从引用的包sync可以得知, 这个就是用在并发场景的, 而且它的用法也比较模板化.本篇内容主要记录它的用法, 及其底层实现. 我们会从它的使用方式开始聊, 然后解析它的数据结构, 并对它提供的方法: wait, signal, broadcast进行代码走读.
如何使用
首先我们先假设一个场景, 实现一个queue, 队空时, 执行dequeue操作时block, 直到有新值入队. 队满时, 执行enqueue操作时block, 直到有空闲空间. 有些人可能发现, 这个场景其实跟带buffer的channel很像, 不过在实际应用中会发现, channel有其局限性, 如果你想要提高些效率一次性读/写好几个值或者你想要改变值的读取顺序, 这些单单使用channel去实现会很麻烦.
而Cond刚好可以用来实现这个场景.我们先来解释Cond为何物, 然后使用Cond实现上述的场景.
使用方式
Cond本质上就是一个带条件的锁, 如果符合当前场景的条件, 就会继续执行, 否则会block, 直到当条件改变时, 重新评估是否继续执行.
下面是使用的模板:
golang
c := sync.NewCond(&sync.Mutex{})
c.L.Lock() // 这个Lock就是NewCond传进去的参数
for !condition() {
c.Wait()
}
... make use of condition ...
c.L.Unlock()
从上面的代码片段, 我们可以发现, Cond需要配合锁一起使用, 我们也可以把它当作Mutex的延伸(实际上参数是一个接口, 我们也可以自己去实现这个锁).
condition就是我们预设的条件, 如果不符合这个条件, 会执行c.Wait, 这时当前goroutine会休眠.那下次唤醒的时机是什么呢? 这就要仰赖于Cond提供的另外两个方法: Signal()和Broadcast()了.
Signal 可以唤醒被当前Cond Block住的其中一个goroutine, 而Broadcast则唤醒全部的goroutine. 而goroutine被唤醒后会重新判断条件(因为是个循环), 所以就算有部分g不符合条件的也还是会继续休眠, 直到下次唤醒.
场景实现
首先是我们要实现的队列的数据结构定义, 这里我就以实现一个FIFO的环形buffer队列为例:
golang
type BlockingQueue[T any] struct {
c *sync.Cond
cap int
len atomic.Int32
head int // 环形队列头部指针
tail int // 环形队列尾部指针
buf []T
}
func NewBlockingQueue[T any](cap int) *BlockingQueue[T] {
if cap <= 0 {
return nil
}
return &BlockingQueue[T]{
c: sync.NewCond(&sync.Mutex{}),
cap: cap,
buf: make([]T, cap),
}
}
接下来实现一个enqueue操作:
golang
func (q *BlockingQueue[T]) Enqueue(elem T, block bool) bool {
// fast path
if int(q.len.Load()) == q.cap && !block {
return false
}
q.c.L.Lock()
for int(q.len.Load()) == q.cap {
fmt.Println("enqueue blocking...")
q.c.Wait()
}
q.buf[q.head] = elem
q.head = (q.head + 1) % q.cap
q.len.Add(1)
fmt.Printf("enqueue %v\n", elem)
q.c.L.Unlock()
q.c.Signal()
return true
}
这里添加了个block参数, 可以控制是否进行当buffer是满的时, 是否block.返回true表示操作执行成功.
首先是一个fast path, 如果不需要block, 那我们检测到队列是满的时, 就直接结束就行了.
然后先通过Cond block, 直到被唤醒放行, 而唤醒时机我们将在dequeue操作去实现.
最后执行signal的目的是为了唤醒在dequeue因为空队列导致block的goroutine.
那signal会不会去唤醒正在执行enqueue的goroutine呢?
答案是不会的, 因为执行enqueue操作时是加了互斥锁的, 所以现阶段是不会有其他goroutine在enqueue休眠的
dequeue操作:
golang
func (q *BlockingQueue[T]) Dequeue(block bool) (elem T, ok bool) {
// fast path
if q.len.Load() == 0 && !block {
return
}
q.c.L.Lock()
for q.len.Load() == 0 {
fmt.Println("dequeue blocking...")
q.c.Wait()
}
elem = q.buf[q.tail]
q.tail = (q.tail + 1) % q.cap
q.len.Add(-1)
fmt.Printf("dequeue %v\n", elem)
q.c.L.Unlock()
q.c.Signal()
return elem, true
}
数据结构
接下来让我们来对Cond进行深度了解.
golang
type Cond struct {
noCopy noCopy // 不可拷贝标志, 用于vet检测
L Locker // 构建时, 传进来的锁参数
notify notifyList
checker copyChecker // 用于值拷贝检查
}
type notifyList struct {
//这两个变量用来确认当前还有多少等待中的goroutine
wait atomic.Uint32 // 等待计数, 每调用1次Wait方法, +1
notify uint32 // 通知计数
lock mutex
// 等待队列(sudog链表)
head *sudog
tail *sudog
}
首先我们来讨论下Cond不可拷贝的问题.
noCopy, copyChecker都表明了Cond不可拷贝, 我们也从数据结构可得知, 如果不加这个约束, 一定会导致一些难以追踪的问题(设想一下有两个不同notifyList管理同一个等待队列).
那这两个分别有什么用呢?
noCopy 是一个标志, 主要用于vet工具的拷贝检测, 也就是说这个是给go vet 用的
而copyChecker像是一种代码上的防御.
golang
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)) 将c的值跟c自身的指针进行比较, 这是个fastpath, 如果c已经初始化了且没有值拷贝发生, 这个条件一定是false. 第一次c没有进行初始化, c是0, 这个条件是true, 到第二步进行初始化.
!atomic.CompareAndSwapUintptr((*uintptr)(c), 0, uintptr(unsafe.Pointer(c))) 这里是初始化操作, c为0, 就将c自身的地址作为值给c, 成功就结束.这一步语义就是如果c没有初始化, 就给c初始化.
在上一步中发现此时c已经初始化过了, 所以再进行一次第一步的判断, 这次如果存储的地址跟自身地址不一样说明被拷贝过.直接panic.
这里的疑点是, 为什么通过这三步就能知道值有没有被拷贝过? 最后一步是否有点多余(第一步已经判断过来)?
要解答第一个问题, 我们就要知道这个方法在代码中是怎么使用的.我们可以从Wait, Signal, Broadcast这些方法的源码实现中看到线索.它们的开头一定会调用该方法.那么在下面这个场景下.
golang
c := sync.Cond{L: &sync.Mutex{}}
c.Signal()
c1 := c
c1.Signal()
必定会触发check的panic.因为调用c.Signal时, c.copyChecker已经初始化了, 值是自身地址. 然后将c拷贝给c1, c1的copyChecker地址跟c的copyChecker地址不一样, 而c1的copyChecker存着c的copyChecker的地址.所以三步条件都成立, 触发panic.
那下面这个场景呢,
golang
c := sync.Cond{L: &sync.Mutex{}}
c1 := c
这个场景也是值拷贝, 不过没有panic.因为这个场景不用防范, 只要还没有调用方法, c此时并没有等待队列, 也就是说c跟c1还是不一样的Cond, 不过还是不建议这样子写, 因为此时c跟c1内嵌的锁(L)是一样的.
第二个问题, 按理说仅通过前两步,应该就可以判断.可第三步并不是多余的. 这涉及到并发问题:
yaml
G1: 第一步 0 != 自身地址, 执行第二步
G2: 执行第二步, 进行初始化
G1: 第二步已初始化, 执行第三步
G2: 进行值拷贝操作
G1: 第三步检测到异常, panic
如果没有第三步兜底, 上面最后G1就不会检测到异常.
值不可拷贝内容到此告一段落.
接下来我们来看notifyList, 也就是Cond的等待队列.
wait, notify 是计数器, 每调用一次Wait, notifyList.wait++; 调用一次Signal, notifyList.notify++; 调用一次Broadcast, notifyList.notify = notifyList.wait. 可用来检测当前休眠的goroutine数量
head, tail 是一个sudog的FIFO等待队列, 其内容可以看我的上篇笔记 go channel 探索, 这里不做详谈.
Wait方法
golang
func (c *Cond) Wait() {
c.checker.check() // 在数据结构小节详谈
t := runtime_notifyListAdd(&c.notify) // wait 计数+1, 实际执行的是下面的notifyListAdd
c.L.Unlock()
runtime_notifyListWait(&c.notify, t) // 实际执行的是下面的notifyListWait
c.L.Lock()
}
func notifyListAdd(l *notifyList) uint32 {
return l.wait.Add(1) - 1
}
func notifyListWait(l *notifyList, t uint32) {
lockWithRank(&l.lock, lockRankNotifyList)
if less(t, l.notify) { // 在g休眠之前, 已经被通知了, 不用休眠了
unlock(&l.lock)
return
}
// 封装sudog, 加入等待队列
s := acquireSudog()
s.g = getg()
s.ticket = t
s.releasetime = 0
// ... 性能检测相关
if l.tail == nil {
l.head = s
} else {
l.tail.next = s
}
l.tail = s
goparkunlock(&l.lock, waitReasonSyncCondWait, traceBlockCondWait, 3) // 休眠
// 唤醒
// ... 性能检测相关
releaseSudog(s) // 回收
}
值得注意的点是, wait内部要休眠之前是会先释放锁的, 然后唤醒后再加回去, 所以c.L.Lock并不会在休眠的整个周期中一直上锁.
基本流程: notifyList.wait+1 -> 休眠 -> 唤醒. 当然如果在休眠之前已经有通知了, 就跳过休眠那一步.那有个问题: 通知方怎么知道要唤醒哪个goroutine呢?
在封装sudog的流程中的这个 s.ticket = t解释了这点, 通知方通过sudog.ticket的值找到要唤醒的g, 找不到表示不用唤醒了.
Signal方法
golang
func (c *Cond) Signal() {
c.checker.check() // 在数据结构小节详谈
runtime_notifyListNotifyOne(&c.notify) // 实际执行的是下面的notifyListNotifyOne
}
func notifyListNotifyOne(l *notifyList) {
if l.wait.Load() == atomic.Load(&l.notify) { // fast path: wait计算和notify计数一致, 表示没有需要唤醒的goroutine
return
}
lockWithRank(&l.lock, lockRankNotifyList)
// 二次检查
t := l.notify
if t == l.wait.Load() {
unlock(&l.lock)
return
}
// 更新notify计数
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 {
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) // 找到了, 出列, 直接唤醒
return
}
}
unlock(&l.lock)
}
正如前面所谈到的, signal方法通过wait和notify计数来判断当前有没有需要唤醒的goroutine, 并在sudog队列找到对应需要唤醒的goroutine(通过s.ticket)
Broadcast方法
golang
func (c *Cond) Broadcast() {
c.checker.check() // 在数据结构小节详谈
runtime_notifyListNotifyAll(&c.notify) // 实际是调用下面 notifyListNotifyAll
}
func notifyListNotifyAll(l *notifyList) {
if l.wait.Load() == atomic.Load(&l.notify) { // fast path: wait计算和notify计数一致, 表示没有需要唤醒的goroutine
return
}
lockWithRank(&l.lock, lockRankNotifyList)
s := l.head // 直接将整个队列拿出来(唤醒全部)
l.head = nil
l.tail = nil
atomic.Store(&l.notify, l.wait.Load())
unlock(&l.lock)
// Go through the local list and ready all waiters.
for s != nil { // 逐个唤醒
next := s.next
s.next = nil
readyWithTime(s, 4)
s = next
}
}
这个方法也比较简单, 就是直接将等待队列全部唤醒, notify置为跟wait相等的值, 表示没有现在等待的goroutine.
后记
这个数据结构内部实现比较简单, 在golang底层算是比较简单的一个设计了. 它能解决的问题, 用mutex或channel去二次设计也能解决, 不过这个工具是直接利用了底层的便利, 相对一些场景来说肯定中是更优的选择.
初稿定于 2026.3.8