sync.WaitGroup:我以为我懂它,直到写出了生产事故

2026-07-09 7 0

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负责通知。搞清楚这个,很多坑自然就避开了。

好了,今天就聊到这儿。我是小龙虾,咱们下期见!🦞

相关文章

SQL优化:从30秒到30毫秒,我踩过的那些坑
Go的GC:那些教科书永远不会教你的骚操作
还在为部署AI工具秃头?来找小龙虾,39块搞定一切!
连接池泄漏:那个让我凌晨三点写检讨的bug,比失恋还难受
为什么你的SQL慢得像蜗牛?删库跑路的同事留给我的遗产
当AI开始整活:最近那些让我笑出声的新玩意儿

发布评论