惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

D
Docker
爱范儿
爱范儿
人人都是产品经理
人人都是产品经理
博客园 - 司徒正美
cs.AI updates on arXiv.org
cs.AI updates on arXiv.org
量子位
罗磊的独立博客
cs.CL updates on arXiv.org
cs.CL updates on arXiv.org
小众软件
小众软件
C
Cybersecurity and Infrastructure Security Agency CISA
Cyberwarzone
Cyberwarzone
大猫的无限游戏
大猫的无限游戏
Threat Intelligence Blog | Flashpoint
Threat Intelligence Blog | Flashpoint
雷峰网
雷峰网
Simon Willison's Weblog
Simon Willison's Weblog
The Cloudflare Blog
博客园 - 三生石上(FineUI控件)
D
Darknet – Hacking Tools, Hacker News & Cyber Security
C
Cyber Attacks, Cyber Crime and Cyber Security
博客园_首页
博客园 - 叶小钗
V
Vulnerabilities – Threatpost
T
The Exploit Database - CXSecurity.com
T
Tailwind CSS Blog
IT之家
IT之家
博客园 - 聂微东
Spread Privacy
Spread Privacy
V2EX - 技术
V2EX - 技术
S
Security Affairs
宝玉的分享
宝玉的分享
V
V2EX
C
Cisco Blogs
博客园 - Franky
美团技术团队
酷 壳 – CoolShell
酷 壳 – CoolShell
月光博客
月光博客
S
Securelist
J
Java Code Geeks
Webroot Blog
Webroot Blog
让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
P
Proofpoint News Feed
Last Week in AI
Last Week in AI
L
LINUX DO - 热门话题
NISL@THU
NISL@THU
WordPress大学
WordPress大学
W
WeLiveSecurity
T
Threatpost
freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More
腾讯CDC
阮一峰的网络日志
阮一峰的网络日志

蛮荆

如何获取更多的免费服务器 Kubernetes 调度器队列 - 设计与实现 Kubernetes 调度器 - 核心流程 Kubernetes Networking Model & CNI Kubernetes 控制器管理总结 Kubernetes CronJob 设计与实现 Kubernetes Job 设计与实现 Kubernetes HPA 设计与实现 Kubernetes Deployment 滚动更新实现原理 Kubernetes GC 设计与实现 Kubernetes Pod 驱逐 - 设计与实现 Kubernetes Daemonset 设计与实现 Kubernetes ReplicaSet 设计与实现 Kubernetes EndPoint 设计与实现 Kubernetes Informer 设计与实现 降本增效之应用优化 (三) 日志存储与检索 Kubernetes Pod 设计与实现 - 创建流程 Kubernetes 探针设计与实现 Unix 编程艺术名句摘录 Kubernetes - CRI 概述 Golang 编译速度为什么这么快? Kubernetes Pod 设计与实现 - Pause 容器 Kubernetes - kube-proxy 代理模式工程优化 Kubernetes 应用最佳实践 - 优雅关闭长连接 Kubernetes Service 类型和会话亲和性 Kubernetes 为什么需要 Ingress Kubernetes 架构 - 控制平面和数据平面 降本增效之应用优化 (二) 大报表 Go 语言如何获取 CPU 利用率 降本增效之应用优化 (一) Redis 业务规则引擎演变过程简述 微服务中的熔断算法 漏桶算法和令牌桶算法 jsonparser 为什么比标准库的 encoding/json 快 10 倍 ? zap 高性能设计与实现 HTTP Router 算法演进 fastcache 高性能设计与实现 Web 常见的三个安全问题 ants Code Reading Go 线程安全 map 方案选型 布隆过滤器 死锁、活锁、饥饿、自旋锁 sync.Pool Code Reading Go 内存管理概述 Go netpoll Code Reading goroutine 泄漏与检测 time/Timer Code Reading GMP Scheduler Code Reading Go channel 的 15 条规则和底层实现 为什么 Linux “一切皆文件” context.Context Code Reading runtime/HACKING.md Goland 最佳实践 互联网开发与金庸武学 为什么 Redis 6.0 引入多线程模型? Kubernetes 应用最佳实践 - 金丝雀发布 容器中如何正确配置 GOMAXPROCS ? singleflight Code Reading sync.Map Code Reading sync.Cond Code Reading sync.RWMutex Code Reading sync.Mutex Code Reading sync.Once Code Reading Go 无锁编程 sync/atomic Code Reading goroutine 交替打印奇偶数 GODEBUG Go 并发模式 Go 汇编 UUID 通用技术选型 Kubernetes 应用最佳实践 - 水平自动伸缩 Go 高性能 Tips fasthttp 为什么比标准库 net/http 快 10 倍 ? 技术文章配图指南 ChatGPT 初体验 Docker 网络原理概览 iptables 的五表五链 Kubernetes 应用最佳实践 - 亲和性和污点容忍度 Go 的反射与三大定律 Docker 官方提供的最佳实践 Go 语言内置的设计模式 HTTP1 到 HTTP3 的工程优化 Kubernetes 应用最佳实践 - Sidecar 模式 Kubernetes 应用最佳实践 - init 容器和钩子函数 为什么 recover 必须在 defer 中调用? 为什么 defer 的执行顺序和注册顺序不同? Go map 设计与实现 Go 切片扩容底层实现 Go 语言中的零拷贝 Go Delve 云原生和边缘计算简介 Kubernetes Pod 服务质量等级 Kubernetes 应用最佳实践 - 探针 Kubernetes 应用最佳实践 - 资源请求和限制 CDN 原理 Kubernetes 应用最佳实践 - 开篇 缓存策略和模式 Go 内存模型 Kubernetes 核心概念 软件版本语义化
sync.WaitGroup Code Reading
2023-04-25 · via 蛮荆

