waitGroup源码刨铣

前言

学习下waitGroup的实现

本文是在go version go1.13.15 darwin/amd64上进行的

WaitGroup实现

看一个小demo

func waitGroup() {
var wg sync.WaitGroup wg.Add(4)
go func() {
defer wg.Done()
fmt.Println(1)
}() go func() {
defer wg.Done()
fmt.Println(2)
}() go func() {
defer wg.Done()
fmt.Println(3)
}() go func() {
defer wg.Done()
fmt.Println(4)
}() wg.Wait()
fmt.Println("1 2 3 4 end")
}

1、启动goroutine前将计数器通过Add(4)将计数器设置为待启动的goroutine个数。

2、启动goroutine后,使用Wait()方法阻塞主协程,等待计数器变为0。

3、每个goroutine执行结束通过Done()方法将计数器减1。

4、计数器变为0后,阻塞的goroutine被唤醒。

看下具体的实现

// WaitGroup 不能被copy
type WaitGroup struct {
noCopy noCopy state1 [3]uint32
}

noCopy

意思就是不让copy,是如何实现的呢?

Go中没有原生的禁止拷贝的方式,所以如果有的结构体,你希望使用者无法拷贝,只能指针传递保证全局唯一的话,可以这么干,定义 一个结构体叫 noCopy,实现如下的接口,然后嵌入到你想要禁止拷贝的结构体中,这样go vet就能检测出来。

// noCopy may be embedded into structs which must not be copied
// after the first use.
//
// See https://golang.org/issues/8005#issuecomment-190753527
// for details.
type noCopy struct{} // Lock is a no-op used by -copylocks checker from `go vet`.
func (*noCopy) Lock() {}
func (*noCopy) Unlock() {}

测试下

type noCopy struct{}

func (*noCopy) Lock()   {}
func (*noCopy) Unlock() {} type Person struct {
noCopy noCopy
name string
} // go中的函数传参都是值拷贝
func test(person Person) {
fmt.Println(person)
} func main() {
var person Person
test(person)
}

go vet main.go

$ go vet main.go
# command-line-arguments
./main.go:18:18: test passes lock by value: command-line-arguments.Person contains command-line-arguments.noCopy
./main.go:19:14: call of fmt.Println copies lock value: command-line-arguments.Person contains command-line-arguments.noCopy
./main.go:24:7: call of test copies lock value: command-line-arguments.Person contains command-line-arguments.noCopy

使用vet检测到了不能copy的错误

state1

	// 64 位值: 高 32 位用于计数,低 32 位用于等待计数
// 64 位的原子操作要求 64 位对齐,但 32 位编译器无法保证这个要求
// 因此分配 12 字节然后将他们对齐,其中 8 字节作为状态,其他 4 字节用于存储原语
state1 [3]uint32

这点是wait_group很巧妙的一点,大神写代码的思路就是惊奇

这个设计很奇妙,通过内存对齐来处理wait_group中的waiter数、计数值、信号量。什么是内存对齐可参考什么是内存对齐,go中内存对齐分析

来分析下state1是如何内存对齐来处理几个计数值的存储

计算机为了加快内存的访问速度,会对内存进行对齐处理,CPU把内存当作一块一块的,块的大小可以是2、4、8、16字节大小,因此CPU读取内存是一块一块读取的。

合理的内存对齐可以提高内存读写的性能,并且便于实现变量操作的原子性。

在不同平台上的编译器都有自己默认的 “对齐系数”,可通过预编译命令#pragma pack(n)进行变更,n就是代指 “对齐系数”。

一般来讲,我们常用的平台的系数如下:

  • 32 位:4

  • 64 位:8

state1这块就兼容了两种平台的对齐系数

对于64未系统来讲。内存访问的步长是8。也就是cpu一次访问8位偏移量的内存空间。当时对于32未的系统,内存的对齐系数是4,也就是访问的步长是4个偏移量。

所以为了兼容这两种模式,这里采用了uint32结构的数组,保证在不同类型的机器上都是12个字节,一个uint32是4字节。这样对于32位的4步长访问是没有问题了,64位的好像也没有解决,8步长的访问会一次读入两个uint32的长度。

所以,下面的读取也进行了操作,将两个uint32的内存放到一个uint64中返回,这样就同时解决了32位和64位访问步长的问题。

所以,64位系统和32位系统,state1counter,waiter,semaphore的内存布局是不一样的。

