WaitGroup 是什么以及它能为我们解决什么问题?

WaitGroup在go语言中,用于线程同步,单从字面意思理解,wait等待的意思,group组、团队的意思,WaitGroup就是指等待一组,等待一个系列执行完成后才会继续向下执行。

正常情况下,goroutine的结束过程是不可控制的,我们可以保证的只有main goroutine的终止。

这时候可以借助sync包的WaitGroup来判断goroutine是否完成。

WaitGroup介绍

WatiGroupsync包中的一个struct类型,用来收集需要等待执行完成的goroutine。下面是它的定义:

// WaitGroup用于等待一组线程的结束。
// 父线程调用Add方法来设定应等待的线程的数量。
// 每个被等待的线程在结束时应调用Done方法。同时,主线程里可以调用Wait方法阻塞至所有线程结束。
type WaitGroup struct {
// 包含隐藏或非导出字段
} // Add方法向内部计数加上delta,delta可以是负数;
// 如果内部计数器变为0,Wait方法阻塞等待的所有线程都会释放,如果计数器小于0,方法panic。
// 注意Add加上正数的调用应在Wait之前,否则Wait可能只会等待很少的线程。
// 一般来说本方法应在创建新的线程或者其他应等待的事件之前调用。
func (wg *WaitGroup) Add(delta int) // Done方法减少WaitGroup计数器的值,应在线程的最后执行。
func (wg *WaitGroup) Done() // Wait方法阻塞直到WaitGroup计数器减为0。
func (wg *WaitGroup) Wait()

它有3个方法:

Add():每次激活想要被等待完成的goroutine之前,先调用Add(),用来设置或添加要等待完成的goroutine数量

例如Add(2)或者两次调用Add(1)都会设置等待计数器的值为2,表示要等待2个goroutine完成

Done():每次需要等待的goroutine在真正完成之前,应该调用该方法来人为表示goroutine完成了,该方法会对等待计数器减1

Wait():在等待计数器减为0之前,Wait()会一直阻塞当前的goroutine

也就是说,Add()用来增加要等待的goroutine的数量,Done()用来表示goroutine已经完成了,减少一次计数器,Wait()用来等待所有需要等待的goroutine完成。

示例一

package main

import (
"fmt"
"sync"
"time"
) // 每个协程都会运行该函数。
// 注意,WaitGroup 必须通过指针传递给函数。
func worker(id int, wg *sync.WaitGroup) {
fmt.Printf("Worker %d starting\n", id) // 睡眠一秒钟,以此来模拟耗时的任务。
time.Sleep(time.Second)
fmt.Printf("Worker %d done\n", id) // 通知 WaitGroup ,当前协程的工作已经完成。
wg.Done()
} func main() { // 这个 WaitGroup 被用于等待该函数开启的所有协程。
var wg sync.WaitGroup // 开启几个协程,并为其递增 WaitGroup 的计数器。
for i := 1; i <= 5; i++ {
wg.Add(1)
go worker(i, &wg)
} // 阻塞,直到 WaitGroup 计数器恢复为 0,即所有协程的工作都已经完成。
wg.Wait()
}

main中开启了5个协程,开启协程之前都先调用了Add()方法增加了一个需要等待goroutine计数。每个goroutine都运行worker()函数,这个函数执行完成后调用Done()方法通知 WaitGroup表示当前协程的完成。

有一点需要注意,worker()函数中使用了指针类型的*sync.WaitGroup作为参数,这里不能使用值类型的sync.WaitGroup作为参数,因为这意味着每个goroutine都拷贝一份wg,每个goroutine都使用自己的wg。这显然是不合理的,这5个协程应该共享一个wg,这样才能知道这几个协程都完成了。实际上,如果使用值类型的参数,main goroutine将会永久阻塞而导致产生死锁。

还有一点需要注意AddDone函数一定要配对,否则可能发生死锁,所报的错误信息如下:

fatal error: all goroutines are asleep - deadlock!

运行:

go run waitgroups.go
Worker 5 starting
Worker 3 starting
Worker 4 starting
Worker 1 starting
Worker 2 starting
Worker 4 done
Worker 1 done
Worker 2 done
Worker 5 done
Worker 3 done

每次运行,各个协程开启和完成的时间可能是不同的。

示例二

在工作中使用时,等待一个协程组全部正确完成则结束;但其中一个协程发生错误,这时候就会阻塞了,不推荐这种用法。

这种场景就需要使用到通知机制,这时候可以使用channel来实现。

package main

