最近排查线上服务,好几个同事被 Go 的fatal error: all goroutines are asleep - deadlock!折磨得不轻。多数人第一次遇到 channel 死锁,第一反应就是翻代码、加日志,折腾半天发现运行时根本不会走到你以为的错误分支。这类问题说起来不算复杂,但一个不留神就能把整个进程卡死,尤其是在并发量上来、分支多、goroutine 生命周期交错的时候。
这篇东西我就围绕 Go Channel 的死锁检测和实战排查来写。不看门道的话,死锁就是 go 协程互相等待、谁也没法推进;看门道之后,你会发现死锁的种类就那么几种,检测思路也是可以体系化整理的。适合正在写并发代码、被 goroutine 卡死搞到头疼的 Go 开发者,也适合刚学完 channel 语法、想进阶理解调度模型的人。
1. 死锁本质与 Channel 阻塞机制
1.1 为什么只有阻塞没有抢占
要聊死锁,首先得把 Go 的并发模型摆出来。Go 的 goroutine 不是操作系统线程,而是由运行时调度器管理的轻量级任务。channel 是 goroutine 之间通信的管道,核心操作只有四种:发送、接收、关闭、遍历。关键就在这里:channel 的收发操作是阻塞式的,发送方如果没有接收方准备好,或者接收方没有发送方准备好,这个 goroutine 就会被挂起,进入等待队列。
这个设计在单 goroutine 场景下不会出问题,因为没人跟它抢。但一旦两个或以上的 goroutine 都在等对方先行动,而自己又不肯先松手,死锁就出现了。很多人容易混淆的一点是:死锁不等于程序崩溃,虽然 Go 运行时能检测出一部分死锁并抛异常,但更多时候是进程卡住、CPU 占用为 0、日志停滞,看起来像“假死”。为什么 Go 运行时不总是报错?因为运行时只检测“所有 goroutine 都无法推进”的全局死锁,如果有一个 goroutine 在正常跑着,只是某个业务逻辑等了很久,运行时不会认为这是死锁。
这里有个很常见的误区:以为 channel 阻塞就一定是死锁。实际上阻塞只是死锁的必要条件,不是充分条件。比如一个 goroutine 发数据没人收,它会阻塞,但如果你在主流程里开了另一个 goroutine 去收,只是时间上有先后,那它顶多算“短暂阻塞”,不会死锁。真正的死锁是“阻塞且永远无法解除”。
1.2 GMP 调度对死锁的影响
理解了 M(操作系统线程)、P(处理器)、G(goroutine)的关系,对排查死锁帮助很大。Go 的调度器在 G 阻塞时,会把当前 P 跟 M 解绑,尝试去别的线程队列找可运行的 G。如果所有 G 都堵在 channel 上,且没有任何 G 处于可运行状态,调度器就认为系统进入死锁状态。
我在实际排查中遇到过一种特殊情况:channel 死锁时,运行时会给出所有 goroutine 的堆栈,里面能看到每个 G 卡在哪个文件哪一行。这个堆栈信息非常关键,它能直接告诉你谁在等谁。没有它,排查难度会翻倍。
要确认是不是全局死锁,有一个非常快的应对办法:收到fatal error: all goroutines are asleep - deadlock!之后,不要只盯最后几行,要把完整堆栈保存下来。如果堆栈里全是chan send、chan receive这种调用,而且每个 G 的状态都是chan wait,那基本就定性了。
2. 常见的死锁模式与案例复盘
2.1 无缓冲 Channel 自锁
最简单的死锁:在主 goroutine 里对一个无缓冲 channel 做发送操作,但没有其他 goroutine 接收。这种问题新手常犯,但资深开发者有时也会在某个函数的测试入口处不小心写出来。
func main() { ch := make(chan int) ch <- 1 // 这里会死锁,因为没有人接收 fmt.Println(<-ch) }这个代码我在很多教学群里看到过,运行直接报死锁。解决办法要么把 channel 改成带缓冲的make(chan int, 1),要么把发送和接收放到不同的 goroutine 里。但我不建议用缓冲来“掩盖”设计问题——缓冲 channel 只解决“收发速度不匹配”的问题,不解决“根本没人接收”的问题。万一缓冲区写满,仍然会死锁。
2.2 只读只写方向搞反
channel 可以作为参数传递,函数内部可以用<-chan和chan<-限制读写方向。这个语法本意是好的,但一旦方向搞错,很容易出现“一边等发送,一边等接收”的局面。
典型例子:一个生产者 goroutine 向 channel 发送数据,另一个消费者 goroutine 从 channel 接收数据。如果你创建 channel 时传参方向反了,生产者拿到的其实是只读通道,根本没法发送;消费者拿到的却是只写通道,无法接收。运行时不会在编译期报错,因为参数类型是单方向 channel 时,创建方可以正常拿到双向 channel。结果两个 goroutine 都既不能发也不能收,全局睡眠。
这类问题我建议用强类型封装来规避,比如定义结构体,字段明确写清楚读写方向,而不是裸传 channel 参数。只要方法签名足够清晰,代码审查阶段基本就能看出来。
2.3 多 Channel 循环等待
这是看起来最像教科书死锁的情况:goroutine A 持有 channel1,等待 channel2;goroutine B 持有 channel2,等待 channel1。两边都不放手,于是进入循环等待。
func main() { ch1 := make(chan int) ch2 := make(chan int) go func() { <-ch1 ch2 <- 1 }() go func() { <-ch2 ch1 <- 1 }() time.Sleep(time.Second) }这段代码在time.Sleep结束后主 goroutine 退出,子 goroutine 被强制终止,所以不一定触发运行时死锁检测。真正可怕的是在主 goroutine 里等待这两个子 goroutine 完成,比如用sync.WaitGroup,那进程就真的卡死了。
多 channel 循环等待的排查难度明显更高,因为堆栈信息会分散在多个 goroutine 上,需要逐个对照。我遇到这种情况,第一件事就是画出资源等待图——每个 goroutine 在等哪个 channel,谁在持有这个 channel,这个 channel 的另一端又在等什么。画完基本就水落石出了。
2.4 Range 遍历未关闭 Channel
用for range遍历 channel 是一个非常常见的操作,但很多人忽略了它只在 channel 关闭时才会结束。如果生产者只发送不关闭,消费者就会一直阻塞在range上。
这种死锁有个很迷惑人的地方:主 goroutine 没有阻塞,子 goroutine 也在正常接收数据,但程序就是无法退出。很多人压根不会把它当死锁,而会说成“主程序为什么等不到结果”。实际上这就是 channel 生命周期管理不到位。
正确写法是生产者负责关闭 channel,或者使用sync.Once避免重复关闭。关闭 channel 这个动作要放在生产者一侧,而且要保证在业务逻辑完成后执行。被反复强调的“谁创建谁关闭”,本质就是把责任边界画清楚,避免消费者反过来关 channel 导致莫名其妙的 panic。
3. 死锁检测的核心手段与工具链
3.1 利用 Go 运行时错误定位现场
Go 运行时的死锁检测其实是最后一道防线,它只针对全局死锁,而且是程序彻底无法推进时才触发。触发后输出类似这样的信息:
fatal error: all goroutines are asleep - deadlock! goroutine 1 [chan send]: main.main() /path/to/main.go:12 +0x45这个堆栈里最关键的是[chan send]这个状态,它表示 goroutine 正在 channel 上做发送等待。同理,[chan receive]、[select]、[semacquire]都能揭示 goroutine 当前卡住的类型。
不过这里有个缺陷:如果程序里开启了定时器、有活跃的网络连接或者其它计数器在跑,运行时可能不认定是死锁,堆栈也就不会自动打出来。这时候就要靠下面的方法主动去碰现场。
3.2 pprof 抓取 goroutine 全量快照
比 Go 运行时死锁检测更主动的方式,是对进程发一个 SIGQUIT 信号(在 Linux 上),或者直接在代码里注册 pprof 接口,通过/debug/pprof/goroutine?debug=1获取全量 goroutine 堆栈。
我特别推荐在开发环境或测试环境直接把 pprof 挂到默认的http.ListenAndServe上,遇到疑似死锁现场,直接用curl拉一份堆栈。实战中最大的优势是:即使不是全局死锁、只是局部业务卡住,也可以通过堆栈发现每个 goroutine 到底在等什么。
抓下来的堆栈文件很长,动辄几百行甚至上千行。不要全部打印出来看,先用文本编辑器搜索关键词,比如chan send、chan receive、sync.Mutex.Lock、sync.runtime_SemacquireMutex,把这些行标记出来。然后按 goroutine 分组,逐个看它们之间的依赖关系。
3.3 go-deadlock 与 goleak 工具链
有一个第三方工具go-deadlock我平时用得比较多,它实际上是对sync.Mutex、sync.RWMutex和 channel 相关操作的包装,能够在锁等待时间超过阈值时输出堆栈。这个工具的思路是:死锁发生前一定有一个不正常的长时间等待,那我就在等待超过某个时间后主动报警,不等运行时来判定。
用法非常简单,在文件头部用 build tag 替换标准库的锁:
//go:build deadlock package main import ( "github.com/sasha-s/go-deadlock/deadlock" ) var mu = &deadlock.Mutex{}这样正常构建时用标准库的sync.Mutex,开启-tags deadlock时自动换成带超时检测的版本。另一个工具goleak则用来检测 goroutine 泄漏,严格意义上不算死锁检测,但很多死锁现场都会伴随 goroutine 泄漏,可以组合使用:
func TestMain(m *testing.M) { code := m.Run() goleak.VerifyTestMain(m) os.Exit(code) }一旦某个测试结束后还有 goroutine 存活,goleak就会报出来,通常能顺藤摸瓜找到死锁的根源。
3.4 Delve Debugger 的断点技巧
遇到特别难查的死锁,我会直接用 Delve 调试器。Delve 对 goroutine 的支持很到位,可以列出所有 goroutine,切换上下文,甚至能直接查看某个 goroutine 当前阻塞在内核的哪个调用上。
比较顺手的一套操作是:
dlv debug main.go启动调试会话;- 程序跑起来后,用
goroutines命令查看所有 G 的状态; - 找到
chan send、chan receive状态的 goroutine; goroutine <id> stack查看具体阻塞位置;- 对照多个 goroutine 的栈,定位互相等待的关系。
Delve 不好用的地方是,在 docker 容器里调试时会遇到 ptrace 权限问题,需要加--security-opt seccomp:unconfined,而且调试对并发问题的干扰也很明显,加了断点后可能就复现不了了。所以我一般只在本地复现阶段用,线上还是靠 pprof 和运行时报错。
4. 代码层面的死锁检测与实践细节
4.1 用 select 超时机制兜底
写生产级并发代码,我强烈建议所有可能阻塞的 channel 操作都加超时或 default 分支兜底。select是 Go 里少数几个天然支持“非阻塞尝试”的语法结构:
select { case ch <- data: // 发送成功 case <-time.After(3 * time.Second): // 超时走这里 }这个模式特别适合外部依赖调用、异步通知、任务分发之类的场景。它不能防止死锁本身,但能保证一个 goroutine 不会无期限地卡在 channel 上,相当于给系统加了限行。
需要注意time.After会创建新的定时器,如果这个 select 在循环里频繁执行,会带来额外的定时器开销。建议用time.NewTimer复用同一个 timer:
timer := time.NewTimer(3 * time.Second) defer timer.Stop() select { case ch <- data: case <-timer.C: // 超时处理 }4.2 解决 main 提前退出的问题
新手写 Go 并发时最常踩的坑之一:子 goroutine 还没跑完,main 已经退出了。Go 的程序不会等所有 goroutine 跑完,main 返回整个进程就结束了,所以即便不是死锁,你也会觉得“程序没输出完就没了”。
标准解法是用sync.WaitGroup,把 goroutine 的生命周期纳入主流程管理:
var wg sync.WaitGroup wg.Add(1) go func() { defer wg.Done() // 业务逻辑 }() wg.Wait()如果业务中要同时等待多个 goroutine 并且还要支持超时,WaitGroup就有点不够用了,因为Wait不能设置超时。这时候我通常再配合一个额外的 done channel,在超时分支里做退出判断。
4.3 关闭 Channel 的边界问题
关闭 channel 本身不会引起死锁,但是关错了会引出下一层的 panic:向已关闭的 channel 发送数据会直接 panic,重复关闭 channel 也会 panic。这两个 panic 如果没 recover,进程照样崩。
所以我在团队里定的规矩是:只有发送方才能关闭 channel,接收方永远不要自行关闭;一个 channel 只对应一个发送方逻辑。如果确实存在多个发送方,就别用裸 channel 了,改用sync.WaitGroup协调,或考虑用context取消信号来做关闭通知,而不是物理关闭 channel。
4.4 context 在取消场景下的作用
context虽然不是 channel 的直接替代品,但在控制 goroutine 生命周期上作用很大。ctx.Done()本身就是一个 channel,当调用cancel或超时时它会自动关闭,所有监听<-ctx.Done()的 goroutine 都会立刻醒来。
在设计长任务时,我习惯把 context 作为第一个参数传下去,所有 channel 操作都放在 select 里同时监听ctx.Done()。这样即便某个业务分支卡死了,取消信号也能钻进来,把 goroutine 从死锁边缘拉走。
5. 实战排查流程与问题速查表
5.1 一个典型案例的完整排查
拿我最近调的一个任务分发系统来举例。需求很简单:主 goroutine 把任务切片发给若干 worker goroutine,等所有 worker 处理完再汇总。第一次实现是这样的:
jobs := make(chan Job) results := make(chan Result) for i := 0; i < 10; i++ { go worker(jobs, results) } for _, job := range jobList { jobs <- job } close(jobs) for i := 0; i < 10; i++ { res := <-results handleResult(res) }跑起来后程序卡住,无输出,进程不退出。先加 pprof,拉 goroutine 堆栈,发现所有 worker 都卡在results <- res这一行。原因是我只启动了 worker,但主 goroutine 消费results是在最后才开始的,当 worker 数量和 job 数量不成比例时,worker 产生的中间结果没有消费者,results是无缓冲 channel,于是 worker 全部阻塞。
修复思路有两个方向:一是把results缓冲区加大或改成有缓冲 channel;二是把结果消费的循环提到任务发送之前,和发送并行。因为任务数量不大,我直接用带缓冲的results := make(chan Result, 10*n)解决。但要说明,这只是应急方案,在生产环境大规模任务下,无缓冲 channel 配并行消费才是更稳的设计。
5.2 经验速查表
| 症状 | 根因 | 快速定位 | 推荐解法 |
|---|---|---|---|
| 启动即报 all goroutines asleep | main 对无缓冲 chan 收发无配套 | 看主 goroutine 堆栈 | 拆 goroutine 或加缓冲区 |
| 程序卡死但无 fatal error | 局部 goroutine 陷入循环等待 | SIGQUIT 或 pprof 拉堆栈 | find 阻塞点,重新设计依赖 |
| range 循环不结束 | channel 未关闭 | 看消费 goroutine 栈 | 发送方负责 close |
| worker 数量多,任务结果无法汇总 | 结果无缓冲且消费者后置 | 堆栈显示 chan send | 并行消费或加缓冲 |
| 锁等待异常但无死锁报错 | 可能活锁或资源争抢 | go-deadlock 超时报警 | 检查锁粒度和顺序 |
| 测试结束有遗留 goroutine | 泄漏或等待未取消 | goleak 验证 | 用 context 取消 |
5.3 从堆栈里快速判断死锁方向
实际排查中,拿到堆栈后先数一数“卡住的 goroutine 总数”。如果所有 goroutine 都处于阻塞态,优先级最高的是找有没有一个 goroutine 是主 goroutine,它在等什么;第二步把所有chan send和chan receive的互指关系列出来,两两配对。
比如 A goroutine 在ch1 <- v,B 在<- ch1,这种至少说明ch1这一层有收发不对称。再看 A 同时有没有在执行<- ch2,B 是否在执行ch2 <- v,如果有,这就是经典的循环等待。这时候只要破坏一个等待分支,死锁就解开了,比如把其中一个 channel 操作换成 select 加超时。
6. 死锁检测前的设计思维调整
6.1 分清“数据流方向”和“控制流方向”
很多死锁其实在设计阶段就可以避免。一个核心思路是区分数据流方向和控制流方向:数据流是指任务从生产者流向消费者,控制流是指 worker 完成后向主流程汇报结果。同一个 channel 如果既传数据又传信号,很容易把方向搞混。
我见过不少代码,用一个 channel 既发业务数据又发“退出通知”,结果就是接收方得靠业务类型来区分,一旦某个类型处理得慢,其他类型就全堵住了。更合理的方式是数据 channel 和 context 或专门的控制 channel 分开,各管各的。
6.2 并发模型的取舍:CSP 还是 Actor
Go 官方的口号是“Do not communicate by sharing memory; instead, share memory by communicating”,这本质上就是 CSP(Communicating Sequential Processes)模型。CSP 模型强调通过 channel 连接各个独立的顺序进程,每个模块内部都是顺序执行的。
实际写代码时,过度使用 channel 不一定好。比如两个 goroutine 之间需要频繁交换最新状态,用 channel 做“最新值广播”很容易堵塞;这种情况下不如用原子操作加锁来共享内存。反过来,如果任务是流水线型的,每个环节独立处理,channel 就是很好的队列结构。没有哪种模型普遍最优,只有合不合适。
6.3 用单元测试约束并发行为
死锁问题最好的复现场地是单元测试。因为测试里通常能控制 goroutine 数量、channel 缓冲大小、以及收发顺序。我在项目里会给每个 channel 场景写几条基础用例:
- 只有 1 个发送方、1 个接收方,缓冲为 0;
- 多个发送方、1 个接收方;
- 1 个发送方、多个接收方;
- 关闭 channel 后继续遍历。
每条用例都加超时断言,超过 2 秒直接 fail,这样死锁能被测试框架自动拦截,而不是流到生产环境再炸。测试里再配合 goleak 验证 goroutine 泄漏,基本能提前拦住大部分问题。
7. 最后的实际操作心得
分享几个我用真金白银换来的经验。
第一,死锁排查优先看堆栈,而不是猜代码。百分之九十的情况下,堆栈会直接告诉你卡在哪一行。不要靠“我感觉是这里出问题”去反复加日志,加日志本身会改变 goroutine 的执行时序,问题可能就复现不出来了。
第二,无缓冲 channel 是默认陷阱,用之前一定要想清楚“谁在什么时候接收”。很多人看到make(chan int)就觉得简单,实际上它要求收发严格配对,本质上是“同步通道”。如果你只是想传递数据,缓冲为 1 或合理数值的 channel 更稳。
第三,select 不是银弹。虽然 select 可以监听多个 channel,但如果没有 default 分支且所有分支都不可用,select 同样会阻塞。所以检查死锁时,select 分支也是一个重点排查对象——如果每个 case 都在等另一个 goroutine 发数据,而另一个 goroutine 又在等这个 select 返回,照样死锁。
第四,不要迷信死锁检测工具。go-deadlock、goleak 都很好用,但它们都有误报漏报的可能,最终判断还是要回归到对代码并发模型的理解上。工具只是帮助你更快看到现场,真正解决问题靠的还是设计层面把“谁创建、谁关闭、谁消费、谁超时”这些边界划清楚。
Deadlock 这词听起来很高大上,但本质上就是 goroutine 之间的关系构成环了。把环打破,系统就能继续往前走。希望这篇实战总结能帮你少走点弯路。