今天分享一道非常经典的并发问题,使用多个协程按照顺序打印字母表的字母,每个打印 10 次。
思路:显然这里是要我们管道和协程完成同步交替打印,先把问题缩小,思考三个协程打印 a、b、c 的情形。最直接的思路就是定义三个管道,第 1 个协程打印完之后之后通知下一个协程,最后一个协程打印完成之后通知第 1 个协程继续打印,从而形成一个环。
代码如下:
// 使用三个管道实现三个协程同步顺序打印a b c
func printLetter(letter string, prevCh, nextCh chan struct{}, wg *sync.WaitGroup) {
defer wg.Done()
for i := 0; i < 10; i++ {
// 等待上一个协程通知
<-prevCh
fmt.Print(letter)
// 发送信号给下一个协程
nextCh <- struct{}{}
}
}
func main() {
var wg sync.WaitGroup
wg.Add(3)
ch1 := make(chan struct{})
ch2 := make(chan struct{})
ch3 := make(chan struct{})
go printLetter("a", ch1, ch2, &wg)
go printLetter("b", ch2, ch3, &wg)
go printLetter("c", ch3, ch1, &wg)
// 启动第一个协程
ch1 <- struct{}{}
wg.Wait()
}
运行代码你会惊奇的发现最终结果是打印出来了,但是出现了死锁问题。对于有技术追求的程序员来说,怎么能就这样算了呢,肯定要给他解决了。
分析问题:问题的根源就是我们在通知下一个协程打印字母时,最后会形成一个环形,那么在第 1 个,第 2 个协程打印结束之后就会退出,最后一个协程在打印完成之后会管道 ch1 做 ch1 <- struct{}{}
的操作。因为我们定义的是无缓冲管道,所以第 3 个协程会立刻阻塞,但是第 1 个协程已经退出了没有办法对 ch1 做 <-ch1
操作,所以最后一个协程就会一直阻塞,WaitGroup
的计数器一直无法置零主协程无法退出,最终导致最后一个协程和主协程之间形成死锁,程序崩溃。
解决方法也很简单,只要在 printLetter
函数中加一个判断,判断它是否是第一个协程,如果是那么就对 prevCh 做 <-prevCh
操作以避免死锁问题。
func printLetter(letter string, prevCh, nextCh chan struct{}, wg *sync.WaitGroup) {
defer wg.Done()
for i := 0; i < 10; i++ {
// 等待上一个协程通知
<-prevCh
fmt.Print(letter)
// 发送信号给下一个协程
nextCh <- struct{}{}
}
if letter == "a" {
<-prevCh
}
}
这样第 1 个协程必须得等最后一个协程做 nextCh <- struct{}{}
操作才能退出,或者说最后一个协程等待第 1 个协程做 <-prevCh
操作才能退出。最终主协程也可以安全地退出。
对于使用多协程顺序打印字母表的问题,相信你读到这里也有思路了吧,代码如下:
// 使用26个协程分别顺序打印字母表
func printAlphabet(letter rune, prevCh, nextCh chan struct{}, wg *sync.WaitGroup) {
defer wg.Done()
for i := 0; i < 10; i++ {
<-prevCh
fmt.Printf("%c", letter)
nextCh <- struct{}{}
}
// 第一个协程必须要退出,因为最后一个协程往管道里面写入数据了,需要破环而出不然就会死锁
if letter == 'a' {
<-prevCh
}
}
func main() {
var wg sync.WaitGroup
wg.Add(26)
var signals []chan struct{}
for i := 0; i < 26; i++ {
signals = append(signals, make(chan struct{}))
}
for letter, i := 'a', 0; letter <= 'z'; letter++ {
if letter == 'z' {
go printAlphabet(letter, signals[i], signals[0], &wg)
break
}
go printAlphabet(letter, signals[i], signals[i+1], &wg)
i++
}
// 启动第一个协程
signals[0] <- struct{}{}
wg.Wait()
}
这里我使用了一个切片存储了 26 个管道,这样避免了写重复代码。最终还是跟上面的代码一样,最后一个协程得要等第 1 个协程一起退出才不会死锁。