如何定位 Go 进程卡死:从忙循环采样到 jsoniter 历史案例
原作者:Xargin(曹春晖 / cch123),原文发表于 2017 年 8 月 31 日,网站 No Headback。本文中文整理保留原文的问题、代码思路、采样证据与引用,并补充版本边界。以下演示含无限循环,仅用于静态阅读,未编译、启动服务、压测或附加真实进程。
一个 Go 服务不再响应请求,同时一个 CPU 核心被持续占满,应该先查哪里?原作者曾遇到与群内求助相似的问题:团队花了很长时间阅读 runtime 与 GC,最后发现根因在业务里的循环。这个故事值得保留的,是从现象缩小到热点函数,再顺着循环状态找到不能前进的分支。

第一个例子:忙等 channel 填满
原文的主 goroutine 反复检查 channel 是否积累到 100 个元素,另一个 goroutine 负责发送 100 个整数。满足数量后,接收端取出全部元素并求和。按业务意图看,它应该返回;但等待条件的过程没有阻塞,而是在不停轮询:
// 历史反例:不要作为生产代码使用,也不要运行来验证现代 Go 的行为。
// 与原文差异:删除未使用的 import "time",否则原样无法通过编译。
package main
func main() {
ch := make(chan int, 100)
go func() {
for i := 0; i < 100; i++ {
ch <- 1
}
}()
for {
if len(ch) == 100 {
sum := 0
itemNum := len(ch)
for i := 0; i < itemNum; i++ {
sum += <-ch
}
if sum == itemNum {
return
}
}
}
}
len(ch) 得到的是某一时刻的缓冲区长度,并不是一个通用的同步协议。这个示例恰好只有一个发送者和一个接收者,但主循环仍在等待期间持续耗用 CPU。把这段模式扩展到多个消费者时,更不能假定“先读长度,后接收固定数量”是原子操作。
原文用下面的启动参数观察调度状态:
GODEBUG="schedtrace=300,scheddetail=1" ./test1
这会启动目标二进制并打印调度信息;本文没有执行。作者在当时的环境看到 gcwaiting=1,团队一度怀疑 GC。旧版 Go 的协作式抢占机制中,无函数调用的紧密循环可能长期没有调度让出点,进而阻塞 GC 所需的协调过程。gcwaiting 表示观察到的等待状态,不足以独立证明 GC 是根因。
作者同时说明:上述第一个例子在他测试的 Go 1.8/1.9 中已经没有复现相同卡死。他没有深究这一变化的原因。本文保留这一“作者当年的观察”,不把它扩写为所有补丁版本、平台与编译条件的结论。
第二个例子:无退出条件的紧密循环
接着,原文启动一个 HTTP 服务和一个打印数字的 goroutine,而主 goroutine 只递增整数。下列代码保留原结构,明确它是故意有缺陷的历史演示:
// 历史反例,仅静态阅读。包含无限 CPU 循环和无限日志输出。
// 与原文差异:监听地址由 :12345 改成 127.0.0.1:12345,避免对所有接口开放。
package main
import (
"fmt"
"io"
"log"
"net/http"
"runtime"
"time"
)
func main() {
runtime.GOMAXPROCS(runtime.NumCPU())
go server()
go printNum()
i := 1
for {
i++
}
// 以下两行不可达;保留以说明原文控制流。
fmt.Println("for loop end")
time.Sleep(time.Second * 3600)
}
func printNum() {
i := 0
for {
fmt.Println(i)
i++
}
}
func HelloServer(w http.ResponseWriter, req *http.Request) {
io.WriteString(w, "hello, world!\n")
}
func server() {
http.HandleFunc("/", HelloServer)
err := http.ListenAndServe("127.0.0.1:12345", nil)
if err != nil {
log.Fatal("ListenAndServe: ", err)
}
}
原文运行数秒后用 curl localhost:12345 请求服务,并报告了当时的卡死现象。这里保留的是历史记录,没有把原来的“运行几秒就能看到”改写为今天仍应出现的结果。即使现代调度器允许其他 goroutine 继续运行,无限计算仍然会浪费资源,printNum 也可能持续制造大量输出。
静态检查还可以看到:原示例监听 :12345,意味着可能绑定所有网络接口;http.ListenAndServe 的便捷写法没有配置服务端超时;打印循环没有速率限制。这些对短小的故障展示容易被忽略,但不适合作为生产服务脚手架。本文只做了回环地址修正,其余缺陷保留并说明,不宣称已经完成可部署修复。
从 CPU 热点而不是猜测开始
作者归纳了当时这个问题的三个特征:卡在 for 循环、gcwaiting=1、没有可解释卡死的系统调用。对于主要在用户态持续执行的循环,strace 不一定能直接显示那段循环;它跟踪系统调用,不能替代 CPU 指令采样。但这不意味着 strace 对任何 hang 都无用:如果程序阻塞在 I/O 或锁相关系统调用上,它仍可能提供关键线索。
一个核心打满时,CPU 采样工具可以先把问题缩小到最热的函数。原文使用:
perf top
以下是原作者给出的采样输出,不是本文实测结果:
99.52% ffff [.] main.main
0.06% [kernel] [k] __do_softirq
0.05% [kernel] [k] 0x00007fff81843a35
0.03% [kernel] [k] mpt_put_msg_frame
0.03% [kernel] [k] finish_task_switch
0.03% [kernel] [k] tick_nohz_idle_enter
0.02% perf [.] 0x00000000000824d7
0.02% [kernel] [k] e1000_xmit_frame
0.02% [kernel] [k] VbglGRPerform
main.main 占样本的 99.52%,与主函数中的无限循环相符。这足以把代码检查的重点移到主函数,而不是继续泛泛怀疑整个 GC。它不代表“99.52% 就一定是死循环”:正常的计算密集型函数也可以占很高比例,必须结合服务进展、源码、输入和多次观察解释。
若只是诊断一个获得授权的进程,可考虑限定 PID 的采样范围,例如 perf top -p <PID>,并先核对进程身份、符号和内核 perf 权限。系统范围的 perf 可能看到其他程序的符号或活动;调试权限、采样开销也取决于环境。本稿没有附加进程或提升权限。
从热点函数走到不能前进的输入
真实故障通常不像 for { i++ } 那样明显。循环看起来有正常边界,却可能在某种输入下既不退出,也不推进索引。原作者建议在条件允许时用 Delve 附加到进程,找到正在执行循环的 goroutine,查看局部变量,并沿着执行路径逐步检查状态如何变化。
附加调试器会影响目标进程,还可能暴露内存中的业务数据或秘密,因此应在授权的隔离或维护环境操作。作者最后也提醒:旧版进程若已经被某种死循环卡死,gdb/Delve 可能无法成功附加;此时 CPU 采样已经定位到的函数仍可帮助静态排查。
原文引用了作者参与的 Gin #1086。问题报告者将 JSON 中本应为整数的字段写成字符串,例如 {"number":"5"};请求随后挂起,服务端打印类型解析错误。工单记录的环境是 go1.7.4 darwin/amd64,Gin 提交为 65a6dd46a50bd435597b5bcababd1b2a811c9073。这些具体信息比笼统的“Gin 有卡死问题”更有助于重建背景。
该工单的后续讨论中,cch123 报告了 jsoniter 字符串编码相关热点,维护者进一步复现了无效 UTF-8 字符串在 Marshal 路径中的无限循环,并推进修复。它展示了一条完整的调查线索:错误输入触发异常内容,异常内容又进入序列化路径,最终在编码循环中无法前进。不能只看到 JSON 类型不匹配,就认定 HTTP 解析层本身是最终根因;也不能把工单关闭理解为所有后续版本均已由本文逐一验证。
2017 年的调度结论需要放回版本背景
Go 1.14 官方发行说明明确宣布 goroutine 支持异步抢占:无函数调用的循环不再像此前那样可能阻塞调度器或显著延迟 GC。该发行说明同时列出了当时不支持的目标:windows/arm、darwin/arm、js/wasm 和 plan9/*。这是 Go 1.14 当时的支持范围,不是对所有现今平台的重新核验。
因此,原文关于无调用循环令“整个进程永远停住”的解释应限定于其旧版本场景。现代 Go 的实际表现还与工具链、平台、运行时配置及程序行为有关。版本进步也不会让业务循环自动变得正确:无限循环仍可能占满资源、阻止业务完成或带来拒绝服务风险。
把忙等改成明确的同步协议
下面是针对第一个例子的编辑补充,用关闭 channel 表示发送结束。它改变了同步方式,不是原作者代码,也未经运行验证:
package main
func main() {
ch := make(chan int, 100)
go func() {
defer close(ch)
for i := 0; i < 100; i++ {
ch <- 1
}
}()
sum := 0
count := 0
for value := range ch {
sum += value
count++
}
if sum != count {
panic("unexpected values")
}
}
接收方在没有值时阻塞,在 channel 关闭且数据耗尽后退出;不再通过缓冲区长度决定能否前进。真实服务若存在提前取消或发送方异常,还应设计取消、超时及生命周期,不应把这个有限例子当成通用并发框架。
原文的排查顺序仍然有效:先确认高 CPU 与服务无进展是否同时出现,记录精确版本和输入,再采样找热点,然后检查循环状态和错误路径,最后用针对触发输入的回归验证修复。本文中的命令和修正版示例未运行。
来源与归属:Xargin 原文,2017-08-31;Gin #1086;Go 1.14 发行说明。保留原作者对毛总在 Go 群中指导的致谢。原站页面未标明开放内容许可证;本文中文整理及原创配图依据另行取得的授权使用,原文权利归原作者。图示及明确标注的技术补充由未完纪编辑完成。











暂无评论内容