导读:本期聚焦于白鲨创作的《Go 中如何正确等待所有 goroutine 完成以避免死锁?》,敬请观看详情。goroutine 还没跑完主程序就退出了,或者通道没人读导致整个程序卡死,这类问题在 Go 并发编程中非常常见。本文围绕死锁产生的几个典型原因展开分析,包括无缓冲通道的收发阻塞、主 goroutine 提前结束、循环变量共享引发的意外行为等,并给出对应的解决方案。重点讲解 sync 包中 WaitGroup 的 Add、Done、Wait 三个方法的标准用法,同时介绍带缓冲通道、select 超时控制、errgroup 并发编排等进阶技巧,最后结合常见死锁场景给出排查思路,帮助你在实际项目中写出更安全可靠的并发代码。

Go 的 goroutine 使用起来非常轻量,一个 go 关键字就能启动成千上万个并发任务。但轻量的背后隐藏着不少陷阱,最典型的就是死锁:程序跑着跑着整个卡住不动,或者主流程提前退出导致 goroutine 里的任务根本没执行完。想要正确等待所有并发任务完成,核心在于理解 Go 的调度模型和同步机制,而不是简单粗暴地加个 sleep。

Go 中如何正确等待所有 goroutine 完成以避免死锁?

一、goroutine 死锁的常见原因

死锁的本质是 goroutine 之间互相等待对方释放资源,最终所有相关协程都无法继续执行。在 Go 中最常见的死锁形式有两种:一种是主 goroutine 在等待一个永远不会到来的数据;另一种是多个 goroutine 互相持有对方需要的通道锁。

先看一个经典的无缓冲通道死锁例子:

func main() {
    ch := make(chan int) // 无缓冲通道
    ch <- 1             // 主 goroutine 阻塞在这里,没人接收
    fmt.Println(<-ch)
}

这段代码会直接触发 Go 运行时的死锁检测并 panic。原因是无缓冲通道的发送操作必须等到有接收方就绪才能完成,而主 goroutine 自己在发送,根本没有别的 goroutine 去接收,于是就卡死了。运行时会输出 fatal error: all goroutines are asleep - deadlock!

除了这种显性死锁,还有一种更隐蔽的情况:主 goroutine 提前退出。主函数一旦返回,Go 程序就会终止,此时不管还有多少 goroutine 在跑,它们都会被强制结束。这种问题不会报错,但会造成数据丢失,是并发编程中最容易被忽视的坑。

二、使用 WaitGroup 正确等待所有任务完成

sync 包提供的 WaitGroup 是等待一组 goroutine 结束的标准方案。它内部维护一个计数器,通过三个方法协作工作:Add 增加计数,Done 减少计数,Wait 阻塞直到计数归零。

package main

import (
    "fmt"
    "sync"
)

func main() {
    var wg sync.WaitGroup

    for i := 0; i < 5; i++ {
        wg.Add(1) // 必须在 go 语句之前调用
        go func(id int) {
            defer wg.Done() // 保证无论正常返回还是 panic 都会减少计数
            fmt.Printf("任务 %d 正在执行\n", id)
        }(i)
    }

    wg.Wait() // 阻塞直到所有任务完成
    fmt.Println("所有任务已完成")
}

这段代码有几个关键细节值得注意。第一,wg.Add(1) 必须放在 go 语句之前,如果放到 goroutine 内部,主流程可能先执行到 Wait,此时计数器还是 0,Wait 直接返回,任务就被漏掉了。第二,defer wg.Done() 放在函数第一行,确保即使函数中途 panic 也能释放计数,否则一次异常就会让整个程序永久阻塞。第三,循环变量通过参数传入 goroutine,避免闭包共享同一个变量。

需要强调的是,WaitGroup 一旦开始 Wait 就不能再复用。有些开发者喜欢把同一个 WaitGroup 传递到多个函数里重复 Add,这在并发场景下容易引发竞态问题。如果确实需要循环使用,应该每次创建新的 WaitGroup 实例。

三、Go 1.22 之前的循环变量陷阱

在 Go 1.22 之前,for 循环的循环变量在整个循环过程中是同一个变量,所有闭包引用的都是它的地址。这会导致一个经典 bug:启动了 10 个 goroutine,最后打印出来的全是同一个数字。

func main() {
    var wg sync.WaitGroup
    for i := 0; i < 5; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            fmt.Println(i) // Go 1.22 之前大概率全部输出 5
        }()
    }
    wg.Wait()
}

解决方法有两种:一种是通过函数参数传入,让每次迭代产生独立副本;另一种是在循环体内用 i := i 创建局部变量。Go 1.22 修复了这个语义,每次迭代都会产生新的循环变量,但大量存量代码和团队规范仍要求显式传参,理解这个机制对阅读老代码非常重要。

