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

一、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