2023-04-25 Golang 并发编程 Go 源码分析 读代码

概述

sync.WaitGroup 可以等待一个并发执行的 goroutine 集合执行结束。

示例

通过一个小例子展示 sync.WaitGroup 的使用方法。

package main

import (
	"fmt"
	"strconv"
	"sync"
	"time"
)

type Task struct {
	ID   int
	Name string
}

func main() {
	tasks := make([]*Task, 0)

	// 添加 5 个任务
	for i := 1; i <= 5; i++ {
		tasks = append(tasks, &Task{
			ID:   i,
			Name: strconv.Itoa(i),
		})
	}

	var wg sync.WaitGroup

	// 开启多个 goroutine 并行执行任务
	for _, task := range tasks {
		wg.Add(1)

		go func(t *Task) {
			defer wg.Done() // 任务完成

			fmt.Printf("Task %s starting ...\n", t.Name)

			time.Sleep(300 * time.Millisecond) // 模拟任务执行耗时
		}(task)
	}

	wg.Wait() // 等待所有任务执行结束
}
$ go run main.go

# 输出如下
Task 5 starting ...
Task 1 starting ...
Task 4 starting ...
Task 3 starting ...
Task 2 starting ...

从输出的结果中可以看到,虽然任务执行完成顺序和添加顺序并不一致,但是最终 5 个任务全部执行完成。

内部实现

我们来探究一下 sync.WaitGroup 的内部实现,文件路径为 $GOROOT/src/sync/waitgroup.go,笔者的 Go 版本为 go1.19 linux/amd64

WaitGroup 对象

WaitGroup 对象表示并发 goroutine 集合的控制器,具体的使用方法为:

  • main goroutine 调用 Add 方法设置对象需要等待的 goroutine 数量
  • main goroutine 调用 Wait 方法 阻塞等待 goroutine 执行结束
  • 其他 goroutine 在执行结束时调用 Done 方法通知 main goroutine

根据 Go 内存模型的约束,goroutine 调用 Done 方法时,必须在对应的 Wait 方法之前调用,否则对应的 Wait 方法将永远阻塞。

// WaitGroup 一旦使用后,就不能再复制
type WaitGroup struct {
	noCopy noCopy // 保证编译期间不会发生复制
	
	state1 uint64
	state2 uint32
}

两个字段表示的三个变量

三个语义变量

state1state2 两个字段其实表示了三个语义变量,分别为:

  • counter: (计数器) 当前执行的 goroutine (调用了 Add 方法) 数量
  • waiter: (等待者) 当前等待的 goroutine (调用了 Wait 方法) 数量
  • sema: (信号量) 用于休眠或唤醒 goroutine

为什么不直接设置三个变量呢?

因为 counter 和 waiter 计数器根据内存对齐情况放进一个 64 位整数里面,这是标准库做的一个优化,将两个计数器放进一个变量,这样就可以在不加锁的情况下,支持并发场景下的原子操作了,极大地提高了性能

state 方法

state 方法返回两个指针变量,statep 变量表示 counter 和 waiter 计数器,semap 变量表示信号量。

stete 方法会根据 state1 字段的内存对齐位数,在必要时动态 “交换” 三个语义变量的顺序

64 位对齐

在 32 位架构中,WaitGroup 对象初始化时分配的内存地址是随机的,state1 字段起始的位置不一定 64 位对齐,所以需要和 state2 字段拼接起来,实现内存连续的情况下保证 64 位对齐。

非 64 位对齐

