很多 Go 业务函数之所以越来越长,不是因为单个逻辑难,而是它把几件彼此独立的事都塞进了一次请求里。比如按用户 ID 读数据库、调外部服务查活动、再补一笔登录记录,这些步骤共享输入,却没有前后依赖。本文就围绕这类场景,拆开说明 errgroup 能解决什么、什么时候该配合 SetLimit 和 TryGo、以及 WithContext 到底能不能“自动停掉”其他 goroutine,帮你判断它是否适合放进实际项目。
为什么这类并发场景适合用 errgroup
先看一个常见的业务函数:它需要围绕同一个 userID 拉三类数据,但三项任务彼此并不交换结果。
func FetchUserData(userID string) error {
g := errgroup.Group{}
// 获取用户详细信息
userDetail, _ := fetchUserDetails(userID)
// 获取用户活动
userAct, _ := fetchUserActivity(userID)
// 获取用户登录详细信息
userLoginInfo, _ := fetchUserLoginDetails(userID)
// ...
}
这里的问题不在于代码能不能跑,而在于串行执行会平白拉长总耗时。既然三项任务都只依赖同一个 userID,理论上就可以并行发起,再统一等待结果。
当然,直接写 goroutine 也能完成,但很快就会碰到一组重复问题:谁来等待全部任务结束、错误怎么汇总、一个任务失败后其他任务要不要继续、正在运行的 goroutine 如何收尾、并发量需要不需要限流。这些正是 errgroup 的使用边界,也是它比“手搓 goroutine + channel”更适合业务层的原因。
errgroup 的基本用法:启动任务并统一等待
errgroup 的核心思路很直接:把多个任务通过 g.Go() 加入同一个组,最后用 g.Wait() 统一等待,并返回第一个非空错误。

errgroup适合用一致方式管理一组并发任务,把同步、错误返回和生命周期收口到同一个入口。
一个快速示例如下:
func FetchUserData() error {
g := errgroup.Group{}
// 获取用户详细信息
g.Go(func() error {
time.Sleep(1 * time.Second)
fmt.Println("Fetched user details...")
return nil
})
// 获取用户活动
g.Go(func() error {
time.Sleep(1 * time.Second)
fmt.Println("Fetched user activity...")
return nil
})
// 获取用户登录详细信息
g.Go(func() error {
time.Sleep(2 * time.Second)
fmt.Println("Fetched user login details...")
return nil
})
// 等待所有goroutines完成并返回第一个错误 (如果有)
return g.Wait()
}
这段代码里,三个任务会并发执行,调用方不需要自己维护 WaitGroup,也不用额外设计“谁先报错、谁来收口”的逻辑。对于 3 个任务,这种收益看起来还不算夸张;但任务数扩展到 10 个、20 个时,代码组织会明显更稳,后续也更容易维护。
不过,能并发并不代表要无限并发。任务一多,数据库连接、外部接口配额、CPU 和内存都会开始受影响,这时就需要限制同时运行的 goroutine 数量。
用 SetLimit 和 TryGo 控制并发数量
SetLimit:给同时运行的任务设上限
errgroup 提供了 SetLimit 方法,用来限制同一时刻最多有多少个 goroutine 在运行。


