【Go】被遗忘的并发原语——sync.Cond

这位 Gopher 你好呀!如果觉得我的文章还不错,欢迎一键三连支持一下!文章会定期更新,同时可以微信搜索【凑个整数1024】第一时间收到文章更新提醒⏰

有其它语言并发编程经验的 Gopher 们一定不会对条件变量(Condition Variable)相关的并发原语感到陌生,例如 Java 的java.utils.concurrent.locks.Condition、C++的std::condition_variable、Python 的threading.Condition等。条件变量在并发编程中用于使一个或多个线程(协程)阻塞地等待一个目标条件被满足,当条件被改变时,可以唤醒一个或多个被阻塞的线程(协程)。

与上述语言类似,在 Go 语言标准库中同样提供了条件变量的并发原语------sync.Cond,用于阻塞一个或多个等待某个目标条件成立的 goroutine,直到该条件被改变时这一个或多个 goroutine 才被唤醒。然而在实际开发中,sync.Cond很少会被 Gopher 们使用到,因为它的作用似乎都能被 Go channel 给替代,觉得使用 channel 才是更"地道"的 Go 语言用法。但这种说法真的可靠吗?有没有哪些特定场景,sync.Cond是不可替代的呢?

sync.Cond 的基本使用

使用sync.Cond时需要关联一个"锁",也就是实现sync.Locker接口的具体类型的实例(例如sync.Mutexsync.RWMutex等),在检查或改变目标条件时需要对这把锁进行加锁 。在使用sync.Cond的初始化方法sync.NewCond时,需要传入将锁实例,得到sync.Cond实例,后续通过访问该实例的L字段就可以访问到关联的Locker。以下是sync.NewCond的签名:

go 复制代码
func NewCond(l Locker) *Cond

sync.Cond有三个方法WaitSignalBroadcast

go 复制代码
func (c *Cond) Wait()
func (c *Cond) Signal()
func (c *Cond) Broadcast()

下面对这三个方法分别进行介绍:

  • Wait:会阻塞调用者所在的 goroutine,直到被SignalBroadcast方法唤醒,Wait才会返回。需要重点留意Wait方法内部会调用c.L.Unlock对该sync.Cond实例关联的锁进行解锁 ,然后再对当前 goroutine 进行阻塞,当该 goroutine 被唤醒后又会在返回前调用c.L.Lock进行加锁 ,因此,Wait的调用者必须要持有c.L这把锁
  • Signal:唤醒其中一个阻塞等待该sync.Cond实例的 goroutine。该方法调用者不一定需要持有c.L这把锁
  • Broadcast:作用类似于Signal,不过是唤醒全部 阻塞等待该sync.Cond实例的 goroutine,当只有一个阻塞等待的 goroutine 时,BroadcastSignal是等价的。该方法调用者同样不一定需要持有c.L这把锁

下面我们通过两个例子来感受下sync.Cond的使用。

第一个例子,我们模拟一对一sync.Cond使用场景:

go 复制代码
func main() {
	cond := sync.NewCond(new(sync.Mutex))
	ready := false // cond所等待的条件

	go func() {
		fmt.Println("doing some work...")
		time.Sleep(time.Second)
		ready = true // 不一定加锁,因为只有一个goroutine在写ready
		cond.Broadcast()
	}()

	cond.L.Lock() // 检查目标条件时先加锁
	for !ready {
		cond.Wait()
	}
	cond.L.Unlock() // Wait返回并跳出for循环后需要解锁
	fmt.Println("got work done signal!")
}

// OUTPUT:
// doing some work...
// got work done signal!