这个陷阱虽然不直接造成死锁,但会导致并发任务的结果不符合预期,排查起来非常耗时,建议养成显式传递参数的习惯。

四、带缓冲通道与 select 超时保护

除了 WaitGroup,通道本身也可以用来做等待和结果收集。给通道设置足够的缓冲区,可以让发送方不阻塞地完成任务投递:

func main() {
    results := make(chan int, 10) // 缓冲大小等于任务数

    for i := 0; i < 10; i++ {
        go func(id int) {
            results <- id * id
        }(i)
    }

    close(results) // 不能在这里关闭,见下文说明
    for v := range results {
        fmt.Println(v)
    }
}

上面的写法其实有问题:主 goroutine 在还没收集结果前就 close 了通道,而 close 之后再发送会 panic。正确的做法是先启动所有任务,再用固定次数的接收或者单独的 goroutine 负责 close。通道方案的优势是既能等待又能拿到结果,劣势是需要精确计算缓冲大小,否则同样会死锁。

对于可能长时间不返回的 goroutine,建议用 select 加超时保护,避免无限期等待:

select {
case result := <-ch:
    fmt.Println("收到结果:", result)
case <-time.After(3 * time.Second):
    fmt.Println("等待超时,放弃本次任务")
}

select 会同时监听多个通道操作,哪个先就绪就执行哪个分支。配合 time.After 可以设置兜底超时,这在调用外部服务时几乎是必备的防护手段。

五、进阶方案:errgroup 统一管理并发任务

标准库 golang.org/x/sync/errgroup 包提供了更强大的并发编排能力,它能同时完成等待、收集错误和并发数控制三件事:

package main

import (
    "context"
    "fmt"
    "golang.org/x/sync/errgroup"
)

func main() {
    g, ctx := errgroup.WithContext(context.Background())
    g.SetLimit(3) // 最多 3 个 goroutine 并发执行

    for i := 0; i < 10; i++ {
        g.Go(func() error {
            select {
            case <-ctx.Done():
                return ctx.Err()
            default:
                return doTask(i)
            }
        })
    }

    if err := g.Wait(); err != nil {
        fmt.Println("任务失败:", err)
    }
}

errgroup 的核心价值在于错误传播:任何一个任务返回错误,ctx 就会被取消,其他任务可以通过监听 ctx 提前退出,避免无意义的资源消耗。相比手动维护 WaitGroup 加 error 通道的写法,代码量少一半,出错的概率也低得多。

排查死锁时,可以先用 go runtime 的 pprof goroutine 剖析查看各个 goroutine 停在哪个通道操作上,再对照代码分析谁在发送谁在接收。掌握 WaitGroup、缓冲通道、select 超时和 errgroup 这几把工具,绝大多数 goroutine 等待与死锁问题都能从容应对。

goroutine死锁WaitGroupGo并发编程修改时间:2026-09-15 12:30:35

免责声明:已尽一切努力确保本网站所含信息的准确性。网站作品多为原创整理与精心创作,观点力求客观中立。本站旨在免费分享,内容仅供个人学习、研究或参考使用。若引用了第三方作品,版权归原作者所有。如内容涉及您的权益,请联系我们进行处理Email:chomcom@qq.com。
引用或转载本作品时,请注明当前出处:https://www.ipipp.com/html/20260915/57269.html,基于非商业用途的前提下,欢迎转载或二创本作品。
内容垂直聚焦
专注技术核心技术栏目,确保每篇文章深度聚焦于实用技能。从代码技巧到架构设计,为用户提供无干扰的纯技术知识沉淀,精准满足专业提升需求。
知识结构清晰
覆盖从开发到部署的全链路。AI、前端、编程、数据库、服务器、建站、系统层层递进,构建清晰学习路径,帮助用户系统化掌握开发与运维所需的核心技术。
深度技术解析
拒绝泛泛而谈,深入技术细节与实践难点。无论是数据库优化还是服务器配置,均结合真实场景与代码示例进行剖析,致力于提供可直接应用于工作的解决方案。
专业领域覆盖
精准对应开发生命周期。从前端界面到后端编程,从数据库操作到服务器运维,形成完整闭环,一站式满足全栈工程师和运维人员的技术需求。
即学即用高效
内容强调实操性,步骤清晰、代码完整。用户可根据教程直接复现和应用于自身项目,显著缩短从学习到实践的距离,快速解决开发中的具体问题。
持续更新保障
专注既定技术方向进行长期、稳定的内容输出。确保各栏目技术文章持续更新迭代,紧跟主流技术发展趋势,为用户提供经久不衰的学习价值。