第一章:sync.WaitGroup 的核心原理与常见误用全景图
sync.WaitGroup 是 Go 标准库中用于协程同步的关键原语,其本质是一个带原子操作的计数器,通过 Add、Done 和 Wait 三个方法协同工作:Add 增加待等待的 goroutine 数量(可为负,但需保证最终非负),Done 是 Add(-1) 的快捷调用,Wait 则阻塞直到计数器归零。底层依赖 runtime_Semacquire 和 runtime_Semrelease 实现轻量级信号量等待,不涉及操作系统线程切换,性能优异。
底层行为特征
- 计数器初始值为 0,
Wait在 0 时立即返回; Add必须在任何Wait或Done调用前完成,否则触发 panic(如Add在Wait后执行);Done调用次数超过Add总和将导致负计数器,引发 panic;WaitGroup不可复制——作为函数参数传递时务必传指针,否则因值拷贝导致计数器失效。
典型误用场景与修复方案
| 误用模式 | 危险表现 | 正确写法 |
|---|---|---|
| 值传递 WaitGroup | 子 goroutine 调用 Done() 作用于副本,主 goroutine Wait() 永不返回 |
使用 &wg 传参 |
Add 调用晚于 go 启动 |
Add 尚未执行,goroutine 已 Done(),计数器提前归零 |
Add(1) 必须在 go func() { ... }() 之前 |
var wg sync.WaitGroup
// ✅ 正确:Add 在 goroutine 启动前调用
for i := 0; i < 3; i++ {
wg.Add(1) // 关键:先声明等待数量
go func(id int) {
defer wg.Done() // 确保异常时也能计数
fmt.Printf("Worker %d done\n", id)
}(i)
}
wg.Wait() // 阻塞至全部 Done
生命周期注意事项
WaitGroup 不是“一次性”对象,但重用前必须确保前一轮 Wait 已返回且无活跃 Done 调用;若需多次使用,推荐每次新建实例以避免状态残留风险。切勿在 Wait 返回后继续调用 Add —— 此时可能有 goroutine 仍在执行 Done,引发竞态或 panic。
第二章:WaitGroup 不结束的五大典型陷阱(附可复现代码案例)
2.1 Add() 调用时机错误:在 goroutine 启动后才 Add 导致计数器未生效
数据同步机制
sync.WaitGroup 的 Add() 必须在 Go 启动前调用,否则新增的 goroutine 不被跟踪——因为 Add() 修改的是内部计数器,而 Wait() 仅阻塞直到计数器归零。
典型错误示例
var wg sync.WaitGroup
for i := 0; i < 3; i++ {
go func() {
defer wg.Done()
time.Sleep(100 * time.Millisecond)
}()
wg.Add(1) // ❌ 错误:Add 在 goroutine 启动后执行
}
wg.Wait() // 可能立即返回,goroutine 仍在运行
逻辑分析:go func() 启动瞬间即脱离主 goroutine 控制流,wg.Add(1) 滞后执行,导致 WaitGroup 初始计数为 0;Wait() 无等待直接返回,引发竞态或提前退出。参数说明:Add(1) 表示预期等待 1 个 goroutine 完成,但此时 Done() 已在无人追踪状态下执行。
正确顺序对比
| 阶段 | 错误写法 | 正确写法 |
|---|---|---|
| 计数注册 | go 后 Add() |
Add() 后 go |
| 安全性 | 计数器漏增,Wait 失效 | 计数器精准覆盖所有任务 |
graph TD
A[启动循环] --> B[调用 wg.Add 1]
B --> C[启动 goroutine]
C --> D[goroutine 内 defer wg.Done]
D --> E[Wait 阻塞至全部 Done]
2.2 Done() 遗漏或重复调用:通过 defer + panic 恢复机制验证执行路径完整性
在并发控制中,Done() 的调用必须严格匹配 Start() —— 遗漏导致资源泄漏,重复触发 panic。
安全调用契约验证
func safeTransition(s *State) {
defer func() {
if r := recover(); r != nil {
log.Fatal("Done() contract violation: ", r)
}
}()
s.Start()
// ... critical section ...
s.Done() // 唯一合法出口
}
该 defer+recover 捕获任何未处理的 Done() 异常(如重复调用引发的 sync.Once panic),确保状态机仅经由一条路径退出。
执行路径覆盖表
| 场景 | 是否 panic | 可捕获 | 恢复后行为 |
|---|---|---|---|
| 正常调用 | 否 | — | 平稳结束 |
| 重复 Done() | 是 | ✓ | 记录并终止 |
| 遗漏 Done() | 否 | ✗ | 资源泄漏(需静态分析辅助) |
核心保障逻辑
defer确保无论何种分支均进入恢复块;recover()仅拦截当前 goroutine panic,不干扰其他协程;- 日志含上下文(如 goroutine ID、时间戳)便于根因定位。
2.3 Wait() 被阻塞在非预期位置:结合 goroutine stack trace 定位死锁上下文
当 sync.WaitGroup.Wait() 意外阻塞,往往源于 Add() 与 Done() 的调用不匹配或 goroutine 提前退出未执行 Done()。
数据同步机制
常见误用模式:
var wg sync.WaitGroup
wg.Add(1)
go func() {
// 忘记 wg.Done()
time.Sleep(100 * time.Millisecond)
}()
wg.Wait() // 永久阻塞
逻辑分析:
Add(1)声明需等待 1 个完成,但 goroutine 中无Done()调用,计数器永不归零。Wait()进入休眠态,且无法被唤醒。
快速定位手段
运行时捕获 goroutine 栈:
kill -SIGQUIT <pid>输出所有 goroutine 状态;- 关注
runtime.gopark+sync.runtime_SemacquireMutex行,定位阻塞点。
| 字段 | 含义 |
|---|---|
goroutine X [semacquire] |
正在等待信号量(如 WaitGroup) |
sync.(*WaitGroup).Wait |
阻塞发生在 Wait 方法内 |
created by main.main |
启动该 goroutine 的上下文 |
死锁传播路径
graph TD
A[main goroutine calls Wait] --> B{WaitGroup counter == 0?}
B -- No --> C[Enter semacquire]
C --> D[Block until Done is called]
B -- Yes --> E[Return immediately]
2.4 WaitGroup 跨 goroutine 复用引发竞态:使用 go tool race 检测并重构生命周期管理
数据同步机制
sync.WaitGroup 本身不是线程安全的复用对象——其 Add() 和 Done() 方法仅在计数器未归零时并发调用才安全,但跨 goroutine 多次 Add() + Wait() 循环复用会触发竞态。
典型错误模式
var wg sync.WaitGroup
for i := 0; i < 3; i++ {
wg.Add(1)
go func() {
defer wg.Done()
time.Sleep(10 * time.Millisecond)
}()
}
wg.Wait() // ✅ 第一次正常
wg.Add(1) // ⚠️ 竞态:Wait 返回后复用 Add
go func() { wg.Done() }()
wg.Wait() // ❌ race detector 必报错
逻辑分析:
Wait()返回仅表示计数器归零,不重置内部状态;再次Add()与仍在执行的Done()并发访问同一内存地址(wg.counter),go tool race将标记为“Write at … by goroutine N / Previous write at … by goroutine M”。
安全重构策略
- ✅ 每次任务新建独立
WaitGroup实例 - ✅ 使用
sync.Once+ 闭包封装一次性协调逻辑 - ❌ 禁止在
Wait()后调用Add()或复用同一实例调度多轮任务
| 方案 | 可复用性 | 竞态风险 | 生命周期可控性 |
|---|---|---|---|
| 新建实例 | 高(每次 new) | 零 | 强(作用域明确) |
| 复用单实例 | 高(节省分配) | 极高 | 弱(需人工同步) |
graph TD
A[启动 goroutine] --> B{WaitGroup 已 Wait?}
B -->|是| C[禁止 Add/Done]
B -->|否| D[允许 Add/Wait/Done]
C --> E[race detected]
2.5 结构体嵌入 WaitGroup 时未导出字段导致零值拷贝:通过 unsafe.Sizeof 与 reflect.DeepEqual 验证内存语义
数据同步机制
Go 中 sync.WaitGroup 包含未导出字段(如 noCopy、state1 [3]uint32),其零值具有特定内存布局。当作为匿名嵌入字段出现在结构体中时,若该结构体被值拷贝,WaitGroup 的内部状态不会被复制——因其未导出字段在 reflect.DeepEqual 中被忽略,且 unsafe.Sizeof 显示其大小恒为 24 字节(64位系统)。
type Worker struct {
sync.WaitGroup // 匿名嵌入
id int
}
w1 := Worker{id: 1}
w1.Add(1)
w2 := w1 // 零值拷贝:WaitGroup 状态丢失!
w2.Done() // panic: sync: negative WaitGroup counter
逻辑分析:
w1调用Add(1)后内部计数器非零;但w2 := w1是浅拷贝,WaitGroup的state1数组虽被复制,而noCopy等未导出字段不参与DeepEqual比较,导致语义不一致;unsafe.Sizeof(w1)与unsafe.Sizeof(w2)均为32,但运行时行为割裂。
验证方式对比
| 方法 | 是否感知未导出字段 | 是否反映运行时语义 |
|---|---|---|
unsafe.Sizeof |
✅(按内存布局) | ❌(仅静态大小) |
reflect.DeepEqual |
❌(跳过 unexported) | ✅(模拟值比较逻辑) |
graph TD
A[结构体含嵌入WaitGroup] --> B{执行值拷贝}
B --> C[内存复制全部字段]
B --> D[reflect.DeepEqual忽略未导出字段]
C --> E[运行时panic风险]
第三章:并发调试黄金 checklist 实战推演
3.1 基于 pprof/goroutine dump 的 WaitGroup 状态快照分析法
当 sync.WaitGroup 出现疑似卡死时,仅靠日志难以定位 goroutine 阻塞点。此时可结合运行时快照进行状态推断。
数据同步机制
WaitGroup 内部通过 state1 [3]uint32 原子字段存储计数器与信号量,其中:
state1[0]: 当前计数(低32位)state1[1]: 等待者数量(高32位,若存在sema)
快照获取方式
# 获取 goroutine 栈快照(含阻塞位置)
curl -s "http://localhost:6060/debug/pprof/goroutine?debug=2" > goroutines.txt
# 或直接触发 runtime.Stack()
分析关键线索
- 搜索
runtime.gopark+sync.(*WaitGroup).Wait调用栈; - 统计
Wait()调用 goroutine 数量 vsDone()实际执行次数(需结合pprof -symbolize=none解析);
| 字段 | 含义 | 典型值 |
|---|---|---|
wg.state1[0] |
剩余计数 | (正常结束)、>0(未完成) |
runtime.gopark 出现场景 |
是否在 Wait() 中挂起 |
true 表示等待中 |
// 示例:注入调试钩子(生产环境慎用)
func debugWait(wg *sync.WaitGroup) {
state := (*[3]uint32)(unsafe.Pointer(&wg.state1))
log.Printf("WG state: counter=%d, waiter=%d", state[0], state[1]>>32)
}
该函数通过 unsafe 直接读取 WaitGroup 私有状态,避免反射开销;state[0] 为原子计数器,state[1]>>32 提取等待 goroutine 数量,辅助验证是否存在“漏调 Done”或“误调 Add”。
graph TD A[HTTP /debug/pprof/goroutine?debug=2] –> B[解析 goroutine 栈] B –> C{是否存在 Wait 调用栈?} C –>|是| D[统计 Wait goroutine 数量] C –>|否| E[排除 WaitGroup 阻塞] D –> F[比对 state1[0] 是否为 0]
3.2 使用 debug.SetTraceback(“all”) + runtime.Stack 捕获 goroutine 生命周期全链路
默认情况下,Go 运行时仅在 panic 时打印当前 goroutine 的栈,而其他 goroutine 的阻塞或死锁状态难以观测。debug.SetTraceback("all") 可强制所有 goroutine(包括系统 goroutine)在崩溃时输出完整调用栈。
启用全栈追踪
import "runtime/debug"
func init() {
debug.SetTraceback("all") // 参数值:"0"(默认)、"1"、"2"、"all"
}
"all" 等价于 "2",启用最详细栈帧(含内联函数与寄存器信息),适用于诊断 goroutine 泄漏或长期阻塞。
主动采集全量栈快照
import "runtime"
func dumpAllGoroutines() string {
buf := make([]byte, 4<<20) // 4MB 缓冲区
n := runtime.Stack(buf, true) // true → 打印所有 goroutine
return string(buf[:n])
}
runtime.Stack(buf, true) 返回实际写入字节数;true 触发全 goroutine 栈遍历,包含状态(running/waiting/chan receive 等)。
| 状态字段 | 含义 |
|---|---|
running |
正在执行用户代码 |
chan receive |
阻塞在 channel 接收操作 |
select |
在 select 语句中等待 |
全链路可观测性流程
graph TD
A[goroutine 启动] --> B[执行业务逻辑]
B --> C{是否 panic?}
C -->|是| D[触发 debug.SetTraceback]
C -->|否| E[runtime.Stack(true)]
D --> F[打印所有 goroutine 栈]
E --> F
3.3 构建轻量级 WaitGroup wrapper 实现自动计数审计与 panic 上报
在高并发场景中,原生 sync.WaitGroup 缺乏计数异常检测与崩溃上下文捕获能力。我们封装一层 AuditWaitGroup,注入审计钩子与 recover 机制。
数据同步机制
内部维护原子计数器与 panic 日志通道,确保 Add()/Done() 调用可审计:
type AuditWaitGroup struct {
sync.WaitGroup
mu sync.RWMutex
calls []string // 调用栈快照(仅 debug 模式)
panicCh chan<- PanicReport
}
panicCh为只写通道,由上层统一注册日志/监控服务;calls用于调试阶段追溯Add()来源,生产环境可编译剔除。
审计增强行为
- 每次
Add(delta)自动校验 delta ≠ 0,避免静默失效 Done()触发前检查计数非零,否则向panicCh发送PanicReport{Op: "done_on_zero", Stack: ...}
| 字段 | 类型 | 说明 |
|---|---|---|
| Op | string | 操作类型(”add_zero”, “done_on_zero”) |
| Stack | string | runtime/debug.Stack() 截断后字符串 |
| Timestamp | time.Time | panic 发生时间 |
错误传播路径
graph TD
A[Done()] --> B{count <= 0?}
B -->|是| C[Build PanicReport]
C --> D[Send to panicCh]
B -->|否| E[Decrement & continue]
第四章:腾讯 T1 工程师私藏的生产级防护模式
4.1 Context-aware WaitGroup:集成超时控制与取消信号的增强型封装
传统 sync.WaitGroup 缺乏对上下文生命周期的感知能力,易导致 goroutine 泄漏或无法响应取消。
核心设计思想
- 封装
sync.WaitGroup+context.Context - 在
Done()和Wait()中同步监听ctx.Done()
关键接口定义
type ContextWaitGroup struct {
wg sync.WaitGroup
mu sync.RWMutex
ctx context.Context
done chan struct{}
}
done是内部信号通道,由ctx.Done()触发关闭;mu保障ctx替换的并发安全;wg保留原始计数语义。
等待逻辑流程
graph TD
A[Wait] --> B{ctx.Err() != nil?}
B -->|Yes| C[return ctx.Err()]
B -->|No| D[wg.Wait()]
D --> E[select on done]
超时对比(单位:ms)
| 场景 | 原生 WaitGroup | Context-aware WG |
|---|---|---|
| 正常完成 | ∞ | ≈0 |
| 5s 超时 | 阻塞 | 返回 context.DeadlineExceeded |
| 取消信号触发 | 无响应 | 立即返回 context.Canceled |
4.2 单元测试中模拟 WaitGroup hang 场景的 fuzzing+timeout 断言策略
数据同步机制
sync.WaitGroup 的典型 hang 场景源于 Add() 与 Done() 不匹配(如漏调 Done() 或负值 Add(-1)),导致 Wait() 永久阻塞。
Fuzzing + Timeout 断言组合策略
- 使用
testing.F启动 fuzzing,随机生成n(goroutine 数)和m(Done()调用缺失数) - 每次 fuzz 迭代启动带
context.WithTimeout的 goroutine 组,并断言超时后wg.Wait()仍未返回
func TestWaitGroupHangFuzz(t *testing.T) {
t.Fuzz(func(f *testing.F, n, m int) {
f.Add(3, 0) // seed: 3 goroutines, 0 missing Done
f.Fuzz(func(t *testing.T, n, m int) {
wg := sync.WaitGroup{}
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
wg.Add(n)
for i := 0; i < n-m; i++ { // 故意少调 m 次 Done
go func() { defer wg.Done() }()
}
done := make(chan error, 1)
go func() {
wg.Wait()
done <- nil
}()
select {
case <-done:
t.Fatal("WaitGroup should have hung, but returned early")
case <-ctx.Done():
// ✅ Expected: timeout occurred → hang confirmed
}
})
})
}
逻辑分析:
n控制并发规模,m控制Done()缺失程度(m > 0时必 hang);context.WithTimeout提供硬性截止,避免测试卡死;select分支明确区分「正常完成」(bug)与「预期超时」(正确检测到 hang)。
| 策略组件 | 作用 |
|---|---|
testing.F |
自动生成边界/异常输入组合 |
context.Timeout |
防止单测无限阻塞,保障CI稳定性 |
select+chan |
非阻塞探测 Wait() 是否返回 |
graph TD
A[Fuzz input n, m] --> B[Start goroutines]
B --> C{Call Done() n-m times?}
C -->|No| D[WaitGroup hangs]
C -->|Yes| E[Wait returns immediately]
D --> F[Timeout triggers → PASS]
E --> G[Select hits 'done' → FAIL]
4.3 在 Kubernetes Operator 中安全使用 WaitGroup 的生命周期对齐实践
为什么 WaitGroup 容易引发 Goroutine 泄漏?
Operator 控制循环中,异步事件处理(如 Finalizer 清理、状态同步)常依赖 sync.WaitGroup 等待子任务完成。若 Add() 与 Done() 调用未严格匹配,或 Wait() 在对象被 GC 前阻塞,将导致 Goroutine 永久挂起。
生命周期对齐核心原则
- ✅
Add()必须在资源对象Reconcile 上下文内调用(非 goroutine 内部) - ✅
Done()必须在同一对象生命周期内执行(如 defer 或显式 cleanup 回调) - ❌ 禁止跨 Reconcile 循环复用 WaitGroup 实例
安全模式:绑定到 reconciler 实例的结构体字段
type MyReconciler struct {
client client.Client
// 使用指针避免拷贝,确保所有 goroutine 操作同一实例
wg *sync.WaitGroup
}
func (r *MyReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
r.wg = &sync.WaitGroup{} // 每次 Reconcile 新建(或 Reset)
r.wg.Add(2)
go func() {
defer r.wg.Done() // ✅ 正确配对
r.syncConfigMap(ctx, req.NamespacedName)
}()
go func() {
defer r.wg.Done()
r.cleanupOrphanedJobs(ctx, req.NamespacedName)
}()
r.wg.Wait() // 阻塞至本周期所有子任务完成
return ctrl.Result{}, nil
}
逻辑分析:
r.wg = &sync.WaitGroup{}在每次Reconcile入口新建实例,确保无跨周期残留;defer r.wg.Done()保证即使 panic 也能释放计数;r.wg.Wait()位于函数末尾,自然绑定当前 reconcile 生命周期。
常见陷阱对比表
| 场景 | 是否安全 | 原因 |
|---|---|---|
| WaitGroup 作为全局变量 | ❌ | 多个 reconcile 并发操作导致计数错乱 |
wg.Add() 在 goroutine 内调用 |
❌ | 竞态:Add 可能晚于 Done 执行 |
wg.Wait() 后继续使用 wg |
⚠️ | 需手动 *wg = sync.WaitGroup{} 重置 |
graph TD
A[Reconcile 开始] --> B[初始化新 WaitGroup]
B --> C[Add 子任务数]
C --> D[启动 goroutine]
D --> E[每个 goroutine defer Done]
E --> F[主协程 Wait]
F --> G[Reconcile 结束 → WG 自动失效]
4.4 日志埋点规范:为每个 Add/Done/Wait 注入 traceID 与 goroutine ID 实现跨协程追踪
在分布式任务调度器中,Add/Done/Wait 三类核心操作常跨越多个 goroutine,传统日志缺乏上下文关联。需在每处调用点注入唯一 traceID(来自上游或新生成)与当前 goroutine ID(通过 runtime.Stack 提取)。
埋点实现示例
func (q *TaskQueue) Add(task Task) {
traceID := getTraceID() // 从 context 或 fallback 生成
goid := getGoroutineID() // 如:parseGID(runtime.Stack(nil, false))
log.WithFields(log.Fields{
"trace_id": traceID,
"goid": goid,
"op": "Add",
}).Info("task added")
// ... 实际逻辑
}
逻辑分析:
getTraceID()优先从context.Context中提取trace_idkey,缺失时调用uuid.New().String()保证唯一性;getGoroutineID()解析runtime.Stack第一行数字,精度满足调试需求,无 CGO 依赖。
关键字段对照表
| 字段名 | 来源 | 用途 |
|---|---|---|
trace_id |
Context / UUID | 全链路请求唯一标识 |
goid |
runtime.Stack |
定位协程生命周期与竞争点 |
op |
字面量(”Add”等) | 快速过滤操作类型 |
跨协程追踪流程
graph TD
A[Add: main goroutine] -->|spawn| B[Worker: goroutine 123]
B --> C[Done: goroutine 456]
C --> D[Wait: goroutine 789]
A & B & C & D --> E[统一 trace_id + 各自 goid]
第五章:从 WaitGroup 到更现代的并发原语演进思考
Go 语言早期广泛依赖 sync.WaitGroup 实现 goroutine 协作等待,但随着云原生、高吞吐微服务与结构化并发(Structured Concurrency)理念的普及,其局限性日益凸显——例如无法响应取消、缺乏超时控制、不支持错误传播,且需手动调用 Add()/Done(),极易因漏调或重复调用引发 panic 或死锁。
WaitGroup 的典型陷阱案例
以下代码在 HTTP 处理器中启动多个 goroutine 并等待完成,但因 wg.Add(1) 被置于 goroutine 内部而失效:
func handler(w http.ResponseWriter, r *http.Request) {
var wg sync.WaitGroup
for i := 0; i < 3; i++ {
go func() { // ❌ wg.Add(1) 在 goroutine 中执行,主 goroutine 已进入 wg.Wait()
defer wg.Done()
wg.Add(1) // 永远不会被执行到
time.Sleep(100 * time.Millisecond)
}()
}
wg.Wait() // 立即返回,无等待效果
}
基于 context.Context 的替代方案
使用 context.WithTimeout 配合通道关闭信号,可实现带超时与取消能力的协作等待:
func waitForTasks(ctx context.Context, tasks []func(context.Context) error) error {
done := make(chan error, len(tasks))
for _, task := range tasks {
go func(t func(context.Context) error) {
done <- t(ctx)
}(task)
}
for i := 0; i < len(tasks); i++ {
select {
case err := <-done:
if err != nil {
return err
}
case <-ctx.Done():
return ctx.Err()
}
}
return nil
}
并发原语能力对比表
| 原语 | 取消支持 | 超时支持 | 错误聚合 | 自动生命周期管理 | 适用场景 |
|---|---|---|---|---|---|
sync.WaitGroup |
❌ | ❌ | ❌ | ❌(需手动配对) | 简单无状态同步 |
errgroup.Group |
✅(通过 context) | ✅(结合 context) | ✅(首个非nil错误) | ✅(Wait 阻塞至全部完成或取消) | 微服务批量调用 |
golang.org/x/sync/errgroup |
✅ | ✅ | ✅ | ✅ | 推荐生产级替代 |
errgroup.Group 实战迁移示例
将原有 WaitGroup 逻辑重构为 errgroup.Group,自动继承父 context 的取消信号,并在任一子任务失败时立即中止其余任务:
func processUserBatch(ctx context.Context, users []string) error {
g, groupCtx := errgroup.WithContext(ctx)
for _, user := range users {
u := user // 避免闭包变量复用
g.Go(func() error {
return fetchUserProfile(groupCtx, u) // 若 groupCtx 被取消,此函数应主动检查 ctx.Err()
})
}
return g.Wait() // 返回首个非nil错误,或 nil(全部成功)
}
演进背后的工程权衡
WaitGroup 的轻量设计曾适配 Go 1.0 时代对简洁性的追求;而 errgroup 与 context 的组合,则是面向分布式系统可观测性、SLO 保障与故障隔离需求的自然延伸。Kubernetes 控制器、TiDB 的 DDL 执行器、Docker CLI 等项目均已将 errgroup 作为标准等待原语。
Mermaid 流程图:WaitGroup vs errgroup 生命周期对比
flowchart LR
A[启动 goroutine] --> B[WaitGroup.Add\\n手动计数]
B --> C[goroutine 执行]
C --> D[defer wg.Done\\n手动递减]
D --> E[wg.Wait\\n阻塞直至计数归零]
F[启动 goroutine] --> G[errgroup.Go\\n自动注册]
G --> H[goroutine 执行\\n接收 groupCtx]
H --> I{是否 ctx.Done?}
I -->|是| J[立即中止并返回 cancel error]
I -->|否| K[返回 error 或 nil]
K --> L[errgroup.Wait\\n聚合结果并释放资源] 