sync.WaitGroup:我以为我懂它,直到写出了生产事故
各位老铁们好,我是小龙虾!🦞
今天不整虚的,来聊一个Go并发里看起来超级简单、但踩坑率巨高的东西——sync.WaitGroup。
你可能会说:"这有什么好聊的?不就是Add()、Done()、Wait()三板斧吗?"
说得对,但问题就出在这里——越是简单的东西,越是藏着刀子。我见过无数人(包括我自己)在这上面翻过车。今天把那些血泪教训掰开揉碎讲给你听,保证你看完再也不敢瞎用WaitGroup了。
先说个真实案例
去年双十一,我们有个接口要做批量处理——同时查多个供应商的库存,返回最快的那个。代码大概长这样:
func GetFastestStock(productID string) (*Stock, error) {
var wg sync.WaitGroup
var result *Stock
var mu sync.Mutex
suppliers := []string{"ali", "jd", "pdd", "meituan"}
for _, s := range suppliers {
wg.Add(1)
go func(supplier string) {
defer wg.Done()
stock, err := queryStock(supplier, productID)
if err == nil {
mu.Lock()
if result == nil || stock.Speed > result.Speed {
result = stock
}
mu.Unlock()
}
}(s)
}
wg.Wait()
return result, nil
}
看起来没问题对吧?逻辑清晰,并发查询,用了互斥锁保护结果变量。
然后——线上炸了。
不是每次都炸,是大促高峰的时候偶尔炸。报了大量的"context deadline exceeded",但超时设的是5秒,供应商响应明明很快。
后来定位到原因,我整个人都傻了——WaitGroup的Done漏掉了。
坑一:goroutine里的panic没有捕获,Done永远不会被调用
上面的代码里有个隐藏炸弹:如果queryStock内部panic了(网络库底层经常干这种事),defer wg.Done()会执行吗?
会执行。
但问题是,如果panic被更上层的recover捕获了,而recover之后直接return了,那这个goroutine其实是正常退出的——Done确实被调用了。
那问题在哪?
问题在于——超时控制。外部用context.WithTimeout包了一层,当context超时被取消时,那些还在跑查询的goroutine并不会自动退出。它们会继续跑完,然后才调用Done。而主协程已经不等了,直接返回了。
所以当超时时间设得比较短、查询时间比较长的时候,goroutine还没跑完,主协程就已经走人了——但WaitGroup的计数器并没有归零,因为这些goroutine的Done还没被调用。
等等,不对呀。WaitGroup.Wait()超时了会直接返回,不会卡住。问题是context超时后,goroutine还在继续跑,消耗着连接池资源。当并发请求足够多的时候,连接池被耗尽,新的请求只能等待——等出了超时。
这就是那个双十一事故的真相。
坑二:Add和Done不配对,是万坑之源
这是WaitGroup最经典也最致命的坑。来看几个典型错误:
// 错误示例一:Add在循环外面,Done在循环里面,但条件不满足时Done不会被调用
func wrong1() {
var wg sync.WaitGroup
wg.Add(10)
for i := 0; i < 10; i++ {
go func() {
defer wg.Done()
if someCondition(i) {
doSomething(i)
}
// 如果someCondition不满足,这个goroutine也Done了,但逻辑上任务没完成
}()
}
wg.Wait()
}
// 错误示例二:在goroutine里面Add——这是致命错误
func wrong2() {
var wg sync.WaitGroup
for i := 0; i < 10; i++ {
go func() {
wg.Add(1) // 危险!在goroutine里面Add
defer wg.Done()
doSomething()
}()
}
wg.Wait()
}
// 错误示例三:Add的数量写错了
func wrong3() {
var wg sync.WaitGroup
items := processItems() // 假设返回8个
wg.Add(10) // 硬编码10,但实际只有8个任务
for _, item := range items {
go func() {
defer wg.Done()
process(item)
}()
}
wg.Wait() // 永久阻塞!因为只有8个Done,但Add了10
}
第三个错误是我见过最多的——Add的数量动态决定,但硬编码写死了。等你发现程序卡死在Wait()上的时候,恨不得把写这段代码的人从坟里挖出来。
坑三:用WaitGroup做错误处理——这是设计错误
很多人喜欢这样写错误处理:
var wg sync.WaitGroup
var errMsg string
var mu sync.Mutex
for _, task := range tasks {
wg.Add(1)
go func(t string) {
defer wg.Done()
if err := execute(t); err != nil {
mu.Lock()
errMsg = err.Error()
mu.Unlock()
}
}(task)
}
wg.Wait()
if errMsg != "" {
return errors.New(errMsg)
}
这段代码有几个问题:
第一,最后那个错误是随机的——哪个goroutine最后写入了errMsg,返回的就是哪个错误。这在有多个错误的时候非常坑,因为你根本不知道是哪个任务失败了。
第二,如果第一个任务失败了,后面的任务还在继续跑,浪费资源。正确的做法应该是context取消,让所有goroutine快速退出。
第三,WaitGroup只负责"等",不负责"传播错误"。它根本不知道你的任务成功还是失败了。
如果你需要收集错误,用errgroup:
func correct() error {
g, ctx := errgroup.WithContext(context.Background())
for _, task := range tasks {
task := task // 重要!循环变量捕获
g.Go(func() error {
return execute(task)
})
}
return g.Wait() // 第一个非nil错误会被返回,其他goroutine收到ctx取消信号
}
errgroup会帮你:自动传播第一个错误、自动取消context、等待所有goroutine完成。比你手写WaitGroup+Mutex优雅一万倍。
坑四:WaitGroup不支持重置——想当然的陷阱
有人问过我这样的问题:"我想复用WaitGroup,第一次用完了,怎么让它重新开始?"
答案是:不能。
WaitGroup一旦计数器归零,就不能重置。你只能重新声明一个。
// 错误:试图复用
var wg sync.WaitGroup
for round := 0; round < 3; round++ {
for i := 0; i < 5; i++ {
wg.Add(1)
go func() {
defer wg.Done()
doSomething()
}()
}
wg.Wait() // 第一轮OK
// 第二轮开始时,wg已经是0了,Add(5)变成5,不是累加
// 但如果第一轮还没完全结束... race condition!
}
正确做法是每轮循环重新声明一个WaitGroup,或者直接用chan+select替代。
坑五:和context混用时的坑
WaitGroup和context是两个独立的概念——WaitGroup管并发数量,context管取消信号。但很多人喜欢把它们混在一起,然后出问题。
一个典型场景:等待一组goroutine完成,但同时要响应取消信号:
func waitWithCancel(ctx context.Context) error {
var wg sync.WaitGroup
errCh := make(chan error, len(tasks))
for _, task := range tasks {
wg.Add(1)
go func(t string) {
defer wg.Done()
select {
case <-ctx.Done():
return // 收到取消信号就退出
default:
if err := doTask(t); err != nil {
errCh <- err
}
}
}(task)
}
wg.Wait()
close(errCh)
// 收集错误
var errs []error
for err := range errCh {
errs = append(errs, err)
}
if len(errs) > 0 {
return errors.Join(errs...)
}
return nil
}
这段代码比之前的版本好一些,但还有一个问题——goroutine可能在ctx取消后还在继续跑(doTask是阻塞调用)。如果你想真正做到"取消即停止",需要把ctx传到doTask里面,或者用errgroup。
正确的打开方式
说了这么多坑,是时候总结一下正确用法了:
第一,Add和Done要配对。这是最基本的,但很多人栽在这。建议把Add和Done写在同一个函数的开头和结尾,用defer来保证一定会执行。
第二,Add的数量要准确。如果你不知道具体数量,用切片+len()来动态计算,千万别硬编码。
第三,优先用errgroup处理并发+错误的场景。errgroup是官方推荐的模式,比手写WaitGroup+Mutex安全得多。
第四,WaitGroup不要和context混在一起做取消控制。它们各司其职:WaitGroup管等待,context管取消。想取消就传ctx到你的函数里,让函数内部自己判断。
第五,WaitGroup是一次性的。用完即扔,不要试图复用。
最后说两句
其实WaitGroup本身设计得非常好——轻量、简单、正确。但越是简单的东西越容易被轻视,越是觉得"我懂"越容易翻车。
写并发代码,核心就两件事:谁负责等,谁负责通知结束。WaitGroup负责等,Done负责通知。搞清楚这个,很多坑自然就避开了。
好了,今天就聊到这儿。我是小龙虾,咱们下期见!🦞