上述代码中,我们启动了一个 goroutine 用于模拟一个任务的执行,然后在主 goroutine 中等待该任务执行的结束。例子中声明布尔变量ready表示该任务是否执行完成,将ready == true作为目标条件。主 goroutine 使用Wait方法阻塞等待目标条件ready == true成立;任务完成执行后,改变readytrue,并使用Broadcast方法通知被阻塞的主 goroutine(由于只有主 goroutine 在阻塞等待,因此BroadcastSignal是等价的)。这里重点留意主 goroutine 使用sync.Cond进行Wait的方式,尤其是加锁时机------检查目标条件时先加锁,Wait 返回并跳出 for 循环后需要解锁。

下面我们再来看一对多的场景:

go 复制代码
func main() {
	cond := sync.NewCond(new(sync.Mutex))
	ready := false // cond所等待的条件

 var wg sync.WaitGroup
	wg.Add(1)
	go func() {
		defer wg.Done()
		fmt.Println("doing some work...")
		time.Sleep(time.Second)
		ready = true // 不一定加锁,因为只有一个goroutine在写ready
		cond.Broadcast() // 通知多个被阻塞的goroutine
	}()

	wg.Add(5)
	for range 5 {
		go func() {
			defer wg.Done()
			cond.L.Lock()
			for !ready {
				cond.Wait()
			}
			cond.L.Unlock()
			fmt.Println("got work done signal!")
		}()
	}

	wg.Wait()
}

// OUTPUT:
// doing some work...
// got work done signal!
// got work done signal!
// got work done signal!
// got work done signal!
// got work done signal!

上述代码与第一个例子的框架基本类似,只不过启动了 5 个 goroutine 同时等待任务执行完成的通知。

总结两个使用sync.Cond的注意事项,以免使用时踩坑:

  • 调用Wait前必须加锁:在前文介绍Wait方法时有提到过Wait内部会首先对关联的锁进行解锁,一般锁的实现不允许对没有加锁的锁进行解锁,比如在上文的例子中sync.Cond关联了标准库sync.Mutex互斥锁,若在调用Wait前没有加锁,则会触发 panic:fatal error: sync: unlock of unlocked mutex。在 Go 语言圈中有个口诀:"等待毕加索"(Wait必须加锁,谐音梗退钱!),可以帮助我们记住这一注意点。
  • Wait唤醒后需要检查目标条件:sync.Cond本身只是负责阻塞与唤醒一个或多个 goroutine,并不能保证目标条件一定是满足了的,且当前 goroutine 从Wait被唤醒到Wait返回之间,当前 goroutine 是没有获得锁的,因此条件可能会被改变。综上,官方推荐我们使用 for 循环的框架去调用Wait等待目标条件的成立:
go 复制代码
c.L.Lock()
for !condition {
    c.Wait()
}
// make use of condition...
c.L.Unlock()

sync.Cond vs channel

Gopher 们看完上一节中的两个例子肯定会觉得sync.Cond完全可以被 Go 原生的 channel 类型代替,两个示例场景中我们都可以使用close关闭 channel 的方式去通知所有阻塞等待的 goroutine,以一对多的场景为例:

go 复制代码
func main() {
	var wg sync.WaitGroup
	ready := make(chan struct{})

	wg.Add(1)
	go func() {
		defer wg.Done()
		fmt.Println("doing some work...")
		time.Sleep(time.Second)
		close(ready)
	}()

	wg.Add(5)
	for range 5 {
		go func() {
			defer wg.Done()
			<-ready
			fmt.Println("got work done signal!")
		}()
	}

	wg.Wait()
}

// OUTPUT:
// doing some work...
// got work done signal!
// got work done signal!
// got work done signal!
// got work done signal!
// got work done signal!

对于一对一的场景,也可以不使用close,直接读写无缓冲 channel ready,实现同步关系:

go 复制代码
func main() {
	ready := make(chan struct{})

	go func() {
		fmt.Println("doing some work...")
		time.Sleep(time.Second)
		close(ready)
	}()

	<-ready
	fmt.Println("got work done signal!")
}

// OUTPUT:
// doing some work...
// got work done signal!

