ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Go并发调试利器:runtime.Stack定位goroutine阻塞与泄漏

Go并发调试利器:runtime.Stack定位goroutine阻塞与泄漏 排查 Go 并发问题的时候有一句台词最让人头疼接口卡住了但没有任何 panic也没有 error。我第一次遇到这种问题时日志里只有一行超时根本看不出是哪个 goroutine 在等谁。后来同事让我在日志里输出一份 runtime.Stack直接把当前 goroutine 的调用栈打出来问题瞬间就暴露了——一个 goroutine 正卡在一个没人接收的 channel 发送操作上。如果你也遇到过“服务还活着请求就是不返回”的情况或者想知道 goroutine 泄漏到底发生在哪个入口runtime.Stack 值得放进你的调试工具箱。它能把某个 goroutine 当前正在执行什么函数、这一路是怎么调进来的、这个 goroutine 又是在哪一行被创建的全部输出成一段可读文本。如果你需要它还能一次性把所有 goroutine 的调用栈都拉出来。这篇我来聊聊这个函数实际怎么用、有哪些容易踩的坑以及怎么把它变成项目里的“急救工具”。1. 一个看似很简单的需求当前 goroutine 到底卡在哪1.1 为什么要盯着一份“栈”看Go 的并发模型让启动一个 goroutine 非常便宜两三行代码就能把一个异步任务丢到后台跑。但代价也很直观任务一旦卡住你手头通常只有一个超时 error没有任何位置信息。传统 debug 手段在并发场景下会失灵因为崩溃不是必然发生的大多数时候是某个 goroutine 安静地阻塞在那里占着资源不放。这时候我会先想一个问题如果能让我看一眼它现在停在哪个函数、等在哪一行很多问题一眼就能定位。刚好runtime.Stack 就是干这个的。它的作用是把调用栈格式化到一段字节数组里调用方不需要知道 goroutine 内部怎么调度只要把结果打印出来就行。我后来养成了一个习惯凡是新写的常驻后台服务都会预留一个“现场收集”入口。这个入口不需要太复杂能在线上环境手动触发把当前所有 goroutine 的调用栈 dump 出来就足够。很多看起来诡异的问题到了这一步都会自动现出原形。1.2 先看一眼一段调用栈输出直接调用 runtime.Stack 的代码其实很简短buf : make([]byte, 120) n : runtime.Stack(buf, true) fmt.Printf(%s\n, buf[:n])服务卡住的时候输出大概是这种感觉goroutine 6 [chan send]: main.sendTask(0xc0000180a0) /home/user/project/main.go:42 0x55 created by main.main /home/user/project/main.go:18 0x2e别被这堆文本吓到我们只看几处关键信息。第一行的goroutine 6是运行时给这个 goroutine 的编号虽然不是官方承诺的稳定 API但在一次 dump 里足够用来指代某个 goroutine。中括号里的chan send是它当前的状态说明这个 goroutine 正阻塞在一次 channel 发送上。后面跟着的函数调用顺序就是它从哪个入口一路执行到这里的完整轨迹。最后一行created by main.main是我格外关注的信息。它能告诉你这个 goroutine 是在哪里诞生的排查 goroutine 泄漏时基本能少走一半弯路。很多时候你会发现堵住的 goroutine 并不是业务里那个核心函数出了问题而是它创建的协程过早退出导致后续没人配合接力于是大家都停在 channel 操作上。2. runtime.Stack 的基础功看清 API 和输出格式2.1 一个最容易出错的点buf 会被截断runtime.Stack 的函数签名非常简单func Stack(buf []byte, all bool) int。把缓冲区传进去它会往里面写栈文本返回写入了多少字节。很多第一次用的人会直接这样写buf : make([]byte, 120) n : runtime.Stack(buf, true) fmt.Printf(%s\n, buf) // 这样不对这样打印出来的内容可能带着一串莫名其妙的空字节因为buf底层的数组比实际写入长度要大。正确的做法是严格截取返回长度也就是buf[:n]。更隐蔽的问题是缓冲区大小。goroutine 数量少的时候1MB 缓冲区基本够用一旦线上有成千上万个 goroutine一个 1MB 的 buf 就极其容易被打满。runtime.Stack 内部并不会帮你自动扩容它只会把能写的都写进去然后返回len(buf)给你。当返回字节数等于缓冲区长度时结果到底有没有被截断其实是无法确定的因为可能恰好写得满满当当也可能是真的写不下了。最稳妥的办法是循环扩容直到剩余空间明显大于写入长度func DumpAll() []byte { buf : make([]byte, 4096) for { n : runtime.Stack(buf, true) if n len(buf) { return buf[:n] } buf make([]byte, len(buf)*2) } }我习惯把初始容量设成 4KB深一点的调用栈第一次就够用不够就按 2 倍往上翻。这个扩容逻辑和标准库里 runtime/debug.Stack 的思路几乎一样所以如果不是非要拿所有 goroutine 的栈直接用 debug.Stack 会更省事。2.2 all 参数只看当前还是拉全量第二个参数all是 runtime.Stack 里最容易让人忽略但影响很大的开关。当all为 false 时它只返回当前 goroutine 的调用栈。大多数 panic recovery 场景下我们用 false 就够了因为 panic 一定是当前 goroutine 触发的谁触发打谁的栈即可。输出内容少涉及的系统调用也轻可以放心放在 defer 里。当all为 true 时输出内容会覆盖当前 goroutine 以及所有“其他 goroutine”。注意文档里的措辞是当前 goroutine 的栈在前其他 goroutine 跟在后面。这个开关适合用来排查死锁、goroutine 堆积、服务整体卡死这类全局问题。只看某一个 goroutine 往往看不出问题全貌你需要知道有多少个 goroutine 都堵在同一类操作上才能判断这是个别现象还是集体事故。不过alltrue是有代价的。为了拿到一份相对一致的快照运行时需要把其他 goroutine 短暂协调住再扫描它们的栈。goroutine 总量小无所谓几万个 goroutine 的时候一次全量 dump 可能让本就紧张的服务雪上加霜。所以不要好奇地在每个请求里都打一份全量栈否则并发一高你可能会亲手把服务打到不可用。2.3 runtime.Stack、debug.Stack、pprof 怎么选很多同学看到 runtime.Stack 之后会问这不是有 runtime/debug.Stack 可以用吗的确如果只需要打当前 goroutine直接用runtime/debug包里的debug.Stack()更省心。它会自动做扩容循环你只需要把返回值打到日志里log.Printf(current stack:\n%s, debug.Stack())它无法做到的事情是拉全量 goroutine。debug.Stack()内部写死了runtime.Stack(buf, false)所以需要看全部协程的时候还是得回到 runtime.Stack 自己控制all参数。还有一个更“重型”的方案是走 pprof。标准库runtime/pprof里提供了一个名为goroutine的 profile可以在服务中通过net/http/pprof暴露出来。效果上它输出的内容和 runtime.Stack 类似但多了一层聚合信息还会在性能剖析工具里按调用栈汇总数量。简单做个对比方案能否拉全量 goroutine是否需要手动扩容主要用途runtime.Stack(buf, false/true)可以需要自己判断随时在代码里打印debug.Stack()只能当前不需要内部已处理panic 现场快速打印pprof goroutine profile可以不需要聚合分析、上传 pprof 工具我通常这样分配使用场景日志告警里想快速看现场用 debug.Stack要 dump 全量给人工排查用自己封装好的 runtime.Stack 工具需要按调用栈统计 goroutine 数量分布就抓一份 pprof 的 goroutine profile。3. 把 runtime.Stack 变成项目的“急救工具”3.1 先封一个自动扩容的 Dump 函数单独裸调 runtime.Stack 很容易把“是否扩容”这个分支逻辑散落在项目各处。我建议在项目内部统一封装一个小工具包所有需要打印 goroutine 栈的地方都从这里走。这样未来如果想加日志脱敏、加告警钩子只需要改一个函数。简单的封装长这样package gostackdump import runtime func dump(all bool) []byte { buf : make([]byte, 4096) for { n : runtime.Stack(buf, all) if n len(buf) { return buf[:n] } buf make([]byte, 2*len(buf)) } } // DumpCurrent 返回当前 goroutine 的调用栈。 func DumpCurrent() []byte { return dump(false) } // DumpAll 返回当前及所有其他 goroutine 的调用栈。 func DumpAll() []byte { return dump(true) }我特意把内部逻辑抽成了dump(all bool)避免两个公开方法里写重复代码。返回值设计成[]byte而不是string主要是为了减少一次拷贝。如果你要打到日志里fmt 的格式化输出或者log.Printf(%s)都可以直接消费[]byte。3.2 recovery 包装器让 panic 不再只有 err在一个后台 goroutine 里跑任务时最怕的就是 panic 被中间某个框架兜住然后只记录一行 “panic: nil pointer”调用栈却丢了。我自己写了一个 Goroutine 包裹函数专门用于后台任务的 panic 现场收集func SafeGo(name string, fn func()) { go func() { defer func() { if r : recover(); r ! nil { log.Printf(goroutine[%s] panic: %v\n%s, name, r, gostackdump.DumpCurrent()) } }() fn() }() }name不是 goroutine 本身的属性而是我为了在日志里区分不同任务入口加的标识。一旦发生 panic这条日志会包含异常值、当前调用栈、任务名三部分。实际排查时哪怕 error 信息模棱两可通过栈里的函数名也能快速定位到具体业务代码。有一点需要留意如果在同一个 goroutine 里既recover又继续往下执行业务可能会掩盖初始化未完成的问题。所以对大多数后台任务我会在记录完调用栈之后根据团队约定决定是否os.Exit(1)原则是不要让一个状态已经不可控的进程继续提供服务。3.3 给服务加一个只能内网访问的诊断端点线上排查不可能每次都用dlv或者重启服务。我更倾向于在管理端口上暴露一个诊断接口返回格式为text/plain内容就是当前全量 goroutine 的调用栈。这样出问题时直接 curl 一下就能看到现场。接入 gin 也很简单写个 middleware 或者在 router 上注册 handler 都行。参考实现如下func dumpHandler(w http.ResponseWriter, r *http.Request) { token : r.Header.Get(X-Debug-Token) if token ! os.Getenv(DEBUG_TOKEN) { http.Error(w, forbidden, http.StatusForbidden) return } w.Header().Set(Content-Type, text/plain; charsetutf-8) _, _ w.Write(gostackdump.DumpAll()) }我强调两点。第一这个接口绝不能裸奔在公网。虽然它不直接改数据但能泄露内部函数名、文件路径、调用关系属于典型的信息泄露入口所以至少要有 token 校验或者绑定在独立的 127.0.0.1/内网管理端口。第二如果项目已经引入了标准库的net/http/pprof那里面的/debug/pprof/goroutine?debug1已经提供了类似能力没必要再造一套带鉴权的轮子只有在不能引入额外路由或需要自定义收集策略时上面的 handler 才更合适。4. 两类经典卡死让我吃了亏的排查实录4.1 案例一channel 发送无人接收有段时间我们的任务系统出现诡异现象某个 worker 进程 CPU 不高内存也不暴涨但业务指标完全停止。我看了一眼 goroutine dump发现大量 goroutine 头部清一色是goroutine 812 [chan send]: main.processTask(0xc00007e200) /home/user/project/task.go:58 0x1c9 created by main.dispatchLoop /home/user/project/main.go:120 0x6e中括号里的chan send已经说明问题核心这些 goroutine 全都在执行一次 channel 发送而且发送不出去了。往上游看processTask在 task.go 第 58 行调用了resultCh - resp这条管道是某个工作协程用来回收结果的。再往下看底部created by main.dispatchLoop确认这些 goroutine 是从任务分发循环里拉起来的。真正的根因是下游消费者逻辑里有一个提前 return 的分支导致对应的 goroutine 已经退出但分发循环不知道还在不断往同一个 channel 里塞数据。没有 receiver 接收发送方自然全部挂起。这类问题靠逻辑 review 不容易发现因为只看发送方永远看不出问题必须配合接收方的启动和退出条件一起看。而 runtime.Stack 的价值恰恰在于它一次把发送方和接收方都摆在那里只要再注意看created by位置就能顺着生命周期找出退出的那一个。4.2 案例二锁被某个慢任务长时间持有另一类高频卡死是 sync.Mutex 导致的服务停顿。此时 goroutine dump 里会出现这类片段goroutine 33 [semacquire]: sync.runtime_SemacquireMutex(0xc0000a2008, 0x0, 0x0) sync/mutex.go:150 0x50 sync.(*Mutex).Lock(0xc0000a2008) sync/mutex.go:73 0x15 main.saveRecord(0xc00009c000) /home/user/project/store.go:33 0x6c状态标记是semacquire说明它等在一把互斥锁上。这里要提醒一句这份栈只会告诉你它在等哪把锁锁的地址也给了但它不会直接告诉你“当前锁被哪把 goroutine 持有”。刚开始我天真地以为往下翻几行就能找到持锁者翻完之后发现持锁者如果没有同时阻塞在做别的操作它很可能正勤勤恳恳地跑一段很重的业务逻辑并没有输出一个醒目的“我持锁”状态。遇到这类问题我的一般排查顺序是先全量 grep 同一把锁地址看还有没有其他 goroutine 也阻塞在这个地址上然后看阻塞 goroutine 的函数栈里从哪里进入临界区推测哪些操作持锁时间可能过长。如果怀疑是某条慢 SQL 或远端调用拖住了锁最直接的办法就是在锁的持有路径上加日志统计进入临界区和离开临界区的时间差。Go 1.18 之后提供的TryLock不推荐当作业务主路径来用但在诊断时临时判断“这把锁是不是被占着”还是有点用的。4.3 goroutine 状态标记速查表我早期看 runtime.Stack 输出时对中括号里的状态词非常头疼。每个版本之间状态文本可能略有差异但常见的状态不会变。整理一张快速对应表放在手边很实用栈顶部状态文本通常含义排查重点[running]当前正在运行通常是当前 goroutine看调用栈正在执行什么逻辑[runnable]等待被调度器调度可能 CPU 不够或调度队列拥塞[sleep]正在 time.Sleep 或等待定时器看 sleep 之后是否还醒得来[chan receive]阻塞在 channel 接收检查有没有 goroutine 在往该 channel 发[chan send]阻塞在 channel 发送检查有没有 goroutine 在收[select]阻塞在 select 多路等待检查所有 case 的 channel 参与者[semacquire]阻塞在锁或信号量检查持锁者的退出条件[IO wait]等待网络或文件 IO 完成检查下游依赖的超时设置[syscall]进入系统调用排查 cgo、epoll、磁盘等问题这份表不是绝对标准真实环境可能出现半休眠之类的细分状态但作为初判足够。看到chan send这类关键词能第一时间把问题归类到并发原语而不是业务逻辑本身。5. 这些坑踩过一次就够了runtime.Stack 使用细节5.1 不要在业务主路径上做全量 dump有些同学一旦通过 runtime.Stack 解决了问题就会顺手把它加进每一条请求日志里想全链路留痕。我的建议是当前 goroutine 的栈可以接受全量 goroutine 的栈不要这么干。全量 dump 需要在运行时协调所有 goroutine 的栈状态goroutine 数量一旦上去这个动作的耗时可能从几毫秒飙升到几百毫秒。更麻烦的是假设一个实例有 5 万个 goroutine每个栈文本按平均 1KB 估算一次 dump 就有 50MB 文本产生。在 QPS 高的时候这么干日志系统会先撑不住。我通常只在三类时机触发全量 dump服务启动后做健康自检、监控发现某个指标超过阈值时、人工通过诊断接口主动触发。5.2 栈文本适合人读别拿它当程序接口如果你打算解析 runtime.Stack 的文本去提取函数名、面板地址、判断是否存在某个调用我劝你停一停。不同 Go 版本之间输出格式可能有细微差异代码行号前的十六进制偏移量也不是稳定接口。解析文本这件事极其脆弱今天能跑明天升级一个 Go 小版本可能就挂了。如果需要结构化获取当前调用帧信息标准库准备了 runtime.Callers 配合 runtime.CallersFrames。它返回的是程序计数器切片你可以迭代拿到每个帧的函数名、文件、行号再按自己的逻辑过滤这套才是能给程序消费的 API。runtime.Stack 应该定位成“给人看的现场信息”而不是“给程序判断的埋点数据”。5.3 先试试系统自带的 SIGQUIT在 Linux 和 macOS 下Go 进程收到 SIGQUIT 信号时运行时会默认打印一份非常详细的全量 goroutine 栈然后退出进程。哪怕你还没接入任何自己的 dump handler这个机制也一直存在。所以你可以在测试环境这样模拟kill -QUIT PID它会输出一段以SIGQUIT: quit开头的信息后面跟着所有 goroutine 的调用栈内容和 runtime.Stack 几乎一致。调试本地程序时甚至可以直接按 Ctrl\ 触发。不过生产环境要谨慎使用因为默认行为是直接退出进程。如果你只是希望看到栈但不退出还是老老实实走自己封装的诊断接口更安全。Windows 下这个信号机制的支持差异比较大一般不作为跨平台方案依赖。5.4 Cgo 和 runtime 内部调用
返回列表