import (
"fmt"
"sync"
"time"
) func main(){
// 这个 WaitGroup 被用于等待该函数开启的所有协程。
var wg sync.WaitGroup // Add()方法开启了3个等待的协程计数
wg.Add(3) // 开启3个协程,用于工作处理
go work1(&wg)
go work2(&wg)
go work3(&wg) // 阻塞,直到 WaitGroup 计数器恢复为 0,即所有协程的工作都已经完成。
wg.Wait()
} func work1(wg *sync.WaitGroup){
fmt.Println("work1 starting") // 睡眠一秒钟,以此来模拟耗时的任务。
time.Sleep(time.Second)
fmt.Println("work1 done") // 通知 WaitGroup ,当前协程的工作已经完成。
wg.Done()
} func work2(wg *sync.WaitGroup){
fmt.Println("work2 starting") // 睡眠一秒钟,以此来模拟耗时的任务。
time.Sleep(time.Second)
fmt.Println("work2 done") // 通知 WaitGroup ,当前协程的工作已经完成。
wg.Done()
} func work3(wg *sync.WaitGroup){
fmt.Println("work3 starting") // 睡眠一秒钟,以此来模拟耗时的任务。
time.Sleep(time.Second)
fmt.Println("work3 done") // 通知 WaitGroup ,当前协程的工作已经完成。
wg.Done()
}

源码分析

type WaitGroup struct {
noCopy noCopy // 64-bit value: high 32 bits are counter, low 32 bits are waiter count.
// 64-bit atomic operations require 64-bit alignment, but 32-bit
// compilers do not ensure it. So we allocate 12 bytes and then use
// the aligned 8 bytes in them as state, and the other 4 as storage
// for the sema.
state1 [3]uint32
}

WaitGroup 结构十分简单,由 nocopystate1 两个字段组成,其中 nocopy 是用来防止复制的

type noCopy struct{}

// Lock is a no-op used by -copylocks checker from `go vet`.
func (*noCopy) Lock() {}
func (*noCopy) Unlock() {}

由于嵌入了 nocopy 所以在执行 go vet 时如果检查到 WaitGroup 被复制了就会报错。这样可以一定程度上保证 WaitGroup 不被复制,对了直接 go run 是不会有错误的,所以我们代码 push 之前都会强制要求进行 lint 检查,在 ci/cd 阶段也需要先进行 lint 检查,避免出现这种类似的错误。

~/project/Go-000/Week03/blog/06_waitgroup/02 main*
❯ go run ./main.go ~/project/Go-000/Week03/blog/06_waitgroup/02 main*
❯ go vet .
# github.com/mohuishou/go-training/Week03/blog/06_waitgroup/02
./main.go:7:9: assignment copies lock value to wg2: sync.WaitGroup contains sync.noCopy

state1 的设计非常巧妙,这是一个是十二字节的数据,这里面主要包含两大块,counter 占用了 8 字节用于计数,sema 占用 4 字节用做信号量

为什么要这么搞呢?直接用两个字段一个表示 counter,一个表示 sema 不行么?

不行,我们看看注释里面怎么写的。

// 64-bit value: high 32 bits are counter, low 32 bits are waiter count. > // 64-bit atomic operations require 64-bit alignment, but 32-bit > // compilers do not ensure it. So we allocate 12 bytes and then use > // the aligned 8 bytes in them as state, and the other 4 as storage > // for the sema.

这段话的关键点在于,在做 64 位的原子操作的时候必须要保证 64 位(8 字节)对齐,如果没有对齐的就会有问题,但是 32 位的编译器并不能保证 64 位对齐所以这里用一个 12 字节的 state1 字段来存储这两个状态,然后根据是否 8 字节对齐选择不同的保存方式。

这个操作巧妙在哪里呢?

  • 如果是 64 位的机器那肯定是 8 字节对齐了的,所以是上面第一种方式
  • 如果在 32 位的机器上

    如果恰好 8 字节对齐了,那么也是第一种方式取前面的 8 字节数据

    如果是没有对齐,但是 32 位 4 字节是对齐了的,所以我们只需要后移四个字节,那么就 8 字节对齐了,所以是第二种方式

所以通过 sema 信号量这四个字节的位置不同,保证了 counter 这个字段无论在 32 位还是 64 为机器上都是 8 字节对齐的,后续做 64 位原子操作的时候就没问题了。

这个实现是在 state 方法实现的

func (wg *WaitGroup) state() (statep *uint64, semap *uint32) {
if uintptr(unsafe.Pointer(&wg.state1))%8 == 0 {
return (*uint64)(unsafe.Pointer(&wg.state1)), &wg.state1[2]
} else {
return (*uint64)(unsafe.Pointer(&wg.state1[1])), &wg.state1[0]
}
}