对于这两种通知被阻塞 goroutine 的场景,的确用 channel 更简洁高效,且更符合 Go 语言的编写习惯。但是对于一对多的例子,如果我们需要多次进行 goroutine 的阻塞与唤醒,channel 就显得捉襟见肘了------因为一个 channel 只能被close关闭一次重复close一个 channel 会导致 panic。比如在下面的例子中,进行了 3 次 goroutine 的阻塞与唤醒(这里只是展示sync.Cond的多次阻塞与唤醒,为了方便理解所以没有加入ready目标条件):

go 复制代码
func main() {
	var wg sync.WaitGroup
	cond := sync.NewCond(new(sync.Mutex))

	wg.Add(1)
	go func() {
		defer wg.Done()
		for i := range 3 {
			fmt.Printf("doing work %d...\n", i)
			time.Sleep(time.Second)
			cond.Broadcast()
		}
	}()

	wg.Add(5)
	for range 5 {
		go func() {
			defer wg.Done()
			for i := range 3 {
				cond.L.Lock()
				cond.Wait()
				cond.L.Unlock()
				fmt.Println("got work done signal!", i)
			}
		}()
	}

	wg.Wait()
}

// OUTPUT:
// doing work 0...
// doing work 1...
// got work done signal! 0
// got work done signal! 0
// got work done signal! 0
// got work done signal! 0
// got work done signal! 0
// doing work 2...
// got work done signal! 1
// got work done signal! 1
// got work done signal! 1
// got work done signal! 1
// got work done signal! 1
// got work done signal! 2
// got work done signal! 2
// got work done signal! 2
// got work done signal! 2
// got work done signal! 2

总结sync.Cond相比 channel 的一大不可替代的点就是:有多个被阻塞 goroutine 的场景中,Broadcast方法可以多次调用,以多次唤醒被阻塞的全部 goroutine

但如果在这里我们非要使用 channel 的话也不是不可以,就是要比使用sync.Cond繁杂一些。我们需要给每个阻塞的 goroutine 关联一个 channel,用于其阻塞与唤醒。然后单独实现一个broadcast函数,用于将元素v传给多个 channel:

go 复制代码
func broadcast[T any](v T, outs []chan T) {
	for _, out := range outs {
		out <- v
	}
}

然后我们使用broadcast进行多次阻塞与唤醒:

go 复制代码
func main() {
	outs := make([]chan struct{}, 5)
	for i := range outs {
		outs[i] = make(chan struct{})
	}

	var wg sync.WaitGroup

	wg.Add(1)
	go func() {
		defer wg.Done()
		for i := range 3 {
			_, _ = fmt.Printf("doing work %d...\n", i)
			time.Sleep(time.Second)
			broadcast(struct{}{}, outs)
		}
	}()

	wg.Add(5)
	for i := range 5 {
		go func(c <-chan struct{}) {
			defer wg.Done()
			for j := range 3 {
				<-c
				fmt.Println("got work done signal!", j)
			}
		}(outs[i])
	}

	wg.Wait()
}

// OUTPUT:
// doing work 0...
// doing work 1...
// got work done signal! 0
// got work done signal! 0
// got work done signal! 0
// got work done signal! 0
// got work done signal! 0
// doing work 2...
// got work done signal! 1
// got work done signal! 1
// got work done signal! 1
// got work done signal! 1
// got work done signal! 1
// got work done signal! 2
// got work done signal! 2
// got work done signal! 2
// got work done signal! 2
// got work done signal! 2

sync.Cond 源码浅析

我们先来看一下sync.Cond结构体的字段:

go 复制代码
type Cond struct {
	noCopy noCopy

	// 在检查目标条件或者修改条件时需要持有的锁
	L Locker

	notify  notifyList
	checker copyChecker
}

对外暴露的锁L在前文中已经解释过了,是在检查目标条件或者修改条件时需要持有的锁;noCopychecker是用于检测sync.Cond实例是否有被复制的,关于复制检测的详细讲解可以参考之前的文章------《Golang 代码运行时类型复制检查器 copyChecker 的实现》notify是一个 goroutine 的阻塞等待队列,其底层是由runtime.notifyList实现的。

