2128 字
约 7 分钟
16
S09-A24 · `errgroup`:并发里"第一个错误"为什么常常不是根因
2024-12-31
2026-11-10

一段"并行拉取三个下游"的代码用 errgroup:任一失败即取消其余。日志里记录的总是"context canceled"——因为 errgroup 返回的是"第一个返回的 error",而那个常常是"被取消的那一路",不是"真正失败的那一路"。

errgroup 用起来只有三行,但它的语义有三处需要专门处理:谁的错误被返回、取消如何传播、这三处的共同点是"默认行为不报错":你不处理也能跑,只是"日志里的信息"与"上层的决策依据"会静默变差。
以及如何同时收全错误。

1. 三种"错误来源"的优先级

来源 是否"根因"
真正的失败(超时/5xx) 是
被取消(context canceled) 否(是结果)
包装后的错误(fmt.Errorf) 视情况

第一项与第二项的"返回顺序"由"谁先返回"决定——而"取消"往往比"真失败"更快返回这个"更快"有机制原因:取消是"本地信号"(几乎没有延迟),而"真失败"要走完网络超时——于是"被取消的那一路"几乎总是"第一个返回"。
,于是"根因被掩盖"。

2. errgroup 的三个默认行为

行为 说明
第一个非 nil error 被返回 其余被丢弃
WithContext 时"一个失败即取消" 其余 goroutine 收到取消
Wait() 阻塞到全部结束 包括被取消的那些

第三项的"代价"是"Wait() 会等到'被取消的 goroutine 真正退出'":如果某一路没有检查 ctx,它会把 Wait() 拖到它自己完成——于是"取消"变成"看起来没生效"。
第二项是"设计意图",第三项是"保证"——而"被丢弃的其余错误"是"信息损失"。

3. "收全错误"的三种做法

做法 效果
每个 goroutine 自己记日志 简单(日志里能看到全部)
用 errors.Join(Go 1.20+)聚合 结构化(能"一次拿到多个")
自己收集到 slice(加锁) 最灵活

第一项"最简单",而它的代价是"日志里有、返回值里没有"这个"代价"在"重试"上会放大:上层只看到"第一个错误",于是按它决定"重试还是降级"——而那个错误可能是"取消",压根不该重试。
——于是"上层拿不到细节"。

// 三种做法(同一场景)
// 1 各自记日志(返回值只有第一个)
g.Go(func() error { err := fetchA(ctx); if err != nil { log(err) }; return err })

// 2 errors.Join 聚合(Go 1.20+)
var mu sync.Mutex; var errs []error
g.Go(func() error { if err := fetchA(ctx); err != nil { mu.Lock(); errs = append(errs, err); mu.Unlock() }; return nil })
// 全部跑完后:return errors.Join(errs...)

"errgroup 与 semaphore"常一起用,而它们的组合有个"坑":

// 限制并发 3,同时收集错误
sem := make(chan struct{}, 3)
for _, u := range urls {
    u := u
    g.Go(func() error {
        sem <- struct{}{}; defer func() { <-sem }()
        return fetch(ctx, u)          // 注意:这里是"阻塞排队",不是"取消"
    })
}
// 坑:排队中的 goroutine 不检查 ctx → "取消"要等它们"轮到"才生效

4. "取消"为什么不等于"失败"

现象 含义
context canceled 别人失败了,我被取消
context deadline exceeded 我自己超时了
net.ErrClosed 连接被关(可能是取消的副作用)

这个"指向同一个词"是日志分析的陷阱:grep canceled 会把"根因"和"结果"混在一起——于是"数量最多的那个"看起来最像问题。
第二项是"自己的问题",第一项是"别人的问题"——而它们在日志里都指向"context",于是"看起来一样"。

5. "第一个错误"该怎么用

用法 适合
直接返回(默认) "任一失败即整体失败"
过滤掉"取消类" 想让"根因"浮出
聚合全部 需要"一次看全"