state 方法返回 counter 和信号量,通过 uintptr(unsafe.Pointer(&wg.state1))%8 == 0 来判断是否 8 字节对齐

Add

func (wg *WaitGroup) Add(delta int) {
// 先从 state 当中把数据和信号量取出来
statep, semap := wg.state() // 在 waiter 上加上 delta 值
state := atomic.AddUint64(statep, uint64(delta)<<32)
// 取出当前的 counter
v := int32(state >> 32)
// 取出当前的 waiter,正在等待 goroutine 数量
w := uint32(state) // counter 不能为负数
if v < 0 {
panic("sync: negative WaitGroup counter")
} // 这里属于防御性编程
// w != 0 说明现在已经有 goroutine 在等待中,说明已经调用了 Wait() 方法
// 这时候 delta > 0 && v == int32(delta) 说明在调用了 Wait() 方法之后又想加入新的等待者
// 这种操作是不允许的
if w != 0 && delta > 0 && v == int32(delta) {
panic("sync: WaitGroup misuse: Add called concurrently with Wait")
}
// 如果当前没有人在等待就直接返回,并且 counter > 0
if v > 0 || w == 0 {
return
} // 这里也是防御 主要避免并发调用 add 和 wait
if *statep != state {
panic("sync: WaitGroup misuse: Add called concurrently with Wait")
} // 唤醒所有 waiter,看到这里就回答了上面的问题了
*statep = 0
for ; w != 0; w-- {
runtime_Semrelease(semap, false, 0)
}
}

Wait

wait 主要就是等待其他的 goroutine 完事之后唤醒

func (wg *WaitGroup) Wait() {
// 先从 state 当中把数据和信号量的地址取出来
statep, semap := wg.state() for {
// 这里去除 counter 和 waiter 的数据
state := atomic.LoadUint64(statep)
v := int32(state >> 32)
w := uint32(state) // counter = 0 说明没有在等的,直接返回就行
if v == 0 {
// Counter is 0, no need to wait.
return
} // waiter + 1,调用一次就多一个等待者,然后休眠当前 goroutine 等待被唤醒
if atomic.CompareAndSwapUint64(statep, state, state+1) {
runtime_Semacquire(semap)
if *statep != 0 {
panic("sync: WaitGroup is reused before previous Wait has returned")
}
return
}
}
}

Done

func (wg *WaitGroup) Done() {
wg.Add(-1)
}

总结

通过WaitGroup提供的三个函数:Add,Done,Wait,可以轻松实现等待某个协程或协程组完成的同步操作。但在使用时要注意:

  • WaitGroup 可以用于一个 goroutine 等待多个 goroutine 干活完成,也可以多个 goroutine 等待一个 goroutine 干活完成,是一个多对多的关系

    多个等待一个的典型案例是 singleflight,这个在后面将微服务可用性的时候还会再讲到,感兴趣可以看看源码
  • Add(n>0) 方法应该在启动 goroutine 之前调用,然后在 goroution 内部调用 Done 方法
  • WaitGroup 必须在 Wait 方法返回之后才能再次使用
  • Done 只是 Add 的简单封装,所以实际上是可以通过一次加一个比较大的值减少调用,或者达到快速唤醒的目的。
  • 协程函数要使用指针类型的*sync.WaitGroup作为参数,不能使用值类型的sync.WaitGroup作为参数
  • Add的数量和Done的调用数量必须相等,否则可能发生死锁

WaitGroup在需要等待多个任务结束再返回的业务来说还是很有用的,但现实中用的更多的可能是,先等待一个协程组,若所有协程组都正确完成,则一直等到所有协程组结束;若其中有一个协程发生错误,则告诉协程组的其他协程,全部停止运行(本次任务失败)以免浪费系统资源。

该场景WaitGroup是无法实现的,那么该场景该如何实现呢,就需要用到通知机制,其实也可以用channel来实现,具体的解决办法,请看后续的文章。

这样说来,WaitGroup的使用场景是有限的。

