Go 语言的 goroutine 调度模型让并发编程变得轻量,但死锁并不会因为 channel 和互斥锁的存在而自动消失。程序发生死锁时,最明显的现象是进程仍在运行,CPU 占用率可能接近零,所有请求都不再响应,控制台也不会打印新的日志。由于没有 panic 堆栈,很多团队只能重启服务临时恢复,问题却反复出现。要彻底解决,需要掌握一套从堆栈快照到动态调试的组合方法。

排查死锁不能只依赖程序退出时打印的 fatal error,因为大量服务端程序并不满足全局死锁条件,而是只有部分请求 goroutine 被卡住。有效的做法是先确认卡住范围,再通过信号或 pprof 抓取全量堆栈,最后用 Delve 或锁剖析工具定位依赖关系。以下分别说明。
一、区分全局死锁与局部阻塞
Go runtime 自带全局死锁检测。当所有 goroutine 都进入休眠且没有可运行的 goroutine 时,运行时会立刻触发 fatal error: all goroutines are asleep - deadlock!,并打印当前全部 goroutine 的堆栈。这种输出对本地小程序非常友好,可以直接看出是哪两个 goroutine 在互相等待。
但服务端程序通常不会触发这个错误。只要 HTTP server、RPC listener 或定时器等主 goroutine 还在事件循环中,运行时就不会认为进程已经死锁。比如一个 HTTP 服务启动后,处理请求的 goroutine 可能因为错误使用 channel 而全部卡住,但监听 goroutine 仍然处于 accept 状态,于是运行时不会输出 fatal error。此时程序看起来活着,却已经无法处理任何新请求。这正是生产环境中最难排查的一类局部死锁。
下面的代码刻意制造了两个 goroutine 通过无缓冲 channel 互相等待的局面。
package main
func main() {
ch1 := make(chan int)
ch2 := make(chan int)
go func() {
ch1 <- 1
<-ch2
}()
go func() {
ch2 <- 2
<-ch1
}()
select {}
}
第一个 goroutine 先向 ch1 发送数据,然后等待 ch2;第二个 goroutine 先向 ch2 发送数据,然后等待 ch1。由于两个 channel 都是无缓冲的,发送操作必须等待对端接收,最终双方都永远停在自己的发送语句上。程序在本地运行时会直接输出 fatal error: all goroutines are asleep - deadlock!,并给出类似下面的堆栈。
fatal error: all goroutines are asleep - deadlock!
goroutine 1 [select (no cases)]:
main.main()
/tmp/deadlock/main.go:16 +0x8f
goroutine 6 [chan send]:
main.main.func1()
/tmp/deadlock/main.go:8 +0x3c
goroutine 7 [chan send]:
main.main.func2()
/tmp/deadlock/main.go:12 +0x3c
从堆栈可以清楚看到,两个业务 goroutine 都卡在 chan send,也就是等待发送。再结合源码行号,就能快速推断出 ch1 和 ch2 的互相等待关系。对于全局死锁,这一步往往已经足够。
二、用 SIGQUIT 或 pprof 抓取全量 goroutine 堆栈
当程序没有因全局死锁退出时,我们就需要主动获取所有 goroutine 的栈信息。最经典的方法是给运行中的进程发送 SIGQUIT 信号。在 Linux 或 macOS 上可以执行 kill -QUIT <pid>,Go runtime 会输出全部 goroutine 的堆栈并默认退出。为了避免只看到少量栈帧,建议在启动程序前设置 GOTRACEBACK=all,或在代码里调用 debug.SetTraceback("all")。
不过 SIGQUIT 会终止进程,很多生产服务并不允许直接退出。对于已经暴露 HTTP 端口并导入 net/http/pprof 的服务,更推荐通过 pprof 的 goroutine 端点获取堆栈。下面代码展示了最小接入方式。
package main
import (
"log"
"net/http"
_ "net/http/pprof"
)
func main() {
go func() {
log.Println(http.ListenAndServe("localhost:6060", nil))
}()
select {}
}
程序运行后,可以用 curl 请求 /debug/pprof/goroutine?debug=2:
curl "http://localhost:6060/debug/pprof/goroutine?debug=2" > goroutine.txt
输出会按 goroutine 分段,并标记每个 goroutine 的状态。阅读时需要重点关注 chan receive、chan send、semacquire、sync.Mutex.Lock 和 select 等关键字。如果一个 goroutine 长时间停留在 semacquire 或 sync.Mutex.Lock,说明它在等待锁;如果停留在 chan receive 或 chan send,说明它在等待 channel 的另一端。把相关 goroutine 的状态和行号放在一起对比,通常就能拼出死锁链路。
这里有一个重要细节:全量堆栈展示的是某一时刻的快照,可能部分 goroutine 仅仅是正常空闲,而不是死锁。比如 worker pool 中的空闲 goroutine 长期阻塞在 channel receive 上,是完全正常的。因此不要把单个 chan receive 直接定性为死锁,而要看多个 goroutine 之间是否形成了环状等待。
三、使用 Delve 对卡住的进程做交互式调试
堆栈快照能告诉我们卡点,但有时变量值、锁的持有者和上下文信息不够充分。这时可以使用 Delve 附加到正在运行的进程进行交互式分析。Delve 支持 dlv attach <pid>,附加后可以查看所有 goroutine,并切换查看某个 goroutine 的完整调用栈。
为了保证调试信息完整,建议在编译时关闭优化并保留符号:
go build -gcflags="all=-N -l" -o app . dlv attach <pid>
进入 Delve 后,常用命令如下:
(dlv) goroutines
Goroutine 1 - User: ./main.go:12 main.main (0x4a1b0f) (thread 12345)
Goroutine 2 - User: ./main.go:18 main.worker (0x4a1c66)
(dlv) goroutine 2
(dlv) bt
0 0x000000000047a1c1 in runtime.gopark
at /usr/local/go/src/runtime/proc.go:361
1 0x0000000000407b3a in main.worker
at ./main.go:18
(dlv) frame 0
goroutines 命令可以列出所有 goroutine 以及它们当前所在行;goroutine 2 切换到编号为 2 的 goroutine;bt 打印当前 goroutine 的栈;frame 0 进入指定帧查看局部变量。对于 mutex 死锁,可以进一步用 locals 查看锁对象地址,再结合其他 goroutine 的栈判断是谁没有解锁。
在容器或远端环境中,Delve 通常以 headless 模式运行:dlv attach <pid> --headless --listen=:2345 --api-version=2,然后通过 IDE 或 dlv connect 连接。不过需要注意,附加调试器会短暂暂停进程,生产环境操作前应确认影响范围。
四、用竞态检测和 mutex profile 辅助定位
竞态检测器 go run -race 主要用于发现数据竞争,但很多数据竞争背后隐藏着错误的并发假设,这些错误假设也常常导致死锁。比如多个 goroutine 同时读写同一个 map,程序可能先崩溃而不是死锁,但通过 -race 提前暴露这种问题,可以避免后续更复杂的锁设计缺陷。启动方式很简单:go test -race ./... 或 go run -race main.go。检测到问题时,输出会给出两个访问同一内存位置的调用栈。
针对互斥锁的等待情况,Go 还提供了 mutex profile。需要在程序启动时设置 runtime.SetMutexProfileFraction(1),然后通过 pprof 暴露 /debug/pprof/mutex 端点。下面代码同时启用了 mutex 和 block profile。
package main
import (
"net/http"
_ "net/http/pprof"
"runtime"
)
func main() {
runtime.SetMutexProfileFraction(1)
runtime.SetBlockProfileRate(1)
http.ListenAndServe("localhost:6060", nil)
}
进程运行一段时间后,执行 go tool pprof http://localhost:6060/debug/pprof/mutex,就可以进入交互式分析界面。通过 top 命令可以看到哪些锁的等待时间最长;通过 list 命令可以定位到具体加锁代码行。对于已经卡住的死锁,如果 mutex profile 捕获到了等待锁的 goroutine,其调用栈就是定位死锁环的关键证据。
需要明确,mutex profile 记录的是锁竞争和等待时间,而真正的死锁中锁可能根本没有竞争,只是持有者永远不释放。因此 mutex profile 更像辅助工具,不能完全替代堆栈分析。把二者结合起来,通常可以大幅缩短定位时间。
五、从编码规范上规避常见死锁
调试工具解决的是事后定位,更有效的方式是从设计上降低死锁发生的概率。最经典的规则是保持锁顺序一致。如果两个资源需要同时加锁,所有 goroutine 都按相同的顺序获取,就不会形成循环等待。下面示例通过账户 ID 排序保证加锁顺序一致。
package main
import "sync"
type account struct {
mu sync.Mutex
id int
cash int
}
func transfer(from, to *account, amount int) {
if from.id > to.id {
from, to = to, from
}
from.mu.Lock()
to.mu.Lock()
from.cash -= amount
to.cash += amount
to.mu.Unlock()
from.mu.Unlock()
}
func main() {}
if from.id > to.id 这一步确保了无论调用方传入的顺序如何,两个锁始终按照 ID 从小到大获取。这样即使多个转账操作并发执行,也不会出现 A 等待 B、B 等待 A 的环。这样写虽然简单,但在复杂系统中需要所有开发人员严格遵守,并且要避免在持锁期间调用外部函数,否则锁顺序很难保证。
另一个重要手段是给等待操作增加超时。Go 的 channel 和 context.Context 都可以实现超时控制。例如使用 select 同时监听业务 channel、time.After 和 ctx.Done(),一旦等待时间过长就返回错误,既避免了永久阻塞,也能更快暴露死锁位置。超时并不能消除死锁,但能把死锁转化为可观测的错误,并减少对系统整体可用性的影响。
最后,尽量缩小锁粒度、避免嵌套锁、明确 channel 的关闭责任,也是降低死锁风险的有效实践。对于 channel,通常由发送方负责关闭,接收方通过 for range 或 value, ok := <-ch 判断通道是否关闭,可以减少因关闭和发送竞争引发的异常。总之,调试工具和编码规范需要一起使用,才能在 Go 的并发模型下保持排查效率和系统稳定性。
Golang 死锁调试并发死锁定位Go 调试工具修改时间:2026-09-01 07:56:54