32.使用waitgroup + channel 完成协程结果回收与聚合

代码地址: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.AddWaitGroup.Done 方法,在主 goroutine 外部执行 WaitGroup.Wait 方法. 乍一看,Add Done 成对执行没有问题,实则不然,这里存在一个问题:由于子 goroutine 是异步启动的,所以有可能出现 Wait 方法先于 Add 方法执行,此时由于计数器值为 0Wait 方法会被直接放行,导致产生预期之外的执行流程。

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里面,那么如果子协程没有出现panicwg.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 之间的数据传递,这里常用的方式是通过组合使用 WaitGroupchannel 的方式来完成工作.

1. 版本一(有缺陷)

首先抛出一个略有瑕疵的实现版本 1.0

• 创建一个用于数据传输的 channeldataCh. dataCh 可以是无缓冲类型,因为后续会开启一个持续接收数据的读协程,写协程不会出现阻塞的情况

• 主 goroutine 中创建一个 sliceresp,用于承载聚合后的数据结果. 需要注意,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 当中,因此主 goroutineclose(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获取到用户最近一个月玩游戏的场次、和哪位好友开黑胜率最高,胜率多少,最佳损友是哪位好友(开黑胜率最低的队友),胜率多少,然后给用户发邮件。

评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值