如何定位 Go 进程卡死:从忙循环采样到 jsoniter 历史案例

如何定位 Go 进程卡死:从忙循环采样到 jsoniter 历史案例

原作者:Xargin(曹春晖 / cch123),原文发表于 2017 年 8 月 31 日,网站 No Headback。本文中文整理保留原文的问题、代码思路、采样证据与引用,并补充版本边界。以下演示含无限循环,仅用于静态阅读,未编译、启动服务、压测或附加真实进程。

一个 Go 服务不再响应请求,同时一个 CPU 核心被持续占满,应该先查哪里?原作者曾遇到与群内求助相似的问题:团队花了很长时间阅读 runtime 与 GC,最后发现根因在业务里的循环。这个故事值得保留的,是从现象缩小到热点函数,再顺着循环状态找到不能前进的分支。

Go 卡死排查流程:核对版本和现象,CPU 采样定位热点,检查循环状态与输入,再用修复与回归验证根因;gcwaiting 只是线索。
未完纪编辑绘制:依据原文整理的诊断路径;不是 perf 或调试器截图。

第一个例子:忙等 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 群中指导的致谢。原站页面未标明开放内容许可证;本文中文整理及原创配图依据另行取得的授权使用,原文权利归原作者。图示及明确标注的技术补充由未完纪编辑完成。

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享
评论 抢沙发

请登录后发表评论

    暂无评论内容