登录
推荐 文章 Go 技术 课程 下载 专题 AI
首页 >  Golang >  Go问答

Go errgroup 如何限定取消范围

来源:17golang原创

时间:2026-09-13 08:49:31 292浏览 收藏

我在批量调用外部接口时最容易误判的一点,是把“限制并发”和“取消任务”当成了同一件事。errgroup 实际上把两条线分开了:SetLimit 只管同时活跃的 goroutine 数量,WithContext 才负责把首个错误传播给同组任务。要让取消真正生效,子任务还必须把派生的 ctx 传给可取消的 I/O 操作。

官方地址:https://pkg.go.dev/golang.org/x/sync/errgroup

要点速览
  • WithContext 的取消范围是当前 group 的派生上下文,不会自动终止不接收 ctx 的 goroutine。
  • SetLimit(4) 表示最多 4 个活跃任务,Go 达到上限会阻塞,TryGo 则直接返回是否提交成功。
  • 发布端要同时处理 Wait 的业务错误、ctx.Err() 的取消原因和未提交的待处理任务。
如果需求是“同一批任务中一个失败就停止剩余可取消工作”,用 WithContext;如果还要控制资源压力,再加 SetLimit。不要只设置并发上限就期待任务自动停止。

用 WithContext 明确组内取消边界

errgroup.WithContext(parent) 返回 group 和一个派生上下文。组内函数第一次返回非 nil 错误时,派生上下文会被取消;Wait 返回后也会取消它。因此每个任务都应使用这个 ctx,而不是继续使用外层 parent。

func loadBatch(parent context.Context, ids []string) error {
    // 派生 ctx 的生命周期绑定到这一组任务。
    g, ctx := errgroup.WithContext(parent)
    for _, id := range ids {
        id := id
        g.Go(func() error {
            // 在开始和阻塞 I/O 前都能响应同组取消。
            if err := ctx.Err(); err != nil {
                return err
            }
            return loadOne(ctx, id)
        })
    }
    // Wait 返回第一个非 nil 错误;调用方仍应处理取消来源。
    return g.Wait()
}

这里的“取消范围”是语义范围,不是强杀范围。已经不检查 ctx 的计算仍可能继续;一个不接受 context 的第三方调用也不会因为 group 取消而自动停下。实践中要把 ctx 传入 HTTP、数据库、文件或队列客户端,并保证错误路径释放响应体、连接和临时资源。

Go errgroup WithContext 将父级上下文、派生上下文和可取消子任务分组的结构示意图
图1:WithContext 的取消范围示意图;派生 ctx 只连接当前任务组,子任务需要主动接收它。

用 SetLimit 限制活跃任务而不是限制总任务

g.SetLimit(4) 的含义是最多同时有 4 个活跃 goroutine,并不是整个批次最多只能提交 4 个任务。调用 g.Go 时如果名额已满,提交方会等待;前面的任务返回后,后面的任务才会加入。

设置行为适合场景
负数不限制活跃数量任务本身已有外部限流
正数限制活跃 goroutine,Go 可能阻塞保护连接池、CPU 或对端接口
0不允许新的任务加入暂时封住提交入口

并发值应对齐最窄的资源:数据库连接池只有 8 个时,把 group 设成 100 并不会让查询更快。另一个硬边界是不能在仍有活跃任务时修改 limit;需要调整时先等待这一组结束,再创建下一组或重新设置。

用 TryGo 决定新任务是否进入任务组

当提交方不能因为资源满而长时间卡住,可以改用 TryGo。它只在当前活跃数低于上限时启动 goroutine,满额就立即返回 false。这个返回值不是任务执行失败,而是“这次没有入组”,调用方必须保存、重试或明确丢弃对应输入。

func submitReady(ctx context.Context, g *errgroup.Group, jobs []Job) []Job {
    var pending []Job
    for _, job := range jobs {
        // 首个错误发生后,不再把新任务塞进已取消的组。
        if err := ctx.Err(); err != nil {
            pending = append(pending, job)
            continue
        }
        current := job
        if !g.TryGo(func() error {
            // 任务开始后再次检查,覆盖提交与执行之间的竞态窗口。
            if err := ctx.Err(); err != nil {
                return err
            }
            return process(ctx, current)
        }) {
            pending = append(pending, job)
        }
    }
    return pending
}

这类写法适合“失败后不再扩散”的入口,但它不会替你安排 pending 任务。若每个任务都必须完成,保留 pending 并在下一轮重新提交;若任务可丢弃,则把丢弃原因写入指标或日志。相反,使用 Go 的场景是必须把所有任务纳入这一组,并接受提交端的背压。

Go errgroup SetLimit 与 TryGo 在活跃槽位满载时分别阻塞和返回 false 的结构示意图
图2:SetLimit 与 TryGo 的提交边界示意图;同一上限下,Go 等待空槽,TryGo 将未提交任务交给 pending。

用 Wait 和父级超时做收口检查

收口时至少看两个结果:err := g.Wait() 告诉你组内第一个非 nil 错误,ctx.Err() 帮助区分父级超时、主动取消和同组失败。不要看到 context.Canceled 就认定业务处理失败,它可能只是另一个任务先失败后的连锁结果。

我通常按下面的顺序处理:先记录 Wait 返回值,再判断父级 ctx 是否已经到期;如果是业务错误,返回原错误并让上层决定是否重试;如果是父级 deadline,则返回超时并保留未提交任务;如果只是同组取消,则避免对每个被动退出的任务重复报警。

现象优先判断动作
Wait 返回业务错误哪个任务最先失败取消同组可取消工作
parent 到期ctx.Err() 是否为 deadline返回超时,保留可重试输入
TryGo 返回 false是否只是活跃数已满处理 pending,不当成执行错误

常见问题

SetLimit 能不能保证所有任务都响应取消?

不能。它只限制活跃数量;任务是否停止取决于函数是否检查 ctx,以及底层 I/O 是否支持取消。

TryGo 返回 false 后要不要调用 Wait?

要。已经成功加入 group 的任务仍需由 Wait 收口;false 对应的任务则由调用方另行重试或记录。

能不能在任务运行中重新 SetLimit?

不建议也不允许。先让当前 group 没有活跃任务,再调整限制,避免信号量状态和任务生命周期互相覆盖。

声明:本文转载于:17golang原创 如有侵犯,请联系study_golang@163.com删除
相关阅读
更多>
最新阅读
更多>
课程推荐
更多>