Golang WaitGroup 原理深度剖析

peter
• 阅读 1585

sync.WaitGroup 是 Golang 中常用的并发措施,我们可以用它来等待一批 Goroutine 结束。

WaitGroup 的源码也非常简短,抛去注释外也就 100 行左右的代码。但即使是这 100 行代码,里面也有着关乎内存优化、并发安全考虑等各种性能优化手段。

本文将基于 go-1.13 的源码 进行分析,将会涉及以下知识点:

  1. WaitGroup 的实现逻辑

  2. WaitGroup 的底层内存结构及性能优化

  3. WaitGroup 的内部如何实现无锁操作

WaitGroup 的使用

在正式分析源码之前,我们先看下 WaitGroup 的基本用法:

func main() {  
 var wg sync.WaitGroup  
 for i := 1; i <= 5; i++ {  
  wg.Add(1)  
  go func() {  
   defer wg.Done()  
   println("hello")  
  }()  
 }  

 wg.Wait()  
}  

从上述代码可以看出,WaitGroup 的用法非常简单:使用 Add 添加需要等待的个数,使用 Done 来通知 WaitGroup 任务已完成,使用 Wait 来等待所有 goroutine 结束。

WaitGroup 的实现逻辑

我们首先看下 WaitGroup 的组成结构,代码如下:

type WaitGroup struct {  
 noCopy noCopy  
 state1 [3]uint32  
}  

其中 noCopy 是 golang 源码中检测禁止拷贝的技术。如果程序中有 WaitGroup 的赋值行为,使用 go vet 检查程序时,就会发现有报错。但需要注意的是,noCopy 不会影响程序正常的编译和运行。

state1 [3]uint32 字段中包含了 WaitGroup 的所有状态数据。该字段的整个设计其实非常复杂,为了便于快速理解 WaitGroup 的主流程,我们将在后面部分单独剖析 state1

为了便于理解 WaitGroup 的整个实现过程,我们暂时先不考虑内存对齐和并发安全等方面因素。那么 WaitGroup 可以近似的看做以下代码:

type WaitGroup struct {  
 counter int32  
 waiter  uint32  
 sema    uint32  
}  

其中:

  • counter 代表目前尚未完成的个数。WaitGroup.Add(n) 将会导致 counter += n, 而 WaitGroup.Done() 将导致 counter--

  • waiter 代表目前已调用 WaitGroup.Wait 的 goroutine 的个数。

  • sema 对应于 golang 中 runtime 内部的信号量的实现。WaitGroup 中会用到 sema 的两个相关函数,runtime_Semacquireruntime_Semreleaseruntime_Semacquire 表示增加一个信号量,并挂起 当前 goroutine。runtime_Semrelease 表示减少一个信号量,并唤醒 sema 上其中一个正在等待的 goroutine。

WaitGroup 的整个调用过程可以简单地描述成下面这样:

  1. 当调用 WaitGroup.Add(n) 时,counter 将会自增: counter += n

  2. 当调用 WaitGroup.Wait() 时,会将 waiter++。同时调用 runtime_Semacquire(semap), 增加信号量,并挂起当前 goroutine。

  3. 当调用 WaitGroup.Done() 时,将会 counter--。如果自减后的 counter 等于 0,说明 WaitGroup 的等待过程已经结束,则需要调用 runtime_Semrelease 释放信号量,唤醒正在 WaitGroup.Wait 的 goroutine。

以上就是 WaitGroup 实现过程的简略版。但实际上,WaitGroup 在实现过程中对并发性能以及内存占用优化上,都有一些非常巧妙的设计点,我们接下来要着重讨论下。

WaitGroup 的底层内存结构

我们回来讨论 WaitGroup 中 state1 的内存结构。state1 长度为 3 的 uint32 数组,但正如我们上文讨论,其中 state1 中包含了三个变量的语义和行为,其内存结构如下:

Golang WaitGroup 原理深度剖析

WaitGroup 内存布局

我们在图中提到了 Golang 内存对齐的概念。简单来说,如果变量是 64 位对齐 (8 byte), 则该变量的起始地址是 8 的倍数。如果变量是 32 位对齐 (4 byte),则该变量的起始地址是 4 的倍数。

从图中看出,当 state1 是 32 位对齐和 64 位对齐的情况下,state1 中每个元素的顺序和含义也不一样:

  • state1 是 32 位对齐:state1 数组的第一位是 sema,第二位是 counter,第三位是 waiter。

  • state1 是 64 位对齐:state1 数组的第一位是 counter,第二位是 waiter,第三位是 sema。

为什么会有这种奇怪的设定呢?这里涉及两个前提:

前提 1:在 WaitGroup 的真实逻辑中, counter 和 waiter 被合在了一起,当成一个 64 位的整数对外使用。当需要变化 counter 和 waiter 的值的时候,也是通过 atomic 来原子操作这个 64 位整数。但至于为什么合在一起,我们会在下文详细讨论。

前提 2:在 32 位系统下,如果使用 atomic 对 64 位变量进行原子操作,调用者需要自行保证变量的 64 位对齐,否则将会出现异常。golang 的官方文档 sync/atomic/#pkg-note-BUG 原文是这么说的:

On ARM, x86-32, and 32-bit MIPS, it is the caller’s responsibility to arrange for 64-bit alignment of 64-bit words accessed atomically. The first word in a variable or in an allocated struct, array, or slice can be relied upon to be 64-bit aligned.

因此,在前提 1 的情况下,WaitGroup 需要对 64 位进行原子操作。根据前提 2,WaitGroup 需要自行保证 count+waiter 的 64 位对齐。这也是 WaitGroup 采用 [3]uint32 存储变量的目的:

  • state1 变量是 64 位对齐时,也就意味着数组前两位作为 64 位整数时,自然也可以保证 64 位对齐了。

  • state1 变量是 32 位对齐时,我们把数组第 1 位作为对齐的 padding,因为 state1 本身是 uint32 的数组,所以数组第一位也有 32 位。这样就保证了把数组后两位看做统一的 64 位整数时是64位对齐的。

这个方法非常的巧妙,只不过是改变 sema 的位置顺序,就既可以保证 counter+waiter 一定会 64 位对齐,也可以保证内存的高效利用。

Golang 官方文档中也给出了 判断当前变量是 32 位对齐还是 64 位对齐的方法::

uintptr(unsafe.Pointer(&x)) % unsafe.Alignof(x) == 0  

WaitGroup 中从 state1 中取变量的方法如下:

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]  
 }  
}  

注: 有些文章会讲到,WaitGroup 两种不同的内存布局方式是 32 位系统和 64 位系统的区别,这其实不太严谨。准确的说法是 32 位对齐和 64 位对齐的区别。因为在 32 位系统下,state1 变量也有可能恰好符合 64 位对齐。

WaitGroup 的无锁实现

我们上文讲到,在 WaitGroup 中,其实是把 counterwaiter 看成一个 64 位整数进行处理,但为什么要这么做呢?分成两个 32 位变量岂不是更方便?这其实是 WaitGroup 的一个性能优化手段。

counterwaiter 在改变时需要保证并发安全。对于这种场景,我们最简单的做法是,搞一个 Mutex 或者 RWMutex 锁, 在需要读写 counterwaiter 的时候,加锁就完事。但是我们知道加锁必然会造成额外的性能开销,作为 Golang 系统库,自然需要把性能压榨到极致。

WaitGroup 直接把 counterwaiter 看成了一个统一的 64 位变量。其中 counter 是这个变量的高 32 位,waiter 是这个变量的低 32 位。在需要改变 counter 时, 通过将累加值左移 32 位的方式:atomic.AddUint64(statep, uint64(delta)<<32),即可实现 count += delta 同样的效果。

在 Wait 函数中,通过 CAS 操作 atomic.CompareAndSwapUint64(statep, state, state+1), 来对 waiter 进行自增操作,如果 CAS 操作返回 false,说明 state 变量有修改,有可能是 counter 发生了变化,这个时候需要重试检查逻辑条件。

还有一个小细节值得一提的是,因为 WaitGroup 是可以复用的,因此在 Wait 结束的时候需要将 waiter--,重置状态。但这肯定会涉及到一次原子变量操作。如果调用 Wait 的 goroutine 比较多,那这个原子操作也会随之进行很多次。但 WaitGroup 这里直接在Done 的时候,当 counter 等于 0 时,直接将 counter+waiter 整个 64 位整数全部置 0,既可以达到重置状态的效果,也免于进行多次原子操作。

总结

Waitgroup 虽然只有 100 行左右的代码。作为语言的内置库,我们从中可以看出作者对每个细节的极致打磨,非常精细的针对场景优化性能,这也给我们写程序带来了很多启发。

参考

  • Go:Size_and_alignment_guarantees

  • Dig101-Go 之聊聊 struct 的内存对齐


本文转自 https://mp.weixin.qq.com/s/CkSd2aldYaoLbd-IKhkpWg,如有侵权,请联系删除。