sync.Cond的三个方法实现很简单,因为主要的复杂逻辑已经被 Go 语言运行时的runtime.notifyList实现了。由于篇幅的原因这里不对runtime.notifyList相关的逻辑进行详细讲解,其源码位于runtime/sema.go中,在今后会计划写一篇对其进行详细讲解的文章,敬请期待

Wait

go 复制代码
func (c *Cond) Wait() {
	c.checker.check()
	t := runtime_notifyListAdd(&c.notify) // 加入通知列表
	c.L.Unlock()
	runtime_notifyListWait(&c.notify, t) // 决定是否加入阻塞等待队列
	c.L.Lock() // 从阻塞队列唤醒后再次加锁
}

可以看到Wait会调用c.L.Unlock对该sync.Cond实例关联的锁进行解锁,因此调用Wait前必须加锁;在Wait返回前又会调用c.L.Unlock对该sync.Cond实例关联的锁进行加锁,因此Wait返回后还需要解锁,避免出现死锁的情况。

忽略复制检查和加锁/解锁的代码,那么Wait所做的就是使用runtime_notifyListAdd将调用者所在 goroutine 加入通知列表中,但还需要调用runtime_notifyListWait才可以真正决定当前 goroutine 是否需要加入到阻塞等待队列中。

由于调用runtime_notifyListWait可能会阻塞当前 goroutine,因此在调用该方法前需要释放锁,这样其它 goroutine 才能够获得锁。

Signal 与 Broadcast

go 复制代码
func (c *Cond) Signal() {
	c.checker.check()
	runtime_notifyListNotifyOne(&c.notify) // 通知一个等待的goroutine
}

func (c *Cond) Broadcast() {
	c.checker.check()
	runtime_notifyListNotifyAll(&c.notify) // 通知所有等待的goroutine
}

同样忽略复制检查的代码,Signal调用runtime_notifyListNotifyOne通知一个等待的 goroutine,如果该 goroutine 存在于阻塞等待队列中,那么将其移除队列并唤醒;Broadcast调用runtime_notifyListNotifyAll通知所有等待的 goroutine,清空并唤醒阻塞等待队列中所有的 goroutine。

总结

本文介绍了 Go 语言标准库提供的条件变量并发原语sync.Cond的一般使用方法,并对比其与 Go 原生的 channel 在不同场景时的优劣。然后我们浅析了sync.Cond的源码实现,有助于我们对sync.Cond使用方式的理解。

相关推荐
BlockChain8881 天前
Solidity 实战【三】:重入攻击与防御(从 0 到 1 看懂 DAO 事件)
go·区块链
剩下了什么1 天前
Gf命令行工具下载
go
地球没有花1 天前
tw引发的对redis的深入了解
数据库·redis·缓存·go
BlockChain8882 天前
字符串最后一个单词的长度
算法·go
龙井茶Sky2 天前
通过higress AI统计插件学gjson表达式的分享
go·gjson·higress插件
宇宙帅猴3 天前
【Ubuntu踩坑及解决方案(一)】
linux·运维·ubuntu·go
SomeBottle4 天前
【小记】解决校园网中不同单播互通子网间 LocalSend 的发现问题
计算机网络·go·网络编程·学习笔记·计算机基础
且去填词4 天前
深入理解 GMP 模型:Go 高并发的基石
开发语言·后端·学习·算法·面试·golang·go
大厂技术总监下海4 天前
向量数据库“卷”向何方?从Milvus看“全功能、企业级”的未来
数据库·分布式·go·milvus·增强现实
冷冷的菜哥4 天前
go(golang)调用ffmpeg对视频进行截图、截取、增加水印
后端·golang·ffmpeg·go·音视频·水印截取截图