死锁测试必须触发运行时 panic,核心是验证逻辑配对;无缓冲 channel 创建后直接发送(如 ch := make(chan int); ch

死锁测试:用 panic 触发并验证逻辑配对
Go 死锁不是“可能卡住”,而是运行时强制 panic,所以测试它的核心是让错误逻辑跑起来,看是否真触发 fatal error: all goroutines are asleep - deadlock!。别依赖“好像没卡”就认为安全——它只在所有 goroutine 同时阻塞时才爆发。
常见可复现死锁的写法:
-
ch := make(chan int)后直接ch ,不启接收 goroutine - 单个 goroutine 写
ch (无缓冲 channel 先发后收) -
for range ch但没人调用close(ch) -
select {}空语句,或所有case都依赖未就绪的 channel
测试时加 GODEBUG=schedtrace=1000 能看到每秒调度快照,若 G 数长期为 0,基本就是全阻塞了。注意:panic 日志里列出的所有 goroutine 栈帧,必须逐行看——重点不是“谁在等”,而是“谁本该唤醒它却没动”。
协程泄漏测试:用 goleak.VerifyNone(t) 拦在测试执行前
协程泄漏不会立即崩溃,但会在测试结束时漏出痕迹。goleak 是目前最轻量、最可靠的拦截工具,它靠比对测试前后 goroutine 快照,报告“新增但未退出”的协程及其初始调用栈。
立即学习“go语言免费学习笔记(深入)”;
关键实操点:
使用 uber-go/dig 在 Golang 中实现依赖注入——基于反射容器,提供 Provide/Invoke、dig.In/dig.Out 参数与结果对象,支持命名依赖等
- 必须写成
defer goleak.VerifyNone(t),且放在测试函数开头——否则 test panic 时不会执行 - 别在
defer goleak.VerifyNone(t)后立刻return,否则 verify 执行时协程已退出,漏检 - 若测试中合法启动了 HTTP server、ticker 或 long-running goroutine,得提前调用
goleak.IgnoreCurrent(),否则误报 - goleak 不捕获运行时内部 goroutine(如 gc、sysmon),只报用户代码启动的“可疑新增”
pprof 和 trace 工具:定位阻塞点而非猜原因
当线上服务协程数持续上涨,pprof 的 /debug/pprof/goroutine?debug=2 是第一响应手段。它输出的是当前所有 goroutine 的完整栈,不是采样——所以你要盯的是“大量 goroutine 停在哪一行”。
典型线索:
- 一堆停在
(*RWMutex).RLock或(*Mutex).Lock:说明锁被长期持有,或出现读锁重入(RWMutex禁止同 goroutine 多次RLock) - 大量停在
chan receive或chan send:查 channel 是否无人收/发,或是否用了无缓冲 channel 却没配对启动 goroutine - 停在
select {}或空for {}:说明退出条件缺失,不是资源争用,是逻辑缺陷
进一步用 go tool trace 可还原状态变迁:打开 Web UI 后进 “Goroutine” 标签页,筛选状态为 chan recv 的 goroutine,看它等了多久、有没有 sender 曾经唤醒过它——这是判断“暂时阻塞”还是“永久死锁”的分水岭。
容易被忽略的三个隐性坑
很多泄漏和死锁问题在线下测试完全不暴露,因为环境差异掩盖了时序问题:
-
RWMutex的读锁重入:Go 官方明确禁止同 goroutine 多次RLock(),它不会 panic,但会让其他 goroutine 永久等待——pprof 里表现为一堆卡在RLock,而持有者早已“消失” - channel 关闭责任混乱:多个 goroutine 都可能想关同一个 channel,但只有第一个
close()有效,后续会 panic;没人 close 又导致range永不退出——必须约定“发送方负责关闭” - context.Context 未传递到底层:比如 HTTP handler 传了
ctx,但启动的 goroutine 没用它做超时或取消,结果请求取消后 goroutine 还在等 channel,慢慢积压
这些点不靠工具扫不出来,得在代码评审时主动问:“这个 goroutine 怎么退出?”“这把锁谁释放?”“这个 channel 谁关?”——问题不在语法,而在生命周期契约是否清晰。

















