Go的WaitGroup源码分析
WaitGroup
是开发中经常用到的并发控制手段,其源代码在 src/sync/waitgroup.go
文件中,定义了 1 个结构体和 4 个方法:
WaitGroup{}
:结构体。state()
:内部方法,在Add()
、Wait()
中调用。Add()
:添加任务数。Done()
:完成任务,其实就是Add(-1)
。Wait()
:阻塞等待所有任务的完成。
以下源代码基于 Go 1.17.5
版本,有删减。
$ go version
go version go1.17.5 darwin/amd64
在学习之前可以先了解一些概念:
- 结构体对齐相关的内容,可参考之前的笔记。
- 信号量函数有两个:
runtime_Semacquire
表示增加一个信号量,并挂起当前goroutine
。在Wait()
里用到。
runtime_Semrelease
表示减少一个信号量,并唤醒sema
上其中一个正在等待的goroutine
。在Add()
里用到。 unsafe.Pointer
用于各种指针相互转换;
uintptr
是golang
的内置类型,能存储指针的整型,其底层类型是int
,可以和unsafe.Pointer
相互转换。
一、结构体
1.1 state1 数组的组成
type WaitGroup struct {
// 表示 `WaitGroup` 是不可复制的,只能用指针传递,保证全局唯一。
noCopy noCopy
// state1 = state(*unit64) + sema(*unit32)
// state = counter + waiter
state1 [3]uint32
}
state1
是一个 uint32
数组,包含了counter
总数、waiter
等待数 和 sema
信号量,其中:
- counter:通过
Add()
设置的子goroutine
的计数值。 - waiter:通过
Wait()
陷入阻塞的waiter
数。 - sema:信号量。
1.2 state 和 sema 的位置
实际上,counter
和 waiter
合在一起,当成一个 64 位的整数来使用,所以 state1
数组又可以看成由 *unit64
的 state
和 *unit32
的 sema
组成,即:
state1 = state + sema,
其中 state = counter + waiter。
32 位系统下4字节对齐,64位系统下8字节对齐,下面的内部方法 state()
有进行判断。
state()
方法将 state1
数组中存储的状态取出来,返回值 statep
就是计数器的状态,也就是 counter
和 waiter
的整体,semap
是信号量。
func (wg *WaitGroup) state() (statep *uint64, semap *uint32) {
// 判断是否64位对齐
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()
中,根据运行时分配的地址转化成 uintptr
后,再 %8
,判断结果是否等于0,若为 0, 则说明分配的地址是 64 位对齐。
- 如果是 64 位对齐,则数组前两位是
state
,后一位是sema
; - 如果不是64位对齐,则前面一位是
sema
(32位) 后面两位是state
。
对齐方式 | state[0] | state[1] | state[2] |
---|---|---|---|
64位 | waiter | counter | sema |
32位 | sema | waiter | counter |
当我们初始化一个 waitGroup
对象时,其 counter
值、waiter
值、sema
值均为 0。
1.3 为什么这么设计 state1 数组
为什么要把 counter
和 waiter
当成一个整体来设计?这是因为对 state
使用了 atomic.64
的操作,如:
Add()
state := atomic.AddUint64(statep, uint64(delta)<<32)
Wait()
state := atomic.LoadUint64(statep)
if atomic.CompareAndSwapUint64(statep, state, state+1) {}
要保证 state
的 64 位的原子性,就要保证数据是一次读入内存的,而要保证这种一次性,就要保证 state
是 64 位对齐的。
二、Add()函数
利用64位的原子加,给 counter
加 delta
(delta
可能为负),当 counter
变零,通过信号量唤醒等待的 goroutine
。这里将 Add()
分成几步来分析:
- step 1:获取
counter
、waiter
、和sema
对应的指针,并将delta
加到counter
上。
// 获取statep、semap 的指针,也就是counter、waiter和sema
statep, semap := wg.state()
// 把delta左移32位累加到state,也就是把等待的couter利用原子加,加上delta
state := atomic.AddUint64(statep, uint64(delta)<<32)
v := int32(state >> 32) // 低32位是couter,也就是增加的,注意,这里转换成了int32类型
w := uint32(state) // 高32位是waiter
- step 2:
counter
不允许为负数,否则报panic
。
if v < 0 {
panic("sync: negative WaitGroup counter")
}
counter
是活跃的 goroutine
数量,肯定大于 0,如果它为负数,有两种情况:
第一种是 Add()
的时候,delta
直接就是负数,进行原子加操作后,counter
就小于0,我们一般不这么写;
第二种是执行 Done()
,也就是 Add(-1)
的时候,前一个 goroutine
减到了 0,还没执行完,被挂起了,又来了一个 Done()
,逻辑就出错了。
- step 3:已经执行了
Wait
,此时不允许Add
。
if w != 0 && delta > 0 && v == int32(delta) {
panic("sync: WaitGroup misuse: Add called concurrently with Wait")
}
waiter
是等待的 goroutine
数量,只有加 1 和置零两种操作,所以肯定大于等于 0,第一次 Add(n)
的时候,counter=n,waiter=0
,w != 0
说明已经执行了 Wait
;
delta > 0
说明这是一次加的操作。如果 v == int32(delta)
也就是 v + delta == delta
,推导出 v=0
,那就可能是第一次 Add()
或者是执行 Add(-1)
把 v
减到了 0,即先 Wait
后 Add
了。
- step 4:
counter > 0
或waiter = 0
,直接返回。
if v > 0 || w == 0 {
return
}
经过累加后,此时,counter >= 0
。
如果 counter
为正,说明不需要释放信号量,直接退出;
如果 waiter
为 0,说明没有等待者,也不需要释放信号量,直接退出。
- step 5:检查
WaitGroup
是否被滥用,即Add
不能与Wait
并发调用。
if *statep != state {
panic("sync: WaitGroup misuse: Add called concurrently with Wait")
}
执行到这里,counter=0 && waiter>0
,说明之前的 Done
已经完成了,计数器清零,该释放信号,唤醒所有在 wait
的 goroutine
了。如果这时候 state
状态发生变化,则说明当前有人修改过,进行了 Add
操作,报 panic
。
这一步的判断相当于锁,保证 WaitGroup
没有被滥用。
- step 6:释放所有排队的
waiter
。
*statep = 0
for ; w != 0; w-- {
runtime_Semrelease(semap, false, 0)
}
如果执行到这里,一定是负 delta
的操作,counter=0,waiter>0
说明已经完成任务,没有活跃的 goroutine
了,需要释放信号量。将状态全部归 0,并释放所有阻塞的 waiter
。
三、Wait()函数
执行 Wait()
函数的主 goroutine
会将 waiter
值加 1,并阻塞等待该值为 0,才能继续执行后续代码。
func (wg *WaitGroup) Wait() {
// 获取statep、semap 的指针,也就是counter、waiter和sema
statep, semap := wg.state()
for {// 注意这里在死循环中
state := atomic.LoadUint64(statep)// 原子操作
v := int32(state >> 32) // couter
w := uint32(state) // waiter
// counter为0,说明所有的goroutine都退出了,不需要等待
if v == 0 {
return
}
// CAS操作增加waiter
if atomic.CompareAndSwapUint64(statep, state, state+1) {
// 一旦信号量sema大于0,就挂起当前goroutine
runtime_Semacquire(semap)
// Add()函数,触发信号量前,会将counter和waiter置为0,所以此时*statep一定为0。如果*statep不为0,说明还未等Waiter执行完Wait(),又执行了Add()或Wait()操作了,WaitGroup发生了复用。
if *statep != 0 {
panic("sync: WaitGroup is reused before previous Wait has returned")
}
return
}
}
}
四、竞争分析
在 Add()
和 Wait()
中,对 state
数据的操作都存在数据竞争:
写 | 读 | |
---|---|---|
Add() | 将 delta 加到 counter |
最后信号释放的时候,需要读 waiter 和 sema |
Wait() | CAS 操作,给 waiter 加 1,增加 sema 信号量 |
读 counter ,为 0 直接返回 |
解决数据竞争,可以通过加锁来实现,操作前给 state1
数组加锁,结束后释放锁,这样肯定没有安全性的问题但是低效。
源码里解决数据竞争,没有使用锁,它分了几种情况来解决:
Add
和Add
并发
多个 Add
同时加,只加数,不管是加正数还是加负数,只要加过之后 counter
大于0,就直接 return
。因为是原子加,总有先后顺序,保证了不会加丢。
if v > 0 || w == 0 {
return
}
如果加负数之后 counter
等于0,这个时候要进行信号的释放操作,不能允许其他的 Add
同时改这个数据了。
if w != 0 && delta > 0 && v == int32(delta) {
panic("sync: WaitGroup misuse: Add called concurrently with Wait")
}
Add
和Wait
并发
如果Add
加负数之后counter
等于0,这个时候要进行信号的释放操作,不允许Wait
去修改这个数据。如果Wait
先读出了state
又改了state
,就会panic
。
if *statep != state {
panic("sync: WaitGroup misuse: Add called concurrently with Wait")
}
五、实例分析
func main() {
var wg sync.WaitGroup...............①
wg.Add(2)...........................②
go func() {
fmt.Println(1)
wg.Done().......................③
}()
go func() {
fmt.Println(2)
wg.Done().......................④
}()
wg.Wait()...........................⑤
fmt.Println("all work done!")
}
执行完 1,2 后,3、4、5 随机执行。
假设按【1、2、3、4、5】的顺序执行,
counter
和waiter
的数值变化如下:
① counter=0,waiter=0 //初始化的默认值为0
② counter=2,waiter=0 //原子加操作,给counter加2
③ counter=1,waiter=0 //完成一个Done,给counter减1,counter从2变成1
④ counter=0,waiter=0 //又完成一个Done,给counter减1,counter变成0,因为满足v>0或w=0,直接return了,不用发信号
⑤ counter=0,waiter=0 //因为v=0,所以直接return,不用CAS操作假设按【1、2、5、3、4】的顺序执行,
counter
和waiter
的数值变化如下:
① counter=0,waiter=0 //初始化的默认值为0
② counter=2,waiter=0 //原子加操作,给counter加2
⑤ counter=2,waiter=1 //CAS给waiter加1,所以waiter由0变成2
③ counter=1,waiter=1 完成一个Done,给counter减1,counter从2变成1
④ counter=0,waiter=1 又完成一个Done,给counter减1,counter变成0,发信号,通知waiter不再阻塞,main继续执行
Go的WaitGroup源码分析的更多相关文章
- 转-filebeat 源码分析
背景 在基于elk的日志系统中,filebeat几乎是其中必不可少的一个组件,例外是使用性能较差的logstash file input插件或自己造个功能类似的轮子:). 在使用和了解filebeat ...
- Docker源码分析(三):Docker Daemon启动
1 前言 Docker诞生以来,便引领了轻量级虚拟化容器领域的技术热潮.在这一潮流下,Google.IBM.Redhat等业界翘楚纷纷加入Docker阵营.虽然目前Docker仍然主要基于Linux平 ...
- 7.深入k8s:任务调用Job与CronJob及源码分析
转载请声明出处哦~,本篇文章发布于luozhiyun的博客:https://www.luozhiyun.com 在使用job中,我会结合源码进行一定的讲解,我们也可以从源码中一窥究竟,一些细节k8s是 ...
- 死磕以太坊源码分析之txpool
死磕以太坊源码分析之txpool 请结合以下代码阅读:https://github.com/blockchainGuide/ 写文章不易,也希望大家多多指出问题,交个朋友,混个圈子哦 交易池概念原理 ...
- go中waitGroup源码解读
waitGroup源码刨铣 前言 WaitGroup实现 noCopy state1 Add Wait 总结 参考 waitGroup源码刨铣 前言 学习下waitGroup的实现 本文是在go ve ...
- kube-scheduler源码分析(2)-核心处理逻辑分析
kube-scheduler源码分析(2)-核心处理逻辑分析 kube-scheduler简介 kube-scheduler组件是kubernetes中的核心组件之一,主要负责pod资源对象的调度工作 ...
- ABP源码分析一:整体项目结构及目录
ABP是一套非常优秀的web应用程序架构,适合用来搭建集中式架构的web应用程序. 整个Abp的Infrastructure是以Abp这个package为核心模块(core)+15个模块(module ...
- HashMap与TreeMap源码分析
1. 引言 在红黑树--算法导论(15)中学习了红黑树的原理.本来打算自己来试着实现一下,然而在看了JDK(1.8.0)TreeMap的源码后恍然发现原来它就是利用红黑树实现的(很惭愧学了Ja ...
- nginx源码分析之网络初始化
nginx作为一个高性能的HTTP服务器,网络的处理是其核心,了解网络的初始化有助于加深对nginx网络处理的了解,本文主要通过nginx的源代码来分析其网络初始化. 从配置文件中读取初始化信息 与网 ...
随机推荐
- [BUUCTF]PWN——ciscn_2019_ne_5
ciscn_2019_ne_5 题目附件 步骤: 例行检查,32位,开启了nx保护 试运行一下程序,看一下程序的大概执行情况 32位ida载入,shift+f12查看程序里的字符串,发现了flag字符 ...
- 显卡不是你学习 Deep Learning 的借口
显卡不是你学习 Deep Learning 的借口 很多人在学习深度学习的时候会以自己没有 RTX N 卡的理由不动手实操,只满足于看看"娱乐"视频,听几节基础知识.当然,如果只是 ...
- 分组依据(Project)
<Project2016 企业项目管理实践>张会斌 董方好 编著 [视图]选项卡下,[筛选器]楼下,住着个[分组依据]. 这个功能,说白了,就是指定个"组",把同一组的 ...
- LuoguP7505 「Wdsr-2.5」小小的埴轮兵团 题解
Content 给出一个范围为 \([-k,k]\) 的数轴,数轴上有 \(n\) 个点,第 \(i\) 个点的位置为 \(a_i\).有 \(m\) 次操作,有且仅有以下三种: 1 x:所有点往右移 ...
- mysql 字符串转日期及其他日期转换
-- 字符串转日期 select str_to_date('2019/1/1', '%Y/%m/%d') -- 2019-01-01 SELECT STR_TO_DATE(concat(Cyear,' ...
- java 编程基础 反射方式获取泛型的类型Fileld.getGenericType() 或Method.getGenericParameterTypes(); (ParameterizedType) ;getActualTypeArguments()
引言 自从JDK5以后,Java Class类增加了泛型功能,从而允许使用泛型来限制Class类,例如,String.class的类型实际上是 Class 如果 Class 对应的类暂时未知,则使 C ...
- Mybatis-Plus一键生成代码
Mybatis-Plus一键生成代码 一.闲言碎语 闲来无事看了看了MP的官网看到一键生成的代码更新了! 整个Ui风格都变了,遂决定瞅一眼新的代码生成器 官网地址~~ 二.引入依赖 新的代码生成只有在 ...
- JAVA比较两个版本号的大小
/** * 比较版本号的大小 (两个版本号格式应尽量相同) * * @param v1 版本号1 * @param v2 版本号2 * @return 正数:v1大 负数:v2大 0:相等 */ pu ...
- C语言之可变长参数格式化
概述 本文演示环境: win10 + Vs2015 可变长参数格式化 两个概念: 1. 参数长度不定, 2. 参数格式化. 使用函数 vsnprintf 结合 va_list. 源码 写好了函数, 照 ...
- 【LeetCode】1409. 查询带键的排列 Queries on a Permutation With Key
作者: 负雪明烛 id: fuxuemingzhu 个人博客:http://fuxuemingzhu.cn/ 目录 题目描述 题目大意 解题方法 模拟 日期 题目地址:https://leetcode ...