state [0] state [1] state [2]
64位 waiter counter semaphore
32位 semaphore waiter counter

counter位于高地址位,waiter位于地址位

下面是state的代码

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

对于count和wait在高低地址位的体现,在add中的代码可体现

        // // 将 delta 加到 statep 的前 32 位上,即加到计数器上
state := atomic.AddUint64(statep, uint64(delta)<<32)
v := int32(state >> 32) // count
w := uint32(state) // wait

通过补码的移位来看下分析下

statep               0000 0000 0000 0000 0000 0000 0000 0001 0000 0000 0000 0000 0000 0000 0000 0000  

int64(1)             0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0001

int64(1)<<32:       0000 0000 0000 0000 0000 0000 0000 0001 0000 0000 0000 0000 0000 0000 0000 0000  

statep+int64(1)<<32  

state:              0000 0000 0000 0000 0000 0000 0000 0001 0000 0000 0000 0000 0000 0000 0000 0000  

int32(state >> 32)   0000 0000 0000 0000 0000 0000 0000 0001

uint32(state):      0000 0000 0000 0000 0000 0000 0000 0000

再来看下-1操作

statep               0000 0000 0000 0000 0000 0000 0000 0001 0000 0000 0000 0000 0000 0000 0000 0000  

int64(-1)            1111 1111 1111 1111 1111 1111 1111 1111 1111 1111 1111 1111 1111 1111 1111 1111

int64(-11)<<32:     1111 1111 1111 1111 1111 1111 1111 1111 0000 0000 0000 0000 0000 0000 0000 0000  

statep+int64(-11)<<32  

state:              0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000  

int32(state >> 32)   0000 0000 0000 0000 0000 0000 0000 0000

uint32(state):      0000 0000 0000 0000 0000 0000 0000 0000

关于补码的操作可参考原码, 反码, 补码 详解

信号量(semaphore)

信号量是Unix系统提供的一种保护共享资源的机制,用于防止多个线程同时访问某个资源。

可简单理解为信号量为一个数值:

  • 当信号量>0时,表示资源可用,获取信号量时系统自动将信号量减1;

  • 当信号量==0时,表示资源暂不可用,获取信号量时,当前线程会进入睡眠,当信号量为正时被唤醒。

WaitGroup中的实现就用到了这个,在下面的代码实现就能看到

Add(Done)

// Add将增量(可能为负)添加到WaitGroup计数器中。
// 如果计数器为零,则释放等待时阻塞的所有goroutine。
// 如果计数器变为负数,请添加恐慌。
//
// 请注意,当计数器为 0 时发生的带有正的 delta 的调用必须在 Wait 之前。
// 当计数器大于 0 时,带有负 delta 的调用或带有正 delta 调用可能在任何时候发生。
// 通常,这意味着对Add的调用应在语句之前执行创建要等待的goroutine或其他事件。
// 如果将WaitGroup重用于等待几个独立的事件集,新的Add调用必须在所有先前的Wait调用返回之后发生。
func (wg *WaitGroup) Add(delta int) {
// 获取counter,waiter,以及semaphore对应的指针
statep, semap := wg.state()
...
// 将 delta 加到 statep 的前 32 位上,即加到计数器上
state := atomic.AddUint64(statep, uint64(delta)<<32)
// 高地址位counter
v := int32(state >> 32)
// 低地址为waiter
w := uint32(state)
...
// 计数器不允许为负数
if v < 0 {
panic("sync: negative WaitGroup counter")
}
// wait不等于0说明已经执行了Wait,此时不容许Add
if w != 0 && delta > 0 && v == int32(delta) {
panic("sync: WaitGroup misuse: Add called concurrently with Wait")
}
// 计数器的值大于或者没有waiter在等待,直接返回
if v > 0 || w == 0 {
return
}
// 运行到这里只有一种情况 v == 0 && w != 0 // 这时 Goroutine 已经将计数器清零,且等待器大于零(并发调用导致)
// 这时不允许出现并发使用导致的状态突变,否则就应该 panic
// - Add 不能与 Wait 并发调用
// - Wait 在计数器已经归零的情况下,不能再继续增加等待器了
// 仍然检查来保证 WaitGroup 不会被滥用 // 这一点很重要,这段代码同时也保证了这是最后的一个需要等待阻塞的goroutine
// 然后在下面通过runtime_Semrelease,唤醒被信号量semap阻塞的waiter
if *statep != state {
panic("sync: WaitGroup misuse: Add called concurrently with Wait")
}
// 结束后将等待器清零
*statep = 0
for ; w != 0; w-- {
// 释放信号量,通过runtime_Semacquire唤醒被阻塞的waiter
runtime_Semrelease(semap, false, 0)
}
}