Go语言之sync包 WaitGroup的使用的更多相关文章

  1. golang 中 sync包的 WaitGroup

    golang 中的 sync 包有一个很有用的功能,就是 WaitGroup 先说说 WaitGroup 的用途:它能够一直等到所有的 goroutine 执行完成,并且阻塞主线程的执行,直到所有的 ...

  2. go语言中sync包和channel机制

    文章转载至:https://www.bytelang.com/article/content/A4jMIFmobcA= golang中实现并发非常简单,只需在需要并发的函数前面添加关键字"Go&quo ...

  3. Go中sync包学习

    前面刚讲到goroutine和channel,通过goroutine启动一个协程,通过channel的方式在多个goroutine中传递消息来保证并发安全.今天我们来学习sync包,这个包是Go提供的 ...

  4. 深度解密 Go 语言之 sync.Pool

    最近在工作中碰到了 GC 的问题:项目中大量重复地创建许多对象,造成 GC 的工作量巨大,CPU 频繁掉底.准备使用 sync.Pool 来缓存对象,减轻 GC 的消耗.为了用起来更顺畅,我特地研究了 ...

  5. R语言︱H2o深度学习的一些R语言实践——H2o包

    每每以为攀得众山小,可.每每又切实来到起点,大牛们,缓缓脚步来俺笔记葩分享一下吧,please~ --------------------------- R语言H2o包的几个应用案例 笔者寄语:受启发 ...

  6. Go语言基础之包

    Go语言基础之包 在工程化的Go语言开发项目中,Go语言的源码复用是建立在包(package)基础之上的.本文介绍了Go语言中如何定义包.如何导出包的内容及如何导入其他包. Go语言的包(packag ...

  7. R语言:recommenderlab包的总结与应用案例

    R语言:recommenderlab包的总结与应用案例   1. 推荐系统:recommenderlab包整体思路 recommenderlab包提供了一个可以用评分数据和0-1数据来发展和测试推荐算 ...

  8. 使用R语言的RTCGA包获取TCGA数据--转载

    转载生信技能树 https://mp.weixin.qq.com/s/JB_329LCWqo5dY6MLawfEA TCGA数据源 - R包RTCGA的简单介绍 - 首先安装及加载包 - 指定任意基因 ...

  9. sync包 — 汇总

    sync包 package main; import ( "time" "fmt" ) func main() { //time.Time代表一个纳秒精度的时间 ...

  10. Go语言内置包之strconv

    文章引用自 Go语言内置包之strconv Go语言中strconv包实现了基本数据类型和其字符串表示的相互转换. strconv包 strconv包实现了基本数据类型与其字符串表示的转换,主要有以下 ...

随机推荐

  1. Qt编写物联网管理平台50-超强跨平台

    一.前言 跨平台的需求,除了是用户的需求外,也是为了适应日益增长的国产操作系统的发展的需要,当前国产操作系统发展的如火如荼,100%都是围绕linux系统展开,说的好听点就是站在巨人的肩膀上开发,不好 ...

  2. [转]Automatic Image Stitching with Accord.NET

    原文链接:Automatic Image Stitching with Accord.NET

  3. 《刚刚问世》系列初窥篇-Java+Playwright自动化测试-9- 浏览器的相关操作 (详细教程)

    1.简介 在自动化测试领域,元素定位是非常重要的一环.正确定位页面元素是测试用例能否成功执行的关键因素之一.playwright是一种自动化测试工具,它提供了丰富的元素定位方法,可以满足不同场景下的定 ...

  4. AngleSharp 自带的HttpRequest参数设置

    AngleSharp自带一个获取网址源码的api,可以方便的从web取得html var config = Configuration.Default.WithDefaultLoader(); var ...

  5. Appium_WebDriverAgent设置

            在使用真机调试的时候犯了一个错误,我把WebDriverAgent 下载到本地的A目录下,然后进行build安装,这样在模拟器上执行是无法发现问题的,但是使用appium 在真机上执行 ...

  6. Ps cs4 -把GIF背景变透明-简单操作,还可以将视频转换GIF

    准备软件: 1.Ps cs4 2.QuickTime Player 7.74 开始: 1. 2.弹出文件选择框,但是发现不能选择GIF格式. 3.没关系,在文件名框输入*.*回车,就发现可以选择GIF ...

  7. HttpClient.PostAsynct 发送Json数据

    HttpClient.PostAsync第二个参数设置HttpContent 发送Json数据. 需要这是这个content.Headers.ContentType = new System.Net. ...

  8. biancheng-Redis教程

    目录http://c.biancheng.net/redis/ 1Redis是什么2Windows下载安装Redis3Ubuntu下载安装Redis4Redis配置文件5Redis数据类型6Redis ...

  9. bug的合规描述

    bug的合格描述:   发现问题的版本bug的合格描述: 开发人员需要知道出现问题的版本,才能够获取对应版本的代码来重现故障问题出现的环境 环境分为硬件环境和软件环境,详细的环境描述有利于故障的重现( ...

  10. 牛客周赛 Round 77

    题目链接:牛客周赛 Round 77 A. 时间表 tag:签到 B. 数独数组 tag:签到 Description:给定n个数,每个数的范围为1-9,问能否经过排列,使其每个长度为9的连续子数组都 ...