go cond 探索

前言

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

相关推荐
程序员爱钓鱼1 天前
GoHTML解析利器:github.com/PuerkitoBio/goquery实战指南
后端·google·go
我叫黑大帅1 天前
Go中的interface的两大用法
后端·面试·go
用户9003486133462 天前
GO语言基础:Context 上下文的概念、取消信号、截止时间、值传递
go
程序员爱钓鱼2 天前
Go语言WebP图像处理实战:golang.org/x/image/webp
后端·google·go
PFinal社区_南丞2 天前
Go语言开发AI智能体:从Function Calling到Agent框架
后端·go
golang学习记2 天前
Fiber v3 适配器模式:17 种写法随便用,老代码"即插即用"🔌
后端·go
用户9003486133462 天前
GO语言基础:变量
go
用户9003486133462 天前
GO语言基础:接口和结构体
go