type
status
date
slug
summary
tags
category
icon
password
1. 引言
在现代软件开发中,利用多核处理器的并发能力是提高程序性能的关键。Go语言,因其轻量级的goroutine和强大的内置并发支持,成为了处理并发任务的首选语言之一。
sync.WaitGroup是Go语言标准库中的一个同步原语,用于等待一组goroutine完成。它在开发中广泛用于协调和同步goroutine的执行。2. sync.WaitGroup的基本使用
在Go语言的
sync包中,WaitGroup用于等待一组goroutine执行结束。它的基本使用方法如下:- 导入sync包
- WaitGroup的方法
Add(int):添加或减少等待goroutine的数量Done():相当于Add(-1)Wait():阻塞直到所有goroutine执行完毕
- 示例代码
3. sync.WaitGroup的底层实现
3.1. 结构体解析
sync.WaitGroup的结构体定义如下:3.1.1. noCopy
noCopy字段是一个空结构体,它被用来防止WaitGroup结构体被复制。在Go中,某些对象如果被复制,可能会引起运行时的并发错误或者状态不一致的问题。noCopy不占用内存空间,但可以被go vet工具用来检测结构体是否被复制,从而帮助开发者避免这类错误。3.1.2. state
state字段是一个使用atomic.Uint64类型的原子变量,它存储了两个非常重要的信息:高32位用来存储当前的goroutine计数器(counter),低32位用来存储等待该WaitGroup完成的goroutine的数量(waiter count)。这种设计允许通过单个原子操作来更新这两个值,从而保证了操作的原子性和线程安全性。- 高32位(计数器):这部分记录了需要等待结束的goroutine的数量。每次调用
Add()方法时,这个计数器会根据传入的参数增加或减少。当计数器达到0时,表示所有的goroutine都已经完成了它们的任务。
- 低32位(等待者计数):这部分记录了调用
Wait()方法并进入阻塞状态的goroutine的数量。这个计数在Wait()方法中增加,在所有goroutine完成后(即计数器为0时)会逐一唤醒这些等待的goroutine。
3.1.3. sema
sema字段是一个信号量,用于实现goroutine之间的同步。当WaitGroup的计数器不为0时,调用Wait()的goroutine会通过这个信号量进入休眠状态,等待被唤醒。当计数器变为0时,所有在此信号量上等待的goroutine将被唤醒。这个信号量是通过底层的系统调用(如runtime_Semacquire和runtime_Semrelease)来操作的,确保了等待和唤醒操作的正确性和效率。这三个字段共同协作,使得
sync.WaitGroup能够准确地控制一组goroutine的执行流程,直到它们全部完成。通过原子操作和信号量的使用,WaitGroup提供了一种高效且线程安全的方式来同步goroutine的结束,是Go并发编程中非常重要的工具。3.2. Add() 方法实现
Add() 方法是通过原子操作来改变state的。如果增加后的计数器变为0,它会检查是否有goroutine在等待,如果有,它将唤醒所有等待的goroutine。处理数据竞争检测
如果数据竞争检测(由
race包提供支持)是启用的,那么在对WaitGroup进行修改前后会有特定的处理:- 如果
delta是负数,意味着有goroutine完成了它的任务,race.ReleaseMerge函数会被调用来帮助检测潜在的数据竞争问题。
- 在修改操作进行的时候,数据竞争检测会被暂时禁用(
race.Disable()),以避免在原子操作中报告错误的数据竞争,操作完成后再重新启用(defer race.Enable())。
原子地更新计数器
使用
atomic包中的Add方法,将delta值左移32位(因为计数器存储在64位整数的高32位)并原子地加到state上。这一步确保了在多个goroutine同时调用Add()时,计数器的更新操作是线程安全的。检查计数器状态
- 计算新的计数器值:通过右移32位获取高32位的计数器值
v。
- 获取等待计数:通过取低32位得到当前等待
Wait()方法返回的goroutine数量w。
- 如果计数器
v变为负数,则抛出panic错误,因为这意味着有更多的Done()调用(或等效的Add(-1))比Add()调用,这是不合法的。
- 如果在
Wait()并发调用的情况下错误地使用了Add()(例如,在计数器已经为零的情况下仍然调用Add()),也会抛出panic。
唤醒等待的goroutine(如果需要)
- 如果计数器
v的值为0且存在等待的goroutine(w不为0),则进入唤醒阶段。
- 首先确认在唤醒操作前没有其他goroutine修改了状态,这是通过比较
state和重新加载的state值来确认的。
- 重置
state的值为0,清除所有等待计数。
- 使用循环逐个释放所有等待的goroutine,通过调用
runtime_Semrelease函数,每次循环释放一个信号量,直到所有等待的goroutine都被唤醒。
这个方法的设计确保了
WaitGroup在并发环境下的正确性和效率,使得goroutine的同步变得简单而可靠。3.3. Done() 方法实现
Done() 方法是Add(-1)的简写形式,它表示一个goroutine完成了它的工作。3.4. Wait() 方法实现
Wait() 方法会阻塞调用它的goroutine直到计数器为0。如果在调用Wait()时计数器已经是0,它将立即返回。处理数据竞争检测
如果数据竞争检测(由
race包提供支持)是启用的,那么在Wait()方法执行期间,数据竞争检测会被暂时禁用(race.Disable())。这是因为在等待期间,多个goroutine可能会同时操作WaitGroup,而这些操作应当是安全的。检查计数器是否为零
- 首先使用
atomic.Load方法原子地加载当前的state值。
- 通过右移32位获取高32位的计数器值
v(表示剩余未完成的goroutine数量)。
- 如果
v为0,说明没有需要等待的goroutine了,当前goroutine可以立即返回而无需阻塞。
增加等待计数并尝试阻塞
- 如果计数器不为0,那么当前goroutine需要等待。此时,将等待计数(存储在
state的低32位)增加1,这是通过原子比较并交换操作CompareAndSwap完成的,确保更新操作的原子性。
- 在成功增加等待计数后,如果这是第一个进入等待状态的goroutine(即之前没有其他goroutine在等待),则使用
race包中的Write函数来处理数据竞争检测的同步问题。
阻塞当前goroutine
- 使用
runtime_Semacquire函数来阻塞当前goroutine,直到其他地方(通常是Add()方法中计数器变为0时)调用runtime_Semrelease释放信号量。
- 在被唤醒后(即其他goroutine调用了
Done()使计数器减到0),再次检查state的值确保计数器确实为0。如果不为0,则表示WaitGroup被错误地重用了,这时会抛出panic错误。
重新启用数据竞争检测并返回
- 如果使用了数据竞争检测,此时会重新启用检测(
race.Enable()),并通过race.Acquire来同步内存模型,确保当前goroutine看到的是最新的内存状态。
- 最后,
Wait()方法返回,当前goroutine继续执行后续的代码。
通过以上步骤,
Wait()方法确保了只有当WaitGroup中所有的goroutine都调用了Done()之后,阻塞在Wait()方法上的goroutine才会继续执行。这种机制非常适合管理和同步多个goroutine的执行流程。4. 总结
这种设计使得
WaitGroup能够在多个goroutine间安全且高效地同步状态,确保所有goroutine都完成后才继续执行。这些底层实现细节揭示了sync.WaitGroup如何有效地管理并发任务的结束,保证了并发编程的正确性和效率。这篇文章详细介绍了Go语言中sync.WaitGroup的实现和使用。主要包含以下几个方面:
- 基本概念:sync.WaitGroup是Go语言的一个同步原语,用于等待一组goroutine完成执行。
- 基本使用方法:包含三个主要方法:
- Add(int):添加等待的goroutine数量
- Done():标记一个goroutine完成
- Wait():阻塞等待所有goroutine完成
- 底层实现:
- 结构包含noCopy(防止复制)和state1数组(存储状态)
- state1数组存储了计数器值和等待的goroutine数量
- 通过原子操作确保并发安全
正确使用
sync.WaitGroup可以有效地协调多个goroutine之间的执行顺序,是Go并发编程中不可或缺的工具。- Author:Sylvan
- URL:http://sylvan.blog/Golang/golang-sync-waitgroup
- Copyright:All articles in this blog, except for special statements, adopt BY-NC-SA agreement. Please indicate the source!