Go 标准库的 Cond

Go 标准库提供 Cond 原语的目的是,为等待/通知场景下的并发问题提供支持。Cond 通常应用于等待某个条件的一组 goroutine,等条件变为 true 的时候,其中一个 goroutine 或者所有的 goroutine 都会被唤醒执行

顾名思义,Cond 是和某个条件相关,这个条件需要一组 goroutine 协作共同完成,在条件还没有满足的时候,所有等待这个条件的 goroutine 都会被阻塞住,只有这一组 goroutine 通过协作达到了这个条件,等待的 goroutine 才可能继续进行下去

那这里等待的条件是什么呢?等待的条件,可以是某个变量达到了某个闻值或者某个时间点,也可以是一组变量分别都达到了某个闽值,还可以是某个对象的状态满足了特定的条件。总结来讲,等待的条件是一种可以用来计算结果是 true 还是false的条件。

Cond的基本用法

标准库中的 Cond 并发原语初始化的时候,需要关联一个 Locker 接口的实例,一般我们使用Mutex 或者 RWMutex。 我们看一下 Cond的实现:

type Cond
func NeWCond(l Locker) *Cond
func (c *Cond) Broadcast()
func (c *Cond) Signal()
func (c *Cond) Wait()

首先,它内部维护着一个先入先出的等待队Cond 关联的 Locker 实例可以通过 cL 访问,列。 然后,我们分别看下它的三个方法 Broadcast、Signal 和 Wait 方法。

Signal 方法,允许调用者 Caller 唤醒一个等待此 Cond 的 goroutine。如果此时没有等待的显然无需通知 waiter;如果 Cond 等待队列中有一个或者多个等待的goroutine.goroutine,则需要从等待队列中移除第一个 goroutine 并把它唤醒。在其他编程语言中,比如Java 语言中,Signal方法也被叫做 notify 方法。 调用Signal方法时,不强求你一定要持有 c.L的锁。

Broadcast 方法,允许调用者 Caller 唤醒所有等待此 Cond 的 goroutine。如果此时没有等待的 goroutine,显然无需通知 waiter; 如果 Cond 等待队列中有一个或者多个等待的goroutine,则清空所有等待的 goroutine,并全部唤醒。在其他编程语言中,比如Java语言中,Broadcast 方法也被叫做 notifyAll 方法。 同样地,调用 Broadcast 方法时,也不强求你一定持有 cL的锁。

Wait 方法,会把调用者 Caller 放入 Cond 的等待队列中并阻塞,直到被Signal 或者Broadcast的方法从等待队列中移除并唤醒 调用Wait 方法时必须要持有 cL的锁。

Go 实现的 sync.Cond 的方法名是 Wait、Signal 和 Broadcast,这是计算机科学中条件变量的通用方法名。比如,C语言中对应的方法名是 pthread cond wait.pthread cond signal和 pthread cond broadcast。

知道了 Cond 提供的三个方法后,我们再通过一个百米赛跑开始时的例子,来学习下 Cond的使用方法。10个运动员进入赛场之后需要先做拉伸活动活动筋骨,向观众和粉丝招手致敬,在自己的赛道上做好准备,等所有的运动员都准备好之后,裁判员才会打响发令枪。

每个运动员做好准备之后,将ready 加一,表明自己做好准备了,同时调用 Broadcast 方法通知裁判员。因为裁判员只有一个,所以这里可以直接替换成 Signal 方法调用。调用Broadcast 方法的时候,我们并没有请求c.L锁,只是在更改等待变量的时候才使用到了锁。 裁判员会等待运动员都准备好(第 22 行)。虽然每个运动员准备好之后都唤醒了裁判员,但是裁判员被唤醒之后需要检查等待条件是否满足(运动员都准备好了)。可以看到,裁判员被唤醒之后一定要检查等待条件,如果条件不满足还是要继续等待。

func main() {
	c := sync.NewCond(&sync.Mutex{})
	
	var ready int
	for i := 0; i < 10; i++ {
		go func(i int) {
			time.Sleep(time.Duration(rand.Int63n(10)) * time.Second)
			
			// 加锁更改等待条件
			c.L.Lock()
			ready++
			c.L.Unlock()
			log.Printf("运动员#%d 已准备就绪\n", i)
			
			// 广播唤醒所有的等待者
			c.Broadcast()
		}(i)
	}
	
	c.L.Lock()
	for ready != 10 {
		c.Wait()
		log.Println("裁判员被唤醒一次")
	}
	c.L.Unlock()
	
	//所有的运动员是否就绪
	log.Println("所有运动员都准备就绪。比赛开始,3,2,1, ......")
}

Cond 的使用其实没那么简单。它的复杂在于:一,这段代码有时候需要加锁,有时候可以不加;二,Wait 唤醒后需要检查条件;三,条件变量的更改,其实是需要原子操作或者百斥锁保护的。所以,有的开发者会认为,Cond 是唯一难以掌握的 Go 并发原语。

Cond 的实现原理

其实,Cond 的实现非常简单,或者说复杂的逻辑已经被 Locker 或者 runtime 的等待队列实现了。我们直接看看 Cond 的源码吧

type Cond struct {
	noCopy noCopy
	// 当观察或者修改等待条件的时候需要加锁
	L Locker
	// 等待队列
	notify notifyList
	checker copyChecker
}

func NewCond(l Locker) *Cond {
	return &Cond{L: l}
}

func (c *Cond) Wait() {
	c.checker.check()
	// 增加到等待队列中
	t := runtime_notifyListAdd(&c.notify)
	c.L.Unlock()
	// 阻塞休眠直到被唤醒
	runtime_notifyListWait(&c.notify, t)
	c.L.Lock()
}

func (c *Cond) Signal() {
	c.checker.check()
	runtime_notifyListNotifyOne(&c.notify)
}

func (c *Cond) Broadcast() {
	c.checker.check()
	runtime_notifyListNotifyAll(&c.notify)
}