Golang并发之死锁检测

概述

来看一段简单的测试代码时

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.gocheckdead() 函数中。它不是去构建复杂的“资源分配图”(Resource Allocation Graph)来检测环路(因为那样太耗费性能了),而是采用了一种计数器的方式。

简单来说,Runtime 维护了几个关键指标:

  1. mcount(): 系统线程数。
  2. runningG: 正在运行的 Goroutine 数量。
  3. timers: 系统中活跃的定时器数量。
  4. 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 才是王道。

订阅评论
提醒
guest
0 评论
最新
最旧
内联反馈
查看所有评论
0
希望看到您的想法,请发表评论。x