func FetchUserData() error {
g := errgroup.Group{}
// 将限制设置为2
g.SetLimit(2)
// 获取用户详细信息
g.Go(func() error {
time.Sleep(1 * time.Second)
fmt.Println("Fetched user details...")
return nil
})
// 获取用户活动
g.Go(func() error {
time.Sleep(1 * time.Second)
fmt.Println("Fetched user activity...")
return nil
})
// 获取用户登录详细信息
g.Go(func() error {
time.Sleep(2 * time.Second)
fmt.Println("Fetched user login details...")
return nil
})
// 等待所有goroutines完成并返回第一个错误 (如果有)
return g.Wait()
}
这里把并发上限设为 2 之后,前两个任务会先运行,第三个任务要等其中一个结束后才会进入执行。按示例里的耗时估算,总时间大约是 3 秒,而不是完全并发时的 2 秒。代价是慢一点,好处是资源消耗更可控。
这在实际服务里很常见,比如:
- 外部服务有 QPS 限制,不适合瞬时放出太多请求;
- 数据库查询成本高,需要控制并发访问数;
- 单次批处理任务量大,但机器资源有限。
有一个细节需要特别记住:SetLimit 不是随时都能改。如果已经有 goroutine 在运行,再调用 SetLimit 会直接触发 panic。更稳妥的做法是,在任何任务启动前就把上限设置好。
TryGo:组已满时直接返回,而不是阻塞
如果你的需求不是“排队等空位”,而是“能塞进去就执行,塞不进去就算了”,那就要看 TryGo。
// TryGo: 检查并添加 goroutine
func (g *Group) TryGo(fn func () error) bool
// Go: 只需添加一个 goroutine
func (g *Group) Go(fn func () error)
TryGo 和 Go 的关键差别在于组满时的行为:
Go:达到上限后会阻塞,直到有空位再把任务加进去;TryGo:达到上限后立即返回false,调用方自行决定后续策略。
这让 TryGo 更适合做动态调度。比如你已经有一批任务在跑,但不想新来的请求继续堆积,就可以根据返回值选择降级、跳过、转异步队列,或者给调用方一个明确反馈。
换句话说,SetLimit 解决的是“最多跑多少”,而 TryGo 解决的是“满了以后怎么办”。这两个能力配合起来,才能把并发控制从“能跑”推进到“可运营”。
WithContext 的取消机制到底怎么生效
并发任务里另一个高频问题是:其中一个任务已经失败了,其他任务还要不要继续跑?如果继续跑,可能浪费资源;如果要停,谁来通知它们停?
WithContext 就是为这件事准备的:
erg, ctx := errgroup.WithContext(context.Background())
它会返回一个绑定到 errgroup 生命周期的 context。当组内有任务返回错误时,这个 context 会被取消;当 Wait() 返回时,相关生命周期也就结束了。
但这里最容易误解的一点是:errgroup 不会替你“强行杀掉”其他 goroutine。它能做的是取消 context,真正退出还得靠任务函数自己感知这个取消信号。
因此,
WithContext的关键不只是“拿到一个 ctx”,而是让每个任务在合适的位置检查ctx.Done(),并愿意尽快返回。
示例代码如下:
func main() {
g, ctx := errgroup.WithContext(context.Background())
g.Go(func() error { return fetchUserDetails(ctx) })
g.Go(func() error { return fetchUserActivity(ctx) })
g.Go(func() error { return fetchUserPaymentHistory(ctx) })
// Wait for tasks to finish and print the error
if err := g.Wait(); err != nil {
fmt.Printf("Encountered an error: %v\n", err)
}
}
如果这里的 fetchUserDetails(ctx) 先返回错误,errgroup 会取消这个 ctx。接下来,其他正在运行的任务只有在内部监听到 ctx.Done(),或者把这个 ctx 继续传给数据库访问、HTTP 请求等下游调用时,才能更早结束。
所以在工程实践里,判断 WithContext 是否真正起作用,可以看两件事:
- 任务函数有没有接收并传递
context.Context; - 阻塞点,例如网络请求、查询、轮询循环,是否支持取消。
如果只是创建了 ctx 却完全不用,那它和普通 errgroup.Group{} 在效果上差别不大。
使用时容易踩的点与适用判断
把前面的几个能力放在一起,errgroup 的定位其实很清楚:它适合管理“一组目标一致、生命周期绑定、需要统一收口”的并发任务。
在使用时,几个容易踩坑的点可以直接记住:
- 任务之间没有依赖关系时,再考虑并行化;有严格前后顺序的流程不必硬上
errgroup; SetLimit要在启动任务前设置,运行中修改会 panic;TryGo适合不想阻塞提交方的场景,返回false后要有明确兜底策略;WithContext只能传播取消,不能替代任务自身的退出逻辑。
如果你的代码里经常出现“一个函数顺手干三件互不依赖的事”,那么 errgroup 通常就是一个值得优先考虑的工具。它没有复杂抽象,API 也不多,但正因为收敛在 Go、Wait、SetLimit、TryGo 和 WithContext 这些关键点上,反而很适合放进真实项目里长期使用。













