Go并发中WaitGroup搭配Cond引发goroutine死锁问题咨询
以下是讨论涉及的示例代码:
func condVarBroadcast() { type button struct { clicked *sync.Cond } b := button{sync.NewCond(&sync.Mutex{})} // 每次调用subscribe启动一个新goroutine,通过条件变量等待点击事件 subscribe := func(c *sync.Cond, fn func()) { var w sync.WaitGroup w.Add(1) go func() { w.Done() c.L.Lock() defer c.L.Unlock() c.Wait() fn() }() w.Wait() } var wg sync.WaitGroup wg.Add(3) subscribe(b.clicked, func() { fmt.Println("Maximize window") wg.Done() }) subscribe(b.clicked, func() { fmt.Println("displaying dialog box") wg.Done() }) subscribe(b.clicked, func() { fmt.Println("mouse clicked") wg.Done() }) b.clicked.Broadcast() wg.Wait() }
问题1:替换w.Done()为defer w.Done()触发死锁的原因,以及提前调用w.Done()的设计逻辑
死锁触发原因
如果将goroutine内部的w.Done()改为defer w.Done(),w.Done()会延迟到当前goroutine所有逻辑执行完毕才会运行,执行链路会变成:
- 第一次调用
subscribe时,主goroutine启动子goroutine后,阻塞在局部变量w的w.Wait()调用上,等待w.Done()信号才会继续往下执行 - 子goroutine启动后,先注册defer逻辑,接着直接执行
c.L.Lock()拿到条件变量绑定的互斥锁,再调用c.Wait()将自己挂起、释放锁,等待广播唤醒 - 此时主goroutine还卡在
w.Wait(),根本走不到后续注册另外两个订阅者、调用b.clicked.Broadcast()的逻辑;挂起的子goroutine永远等不到广播信号,主goroutine永远等不到w.Done(),直接形成死锁。
提前调用w.Done()的作用
这里的w.Done()根本不是用来等待子goroutine完成全部业务逻辑,它的作用只有一个:标记子goroutine已经完成启动流程,走到了即将调用c.Wait()等待信号的就绪位置,此时subscribe就可以返回,继续执行后续逻辑。
这种用局部WaitGroup做goroutine启动点位同步的写法,是Go并发编程的常规实践,适用于所有需要确保新启动的goroutine到达指定执行节点、再推进主流程的场景,不需要等待goroutine全量执行完成。
问题2:局部WaitGroupw的作用,以及移除后死锁的诱因
局部WaitGroup的核心作用
这个局部w是用来做订阅goroutine的启动同步,保证每次subscribe调用返回时,新启动的订阅goroutine已经执行到c.Wait()的前置逻辑,已经做好了接收广播的准备,不会错过后续的广播信号。
移除后死锁的原因
Go标准库sync.Cond的通知是没有粘性的:Signal()和Broadcast()只会唤醒调用当下已经在等待队列里挂起的goroutine,如果调用通知时还没有goroutine执行到c.Wait()进入等待队列,这次通知会直接丢弃,不会缓存给后续才进入等待的goroutine。
如果移除w相关的同步逻辑,subscribe启动子goroutine后会立刻返回,主goroutine会快速完成三次subscribe调用,直接走到b.clicked.Broadcast()这行——此时三个子goroutine大概率还没完成启动、没抢到互斥锁、没执行到c.Wait()进入等待队列,这次广播不会唤醒任何goroutine。
等广播执行完,三个子goroutine才陆续抢到锁、执行c.Wait()把自己挂起,但此时已经不会再有第二次广播触发。最终主goroutine阻塞在外层wg.Wait()等待三个订阅任务完成,三个子goroutine阻塞在c.Wait()等待永远不会到来的唤醒信号,就会抛出"deadlock: all goroutines are asleep"的死锁错误。
内容的提问来源于stack exchange,提问作者learnlearnlearn