点赞
收藏
评论区
推荐文章
秃头王路飞 秃头王路飞
4个月前
webpack5手撸vue2脚手架
webpack5手撸vue相信工作个12年的小伙伴们在面试的时候多多少少怕被问到关于webpack方面的知识,本菜鸟最近闲来无事,就尝试了手撸了下vue2的脚手架,第一次发帖实在是没有经验,望海涵。languageJavaScript"name":"vuecliversion2","version":"1.0.0","desc
光头强的博客 光头强的博客
4个月前
Java面向对象试题
1、请创建一个Animal动物类,要求有方法eat()方法,方法输出一条语句“吃东西”。创建一个接口A,接口里有一个抽象方法fly()。创建一个Bird类继承Animal类并实现接口A里的方法输出一条有语句“鸟儿飞翔”,重写eat()方法输出一条语句“鸟儿吃虫”。在Test类中向上转型创建b对象,调用eat方法。然后向下转型调用eat()方
刚刚好 刚刚好
4个月前
css问题
1、在IOS中图片不显示(给图片加了圆角或者img没有父级)<div<imgsrc""/</divdiv{width:20px;height:20px;borderradius:20px;overflow:h
blmius blmius
1年前
MySQL:[Err] 1292 - Incorrect datetime value: ‘0000-00-00 00:00:00‘ for column ‘CREATE_TIME‘ at row 1
文章目录问题用navicat导入数据时,报错:原因这是因为当前的MySQL不支持datetime为0的情况。解决修改sql\mode:sql\mode:SQLMode定义了MySQL应支持的SQL语法、数据校验等,这样可以更容易地在不同的环境中使用MySQL。全局s
小森森 小森森
4个月前
校园表白墙微信小程序V1.0 SayLove -基于微信云开发-一键快速搭建,开箱即用
后续会继续更新,敬请期待2.0全新版本欢迎添加左边的微信一起探讨!项目地址:(https://www.aliyun.com/activity/daily/bestoffer?userCodesskuuw5n)\2.Bug修复更新日历2.情侣脸功能大家不要使用了,现在阿里云的接口已经要收费了(土豪请随意),\\和注意
晴空闲云 晴空闲云
4个月前
css中box-sizing解放盒子实际宽高计算
我们知道传统的盒子模型,如果增加内边距padding和边框border,那么会撑大整个盒子,造成盒子的宽度不好计算,在实务中特别不方便。boxsizing可以设置盒模型的方式,可以很好的设置固定宽高的盒模型。盒子宽高计算假如我们设置如下盒子:宽度和高度均为200px,那么这会这个盒子实际的宽高就都是200px。但是当我们设置这个盒子的边框和内间距的时候,那
艾木酱 艾木酱
3个月前
快速入门|使用MemFire Cloud构建React Native应用程序
MemFireCloud是一款提供云数据库,用户可以创建云数据库,并对数据库进行管理,还可以对数据库进行备份操作。它还提供后端即服务,用户可以在1分钟内新建一个应用,使用自动生成的API和SDK,访问云数据库、对象存储、用户认证与授权等功能,可专
NVIDIA安培架构下MIG技术分析
关键词:NVIDIA、MIG、安培一什么是MIG2020年5月,NVIDIA发布了最新的GPU架构:安培,以及基于安培架构的最新的GPU:A100。安培提供了许多新的特性,MIG是其中一项非常重要的新特性。MIG的全名是MultiInstanceGPU。NVIDIA安培架构中的MIG模式可以在A100GPU上并行运行七个作业。多实
helloworld_28799839 helloworld_28799839
4个月前
常用知识整理
Javascript判断对象是否为空jsObject.keys(myObject).length0经常使用的三元运算我们经常遇到处理表格列状态字段如status的时候可以用到vue
helloworld_34035044 helloworld_34035044
7个月前
皕杰报表之UUID
​在我们用皕杰报表工具设计填报报表时,如何在新增行里自动增加id呢?能新增整数排序id吗?目前可以在新增行里自动增加id,但只能用uuid函数增加UUID编码,不能新增整数排序id。uuid函数说明:获取一个UUID,可以在填报表中用来创建数据ID语法:uuid()或uuid(sep)参数说明:sep布尔值,生成的uuid中是否包含分隔符'',缺省为
密钥管理系统-为你的天翼云资产上把“锁
本文关键词:数据安全,密码机,密钥管理一、你的云上资产真的安全么?1.2021年1月,巴西的一个数据库30TB数据被破坏,泄露的数据包含有1.04亿辆汽车和约4000万家公司的详细信息,受影响的人员数量可能有2.2亿;2.2021年2月,广受欢迎的音频聊天室应用Clubhouse的用户数据被恶意黑客或间谍窃取。据悉,一位身份不明的用户能够将Clubho