做后端服务维护的朋友,大概率都碰过这种场景:线上进程还挂着,端口能通,业务却像被按了暂停键,请求全部卡住。我第一次被拉去处理这类问题时,第一反应就是掏出 pstack 打一份线程栈,结果面对 80 多行原始输出盯了半个多小时,才勉强找出一个可疑线程。后来我把 Claude Code 引入到分析链路里,攒出了 pstack-claude 这套工作流——用 pstack 采集进程栈,让命令行 AI 工具结合源代码辅助解读,定位效率明显上了一个台阶。这篇文章就把这套方法完整拆开讲清楚,包括采集脚本、上下文组织、实际案例,以及我踩过的一些坑,适合正在维护 C/C++ 服务、或者经常处理线上疑难问题的朋友参考。
1. 项目背景:一次差点通宵的线上卡顿排查
1.1 事故现场:进程活着,但业务全部挂起
先说个典型场景。某个用 C++ 写的数据转发服务,某天下午突然有人工反馈说订单处理延迟飙到几十秒。我登录上去看了一眼:进程还在,CPU 占用率不高,内存正常,端口也能连上,但业务请求就是出不来。用ss -lntp看监听队列,backlog 已经堆满,这说明 socket 层已经接受了连接,但业务线程没有及时处理。
这种“进程活着却什么都干不了”的状态,最怕的就是瞎猜。有人怀疑网络问题,有人怀疑磁盘 IO,还有人怀疑是流量突增导致线程池打满。最后稳定下来的排查手段还是得看线程在干嘛——也就是把每个线程的调用栈打出来,看它到底卡在哪个函数里。这就是 pstack 的经典用法。
那次排查我连续打了好几次栈,对比之后才确认是某个队列加锁顺序出问题,两个线程互相等待形成死锁。整个过程花了将近一个通宵,很大一部分时间都耗在“读栈”上。
1.2 pstack 传统打法的三个痛点
第一,输出非常原始。pstack 打印出来的是一串函数名加内存地址,比如一行是#0 0x00007f7a2b3d7c32 in pthread_cond_wait (),没有调用关系说明,没有源码行号,更没有“这个线程在等哪个锁”这种结论。要读懂它,你得在脑子里把栈帧串起来,然后回源代码里比对。
第二,单次采样不够。一个瞬间的栈只能说明“这一刻线程在哪”,不能区分“正常工作”和“卡死”。比如一个线程停在 epoll_wait 里,那是它在等事件,很正常;但如果它连续三次采样都停在同一个 mutex 的加锁调用上,那问题就大了。没有对比,你根本不敢下结论。
第三,多线程并行分析太烧脑。死锁很少是单个线程的问题,多数是两三组线程互相卡住。你得同时看多个线程栈,在脑子里画“谁在等谁”的图。几个线程还行,几十个线程时,人脑真的不够用。
1.3 把 AI 拉进排查链路的想法是怎么冒出来的
后来我在日常开发里开始用 Claude Code 这个命令行 AI 工具。它不像 IDE 插件那样只做补全,而是能理解整个项目的上下文:能读源码文件、能定位函数定义、能按我的要求输出推理过程。当时我就琢磨,pstack 这种“原始素材多、但结论要靠人推理”的场景,不正是 AI 擅长的吗?
于是我做了一次实验:把两次 pstack 采样喂给 Claude Code,告诉它项目路径,然后问它“哪些线程的栈完全没变、这些线程大概率卡在什么地方、结合源码看它们在等什么”。结果它很快给出了一个很靠谱的判断——不仅列出了可疑线程,还指出了一个我在人工排查时忽略的锁顺序问题。打那以后,pstack-claude 这套工作流就逐渐成型了。
2. 先搞懂 pstack 在干什么,再谈 pstack-claude
2.1 pstack 的工作机制:用户态栈与 gdb attach
pstack 并不是一个读内核数据的工具,它本质上是 gdb 的一个封装脚本。执行pstack <PID>时,它大致做了三件事:
- 检查目标进程是否存在、是否有权限附加。
- 调用 gdb 以
-p <PID>方式附加到进程上。 - 执行
thread apply all bt,打印所有线程的用户态调用栈,然后退出。
换句话说,pstack 的结果来自 ptrace 机制,获取的是用户态线程栈。它能看到你的业务函数调用链,比如WorkerThread::Run -> ProcessMessage -> Queue::Pop -> pthread_mutex_lock,也能看到系统库的等待调用,比如pthread_cond_wait、epoll_wait。
和 pstack 容易搞混的是/proc/<PID>/task/<TID>/stack这个文件,它记录的是内核栈,需要 root 权限且内核开启CONFIG_STACKTRACE才能读,通常用来排查 D 状态进程卡在内核哪里。pstack 一般用不上它,但后面我讲容器里的 D 状态案例时会提到。
还有一个细节:pstack 附加进程的瞬间,目标进程会被暂停。对绝大多数服务来说这只是一瞬间的事,但生产环境务必意识到这个副作用,后面我会专门讲。
2.2 别输在采样姿势上:多时间点对比才算数
我第一次用 pstack 时犯的错误是只打一次栈,然后对着输出瞎猜。后来才明白,单次快照信息量极其有限。假设你看到一个线程停在pthread_cond_wait,你能说它卡住了吗?不能。它很可能只是在等下一个条件变量信号,属于正常工作。
真正的做法是“多次采样 + 对比栈帧”。我一般间隔 2 到 3 秒连续打两次,关键场景打三次。然后看每个线程的栈帧内容是否完全一致:
- 如果某个线程两次采样栈完全不变,说明它在这几秒内纹丝不动,极可能卡在锁、条件变量、IO 等阻塞点上。
- 如果栈一直在变,说明它还在运行或处于正常的事件等待循环中,问题大概率不在它身上。
这一招看起来简单,却极其有效。pstack-claude 这套工作流的核心,就是先把这种对比逻辑自动化,再把对比结果交给 AI 做深度解读。
2.3 pstack 的盲区和不适用场景
pstack 也不是万能的,下面这些情况它帮不上忙,你心里要有数。
- 内存泄漏。泄漏是慢慢积累的,栈快照根本体现不出来,你应该用
valgrind、jemalloc的 prof 接口或者监控 RSS 曲线。 - CPU 飙高但线程栈很活跃。这通常是热点代码频繁执行,单看栈定位不了性能瓶颈,得上
perf top或者perf record。 - 进程已经崩溃。栈都打不出来了,这时候应该回头找 core dump,用 gdb 分析崩溃现场。
- 内核态卡死。比如进程处于 D 状态,用户态栈只能看到它卡在某个系统调用上,再往内部看就得靠
/proc/PID/task/TID/stack。
理解这些边界很重要。pstack-claude 不是全能诊断工具,它解决的是“进程还活着,线程却卡住”的那一类问题,适用面已经很广了,但没必要硬套到所有故障上。
3. pstack-claude 的完整落地实现
3.1 整体链路:采集、清洗、组织、分析四段式
我最终沉淀下来的流程是四段式:采集、清洗、组织、分析。每一步都可以脚本化,整个链路下来基本是半自动的。
- 采集:用 pstack 按固定间隔采样两次,产出原始栈文件。
- 清洗:过滤掉系统库函数、内存地址、行号噪音,把项目自有函数提取出来。
- 组织:把两个时间点的栈按线程 ID 对齐,生成一份“哪些线程栈不变、哪些栈在变”的对比摘要。
- 分析:把摘要和项目路径交给 Claude Code,让它结合源码给出结论。
一开始我也犯过把原始输出直接丢给 AI 的错。后来发现,AI 虽然能读原始栈,但输出里混杂了大量无关系统帧,会严重干扰判断。所以清洗和组织这两个步骤,不是可有可无,而是整个方案能不能落地的前提。
3.2 采样与清洗脚本:从原始栈到分析素材
采集脚本很简单,我直接贴出来。
#!/usr/bin/env bash set -euo pipefail PID="${1:?usage: $0 <pid> [interval]}" INTERVAL="${2:-3}" for i in 1 2; do echo "===== sample $i at $(date '+%F %T') =====" pstack "$PID" | tee "stack_${i}.txt" echo "" if [ "$i" -eq 1 ]; then sleep "$INTERVAL" fi done执行方式就是./collect-stack.sh 12345 3,得到stack_1.txt和stack_2.txt两份原始输出。要注意 PID 别搞错,建议先用pgrep -f 服务名确认一下,多实例部署时尤其要小心。
接下来是清洗。pstack 输出里大量重复的libc、libpthread、libstdc++帧,还有一堆十六进制地址,对 AI 分析来说全是噪音。我会先做一个粗过滤:
# 过滤掉系统库帧和地址,保留项目函数调用链 for f in stack_1.txt stack_2.txt; do grep -E '^#|^Thread' "$f" \ | grep -vE 'libc|libpthread|libstdc|ld-|0x[0-9a-f]+' \ > "${f%.txt}.clean.txt" done这里没有把全部地址删掉,因为后面定位时可能还要用addr2line反查源码行号,所以原始文件要保留。清洗后的文件专门用来给 AI 做快速判断。
3.3 构造向 Claude Code 投喂的上下文包
这一步是整个流程里最重要的。清洗后的栈文件依然是一堆函数名,如果直接丢给 Claude Code,它缺少一个关键维度:两份采样之间的对比。
我一般会先在终端里生成一份对比摘要,格式类似下面这样:
进程信息:>这是两份间隔 3 秒的 pstack 采样对比摘要。服务是>Thread 3: #0 0x00007f97cb5c088f in pthread_cond_wait () #1 0x000055e7c8b0b942 in ?? () #2 0x000055e7c8b0b8a0 in ?? ()?? ()意味着那段地址没有符号信息,可能是项目二进制被 stripped 了,也可能某些静态库没带调试符号。这种情况下 AI 再强也没用,你喂给它的函数名都是空的。
我的处理办法:先确认二进制是否带符号,用file <binary>或者nm <binary>看;如果全被 strip 了,想办法装对应的debug包,或者找 CI 构建时保留符号版本的产物。如果只是个别库丢符号,可以用addr2line把地址还原成文件名和行号:
addr2line -e /usr/local/bin/data-forwarder -f -C 0x555e7c8b0b942还原出来的结果再补充进清洗后的栈文件,AI 才能正常工作。记住一个原则:先给 AI 能吃的东西,再让它发挥推理能力,这个顺序不能反。
5.2 生产环境 attach 的副作用与时间窗口
pstack 靠 gdb attach,而 gdb attach 会让进程短暂停下。绝大多数时候这是毫秒级影响,但极端情况下会有风险:如果进程正持有着某个锁而且正在做关键操作,暂停可能让它的下游调用方超时,甚至引发雪崩。
我第一次在生产环境用 pstack-claude 时,就遇到过一次:连续采样三次,每次停顿叠加,导致某几个依赖方报了很多超时告警。虽然有惊无险,但后来我给自己定了几条规矩:
- 采样前先和业务负责人确认时间窗口,尽量选低峰期。
- 默认采两次,间隔控制在 3 秒以内,最多三次。
- 如果一次采样已经看出端倪,立刻停手,不要再贪多。
- 容器环境更要注意,非特权容器可能根本没有 ptrace 权限,pstack 会直接报错,这时候要么让容器具备
SYS_PTRACE能力,要么在宿主机上对容器进程做采集。
另外提一个容易被忽略的点:如果你的生产环境配置了 systemd 的 coredump 策略,频繁 attach/中断进程可能触发 coredump 线程的额外行为,采样前看一眼系统配置更稳妥。
5.3 上下文组织错误会让 AI 给出误导性结论
最开始的几次尝试里,我犯过一个很典型的错误:把stack_1.txt和stack_2.txt两坨原始文件直接丢给 Claude Code,不做对齐、不做摘要。结果它抓不住重点,一会儿说线程 5 可疑,一会儿又说线程 19 可疑,两边输出矛盾,反而浪费我时间。
后来我把流程改成了前面说的“对比摘要”方式。这个摘要的作用,相当于告诉 AI “请重点关注栈完全不变的线程”,省去它自己逐帧比对的过程。AI 出错率明显下降。
还有另一个细节:告诉 AI 哪些函数是项目自有的。C++ 项目经常依赖 Boost、libevent、gRPC 这些第三方库,它们的内部栈帧也会出现在输出里。如果不对这些帧做标注,AI 可能在第三方库的内部函数上纠结很久。我的做法是在清洗阶段就直接把boost::、grpc::、event::等前缀的帧标为“第三方库”,让 AI 跳过,只在项目代码范围内找卡点。
> 提示:投喂 AI 的上下文,永远要先做“降噪”和“对齐”两步。原始数据再真实,直接堆给 AI 也只会稀释它的注意力。6. 这套方法的边界,以及还能怎么扩展
6.1 它擅长什么、不擅长什么
pstack-claude 解决的核心问题,是“进程活着但线程卡住”时快速给出可疑点排序。我用一张表总结一下它的适用范围:
| 场景 | pstack-claude 是否适用 | 说明 |
|---|---|---|
| 死锁、锁顺序问题 | 适用 | 多线程栈对比 + 源码分析,定位效率很高 |
| 条件变量等待 | 适用 | 栈不变 + 代码路径可定位等待条件 |
| 线程池耗尽、连接不处理 | 适用 | 多个工作线程栈聚集在排队点,一眼可见 |
| D 状态、IO 卡死 | 部分适用 | 用户态栈信息有限,需配合/proc内核栈 |
| 内存泄漏 | 不适用 | 栈快照看不到,应使用内存分析工具 |
| CPU 飙高但无阻塞 | 不适用 | 用 perf 做热点采样更合理 |
| 进程已崩溃 | 不适用 | 走 core dump 分析路线 |
这套工具是“排查链路的加速器”,不是“代替所有诊断工具的万金油”。
6.2 复制到 jstack 与 goroutine dump
pstack-claude 的最大价值,其实是“栈采集 + AI 推理”这套方法论,它完全可以迁移到别的语言生态:
- Java 服务:用
jstack <PID>抓线程栈,两次采样对比后喂给 Claude Code。Java 线程栈信息比 pstack 更丰富,自带锁状态描述,比如locked、waiting to lock,AI 判断死锁会更容易。 - Go 服务:向进程发送
SIGQUIT触发 goroutine dump,或者用go tool pprof goroutine导出。goroutine 栈的模式化很强,AI 很快能发现“大量 goroutine 阻塞在同一个 channel send/receive”这种问题。 - Python 服务:用
py-spy dump --pid <PID>抓线程栈,同样可以复刻这个链路。
只要栈是文本格式,数据能采集,AI 就能参与分析。你不需要为每种语言重新设计方法论,只需要换一个采集命令。
6.3 从手动分析走向半自动巡检
到了这一步,我自然而然地想把它变成半自动巡检工具。思路也很直接:写一个巡检脚本,每隔固定时间采样一次 pstack,连续三次栈完全一致的线程打上“可疑”标签,再自动把摘要推给 Claude Code 生成报告。
目前我这个巡检脚本的原型逻辑大致是:
每 5 分钟执行一次 pstack 采样 保留最近 3 份采样结果 对每个线程做栈帧 hash 对比 连续 3 次 hash 完全一致 -> 标记为 stuck 候选 将 stuck 候选和对应栈帧摘要汇总,发送给 ClCode 生成分析建议这种半自动模式的好处是,不需要等线上事故爆发后再登录机器手动排查,平时就能积累“可疑线程”的观测记录。将来如果再发生卡死,历史数据还能用来对比是突发问题还是渐进劣化。
写在最后:一个对我帮助很大的使用习惯
用了这么久的 pstack-claude,我最大的体会是:AI 分析能力强,但它的输入质量完全取决于你怎么组织信息。不要拿最原始的日志去考验它,先自己完成采集、对齐、降噪、摘要这些脏活,AI 才能真正发挥推理优势。另一个深刻体会是,AI 分析的结论永远是假设,不是最终答案——它每次给我一个“最可能卡点”后,我都会回到源码、加上日志或者 gdb 单步去验证,确认无误才敢下结论。工具能把人从大量低价值阅读里解放出来,但最终对该做的事负责的,仍然是你自己。