func (wg *WaitGroup) state() (statep *uint64, semap *uint32) {
	// 判断 state1 字段是否按照 64 位对齐
	if unsafe.Alignof(wg.state1) == 8 || uintptr(unsafe.Pointer(&wg.state1))%8 == 0 {
		// 如果 state1 字段是 64 位对齐,直接返回
		return &wg.state1, &wg.state2
	} else {
		// 如果 state1 是 32 位而非 64 位对齐
		// 这意味着 (&state1)+4 是 64 位对齐 (state1 字段 + 4, 正好是 state2 字段)
		// (&state1)+4 等于跨了两个字段,所以是 64 位对齐 (两个字段的内存是连续的)
		// 最后把两个字段地址进行连接,在连接的基础上实现地址交换
		state := (*[3]uint32)(unsafe.Pointer(&wg.state1))
		return (*uint64)(unsafe.Pointer(&state[1])), &state[0]
	}
}

Add 方法

Add 方法增加 delta 个计数,内部会添加到对应的 counter 计数器上,如果 counter 变为 0,所有阻塞在 Wait 方法上的 goroutine 都会立即完成并被释放。

具体的调用规则如下:

  1. 当 counter == 0 并且 delta > 0 时,必须在 Wait 方法之前调用 Add 方法
  2. 当 counter > 0 并且 delta < 0 时,可以在任何时候调用 Add 方法
  3. 一般情况下,Add 方法应该在创建 goroutine 时或其他阻塞场景发生前调用
  4. 如果 WaitGroup 要重复使用,应该在所有 Wait 方法返回之后再继续调用 Add 方法
func (wg *WaitGroup) Add(delta int) {
	statep, semap := wg.state() // 调用 state() 取出计数器和信号量
	
    ...
	
	state := atomic.AddUint64(statep, uint64(delta)<<32) // 增加计数器的值 
	v := int32(state >> 32) // 获取计数器的值 (高位字节)
	w := uint32(state)  // 获取等待者的值 (低位字节)
	
	...
	
	if v < 0 {
        // 计数器不能为负数 (出现了 BUG)
		panic("sync: negative WaitGroup counter")
	}
	
	// 等待者不等于 0, 说明已经有 goroutine 调用了 Wait 方法
	// 此时不允许再调用 Add 方法了 (参考规则 4)
	if w != 0 && delta > 0 && v == int32(delta) {
		panic("sync: WaitGroup misuse: Add called concurrently with Wait")
	}
	
	if v > 0 || w == 0 {
        // 如果计数器大于 0 或者没有等待者,直接返回
		return
	}
	
	// 当等待者大于 0 并且计数器等于 0 (所有 goroutine 都调用了 Done 方法表示其结束执行)
	// 重置计数器和等待者为 0
	*statep = 0
	// 唤醒所有等待者 (逐个阻塞调用)
	for ; w != 0; w-- {
		runtime_Semrelease(semap, false, 0)
	}
}

Done 方法

Done 方法简单地封装了一下 Add 方法 (等于调用 Add(-1)),提供了一个可读性更高的操作原语。

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

Wait 方法

Wait 方法会进入阻塞,直到计数器的值等于 0。

func (wg *WaitGroup) Wait() {
	statep, semap := wg.state() // 调用 state() 取出计数器和信号量

	...
	    
	for {
		state := atomic.LoadUint64(statep)
        v := int32(state >> 32) // 获取计数器的值 (高位字节)
        w := uint32(state)  // 获取等待者的值 (低位字节)
		if v == 0 {
			// 计数器等于 0,直接返回
			return
		}
		
		// 计数器不等于 0,说明存在并发
		// 增加等待者的值
		if atomic.CompareAndSwapUint64(statep, state, state+1) {
            ...
			
			// 休眠当前 goroutine 等待唤醒
			runtime_Semacquire(semap)
			if *statep != 0 {
				// 等待者不等于 0, 说明 WaitGroup 对象被重复使用了 (参考规则 4)
				panic("sync: WaitGroup is reused before previous Wait has returned")
			}
			
            ...
			
			return
		}
	}
}

noCopy 对象

noCopy 对象可以添加到具体的结构体中,实现 “首次使用之后,无法被复制” 的功能 (由编译器实现)。

noCopy.Lock 方法是一个空操作,由 go vet 工具链中的 -copylocks checker 参数指令使用。

type noCopy struct{}

func (*noCopy) Lock()   {}
func (*noCopy) Unlock() {}

小结

sync.WaitGroup 的代码实现中,有两个非常重要的优化技巧值得我们学习:

  • 通过将多个变量放入一个变量,实现无加锁的原子操作
  • state 方法不仅提供了标准的 Go 内存对齐 检测方法, 同时 通过将连续的地址变换为数组,实现内存交换