概述
来看一段简单的测试代码时
package main
import "fmt"
func main() {
// 创建一个带缓冲的 channel,但没有放入任何数据
c := make(chan bool, 1)
// 直接尝试读取,导致死锁
fmt.Println(<-c)
}
运行程序,控制台立马给了一记响亮的耳光:
fatal error: all goroutines are asleep - deadlock!
看到这个报错,第一反应是:“哇,Go 语言真贴心,竟然能自动检测死锁!”。这让我产生了一种错觉:只要我的代码发生了死锁,Runtime(运行时)一定会第一时间告诉我。
但事实真的如此吗?Golang 自带的死锁检测真的是“银弹”吗?
答案是否定的。Go 的死锁检测机制其实非常“原始”且“有限”。今天我们就来扒一扒它的底裤,看看它到底检测了什么,又漏掉了什么。
并不是银弹:Runtime 无法检测的死锁
Go Runtime 的检测逻辑其实非常简单粗暴,它只关注全局死锁。也就是说,只有当整个程序所有的 Goroutine 都睡着了,它才会报警。
只要你的程序里还有任何一个 Goroutine 在干活(比如正在空转、正在等待网络请求、或者只是单纯的死循环),Runtime 就会认为程序还在正常运行,哪怕你的核心业务逻辑已经死锁卡死了。
下面给几个经典的“漏网之鱼” Demo。
场景一:有一个“旁观者”在运行
这是最常见的场景。假设你的业务逻辑 G1 和 G2 互相卡死(AB-BA 死锁),但只要有一个 G3(比如心跳上报、日志监控)还在跑,Runtime 就不会报错。
package main
import (
"fmt"
"sync"
"time"
)
func main() {
var mu1, mu2 sync.Mutex
// G1: 占有锁1,想要锁2
go func() {
mu1.Lock()
defer mu1.Unlock()
time.Sleep(100 * time.Millisecond) // 让子弹飞一会儿
mu2.Lock() // <--- 卡死在这里
mu2.Unlock()
}()
// G2: 占有锁2,想要锁1
go func() {
mu2.Lock()
defer mu2.Unlock()
time.Sleep(100 * time.Millisecond)
mu1.Lock() // <--- 卡死在这里
mu1.Unlock()
}()
// G3: 一个无辜的旁观者
// 只要这个协程还在运行,Runtime 就认为系统是健康的
go func() {
for {
time.Sleep(time.Second)
fmt.Println("系统监控: 我还在跑,Runtime 别慌...")
}
}()
select {} // 主协程阻塞
}
结果:控制台会无限打印“系统监控…”,而 G1 和 G2 早就已经死锁了,由于没有触发 fatal error,这种故障在生产环境中极难被第一时间发现。
场景二:HTTP Server 掩盖死锁
在 Web 开发中,http.ListenAndServe 会启动一个主循环来监听端口。这个监听动作本身就代表“程序正在运行”。因此,后台发生的任何死锁,Runtime 都不会报错。
package main
import (
"fmt"
"net/http"
)
func main() {
ch := make(chan int) // 无缓冲 channel
// 生产者:写入数据,因为没有消费者,这里会永久阻塞
go func() {
fmt.Println("准备写入数据...")
ch <- 1
fmt.Println("写入成功") // 永远不会执行到这里
}()
// 启动一个 HTTP 服务
// 这会让 Runtime 认为程序一切正常
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("Hello"))
})
fmt.Println("Server started at :8080")
http.ListenAndServe(":8080", nil)
}
结果:网页能正常访问,但后台那个协程已经永久挂起了。这就是典型的局部死锁。
深入原理:Runtime 到底检测了什么?
为什么 Go 检测不到上面这些死锁?我们需要看看源码。
Go 的死锁检测主要逻辑在 runtime/proc.go 的 checkdead() 函数中。它不是去构建复杂的“资源分配图”(Resource Allocation Graph)来检测环路(因为那样太耗费性能了),而是采用了一种计数器的方式。
简单来说,Runtime 维护了几个关键指标:
mcount(): 系统线程数。runningG: 正在运行的 Goroutine 数量。timers: 系统中活跃的定时器数量。netpoll: 网络轮询器是否有事件等待。
检测逻辑伪代码如下:
func checkdead() {
// 1. 如果还有线程在运行(比如正在执行系统调用),不算死锁
if mcount() > 0 { return }
// 2. 如果还有 Goroutine 在 running 状态,不算死锁
if runningG > 0 { return }
// 3. 如果还有定时器没触发,说明可能在 sleep 等待唤醒,不算死锁
if timers > 0 { return }
// 4. 如果网络轮询器里还有东西,也不算死锁
if netpollWaiters > 0 { return }
// 5. 如果以上都没有,说明所有人都在睡觉,也没人定了闹钟,也没人等电话
// 那就是真的死透了
throw("all goroutines are asleep - deadlock!")
}
实际源码内容(基于 1.18 版本):
// Check for deadlock situation.
// The check is based on number of running M's, if 0 -> deadlock.
// sched.lock must be held.
// 检查死锁情况。
// 检查主要基于正在运行的 M (系统线程) 的数量,如果是 0,则可能死锁。
// 调用此函数前必须持有全局调度锁 sched.lock。
func checkdead() {
// 1. 防御性检查:确保调度器的锁已经被锁住,保证并发安全
assertLockHeld(&sched.lock)
// For -buildmode=c-shared or -buildmode=c-archive it's OK if
// there are no running goroutines. The calling program is
// assumed to be running.
// 2. 特殊模式豁免
// 如果 Go 是以库的形式被 C/C++ 程序调用 (c-shared/c-archive),
// 即使 Go 这边没有协程在跑,宿主程序可能还在运行,所以不能报死锁。
if islibrary || isarchive {
return
}
// If we are dying because of a signal caught on an already idle thread,
// freezetheworld will cause all running threads to block.
// And runtime will essentially enter into deadlock state,
// except that there is a thread that will call exit soon.
// 3. 恐慌状态豁免
// 如果程序已经在 Panic 处理流程中了(panicking > 0),
// 此时系统会冻结所有线程,看起来像死锁,但其实是在打印崩溃堆栈。
// 为了避免死锁报错掩盖了真正的 Panic 原因,直接返回。
if panicking > 0 {
return
}
// If we are not running under cgo, but we have an extra M then account
// for it. (It is possible to have an extra M on Windows without cgo to
// accommodate callbacks created by syscall.NewCallback. See issue #6751
// for details.)
// 4. 修正运行中的 M 数量 (run0)
// 在没有 CGO 的情况下,Windows 可能会有额外的 M 用于处理系统回调。
// 如果有这种情况,我们将基准运行线程数 run0 设为 1,表示允许这种情况存在。
var run0 int32
if !iscgo && cgoHasExtraM {
mp := lockextra(true)
haveExtraM := extraMCount > 0
unlockextra(mp)
if haveExtraM {
run0 = 1
}
}
// 5. 核心判断:计算正在干活的 M (线程)
// mcount(): 总线程数
// nmidle: 处于空闲状态的 M
// nmidlelocked: 处于锁定状态(Locked to G)且空闲的 M
// nmsys: 处于系统调用或系统任务中的 M
// run 表示:除去空闲和系统的,真正正在执行 Go 代码的线程数。
run := mcount() - sched.nmidle - sched.nmidlelocked - sched.nmsys
// 如果正在运行的线程数 > run0 (通常是0),说明还有线程在干活,没死锁,返回。
if run > run0 {
return
}
// 数据一致性校验:如果算出来运行线程是负数,说明 Runtime 内部状态乱了,直接抛异常。
if run < 0 {
print("runtime: checkdead: nmidle=", sched.nmidle, " nmidlelocked=", sched.nmidlelocked, " mcount=", mcount(), " nmsys=", sched.nmsys, "\n")
throw("checkdead: inconsistent counts")
}
// 6. 遍历所有 G (Goroutine) 检查状态
// 既然没有 M 在跑了,我们看看 G 都在干嘛。
grunning := 0
forEachG(func(gp *g) {
// 忽略系统级 Goroutine (如 GC 的后台标记 worker,sysmon 等)
if isSystemGoroutine(gp, false) {
return
}
s := readgstatus(gp)
// 检查 G 的状态
switch s &^ _Gscan {
case _Gwaiting,
_Gpreempted:
// 如果 G 处于等待中 (Waiting) 或 被抢占 (Preempted),
// 说明这是一个有效的、未完成的用户任务,计数器 +1。
grunning++
case _Grunnable,
_Grunning,
_Gsyscall:
// 如果发现有 G 还是 Runnable/Running/Syscall 状态,
// 但前面的步骤却判断没有 M 在运行,这属于 Runtime 的逻辑矛盾。
// 理论上 checkdead 不应该在这种状态下被调用或者走到这一步。
print("runtime: checkdead: find g ", gp.goid, " in status ", s, "\n")
throw("checkdead: runnable g")
}
})
// 7. 检查是否主协程退出了
// 如果遍历完发现没有用户 G 了 (grunning == 0),
// 比如 main 函数调用了 runtime.Goexit() 导致主协程结束但其他协程也没了,
// 抛出特定错误。
if grunning == 0 { // possible if main goroutine calls runtime·Goexit()
unlock(&sched.lock) // 解锁,避免打印日志时挂死
throw("no goroutines (main called runtime.Goexit) - deadlock!")
}
// Maybe jump time forward for playground.
// 8. Go Playground 特殊处理
// Go Playground 为了快进时间(比如 sleep 1小时不用真等1小时),
// 在这里会修改 faketime。如果能调整时间唤醒某个 timer,就唤醒它并返回,不算死锁。
if faketime != 0 {
when, _p_ := timeSleepUntil()
if _p_ != nil {
faketime = when
for pp := &sched.pidle; *pp != 0; pp = &(*pp).ptr().link {
if (*pp).ptr() == _p_ {
*pp = _p_.link
break
}
}
mp := mget()
if mp == nil {
// There should always be a free M since
// nothing is running.
throw("checkdead: no m for timer")
}
mp.nextp.set(_p_)
notewakeup(&mp.park)
return
}
}
// There are no goroutines running, so we can look at the P's.
// 9. 最后一道防线:检查定时器 (Timer)
// 走到这里说明:没有 M 在跑,所有 G 都在睡觉。
// 唯一的希望就是定时器了。如果某个 P (处理器) 上挂着定时器,
// 说明程序只是在 Sleep,时间到了自然会醒,所以不算死锁。
for _, _p_ := range allp {
// 如果 P 的定时器堆里有任务
if len(_p_.timers) > 0 {
return
}
}
// 10. 宣判死刑
// 没有 M 运行,所有 G 都在睡觉,没有定时器会响,也不是库模式。
// 程序彻底死掉了。
getg().m.throwing = -1 // 标记不打印完整的堆栈信息 (避免刷屏)
unlock(&sched.lock) // 解锁
throw("all goroutines are asleep - deadlock!")
}
结论:
Go 的检测机制本质上是一个“兜底策略”。它检测的是“程序彻底停止运转”这一极端状态,而不是检测“逻辑上的死锁”。只要还有一个 G 在呼吸,它就认为你没死。
如何避免与检测死锁?
既然自带的检测不靠谱,我们在开发中就需要依靠规范和工具。
1. 避免死锁的建议
- 规范锁的顺序:这是最根本的解决之道。如果多个协程需要获取多把锁,必须保证所有协程获取锁的顺序是一致的(例如永远先拿 A 锁,再拿 B 锁)。
- 减小锁粒度:只在临界区加锁,不要把耗时的 IO 操作(如文件读写、网络请求)放在锁里面。
-
使用 Select + Timeout:不要傻傻地一直等 channel 或锁。
select { case req <- data: // 成功 case <-time.After(time.Second): // 超时降级,避免卡死 fmt.Println("timeout") } - 严禁 RWMutex 递归:读锁内部不要再去申请读锁,这在有写锁等待时会导致死锁。
2. 检测死锁的方法
对于 Runtime 无法检测的隐形死锁,我们需要主动出击:
-
pprof (神器)
这是排查 Go 并发问题最有效的工具。在程序中开启 pprof:import _ "net/http/pprof" go func() { http.ListenAndServe(":6060", nil) }()当怀疑死锁时,访问
http://localhost:6060/debug/pprof/goroutine?debug=1。查看 Goroutine 的堆栈信息,如果你发现大量的 Goroutine 停留在semacquire(等待锁)或者chan send状态,那就是死锁了。 -
go-deadlock 库
这是一个第三方的 debug 库 (github.com/sasha-s/go-deadlock)。你可以用它替换原生的sync.Mutex。如果在开发环境中锁等待超过指定时间(默认 30s),它会自动 dump 出堆栈信息,明确告诉你死锁发生在哪里。
总结:Go 语言自带的死锁检测只是最后一道防线,防止程序在彻底无响应时变成“僵尸进程”。在复杂的业务开发中,我们绝不能依赖它来发现 Bug,良好的编码规范和熟练使用 pprof 才是王道。