文章目录
代码地址:https://gitee.com/lymgoforIT/golang-trick/tree/master/23-waitgroup+channel
一. waitgroup使用介绍
1. 简单使用
等待组工具 sync.WaitGroup
,本质上是一个并发计数器,暴露出来的核心方法有三个:
-
WaitGroup.Add(n)
:完成一次登记操作,使得WaitGroup
中并发计数器的数值加上n
. 在使用场景中,WaitGroup.Add(n)
背后的含义是,注册并启动了n
个子goroutine
-
WaitGroup.Done()
:完成一次上报操作,使得WaitGroup
中并发计数器的数值减1
. 在使用场景中,通常会在一个子goroutine
退出前,会执行一次WaitGroup.Done
方法 -
WaitGroup.Wait()
:完成聚合操作. 通常由主goroutine
调用该方法,主goroutine
会因此陷入阻塞,直到所有子goroutine
都已经执行完成,使得WaitGroup
并发计数器数值清零时,主goroutine
才得以继续往下执行
下面就给出 sync.WaitGroup 的使用示例:
-
首先声明一个等待组
wg
-
开启
for
循环,准备启动10
个子goroutine
-
在每次启动子
goroutine
之前,先在主 goroutine 中调用WaitGroup.Add
方法,完成子goroutine
的登记(注意,WaitGroup.Add 方法的调用时机应该在主 goroutine 而非子 goroutine 中)
-
依次启动子
goroutine
,并在每个子goroutine
中通过defer
保证其退出前一定会调用一次WaitGroup.Done
方法,完成上报动作,让WaitGroup
中的计数器数值减1
-
主
goroutine
启动好子goroutine
后,调用WaitGroup.Wait
方法,阻塞等待,直到所有子goroutine
都执行过WaitGroup.Done
方法,WaitGroup
计数器清零后,主goroutine
才得以继续往下
package main
import (
"fmt"
"sync"
"time"
)
func main() {
var wg sync.WaitGroup
start := time.Now()
for i := 0; i < 10; i++ {
wg.Add(1) // 易出错点1
go func(i int) {
defer func() {
wg.Done() // 易出错点2
if err := recover(); err != any(nil) {
fmt.Println("出现了panic")
}
}()
fmt.Println(i)
<-time.After(time.Second) // 模拟业务耗时一秒
}(i) // 易出错点3
}
wg.Wait()
fmt.Println(time.Since(start))
}
输出结果:
从输出结果中并非0-9
按顺序输出不难看出,协程启动是需要一定时间的,并非先启动的就先执行,此外尽管10
个协程我们都模拟了耗时一秒,但是10
个协程全部执行完毕,最终也就耗时一秒多一点,这就是使用协程的好处,因为如果串行执行10
个任务,则至少耗时十秒。
2. 错误用法示警
1. Add方法使用位置不对
在使用 sync.WaitGroup
时,需要保证添加计数器数值的 WaitGroup.Add
操作是在 WaitGroup.Wait
操作之前执行,否则可能出现逻辑问题,甚至导致程序 panic
.
这里给出一种反例展示如下,注意易出错点1代码的位置
:
package main
import (
"fmt"
"sync"
"time"
)
func main() {
var wg sync.WaitGroup
start := time.Now()
for i := 0; i < 10; i++ {
// wg.Add(1) // 易出错点1
go func(i int) {
wg.Add(1) // 易出错点1
defer func() {
wg.Done() // 易出错点2
if err := recover(); err != any(nil) {
fmt.Println("出现了panic")
}
}()
fmt.Println(i)
<-time.After(time.Second) // 模拟业务耗时一秒
}(i) // 易出错点3
}
wg.Wait()
fmt.Println(time.Since(start))
}
在上面的代码中,我们在子 goroutine
内部依次执行 WaitGroup.Add
和 WaitGroup.Done
方法,在主 goroutine
外部执行 WaitGroup.Wait
方法. 乍一看,Add
和 Done
成对执行没有问题,实则不然,这里存在一个问题:由于子 goroutine
是异步启动的,所以有可能出现 Wait
方法先于 Add
方法执行,此时由于计数器值为 0
,Wait
方法会被直接放行,导致产生预期之外的执行流程。
2. Done方法使用的位置不对
在启动子协程时,有个约定俗成的惯例就是子协程中一定要用recover捕获panic,避免子协程出现的panic没有被捕获,一直上抛导致主程序panic,程序退出
这里给出一种反例展示如下,注意易出错点2代码的位置
:
package main
import (
"fmt"
"sync"
"time"
)
func main() {
var wg sync.WaitGroup
start := time.Now()
for i := 0; i < 10; i++ {
wg.Add(1) // 易出错点1
go func(i int) {
defer func() {
// wg.Done() // 易出错点2
if err := recover(); err != any(nil) {
wg.Done() // 易出错点2
fmt.Println("出现了panic")
}
}()
fmt.Println(i)
<-time.After(time.Second) // 模拟业务耗时一秒
}(i) // 易出错点3
}
wg.Wait()
fmt.Println(time.Since(start))
}
这里我们将wg.Done()
放到了捕获到panic
里面,那么如果子协程没有出现panic
,wg.Done
是不会执行的,导致预期外的结果出现。
注:如下代码的if
,是当recover
捕获到panic
时才为真
if err := recover(); err != any(nil) {
wg.Done() // 易出错点2
fmt.Println("出现了panic")
}
3. 子协程闭包用外部变量
子协程可以看成是一个闭包,闭包是可以直接使用外部变量的
这里给出一种反例展示如下,注意易出错点3代码的位置
:
package main
import (
"fmt"
"sync"
"time"
)
func main() {
var wg sync.WaitGroup
start := time.Now()
for i := 0; i < 10; i++ {
wg.Add(1) // 易出错点1
go func() {
defer func() {
wg.Done() // 易出错点2
if err := recover(); err != any(nil) {
fmt.Println("出现了panic")
}
}()
fmt.Println(i)
<-time.After(time.Second) // 模拟业务耗时一秒
}() // 易出错点3
}
wg.Wait()
fmt.Println(time.Since(start))
}
输出结果:
可以看到,本次i
并非作为变量传给子协程时,子协程使用了外部变量,因此输出的不再是0-9
了,而是不确定的,因为协程执行时i
的值是什么就输出什么。
二. waitGroup + channel完成数据聚合
工作中经常会遇到这样的场景,主协程需要在所有子协程完成工作后进行结果回收. 因此 主协程光是知道所有子协程已经完成工作这一事件还不够,还需要切实接收到来自子协程传递的返回数据. 这部分内容就涉及到goroutine
之间的数据传递,这里常用的方式是通过组合使用 WaitGroup
和 channel
的方式来完成工作.
1. 版本一(有缺陷)
首先抛出一个略有瑕疵的实现版本 1.0
:
• 创建一个用于数据传输的 channel
:dataCh
. dataCh
可以是无缓冲类型,因为后续会开启一个持续接收数据的读协程,写协程不会出现阻塞的情况
• 主 goroutine
中创建一个 slice
:resp
,用于承载聚合后的数据结果. 需要注意,slice
不是并发安全的数据结构,因此在往 slice
中写数据时需要保证是串行化进行的
• 异步启动一个读 goroutine
,持续从 dataCh
中接收数据,然后将其追加到resp slice
当中. 需要注意,读 goroutine
中通过这种 for range
的方式遍历 channel
,只有在 channel
被关闭且内部数据被读完的情况下,遍历才会终止
• 基于 WaitGroup
的使用模式,启动多个子 goroutine
,模拟任务的进行,并将处理完成的数据塞到 dataCh
当中供读 goroutine
接收和聚合
• 主 goroutine
通过WaitGroup.Wait
操作,确保所有子 goroutine
都完成工作后,执行 dataCh
的关闭操作
• 主 goroutine
从读 goroutine
手中获取到聚合好数据的 resp slice
,继续往下处理
对应的实现源码如下:
package main
import (
"fmt"
"sync"
"time"
)
func main() {
tasksNum := 10
dataCh := make(chan interface{}) // 无缓冲通道
resp := make([]interface{}, 0, tasksNum)
// 启动读 goroutine
go func() {
defer func() {
if err := recover(); err != any(nil) {
// ...
}
}()
for data := range dataCh {
resp = append(resp, data)
}
}()
// 保证获取到所有数据后,通过channel传递到读协程中
var wg sync.WaitGroup
for i := 0; i < tasksNum; i++ {
wg.Add(1)
// 使用只写通道作为参数,意味着协程中只对channel进行写,读操作在读协程中进行
go func(ch chan<- interface{}) {
defer func() {
wg.Done()
if err := recover(); err != any(nil) {
// ...
}
}()
// 模拟协程执行的结果写入channel,工作中一般是rpc调用结果
ch <- time.Now().UnixNano()
}(dataCh)
}
// 确保所有取数据的协程都完成了工作,才关闭channel
wg.Wait()
close(dataCh)
fmt.Printf("resp:%+v\nlen(resp):%v", resp, len(resp))
}
运行结果:
我们明明开启了10
个协程运行,为什么只收集到9
个结果?
问题在于上面的代码存在并发问题:主 goroutine
在通过 WaitGroup.Wait
方法确保子 goroutine
都完成任务后,会关闭 dataCh
,并直接获取 resp slice
进行打印. 此时 dataCh
虽然关闭了,但是由于异步的不确定性,读 goroutine
可能还没来得及将所有数据都聚合到 resp slice
当中,因此主 goroutine
在close(dateCh)
后立马就拿,拿到的 resp slice
可能存在数据缺失.
2. 版本二(无缺陷,但写法不优雅)
在版本 1.0
的基础上对问题进行修复,提出版本 2.0
的方案.
之前存在的问题是,主 goroutine 可能在读 goroutine 完成数据聚合前,就已经取用了 resp slice. 那么我们就额外启用一个用于标识读 goroutine 是否执行结束的 channel:stopCh 即可. 具体步骤包括:
-
主
goroutine
关闭dataCh
之后,不是立即取用resp slice
,而是会先尝试从stopCh
中读取信号,读取成功后,才继续往下 -
读
goroutine
在退出前,往stopCh
中塞入信号量,让主goroutine
能够感知到读goroutine
处理完成这一事件
这样处理之后,逻辑是严谨的,主 goroutine
能够保证从 resp slice
取得完整的数据.
代码如下,相对版本1就加了三处stopCh的代码
package main
import (
"fmt"
"sync"
"time"
)
func main() {
tasksNum := 10
dataCh := make(chan interface{}) // 无缓冲通道
resp := make([]interface{}, 0, tasksNum)
stopCh := make(chan struct{}, 1)
// 启动读 goroutine
go func() {
defer func() {
if err := recover(); err != any(nil) {
// ...
}
}()
for data := range dataCh {
resp = append(resp, data)
}
stopCh <- struct{}{}
}()
// 保证获取到所有数据后,通过channel传递到读协程中
var wg sync.WaitGroup
for i := 0; i < tasksNum; i++ {
wg.Add(1)
// 使用只写通道作为参数,意味着协程中只对channel进行写,读操作在读协程中进行
go func(ch chan<- interface{}) {
defer func() {
wg.Done()
if err := recover(); err != any(nil) {
// ...
}
}()
// 模拟协程执行的结果写入channel,工作中一般是rpc调用结果
ch <- time.Now().UnixNano()
}(dataCh)
}
// 确保所有取数据的协程都完成了工作,才关闭channel
wg.Wait()
close(dataCh)
// 确保读协程处理完成
<-stopCh
fmt.Printf("resp:%+v\nlen(resp):%v", resp, len(resp))
}
运行结果:
3. 版本三(优雅实现)
版本 2.0
需要额外引入一个 stopCh
,用于主 goroutine
和读 goroutine
之间的通信交互,看起来总觉得不够优雅. 下面我们就较真一下,针对于如何省去这个小小的 channel
,进行版本 3.0
的方案探讨.
下面是更优雅的一种实现方式.
-
同样创建一个无缓冲的
dataCh
,用于聚合数据的传递 -
异步启动一个总览写流程的写
goroutine
,在这个写goroutine
中,基于WaitGroup
使用模式,让写goroutine
中进一步启动的子goroutine
在完成工作后,将数据发送到dataCh
当中 -
写
goroutine
基于WaitGroup.Wait
操作,在确保所有子goroutine
完成工作后,关闭dataCh
-
接下来,让主
goroutine
同时扮演读goroutine
的角色,通过for range
的方式持续遍历接收dataCh
当中的数据,将其填充到resp slice
-
当写
goroutine
关闭dataCh
后,主goroutine
才能结束遍历流程,从而确保能够取得完整的resp
数据
代码如下:
package main
import (
"fmt"
"sync"
"time"
)
//func test() {
// var wg sync.WaitGroup
// start := time.Now()
// for i := 0; i < 10; i++ {
// wg.Add(1) // 易出错点1
// go func() {
// defer func() {
// wg.Done() // 易出错点2
// if err := recover(); err != any(nil) {
// fmt.Println("出现了panic")
// }
// }()
//
// fmt.Println(i)
// <-time.After(time.Second) // 模拟业务耗时一秒
// }() // 易出错点3
// }
//
// wg.Wait()
// fmt.Println(time.Since(start))
//}
func main() {
tasksNum := 10
dataCh := make(chan interface{}) // 无缓冲通道
// 启动写goroutine,推进并发获取数据进程,将获取到的数据聚合到channel中
go func() {
defer func() {
if err := recover(); err != any(nil) {
// ...
}
}()
// 保证获取到所有数据后,通过channel传递到读协程中
var wg sync.WaitGroup
for i := 0; i < tasksNum; i++ {
wg.Add(1)
// 使用只写通道作为参数,意味着协程中只对channel进行写,读操作在读协程中进行
go func(ch chan<- interface{}) {
defer func() { // 父协程中的recover不能捕获子协程中的panic,所以这里的recover不可以省略
wg.Done()
if err := recover(); err != any(nil) {
// ...
}
}()
// 模拟协程执行的结果写入channel,工作中一般是rpc调用结果
ch <- time.Now().UnixNano()
}(dataCh)
}
// 确保所有取数据的协程都完成了工作,才关闭channel
wg.Wait()
close(dataCh)
}()
resp := make([]interface{}, 0, tasksNum)
// 主协程作为读协程,持续读取数据,直到所有写协程完成任务,chan被关闭后才会退出for循环,继续往后执行
for data := range dataCh {
resp = append(resp, data)
}
fmt.Printf("resp:%+v\nlen(resp):%v", resp, len(resp))
}
运行结果:
三:场景扩展
日常工作中,存在很多的场景不仅要开启多个协程利用并发能力提高性能,还需要收集各个子协程的执行结果,如子协程执行成功还是失败,以及返回的数据是什么。
上面演示开启的多个协程功能都是一致的,所以写在一个for
循环中,实际工作中可能是需要开启多个不同的协程进行rpc
调用,并收集结果,并且有一个rpc
调用失败,就不应该继续后续动作。
如:有一个这样的场景,需要通过用户id
获取到用户最近一个月玩游戏的场次、和哪位好友开黑胜率最高,胜率多少,最佳损友是哪位好友(开黑胜率最低的队友),胜率多少,然后给用户发邮件。