sync.Cond 同步等待条件
一. 基础
- Cond相关方法
- func NewCond(l Locker) *Cond: 创建一个Cond实例, 需要关联一个锁
- func (c *Cond) Wait(): 阻塞
- func (c *Cond) Broadcast(): 广播唤醒所有等待
- func (c *Cond) Signal(): 只唤醒任意1个等待条件变量
- 使用示例
func TestCond() {
//1.创建一个Cond实例
cond := sync.NewCond(&sync.Mutex{})
for i := 0; i < 10; i++ {
//协程
go func(t int) {
time.Sleep(time.Second)
//2.加锁
cond.L.Lock()
//4.通过defer释放锁
defer cond.L.Unlock()
//3.阻塞
cond.Wait()
fmt.Println(t)
}(i)
}
time.Sleep(2 * time.Second)
//唤醒cond阻塞继续执行
//cond.Signal()
cond.Broadcast()
}
使用场景
- cond的主要作用就是获取锁之后,调用wait()方法阻塞等待通知,来进行下一步锁释放等操作,以此控制锁的释放时机,释放频率,适用于在并发环境下goroutine的等待和通知
- 例如有一个协程正在接收数据,其他协程必须等待这个协程接收完数据,才能读取到正确的数据
- 每个 Cond 都会关联一个 Lock ,当修改条件或者调用Wait方法,必须加锁保护 Condition, 有点类似Java中的Wait和NotifyAll
- 参考博客
二. 源码分析
- 常简单,关键的逻辑调用了运行时中的信号量代码,本文只分析与Cond相关的代码,详细信号量代码源码分析准备专门写一篇文章
- 我们先看一下sync\cond.go下的Cond这个结构体
type Cond struct {
//noCopy可以嵌入到结构中,在第一次使用后不可复制,使用go vet作为检测使用
noCopy noCopy
//根据需求初始化不同的锁,如*Mutex 和 *RWMutex
L Locker
//通知列表,调用Wait()方法的goroutine会被放入list中,每次唤醒,从这里取出
notify notifyList
//复制检查,检查cond实例是否被复制
checker copyChecker
}
- 在Cond内部有一个notifyList 结构体,wait和notify都是一个计数器,它们的初始值都为0,每次调用Wait操作,wait的值都会增加1.wait的值可以理解为调用Wait操作程序所在的goroutine的编号,notify值表示小于它的阻塞的goroutine已经唤醒处理过,调用Signal或者Broadcast时唤醒阻塞在[notify,wait)范围编号上的goroutine。head和tail是一个单链表的头尾指针节点
type notifyList struct {
//wait 表示当前 Wait 的最大 ticket 值
wait uint32
//notify 表示目前已唤醒的 goroutine 的 ticket 的最大值
notify uint32
lock uintptr
//head 和 tail: 等待在这个 sync.Cond 上的 goroutine 链表
head unsafe.Pointer
tail unsafe.Pointer
}
- 通俗理解: notifyList为一个队列,它里面存储是goroutine。wait和notify分别表示生产者和消费者的位置。这个队列是一个单链表,里面的goroutine按照wait值从小到大排列
1. NewCond()创建Cond实例
- 调用NewCond()函数,传递一个sync.Mutex指针,创建Cond实例
func NewCond(l Locker) *Cond {
return &Cond{L: l}
}
2. Wait()阻塞
- 查看Wait()函数,该函数内先后执行了:
- 调用Cond 中的checker函数检查是否被复制
- 执行notifyListAdd(), 获取 ticket,ticket 是一次 Wait 操作的唯一标识,可以用来防止重复唤醒以及保证 FIFO 式的唤醒实际该方法内部就是通过原子自增操作,对Cond 中的 notifyList 下的wait计数器进行累加
- 执行解锁
- 执行notifyListWait()将当前的goroutine挂起阻塞等待在notify队列上,收到唤醒信号之后恢复运行
- 然后再加锁
func (c *Cond) Wait() {
//1.检查c是否是被复制的,如果是就panic
c.checker.check()
//2.wait自增1
t := runtime_notifyListAdd(&c.notify)
//3.解锁,注意这里必须先解锁,因为 runtime_notifyListWait 要切走 goroutine
//所以这里要解锁,要不然其他 goroutine 没法获取到锁了,既然这里会释放锁,所以在调用Wait前,必须进行加锁
c.L.Unlock()
//4.将当前的goroutine挂起阻塞等待在notify队列上,收到唤醒信号之后恢复运行
runtime_notifyListWait(&c.notify, t)
//5.当执行到此处说明这里已经唤醒了,因此需要再度锁上
c.L.Lock()
}
- 实际重点是notifyListAdd()与notifyListWait()
检查c是否被复制
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")
}
}
notifyListAdd()原子操作对等待队列的wait进行累加
func notifyListAdd(l *notifyList) uint32 {
// 将wait的值原子性操作自增1,wait的初始值为0
return atomic.Xadd(&l.wait, 1) - 1
}
notifyListWait()进行阻塞的实际方法
- notifyListWait中会创建一个sudog对象s,并设置s的ticket值,将它和当前的goroutine关联起来。然后加入到队尾。最后调用gopark将当前的goroutine挂起
func notifyListWait(l *notifyList, t uint32) {
lock(&l.lock)
// 小于notify的值的对应编号阻塞的goroutine之前已经唤醒过了,直接返回
if less(t, l.notify) {
unlock(&l.lock)
return
}
// 获取一个sudog对象s
s := acquireSudog()
// 设置s中的g为当前的goroutine
s.g = getg()
// 设置ticket值为传入的t,可以理解为ticket与当前阻塞的goroutine(s.g)对应
s.ticket = t
s.releasetime = 0
t0 := int64(0)
if blockprofilerate > 0 {
t0 = cputicks()
s.releasetime = -1
}
// 将新创建的sudog对象s加入到队列的尾部,这个过程是在lock加锁的条件下进行的
// 不用担心并发将s加入到l.tail冲突问题
if l.tail == nil {
l.head = s
} else {
l.tail.next = s
}
l.tail = s
// 调用gopark阻塞当前的goroutine运行
goparkunlock(&l.lock, waitReasonSyncCondWait, traceEvGoBlockCond, 3)
if t0 != 0 {
blockevent(s.releasetime-t0, 2)
}
releaseSudog(s)
}
3. Signal() 唤醒一个
- 唤醒等待队列中队头的goroutine,真正的实现在notifyListNotifyOne函数,此函数实现也在runtime包中的sema.go文件
func (c *Cond) Signal() {
// 检查c是否是被复制的,如果是就panic
c.checker.check()
// 通知等待列表中的一个
runtime_notifyListNotifyOne(&c.notify)
}
- 查看notifyListNotifyOne(),找到队头中ticket为l.notify的对象,并将该对象关联的goroutine唤醒恢复运行
func notifyListNotifyOne(l *notifyList) {
// 如果wait和notify值相等,说明没有阻塞等待的goroutine,也就没有要唤醒的g了,这里直接返回
if atomic.Load(&l.wait) == atomic.Load(&l.notify) {
return
}
// 加锁执行下面操作
lock(&l.lock)
// 加锁后再次检查wait的值跟notify是否相等,如果相等同上直接释放锁返回
t := l.notify
if t == atomic.Load(&l.wait) {
unlock(&l.lock)
return
}
// notify加1,相当于消费者消费一个数据(g),下面会将队列头的goroutine唤醒
atomic.Store(&l.notify, t+1)
// 执行循环操作,从队列中找出ticket等于notify(l.notify-1,因为此时l.notify已加1)的sudog对象
// 从sudog对象中获取到绑定的g,然后执行readyWithTime,readyWithTime会调用goread将g唤醒
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)
}
4. Broadcast() 唤醒所有
- 唤醒等待队列中的所有goroutine
func (c *Cond) Broadcast() {
// 检查c是否是被复制的,如果是就panic
c.checker.check()
// 唤醒等待队列中所有的goroutine
runtime_notifyListNotifyAll(&c.notify)
}
- notifyListNotifyAll函数也在sema.go文件,将等待队列中所有的goroutine执行goready进行唤醒。在实现的时候,通过拷贝的方法将当前链表拷贝到临时变量s中,达到了快速释放锁。这里锁的粒度比Signal还要小,处理的非常优雅
func notifyListNotifyAll(l *notifyList) {
// 如果wait和notify值相等,说明没有阻塞等待的goroutine,也就没有要唤醒的g了,这里直接返回
if atomic.Load(&l.wait) == atomic.Load(&l.notify) {
return
}
// 加锁,将当前链表拷贝到临时变量s中,然后将原链表释放
// 之后就可以解锁了。通过是拷贝方式达到快速解锁,这里比
// 锁的粒度比Signal还要小。
lock(&l.lock)
s := l.head
l.head = nil
l.tail = nil
// 原子将wait的值赋值给notify,表示[notify,wait)范围内阻塞的goroutine都将被唤醒了
atomic.Store(&l.notify, atomic.Load(&l.wait))
unlock(&l.lock)
// 遍历链表中每一个sudog对象,将绑定在sudog对象上的goroutine唤醒
for s != nil {
next := s.next
s.next = nil
// readyWithTime会调用goready将goroutine唤醒
readyWithTime(s, 4)
s = next
}
}
5. 问题
- 我们知道 sync.Cond 的底层 notifyList 是一个链表结构,我们为何不直接取链表最头部唤醒呢?为什么会有一个 ticket 机制?
这是因为 notifyList 会有乱序的可能。从我们上面 Wait 的过程可以看出,获取 ticket 和加入 notifyList,是两个独立的行为,中间会把锁释放掉。而当多个 goroutine 同时进行时,中间会产生进行并发操作,那么有可能后获取 ticket 的 goroutine,先插入到 notifyList 里面, 这就会造成 notifyList 轻微的乱序