序言
本文简单介绍了golang并发原语中WaitGroup等待组的概念、使用方法、实现原理以及使用注意事项,适合刚入门golang并发的同学观看。
1.什么是WaitGroup
WaitGroup直译过来就是等待组,等待组等待组,golang并发中的等待组能等待啥呢,当然等待的就是golang特有的gorutine协程,那么,要理解要有等待组,就需要结合并发场景 来看。
首先来看一个问题,假设有这样一个场景,我们要完成某个大的任务,需要使用并行的三个协程首先执行完他们的子任务之后我们接下来的任务才可以开始继续进行,那么我们如何才能知道这三个协程的任务都健康的完成了呢,最简单直观就是采用轮询 的方法定时检查询问这三个子任务,但是直接的使用轮询检查会比较消耗性能,有可能任务完成很久了,才会被轮循到,而且无谓的轮询导致时间和次数增加,浪费了CPU资源,这个时候我们的WaitGroup等待组 就派上用场了,它可以阻塞等待监控到三个子协程任务都完成了,才会放行,让外层任务继续向下执行, 既可以避免轮询导致的CPU空转 性能浪费,也能保证任务的正常执行。
接下来我们就来看看WaitGroup等待组是如何使用的。
2.使用方法
2.1 方法
WaitGroup等待组一共只有三个方法,
Add() 设置等待组的计数值,
Done() 计数值-1,
Wait() 阻塞等待,直到计数值为0
这里的Done方法,源码中只有一行,就是直接调用ADD方法,传入值是-1(题外话:整个等待组waitgroup.go文件的源代码行数算上注释总共只有128行,相比于其他语言也算是golang的一个优点,很多源码是可以直接学习看明白的,学习golang,学会看源码是也一个非常不错的学习渠道)
go
// Done decrements the WaitGroup counter by one.
func (wg *WaitGroup) Done() {
wg.Add(-1)
}
2.2 使用案例
使用等待组:
go
// 使用了等待组的结果
// Counter 线程安全的计数器
type Counter struct {
mu sync.Mutex
count uint64
}
// Incr 对计数值加一
func (c *Counter) Incr() {
c.mu.Lock()
c.count++
c.mu.Unlock()
}
// Count 获取当前的计数值
func (c *Counter) Count() uint64 {
c.mu.Lock()
defer c.mu.Unlock()
return c.count
}
// sleep 1秒,然后计数值加1
func worker(c *Counter, wg *sync.WaitGroup) {
defer wg.Done()
time.Sleep(time.Second)
c.Incr()
}
func main() {
var counter Counter
var wg sync.WaitGroup
wg.Add(10) // WaitGroup的值设置为10
for i := 0; i < 10; i++ { // 启动10个goroutine执行加1任务
go worker(&counter, &wg)
}
// 检查点,等待goroutine都完成任务
wg.Wait()
// 输出当前计数器的值
fmt.Println(counter.Count())
}
不使用等待组:
scss
// 不使用等待组
// Counter 线程安全的计数器
type Counter struct {
mu sync.Mutex
count uint64
}
// Incr 对计数值加一
func (c *Counter) Incr() {
c.mu.Lock()
c.count++
c.mu.Unlock()
}
// Count 获取当前的计数值
func (c *Counter) Count() uint64 {
c.mu.Lock()
defer c.mu.Unlock()
return c.count
}
// sleep 1秒,然后计数值加1
func worker(c *Counter) {
//defer wg.Done()
time.Sleep(time.Second)
c.Incr()
}
func main() {
var counter Counter
//var wg sync.WaitGroup
//wg.Add(10) // WaitGroup的值设置为10
for i := 0; i < 10; i++ { // 启动10个goroutine执行加1任务
go worker(&counter)
}
// 检查点,等待goroutine都完成任务
//wg.Wait()
// 输出当前计数器的值
fmt.Println(counter.Count())
}
如该案例中,main函数开了10个协程,对一个并发安全的计数器+1,并使用等待组,在等待这10个协程都跑完,也就是计数器10次加完了才通过打印,从而得到打印结果是10,这里如果不使用等待组,打印结果就会是0,因为main主线程的任务就是开10个协程执行自己的任务,开完这10个协程主协程任务很快就跑完了,直接打印,不会等到10个协程都执行完了才打印。
3.实现原理
主要是state状态和sema信号量两个字段,分别记录任务计数器、等待计数器、等待协程
go
type WaitGroup struct {
noCopy noCopy//避免复制使用的字段,使用go vet工具检查安全时使用
// high 32 bits are counter, low 32 bits are waiter count.
state atomic.Uint64 //高32位是任务计数器,低32位是等待协程的数目计数器(等待计数器)
sema uint32//信号量,用于唤醒等待的协程,同理mutex中的sema
}
任务计数器 用来记录等待组总任务数,也就是该等待组要等待的总协程数,处于state的高64位,等待计数器 是记录有多少个协程在等待,当任务计数器减少到 0(即所有任务都完成)时,等待计数器用于唤醒所有在等待的 goroutine。通过信号量,协程被逐个唤醒,从而继续执行。 每次有一个 goroutine 调用 Wait
方法时,等待计数器会增加+1。
wait方法的实现逻辑for循环就是不断阻塞等待
go
// 阻塞等待 直到任务执行完
func (wg *WaitGroup) Wait() {
// 部分源代码
for {
state := wg.state.Load()//原子操作读取state的值
v := int32(state >> 32)//右移32位得到任务计数器
w := uint32(state)//等待计数器
if v == 0 {// 任务计数器为0 任务都执行功完了,无需等待
if race.Enabled {
race.Enable()
race.Acquire(unsafe.Pointer(wg))
}
return
}
// 否则把等待计数器+1
if wg.state.CompareAndSwap(state, state+1) {
if race.Enabled && w == 0 {
race.Write(unsafe.Pointer(&wg.sema))
}
//调用 `runtime_Semacquire`阻塞当前 goroutine,
//直到计数器变为零并被其他 goroutine 唤醒
runtime_Semacquire(&wg.sema)
return
}
}
}
// add 方法 使用原子操作给计数器加delta
func (wg *WaitGroup) Add(delta int) {
// 使用原子操作将 delta 左移 32 位后加到状态字段中
state := wg.state.Add(uint64(delta) << 32)
// 获取任务计数器的值(高 32 位)
v := int32(state >> 32)
// 获取等待计数器的值(低 32 位)
w := uint32(state)
// 如果任务计数器大于 0 或者等待计数器为 0,则直接返回
if v > 0 || w == 0 {
return
}
// 将等待计数器重置为 0
wg.state.Store(0)
// 释放所有等待的 goroutine
for ; w != 0; w-- {
runtime_Semrelease(&wg.sema, false, 0)
}
}
4.使用WaitGroup的注意事项
1 .add方法和done方法的出现次数要一致 ,否则可能出现死锁的情况,add多了会导致死锁,done多了会导致panic
2 . 任务全部 ,add完毕后才可wait ,否则会导致任务顺序编排不一致,提前wait会导致后续的任务提前执行了。
3 .等待组第一次wait使用完才可以重用在其他任务编排上,否则会导致任务编排混乱,造成意想不到的结果。