梳理下流程

1、首先获取存储在state1中对应的几个变量的指针;

2、counter存储在高位,增加的时候需要左移32位;

3、counter的数量不能小于0,小于0抛出panic;

4、同样也会判断,已经的执行wait之后,不能在增加counter;

5、(这点很重要,我自己看了好久才明白)计数器的值大于或者没有waiter在等待,直接返回

	// 计数器的值大于或者没有waiter在等待,直接返回
if v > 0 || w == 0 {
return
}

因为waiter的值只会被执行一次+1操作,所以这段代码保证了只有在v == 0 && w != 0,也就是最后一个Done()操作的时候,走到下面的代码,释放信号量,唤醒被信号量阻塞的Wait(),结束整个WaitGroup

Done()也是调用了Add

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

Wait

// Wait blocks until the WaitGroup counter is zero.
func (wg *WaitGroup) Wait() {
// 获取counter,waiter,以及semaphore对应的指针
statep, semap := wg.state()
...
for {
// 获取对应的counter和waiter数量
state := atomic.LoadUint64(statep)
v := int32(state >> 32)
w := uint32(state)
// Counter为0,不需要等待
if v == 0 {
if race.Enabled {
race.Enable()
race.Acquire(unsafe.Pointer(wg))
}
return
}
// 原子(cas)增加waiter的数量(只会被+1操作一次)
if atomic.CompareAndSwapUint64(statep, state, state+1) {
...
// 这块用到了,我们上文讲的那个信号量
// 等待被runtime_Semrelease释放的信号量唤醒
// 如果 *semap > 0 则会减 1,等于0则被阻塞
runtime_Semacquire(semap) // 在这种情况下,如果 *statep 不等于 0 ,则说明使用失误,直接 panic
if *statep != 0 {
panic("sync: WaitGroup is reused before previous Wait has returned")
}
...
return
}
}
}

梳理下流程

1、首先获取存储在state1中对应的几个变量的指针;

2、一个for循环,来阻塞等待所有的goroutine退出;

3、如果counter为0,不需要等待,直接退出即可;

4、原子(cas)增加waiter的数量(只会被+1操作一次);

5、整个Wait()会被runtime_Semacquire阻塞,直到等到退出的信号量;

6、Done()会在最后一次的时候通过runtime_Semrelease发出取消阻塞的信号,然后被runtime_Semacquire阻塞的Wait()就可以退出了;

7、整个WaitGroup执行成功。

总结

代码中我感到设计比较巧妙的有两个部分:

1、state1的处理,保证内存对齐,设置高低位内存来存储不同的值,同时32位和64位平台的处理方式还不同;

2、信号量的阻塞退出,这块最后一个Done退出的时候,才会触发阻塞信号量,退出Wait(),然后结束整个waitGroup。再此之前,当Wait()在成功将waiter变量+1操作之后,就会被runtime_Semacquire阻塞,直到最后一个Done,信号的发出。

对于WaitGroup的使用

1、计数器的值不能为负数,可能是Add(-1)触发的,也可能是Done()触发的,否则会panic;

2、Add数量的添加,要发生在Wait()之前;

3、WaitGroup是可以重用的,但是需要等上一批的goroutine 都调用Wait完毕后才能继续重用WaitGroup

参考

【《Go专家编程》Go WaitGroup实现原理】https://my.oschina.net/renhc/blog/2249061

【Go中由WaitGroup引发对内存对齐思考】https://cloud.tencent.com/developer/article/1776930

【Golang 之 WaitGroup 源码解析】https://www.linkinstar.wiki/2020/03/15/golang/source-code/sync-waitgroup-source-code/

【sync.WaitGroup】https://golang.design/under-the-hood/zh-cn/part1basic/ch05sync/waitgroup/

