1. 为什么我会把 pstack 和 Claude Code 凑到一块
先交代一下背景。我平时维护的业务后端是 C++ 写的,线上跑着几十个实例,最头疼的事情之一就是“进程还活着,但业务卡死”或者“偶发崩溃,core 文件却抓不到现场”。这种场景下,pstack 是我最顺手的工具之一——它能在不打断服务的情况下,把指定进程所有线程的用户态调用栈打出来。你不需要重启进程,不需要重新编译,只要 PID 还在,就能拿到一份“案发现场”的快照。
但 pstack 有个很尴尬的问题:输出原始、信息密度高。一个繁忙的进程动辄三五十个线程,每个线程的栈深度能有十几层,夹杂着模板展开、内部符号、系统库调用,一眼扫过去根本定位不到关键链路。我试过把输出贴给同事看,大家第一反应都是“信息太多,眼睛花了”。
后来我开始在终端里用 Claude Code 辅助分析这类文本。思路很简单:让工具负责抓现场,让模型负责做初步推理。pstack 负责把进程的调用栈原样导出来,Claude Code 负责从一堆栈帧里找规律、标可疑点、给出下一步排查建议。这套组合我前后用了一个多月,确实把线上故障的平均定位时间缩短了不少。
这篇文章就把我的整套做法拆开讲一遍:从 pstack 的输出格式怎么读,到怎么把 Claude Code 接入终端工作流,再到一个真实的内存踩踏案例完整复盘,最后附上我踩过的坑。适合谁看?主要是做后端服务运维、C/C++ 服务端开发、以及所有被线上疑难杂症折磨过的同学。如果你对 AI 辅助调试这件事持观望态度,这篇文章也值得你花几分钟看看——因为我会告诉你它哪里靠谱、哪里不靠谱。
2. 读懂 pstack 输出:先会看,再谈分析
2.1 堆栈里的符号到底在说什么
pstack 的输出格式一般长这样:
Thread 1 (process 34865): #0 0x00007f8a2c5d9b83 in epoll_wait () from /lib64/libc.so.6 #1 0x0000000000404c2d in EventLoop::Run() () #2 0x00000000004067a8 in main ()每一行是一个栈帧,#0表示当前正在执行的函数,#1是它的调用者,#2是更上层调用者,以此类推。也就是说,栈是从“最深处”往“最外层”展开的,越往上走,越接近你代码里的入口。
这里有个新手最容易忽略的点:栈帧里的地址和函数名能否对上,取决于符号表是否完整。如果你在编译时加了-g,一般能看到带行号的输出(比如main.cpp:120);如果没有调试信息,那可能只能看到地址,甚至看到??。所以,线上实例想要 pstack 输出有价值,编译时务必保留调试符号,哪怕你是-g0也要在关键模块放开,否则打了白打。
再补充一个经验:pstack 在不同发行版上的表现略有差异。有的系统自带pstack,有的没有,得用gdb -p PID -batch -ex "thread apply all bt"来替代。效果基本一样,但 gdb 方案会更通用。我建议你一条命令两手准备。
2.2 从函数调用关系反推崩溃现场
拿到一份多线程的 pstack 输出后,我的阅读顺序是这样的:
- 先数线程数,确认有没有异常多的线程。正常情况下线程数应该和业务模型匹配,如果突增,多半是线程泄漏或连接风暴。
- 扫一遍每个线程的
#0层。这一层是线程当前停住的位置,能直接看出线程在等什么、忙什么、卡在哪。 - 对卡住的线程,往下看
#1到#3层。如果发现多个线程都停在同一个锁相关的函数上,比如pthread_mutex_lock或者futex,那基本可以断定是锁竞争或死锁。 - 对有异常的线程,再往深处看,找到业务代码的入口函数,反推是哪个业务触发了这个路径。
我用一个实际例子说明。之前有一次线上服务响应变慢,pstack 打出来发现十几个线程全部停在std::condition_variable::wait,而且都是同一个模块里的同一个等待逻辑,调用栈高度相似。这就不是偶然了——所有工作线程都在等某个通知,但通知方大概率已经挂了或者被卡在前面。后来顺着调用栈找到了通知线程,发现它在等一个网络回包超时,而超时时间被配置成了 30 分钟。问题就这么定位了。
所以你看,pstack 本身不会告诉你“哪里错了”,但它能快速告诉你“大家都在哪”,这个“都在哪”就是最关键的线索。我常说,堆栈分析本质上是“找聚集点”的工作,聚集点往往就是问题点。
2.3 不要忽略替代方案:gdb 抓线程栈
前面提到,pstack 在某些环境不可用。这里给你一个我常用的通用命令:
gdb -p $PID -batch \ -ex "set pagination off" \ -ex "thread apply all bt" \ -ex "quit" 2>&1这条命令的效果和 pstack 类似,但有几个额外好处:可以加-ex "thread apply all bt full"打印局部变量和参数,信息量更大;也能配合 core 文件使用;还能在抓完栈之后顺手看一些全局变量。代价是 gdb attach 的时候会短暂冻结进程,在高频交易或强实时场景要谨慎,后面我会专门讲。
另外还有一个冷门但好用的命令:pstack其实不是 Linux 官方工具,而是 Solaris 时代的东西。Linux 上很多发行版是通过一个脚本去调 gdb 实现的,所以一旦你的环境没有封装,直接用 gdb 就行。别把 pstack 想得太神秘,它的底子就是 gdb。
3. 把 Claude Code 接入终端调试工作流
3.1 先搞清楚 Claude Code 在这种场景里的定位
很多人以为让 AI 分析堆栈就是把 pstack 的输出整个扔给它,然后等它给结论。这个用法没错,但太低效。我用 Claude Code 的实践是把“抓现场”和“做推理”分成两个独立阶段,中间用文本文件做接口,而不是在一个会话里反复粘贴大段输出。
举个例子,我的标准操作流程是这样的:
# 抓取现场,保存到文件 pstack $PID > /tmp/stack_$(date +%s).txt # 在 Claude Code 里执行分析 claude \ --print \ # 非交互模式 --output-format text \ "阅读 /tmp/stack_xxx.txt 的内容,找出可疑的线程聚集点"这里有人会问,为什么不直接在对话里粘贴?因为 pstack 的输出经常会超过终端缓冲区,粘贴到对话里也容易丢格式。用文件作为中间媒介,Claude Code 能完整读取全文,而且你可以反复引用这个文件做多轮分析,不用反复复制粘贴。这个习惯是我用了一段时间之后才稳定下来的,强烈推荐。
3.2 三种实用的分析姿势
我把分析姿势分成三种,按信息量从低到高排列:
第一种是“摘要式分析”。你只让 Claude Code 概括一下这个进程里每个线程在干什么,输出一张简表:线程号、栈顶系统调用、栈底业务入口、可能的状态。这是最省 token 的用法,适合快速判断“这个进程是不是出了问题”,以及“问题集中在哪个模块”。
第二种是“关联推理式分析”。你把最近一段时间的代码变更、配置变更、日志片段和 pstack 输出放在一起,让 Claude Code 做交叉推理。这种用法最适合查“没有崩但是卡死”的问题。因为它能结合上下文猜出因果链,比如“A 模块的某个回调没触发,导致 B 模块的等待线程一直挂起”,这种推理要是完全靠人来看,可能需要半天。
第三种是“修复建议式分析”。你把栈帧内容 + 相关源码片段一起提供给 Claude Code,让它给出具体的修复方向和风险提示。这里我不是让你直接抄它的代码,而是把它当“资深同事”过一遍设计——它提出的思路可能不完美,但能帮你少走很多弯路。
3.3 提示词怎么写才有效
用 Claude Code 分析堆栈,提示词的质量直接决定分析质量。我踩过不少坑,现在基本形成了模板:
你是资深 Linux 后端性能分析专家。请阅读以下 pstack 输出文件。 任务分三步: 1. 统计线程总数,列出每个线程的栈顶系统调用(#0 层)分布。 2. 找出超过 3 个线程聚集在同一个函数或同一模块的现象,并分析可能的原因。 3. 结合这些聚集点,猜测最可能导致进程卡死的根因,并给出下一步需要确认的命令或日志查看方向。 注意:不要泛泛而谈,要针对文件里的实际函数名和调用关系给出结论。这里的关键是“给模型明确的任务拆解”,而不是简单说“帮我看看有什么问题”。明确任务之后,模型的输出会结构清晰很多,而且不会跑偏。另外,如果你有日志或监控数据,也一并放在文件里让它读,交叉信息越多,结论越靠谱。
我在实际使用中还要加一句:“如果信息不足以支撑结论,请直接说无法判断,不要强行推理。”这能有效避免 AI 一本正经地胡说八道。就这一点,省了我很多去验证假线索的功夫。
4. 实战复盘:一次内存踩踏的定位全过程
4.1 现象与初步排查
操作背景是有一个数据上报服务,单实例大约每秒处理三千个请求,运行三五天后就会随机崩溃,没有固定规律。崩溃之前没有任何 ERROR 日志,就是“啪”一下进程没了。这种情况是最难查的——你不知道崩溃点在哪个请求、哪个线程,甚至不确定是不是每次都崩在同一处。
我第一步做的是检查 core 文件,结果发现生产环境的 core 开关没开。这就很尴尬了,等于案发现场直接没了。我只好选择守株待兔:先把 pstack 定时采样脚本挂上,每 30 秒打一次快照,等它崩。同时把dmesg输出也接上,看内核有没有记录segfault相关的信息。
这里有个技巧:内存踩踏类崩溃,dmesg一般能给出“触发崩溃的指令地址”和“访问的内存地址”,如果你看到类似:
program[pid] general protection fault ip:7f... sp:7f... error:0这个 IP 地址就是崩溃瞬间的指令指针,配合addr2line能翻译成源码行号。虽然不如 pstack 直观,但它是崩溃现场的硬证据。
4.2 用 pstack 抓取“崩溃前兆”
等了一天半,终于抓到一次“疑似前兆”:有一个线程的栈非常奇怪,#0层停在memcpy里,而且#1层是一个不常走的热点路径。正常情况这个路径一天要执行几百万次,从来没卡在 memcpy 这么久,说明那一刻它复制的内存区域可能已经出问题了。
我立刻把这份 pstack 输出原样保存,再补抓了一次全线程栈,确认其他线程都在正常干活,只有这个线程行为异常。这种“单线程异常、整体正常”的形态,通常会排除锁问题,把矛头指向“这块内存被别人写坏了”。
接下来我很老实地做了四件事:
- 把 pstack 输出翻译成“线程在做什么”的摘要;
- 从
dmesg里挖出崩溃 IP 和寄存器; - 找到这个热点路径对应的源码文件;
- 查看这个线程最近一次操作共享内存的代码段。
4.3 让 Claude Code 做关联推理
我把上面四份材料整理到同一个目录里,然后在 Claude Code 里这样下达任务:
claude --print --output-format text " 请阅读 /tmp/crash_debug/ 下的以下文件: - stack.txt(进程崩溃前的线程栈) - dmesg.txt(内核日志) - hotpath.cpp(热点路径源码) 任务: 1. 找出 stack.txt 中异常线程的完整调用链。 2. 把调用链上每一步涉及的共享内存写入操作列出来。 3. 对照 dmesg.txt 中的崩溃地址,判断崩溃是否与这个线程的 memcpy 有关。 4. 给出最可能的内存踩踏来源。 "这次分析有个很关键的产出:Claude Code 指出这个线程的调用链里有一步写入了某个全局对象,但那个对象在使用前没有加锁,而且从栈帧看,调用方使用的是旧的逻辑分支,与源码里最新的分支不一致。这个“不一致”给了我很大启发——这基本说明线上运行的二进制和源码不是同一版本。
后来一查版本记录,果然。发布流程里有一步是“用最新代码重新编译,但只发布二进制,不更新符号文件”,导致崩溃现场看到的符号和源码对不上。也就是说,之前的排查方向全偏了——我们一直拿旧源码去理解新行为的栈,当然怎么都想不通。真正的问题其实是多线程并发写同一个未加锁的全局缓冲,而新代码恰好放大了这个写操作的频率。
这个复盘里,Claude Code 最大的价值不是“直接指认凶手”,而是它把“栈帧调用链”和“源码版本差异”关联起来,给了我一跳自己都没注意到的线索。换人来看,看到“旧逻辑分支”可能就认为“这里没问题,代码没错”,但模型因为上下文交叉阅读,会本能地提示你“这里与你说的情况矛盾”。
4.4 修复和验证
定位之后,修复倒是很快:给那个全局缓冲加上锁,同时修正发布流程,确保二进制和符号文件一同更新。上线后连续跑了一周,没有再次崩溃。监听脚本也继续跑了两周,确认异常线程不再出现。
这次实战给我最深的印象是:单靠 pstack 能抓到“现象”,但解释“现象”需要跨信息源关联。日志、源码、版本、配置,这些信息如果不放在一起看,很容易停留在表面。而 Claude Code 恰好擅长处理这种多文件、多信息的交叉对比,只要你把材料喂得够整齐,它的推理质量会比大多数人预期的要高。
5. 调试实战中常见的坑与经验
5.1 pstack 输出不全怎么办
我遇到过两种“输出不全”情况。
第一种是进程里线程太多,pstack 默认只打了一部分线程。这种情况可以先用ps -T -p $PID数一下线程总数,再对比 pstack 输出的线程数量,如果差太多,优先怀疑这个。解决办法是用 gdb 替代,因为 gdb 的thread apply all会更可靠地遍历所有线程。
第二种是栈帧信息丢失。这通常是因为进程运行时间太长,栈空间被覆盖,或者编译优化级别高,部分帧被内联了。没什么太好办法,只能降低优化级别重新编译,或者接受“部分栈帧看不全”的现实,结合其他手段补信息。
5.2 attach 冻结进程的问题
pstack 底层会调用ptraceattach 到目标进程。attach 不是免费的——目标进程会短暂暂停。对于绝大多数后端服务来说,这个暂停只有几十到几百毫秒,一般可以接受。但你要是拿它去监控一个毫秒级超时的服务,就要小心了,可能一 attach,人家就超时了。
我后来的做法是:只在“进程看起来不对劲”的时候才手动抓栈,不部署常驻自动抓栈脚本。真要自动化,也是在故障触发信号之后延时抓,而不是轮询抓。轮询抓栈在高负载实例上大概率会惹事。
5.3 符号文件不匹配
这个坑前面提过,值得再强调一次。pstack 输出的函数名来自进程内嵌的符号表,如果你用strip处理过二进制,或者符号文件不是同一编译产物,那读到的栈就是“没有意义的符号”。一套正确的排查流程里,一定要记录二进制对应的代码版本号、编译时间、Git commit。
我现在会在发布包里打包一个buildinfo.txt,内容包括编译时间、commit、编译机、编译参数。出问题的时候先校验这个文件,再决定要不要信任 pstack 的符号。这个小改动帮我避免过好几轮无效排查。
5.4 AI 分析需要人工交叉验证
用 Claude Code 分析堆栈,我总结了三个原则:
- 不要让 AI 独自下结论,一定要让它给出“依据栈帧里的哪些信息”
- 对 AI 给的“可能原因”,按成本从低到高设计验证实验
- 对 AI 说的“不可能”,不要直接信,要有至少一个旁证
这三条帮我避免过至少两次“被 AI 带偏”的经历。比如有一次 AI 信誓旦旦说某个栈全卡在recv,一定是网络问题,但实际是定时器线程把 CPU 跑满,导致recv没有机会返回。AI 的推理基于文本特征,它看不到 CPU 使用率,所以如果你的上下文里没有监控数据,它的结论只能当作“候选假设”,不能当作“诊断结果”。
5.5 让 dump 文件自带上下文
最后一个经验。我后来优化了抓栈脚本,让它不只输出 pstack,而是把以下信息打包成一个目录:
stack_YYYYmmdd_HHMMSS/ ├── pstack.txt ├── dmesg_last_100.txt ├── process_status.txt # /proc/$PID/status ├── thread_count.txt ├── fd_count.txt └── buildinfo.txt这样做的动机很简单:一次故障,手头的信息越全,后置分析的成功率越高。很多时候你抓完 pstack 没时间立刻分析,等腾出手再看时,现场早就变了。把进程状态、fd 数量、内核日志一起打包,能极大减少“想起来还要再去补数据”的窘境。
6. 补充几个还可以继续扩展的方向
这套 pstack + Claude Code 的工作流,目前我已经固定在了日常排障流程里。因为顺手的场景不止“进程崩溃”,比如性能抖动、内存缓慢增长、连接泄漏,这些都需要“看线程栈”作为切入点。pstack 提供事实,Claude Code 提供假设和线索,人在中间做决策和验证,这个分工我觉得是目前最合理的。
另外,Claude Code 这边我现在偶尔也会让它写抓栈脚本或者解析栈文本的小工具。比如我让它生成过一个 Python 脚本,能把 pstack 输出里的栈帧按模块聚合成柱状图,输入是人眼很难读的三百行文本,输出是“哪个模块最可疑、出现频次最高”的结论。这种脚本本身逻辑简单,但让 AI 写比我手写快得多。
我说的这些方法不一定是最优解,但起码是经过线上故障验证过的。如果你也在维护容易出怪问题的服务,下次遇到“进程活着但不对”的情况,不妨先跑一条 pstack,把输出存好,再让 Claude Code 帮你梳理一遍。很多时候,那个纠缠你两天的故障,就差有人帮你把线索串起来。