第二项的实现很轻:**在返回前判断 errors.Is(err, context.Canceled),是则"继续等其他"第二项的"边界"是"如果所有错误都是取消,说明"触发取消的那一路没被记录"——此时该告警,而不是"返回 nil"。
——于是"被取消的那一路"不会"抢走返回值"。

// "过滤取消"的写法(让根因浮出)
var rootErr error
if err := g.Wait(); err != nil && !errors.Is(err, context.Canceled) {
    rootErr = err
}
// 若 rootErr 为 nil 而 g.Wait 有错 → 说明"只有取消,没有真失败"(异常场景,值得告警)

"取消是否生效"可以用一个"短超时实验"验证:

// 三步验证(成本 2 分钟)
1 把 ctx 超时设 100ms,跑一个"下游 sleep 1s"的请求
2 观察:goroutine 数是否"立刻回落",还是"等 1s 才回落"
3 若"等 1s" → 说明某一路没检查 ctx(阻塞在 sleep/IO 里)
// 判据:取消后 goroutine 应在"几十毫秒内"退出

6. 排错:五种"并发错误问题"

现象 成因 处理方向
日志全是 canceled 根因被掩盖 过滤取消/聚合
拿不到"哪一路失败" 只返回第一个 加"来源标签"
取消没生效(goroutine 泄漏) 未传 ctx 检查每个循环体
部分成功被当失败 未区分"可接受失败" 分路处理
重复重试放大 每路都重试 统一重试策略

"每路都重试"是另一个常见放大,而它该"统一"而不是"各自":

// 三种重试位置(效果)
1 每路各自重试:3 路 × 3 次 = 9 次下游请求(放大)
2 errgroup 外层统一重试:3 次(可控)
3 不重试,改"补偿/降级":0 次(最省)
// 判据:下游"能不能承受"重试放大——不能,就统一在外层

7. 边界:什么情况不必纠结"第一个错误"

场景 为什么 做法
"任一失败即整体失败"(强一致) 根因不影响决策 默认即可
只有两路(少) 日志能看清 各自记日志
错误全是同一类(如超时) 区分无意义 默认即可
上层统一"按错误码重试" 不需要细节 默认即可

判断口径一句话:"这个错误会不会被"用来做决策"(重试/告警/降级)。会,就要"根因 + 来源标签";不会(只记日志),默认的"第一个错误"就够。

再补一条"取舍":errgroup 适合"要么全成、要么全败"的聚合(如"凑齐一个页面的五个数据源");**不适合"部分成功也要交付"**的场景(如"批量发通知")。后者的正确写法是"每路独立处理 + 汇总成功/失败清单",而"用 errgroup 一错全取消"会把"已成功的 8 条"也丢掉——这不是 errgroup 的问题,是选错了工具。

而"选错工具"的识别信号很明确:一旦你开始"在 errgroup 里忽略错误"(return nil),就说明它不是这个场景的工具——因为 errgroup 的全部价值就在"错误传播",你把它关掉了,只剩"并发"这一个功能。

还有一条"顺序"细节:g.Go 里的 defer 要"最早注册"(紧跟函数开头),否则"提前 return"会让"清理"被跳过——而在并发场景里,"漏一次清理"往往表现为"goroutine/连接缓慢泄漏",很难归因到"某一行 defer 写了晚了"。

而这条细节,在"代码评审"里几乎没人看。

8. 自测

  1. 三种错误来源里,哪一种"总是抢在前面"?为什么"根因被掩盖"?
  2. errgroup.WithContext 的"一个失败即取消"带来什么好处与什么信息损失?
  3. "各记日志"与"errors.Join"的差别是什么?为什么前者"上层拿不到细节"?
  4. context canceled 与 context deadline exceeded 的差别是什么?
  5. "过滤取消"的实现只要几行,它的判据是什么?
  6. 判断"是否需要根因"的那个问题是什么?
S09-A24 · `errgroup`:并发里"第一个错误"为什么常常不是根因
https://www.clxhxhhr.top/posts/6663/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。