go中waitGroup源码解读的更多相关文章

  1. go中panic源码解读

    panic源码解读 前言 panic的作用 panic使用场景 看下实现 gopanic gorecover fatalpanic 总结 参考 panic源码解读 前言 本文是在go version ...

  2. etcd中watch源码解读

    etcd中watch的源码解析 前言 client端的代码 Watch newWatcherGrpcStream run newWatchClient serveSubstream server端的代 ...

  3. java中jdbc源码解读

    在jdbc中一个重要的接口类就是java.sql.Driver,其中有一个重要的方法:Connection connect(String url, java.util.Propeties info); ...

  4. go中errgroup源码解读

    errgroup 前言 如何使用 实现原理 WithContext Go Wait 错误的使用 总结 errgroup 前言 来看下errgroup的实现 如何使用 func main() { var ...

  5. 【原】Spark中Job的提交源码解读

    版权声明:本文为原创文章,未经允许不得转载. Spark程序程序job的运行是通过actions算子触发的,每一个action算子其实是一个runJob方法的运行,详见文章 SparkContex源码 ...

  6. HttpServlet中service方法的源码解读

    前言     最近在看<Head First Servlet & JSP>这本书, 对servlet有了更加深入的理解.今天就来写一篇博客,谈一谈Servlet中一个重要的方法-- ...

  7. AbstractCollection类中的 T[] toArray(T[] a)方法源码解读

    一.源码解读 @SuppressWarnings("unchecked") public <T> T[] toArray(T[] a) { //size为集合的大小 i ...

  8. go 中 sort 如何排序,源码解读

    sort 包源码解读 前言 如何使用 基本数据类型切片的排序 自定义 Less 排序比较器 自定义数据结构的排序 分析下源码 不稳定排序 稳定排序 查找 Interface 总结 参考 sort 包源 ...

  9. Mybatis源码解读-SpringBoot中配置加载和Mapper的生成

    本文mybatis-spring-boot探讨在springboot工程中mybatis相关对象的注册与加载. 建议先了解mybatis在spring中的使用和springboot自动装载机制,再看此 ...

随机推荐

  1. 牛客练习赛70 D.数树 (模拟,STL)

    题意:每次有\(3\)中操作,对两个点连条边,删去某条边,或者问当前大小不为\(1\)的树的数量.连重边或者删去一条不存在的边,这样的白痴操作可以无视qwq. 题解:水题,用map存一下pair然后分 ...

  2. AtCoder Beginner Contest 179 E - Sequence Sum (模拟)

    题意:\(f(x,m)\)表示\(x\ mod\ m\),\(A_{1}=1\),而\(A_{n+1}=f(A^{2}_{n},M)\),求\(\sum^{n}_{i=1}A_{i}\). 题解:多算 ...

  3. python之字符串split和rsplit的方法

    1.描述 split()方法通过指定分隔符对字符串进行切片,如果参数num有指定值,则分隔num+1个子字符串,默认分隔符为所有空字符,包括空格.换行(\n).制表符(\t)等 rstrip()方法通 ...

  4. 宏&一个简单的宏病毒示例

    基于VisualBasicForApplications 其一:录制宏 在word,视图,宏,录制宏选项. 操作比较简单,不再赘述. (注意根据需求选择normal还是当前文档) 例如:录制宏:快捷键 ...

  5. TCP协议与UDP协议的区别以及与TCP/IP协议的联系

    先介绍下什么是TCP,什么是UDP. 1. 什么是TCP? TCP(Transmission Control Protocol,传输控制协议)是面向连接的.可靠的字节流服务,也就是说,在收发数据前,必 ...

  6. codeforces 5C

    C. Longest Regular Bracket Sequence time limit per test 2 seconds memory limit per test 256 megabyte ...

  7. 操作系统 part5

    1.线程安全 线程安全就是多线程访问时,采用了加锁机制,当一个线程访问该类的某个数据时,进行保护,其他线程不能进行访问直到该线程读取完,其他线程才可使用.不会出现数据不一致或者数据污染. 线程不安全就 ...

  8. 017.NET5_内置容器基本使用

    IOC容器IServiceCollection .net 5已经内置了IOC容器. 什么是IOC? 把对象的创建交给第三方容器去创建 如何使用内置的 IOC IServiceCollection ? ...

  9. 论文 公式 排版 编辑 工具 NoteExpress & MathType

    1 1 论文 公式 排版 编辑 工具: NoteExpress http://www.noteexpress.net/index2.html 1 MathType http://www.dessci. ...

  10. 如何通过 terminal 查看一个文件的 meta信息

    如何通过 terminal 查看一个文件的 meta 信息 Linux shell stat 查看文件 meta 信息 stat stat指令:文件/文件系统的详细信息显示: 使用格式:stat 文件 ...