先别急着关浏览器。你正在用Claude Code跑一个几十个文件的重构任务,起身倒了杯水,回来发现终端光标在闪,日志停在最后一条工具调用上,等了十分钟没有任何新输出。这种“假死”是所有把Claude Code当成自动化基线的人都会遇到的头疼问题。我今天的主题pstack-claude,就是一套把老牌Linux进程堆栈诊断工具pstack,和Claude Code这个AI编码CLI组合到一起的实践方案:当Claude Code假装自己在思考、实际上卡死的时候,怎么用线程堆栈把“它到底卡在哪”这件事挖出来。
pstack大家可能不陌生,它能打印一个进程当前所有线程的栈帧,是排查死锁、假死、CPU异常的标配。Claude Code是Anthropic的终端AI编码工具,本质是一个Node.js写的交互式CLI,会在项目里执行读文件、改代码、调工具、跑命令这些操作。pstack-claude不是某个官方插件,而是我自己在把Claude Code接入自动化任务时沉淀下来的一套进程级诊断方法:定位进程树、抓全线程堆栈、对齐日志尾部、识别栈帧指纹。整套思路不限于Claude Code,任何Node写的Agent类CLI都能用。这篇文章大概率对两类人有价值:一是在Linux服务器或WSL里跑Claude Code自动化任务、遇到偶发卡死的人,二是在折腾MCP server、想搞清楚工具调用为什么迟迟不返回的人。先声明,这套东西能定位进程本身的问题,但它在你自己能控制进程的环境下才有效,Claude Code在官方托管环境跑的时候你拿不到进程,那是另一回事。
1. 为什么Claude Code会“卡住”,以及pstack为什么能对症下药
1.1 先用三句话讲清楚pstack是什么
pstack是一个命令,给定一个进程PID,它会把该进程内部所有线程的调用栈打印出来。实现机制大体是走ptrace系统调用挂到目标进程上,读取栈内存和符号表,再解析成函数名和代码位置。市面上有三个常用实现:老牌且最通用的pstack、elfutils套件里的eu-stack、以及直接用gdb的批处理模式。有些系统上pstack其实是gdb的封装,效果差不多。
用生活化类比解释:一辆汽车踩油门不走,仪表盘还在亮,你怀疑变速箱有问题但又不能拆发动机。pstack就是那台外接诊断仪,不拆任何东西读取变速箱当前处于哪个挡位、齿轮卡在哪个位置。它提供的是进程在“这一瞬间”的快照,而不是历史数据,所以特别适合回答“它现在正在等什么”。网络问题可以猜,模型推理快慢可以猜,但一个进程究竟阻塞在哪个系统调用或函数上,猜一万字都不如打一张栈帧图来得准确。
1.2 Claude Code的进程结构决定了诊断切入点
pstack-claude之所以有效,是因为Claude Code在Linux下的进程结构非常清晰。主进程是一个node实例,所有对话、任务编排、文件读写都在这个进程内完成。它运行时会派生一批子进程,每个都是独立的进程身份:
- 执行git命令、npm安装、构建工具时产生的子进程,通常是child_process创建出来的。
- MCP server,现在装Claude Code基本都会配几个MCP服务,大部分以stdio模式运行,是主进程spawn出来的附属进程。
- 你用
--permission-mode允许Claude自动跑shell命令时,它也会临时起子进程执行具体命令。
这三种身份对应三种不同的“卡死”模式:主进程卡住,通常表现为API请求挂起、事件循环被同步逻辑阻塞、或者在等待某个promise永远不resolve;子进程卡住,表现为Claude迟迟不执行下一步,等的是npm或git的退出码;MCP server卡住,表现为工具调用发出后没有任何返回,超时机制如果没配好,整个任务就僵在那里。
1.3 pstack-claude项目的定位
这个组合拳的定位不是替代Claude Code的调试器,也不是性能分析器,而是一个低成本抓现场的应急工具。Agent进程的生命周期很长,大部分时间其实是在I/O等待,没必要用perf、gprof这类采样开销大的性能工具。pstack式的一次性快照足够轻,可以在容器里用,可以在CI脚本里循环抓,也可以集成到告警链路里。
为什么不用更重的方案,还有一个实际原因:Claude Code卡死往往不是崩溃,没有core dump,没有异常日志。它就像一个人睁着眼发呆,你问它你怎么了它还说没怎么。这种场景只有主动去“看进程的内部状态”这一条路。pstack-claude把ps定位、pstack抓栈、strace补syscall证据、debug日志对齐四个环节串起来,形成一条从现象到根因的完整链路。
2. 核心实现:抓堆栈、读堆栈、对齐日志
2.1 拿到正确的PID,这一步错了一切白搭
抓栈前第一件事是定位进程。Claude Code的进程名在不同版本上有差异,早期版本进程名就是node,后来有的安装方式显示claude,所以不要只靠binary名字过滤,要把命令行的特征一起带上。我自己用的定位命令:
ps -eo pid,ppid,stat,%cpu,%mem,lstart,cmd --sort=-%cpu | grep -iE 'claude|mcp' | grep -v grep这会列出所有名字带claude或mcp的进程,并显示父进程PID。Claude Code主进程通常是最上面的那个node或claude,父进程是你的shell或终端。它派生的MCP server子进程,父进程PID就是主进程的PID,用这条命令把子进程全部找出来:
pgrep -P "$MAIN_PID" -a在Windows上命令不一样,用Get-Process和Get-CimInstance Win32_Process。但我的建议是把pstack-claude跑在WSL或Linux容器里,因为ptrace、strace这些能力在Windows原生环境里不全,折腾成本高。
一个非常容易踩的坑:终端本身可能也是一个node进程(比如某些终端工具),有些版本里VS Code的language server也会匹配到node。抓栈之前仔细看CMD列,确认那是属于Claude Code的进程树,否则你分析半天栈帧对象都搞错了。
2.2 一条命令输出全线程堆栈
拿到PID后,用下面这条命令就能看到所有线程的调用栈。
pstack -p PID系统里没有pstack时,用gdb的batch模式,这个几乎在所有Linux环境都可用:
gdb -q -batch -ex "set pagination off" -ex "thread apply all bt" -p PID还可以用elfutils的eu-stack:
eu-stack -p PID -m三种方式抓到的栈深度和符号丰富度有差别。gdb最详细,能带参数和源码行号;eu-stack出息在不需要gdb的依赖;pstack在部分老系统上是符号化最弱的。我建议把三种都封装成一个脚本,按可用性自动选择。下面的函数我放在~/.bashrc里,实际用下来很顺手:
dump_threads() { local PID="$1" local TS TS=$(date +%Y%m%d-%H%M%S) local OUT="dump-${PID}-${TS}" mkdir -p "$OUT" if command -v pstack >/dev/null 2>&1; then pstack "$PID" > "$OUT/allthreads.txt" elif command -v eu-stack >/dev/null 2>&1; then eu-stack -p "$PID" -m > "$OUT/allthreads.txt" else gdb -q -batch -ex "set pagination off" -ex "thread apply all bt" -p "$PID" > "$OUT/allthreads.txt" fi echo "输出目录: $OUT" }抓的时候要求进程处于卡住状态,如果进程已经恢复了,栈就没了意义。为了留下时间证据,脚本里用date把时间戳写进文件名,后面对齐日志全靠它。
2.3 堆栈阅读:三种典型的卡死指纹
抓回来的栈,不是随便扫一眼就能出结论的,要把栈帧归成指纹。我在实践中发现,绝大多数的Claude Code假死都落在三种指纹里。
第一种指纹是“事件循环空转+等待回调”。栈帧序列大概长这样:
Thread 1 (process 14215): #0 syscall in do_syscall_64 () from /proc/kcore #1 epoll_wait (epfd=12, events=..., maxevents=..., timeout=-1) from /lib/x86_64-linux-gnu/libc.so.6 #2 uv__io_poll (loop=0x563b2e04e860) at ../deps/uv/src/unix/loop.c #3 uv_run (loop=0x563b2e04e860, mode=UV_RUN_ONCE) at ../deps/uv/src/unix/core.c #4 node::common::StartMainThreadExecution () #5 node::Start () #6 main ()这是Node事件循环的空闲等待形态,单看这个栈以为是正常状态,但它出现在“卡住超过五分钟”的时间点上,说明事件循环里没有任何就绪的回调可以跑。到底在等什么,要配合strace看epoll里挂的是哪些文件描述符。比如你发现反复返回的fd对应一个socket,那就是等网络响应;对应一个管道文件,那是等子进程输出。命令参考:
strace -f -p PID -e trace=epoll_wait,read,write -o trace.log等上几十秒,看它最频繁停在哪个系统调用上。
第二种指纹是“等待子进程退出”。栈顶经常是wait4或do_wait,上层是uv__process_wait,再往上能看到child_process相关的帧。出现这个指纹,说明Claude Code启动了一个子进程但一直没等到结束信号。这时候问题的重心从Claude转到子进程上:去看子进程的strace,或者直接看子进程卡在哪。曾经我遇到过一次卡死,抓栈发现是等git命令结束,再抓子进程栈,发现git在等一个ssh凭据交互,而终端已经被Claude Code的控制流接管了,没人能输入密码。
第三种指纹是“锁竞争”。栈顶是futex_wait,对应NPTL的互斥锁等待。Node进程里如果V8的GC、libuv线程池和你自己的async模块在互相抢锁,会出现这类栈。Claude Code的并发任务编排如果长时间拿不到锁,任务就推不动。这种指纹比较少见,一旦出现,基本是当前进程里跑上了不兼容的native插件,或者文件系统监控和编辑流水线形成了互相等待。
2.4 日志时间线对齐,把栈对应到业务动作
单单看栈还不够,必须把栈里的系统动作和Claude Code的业务动作对上。Claude Code启动的时候加上--debug --verbose,它会打印非常详细的日志,包含每次工具调用、每个API请求的开始和结束。我的做法是:抓栈前先在另一个终端记下date +%s.%N,抓完栈立刻把debug日志尾部300行拉出来。日志最后一条业务动作,就是卡死时间窗口之前做的最后一件事。
举一个实际例子。某次MCP工具调用卡了十分钟,抓栈看到大量stdio读取等待帧。拉日志发现最后一行是Calling MCP tool: fetch_web_page,等于Claude发了一条工具调用请求给MCP server,然后就一直等着套接字传回结果。这样根因就很清楚:不是Claude本身的网络问题,而是那个MCP server没有及时响应。之后给MCP工具的调用配了超时控制,问题基本绝迹。如果只抓栈不看日志,你可能永远不知道等着的那个socket对应什么业务。
3. 实操过程:完整复现一次“假死”定位
3.1 用可复现的场景验证整套流程
理论说多了不如自己跑一遍。我建议你把pstack-claude练手时用一个体积适中的Node或TypeScript项目,给Claude Code提一个涉及几十个文件的重构任务。为了制造卡顿,在项目目录里故意跑一个文件监听器,比如tsc --watch、nodemon之类,让系统产生大量文件变更事件。这个场景是我实际发现最容易触发Claude Code内部协调卡顿的条件之一:文件watcher事件风暴会让Claude内部的文件状态频繁失效,编辑流水线反复重读文件,任务进展越来越慢,最后像卡死。
3.2 抓现场的完整操作清单
按这个顺序操作,一步都不要跳:
- 启动Claude Code,加
--debug --verbose参数,开始那个大重构任务。 - 启动文件监听器制造事件风暴。
- 观察到终端超过两分钟没有任何输出,进入“疑似假死”时间窗口。
- 在另一个终端跑
ps -ef --forest | grep claude,找到主进程PID,再通过pgrep -P找到MCP server子进程PID。 - 循环抓5次堆栈,每次间隔5秒,使用前面的dump_threads脚本。
- 抓栈的同时启动
strace -f -p MAIN_PID -c,让它统计60秒内的系统调用分布。 - 抓所有的栈都带上时间戳,保存debug日志尾部。
为了让抓栈不完全依赖手动操作,我有一段循环采样的脚本,放在后台跑:
#!/usr/bin/env bash PID="$1" for i in 1 2 3 4 5; do dump_threads "$PID" sleep 5 done每个输出目录名自带时间戳,后面分析的时候能清晰看到同一个进程在不同时刻的栈变化。
3.3 从堆栈到根因的五步推演
拿到5份堆栈后,别急着读每一行。先做五步推演:
第一步,统计所有线程栈里出现频率最高的帧。因为在同一个时刻所有线程都停在相似的等待点上,才能说明这是全局阻塞,而不是某个后台线程偶然的休眠。我通常写一个Python脚本做去重计数,把出现次数超过线程数一半的帧标出来。
第二步,判断它到底在等什么。看栈顶内核帧,是epoll_wait、futex_wait、read、还是wait4。这四个分别对应网络/事件等待、锁等待、管道读取、子进程等待,完全是四类不同的根源。
第三步,确认这个等待是从Claude Code哪一层发起的。往上翻栈,找node的libuv、child_process、V8的PumpMessageLoop这些标志性帧。看到child_process,就去查对应的子进程;看到网络栈,就去查它连接的对端域名和端口。
第四步,把栈和日志对照。找到第一张栈的时间点对应的debug日志尾部,看最后一条工具调用记录是什么,把它和栈的等待方向对上。
第五步,给出处理方向。事件风暴型的卡死,修法是把Claude Code的工作目录和监听器的watch目录错开,或者直接把监听器暂停;网络等待型的卡死,修法是给MCP工具配置超时;子进程等待型的卡死,修法是打开子进程的stdout,看它究竟卡在哪个交互问题上。
3.4 把pstack-claude沉淀成一套脚本工具箱
实践过几次之后,我把这套流程整理成了不到400行的shell和Python脚本工具箱,主要的组件是四个:
pc-locate.sh:定位Claude Code的进程树,输出主进程PID、子进程PID列表、CPU占用、启动时间。pc-dump.sh:抓栈、抓strace统计、抓debug日志尾部,统一存进带时间戳的目录。pc-analyze.py:分析堆栈,去重统计Top帧,输出最可能的问题指纹。pc-report.sh:把一次诊断周期内的时间戳、进程列表、栈指纹、日志摘要汇总成一份报告。
pc-analyze.py的核心逻辑很简单,从栈文件里提取函数名,按出现次数排序:
import re, sys, collections frames = collections.Counter() for line in open(sys.argv[1], encoding="utf-8", errors="ignore"): line = line.strip() m = re.match(r"#\d+\s+0x[0-9a-fA-F]+\s+in\s+(.+)", line) if m: frames[m.group(1).split(" (")[0]] += 1 else: m2 = re.match(r"in (\S+)", line) if m2: frames[m2.group(1)] += 1 for name, cnt in frames.most_common(15): print(f"{cnt:5d} {name}")我说这个大招也不难,难的是分析思路。脚本只是把重复动作自动化,真正破案的是你脑子里那五步推演。
4. 常见问题与排查技巧实录
4.1 报错速查表,踩过的坑都在这
下面这些是我在折腾过程中真实遇到过的问题,整理成速查表方便你对照。
| 现象 | 含义 | 处理方向 |
|---|---|---|
| 找不到匹配的claude进程 | grep过滤条件不对,或进程名是node | 用ps -ef看完整命令行,Claude Code相关进程的CMD列会含claude相关的路径片段 |
| pstack: No such file or directory | 系统没装pstack | 退到gdb batch模式,或者装elfutils的eu-stack |
| Failed to attach: Operation not permitted | ptrace被限制 | 容器内需要CAP_SYS_PTRACE权限,或临时调整ptrace_scope |
| 栈里都是内核地址,函数名全是问号 | 缺少符号文件或二进制被strip | 确认node和Claude Code的安装来源,尽量用官方编译版 |
| gdb -p执行后像卡住了一样 | gdb进入交互态在读符号 | 加-batch -ex "set pagination off"非交互参数 |
| 抓MCP server栈时输出乱码或线程名不全 | 子进程是npx拉起的临时node,启动时间太短 | 用pgrep -P实时盯,或者给MCP server起固定名字的包装进程 |
| 自动更新报错:no write permission to npm prefix | npm全局安装目录对当前用户没有写权限 | 修改prefix或者改用用户级node管理工具 |
| Windows上安装报需要虚拟机平台 | 系统可选组件VirtualMachinePlatform未开启 | 在Windows功能里开启并重启,或用dism启用对应feature |
关于npm权限那个问题多说两句。这个报错的完整文本是auto-update failed: no write permission to npm prefix,本质是Claude Code想在npm的全局安装目录写入新版本文件,但那个目录归root所有。检查方法:跑npm config get prefix,如果看到/usr/lib/node_modules或/usr/local/lib/node_modules,那基本都是root目录。两个常见修法:第一,如果你用nvm管理node,直接切到一个用户级版本重装Claude Code;第二,把npm的prefix指到自己的用户目录,然后重装:
npm config set prefix "$HOME/.npm-global" export PATH="$HOME/.npm-global/bin:$PATH" npm install -g @anthropic-ai/claude-codeWindows那个“requires the virtual machine platform”报错,本质是系统的虚拟机平台可选组件没开,不是Claude Code本身的问题。进“启用或关闭Windows功能”,勾选“虚拟机平台”和“Windows Hypervisor Platform”,按提示重启。命令行方式也可以:
dism.exe /online /enable-feature /featurename:VirtualMachinePlatform /all /norestart执行完重启即可。这个组件是Windows自带功能,属于操作系统配置范畴,和各平台系统配置差异无关。
4.2 容器与CI环境里的特殊注意点
在容器里跑pstack-claude,有几个坑和前几个问题不太一样。首先是权限,很多精简容器镜像没有CAP_SYS_PTRACE,导致ptrace attach直接被拒。docker启动增加--cap-add=SYS_PTRACE,或者用--privileged直接从底层绕开限制,但后者对安全和运维的冲击很大,说法是如果环境允许,还是加单独的能力最稳妥。
其次是PID namespace。容器内看到的PID和宿主机PID不是一回事,抓到宿主机的PID再在容器内attach会失败。解决思路也很直白:保证pstack和Claude Code跑在同一个PID namespace里,也就是说以Claude Code进程的视角看到哪个PID就是哪个PID。别混用两套namespace的PID。
在CI环境里,不要把抓堆栈的过程做成无限循环轰炸。ptrace attach会把目标进程短暂停顿几十微秒到毫秒级别,对大多数CLI任务来说影响不大,但在生产服务上高频操作会引入抖动。我自己的经验是:一个诊断周期最多抓5到10张栈,足够了。抓太多反而增加定位噪音,因为栈帧快照之间的差异要人工对比,几十份文件反而不知道怎么下手。
4.3 这套方法本身的适用边界
必须承认pstack-claude不是万能的。如果Claude Code卡住的原因是它在云端模型接口那边等响应,进程侧看到的栈往往就是epoll_wait等网络,这时候栈只能告诉你它在等网络,不能告诉你模型服务那边出了什么问题。判断方向是对的:栈确实证明了网络等待,但真正的根因在进程之外。
还有一种情况是进程不卡,但回答质量低效,堆栈看起来一切正常,那这不是pstack-claude能解决的问题范围。抓栈的用途是定位“不干活”,不是审计“干得烂”。
5. 这套方法还能怎么扩展
5.1 配合hooks自动触发假死诊断
Claude Code支持hooks机制,能在工具调用前后执行配置的脚本。顺着这个思路,可以把pstack-claude从手动诊断变成自动预警。一个可行的方案:用前置tool hook检查上次工具调用的时间戳,如果当前时间距离上一次有效输出超过某个阈值,同时本进程的CPU占用率降到很低,就自动执行一次pc-dump.sh抓现场。这样即使你不在电脑前,Claude Code假死时也能留下第一手的堆栈证据。
hook脚本里不一定要真的去做长时间判断,我的建议是让脚本足够轻,只在疑似卡死的条件出现时才启动dump。条件怎么定义?两个信号组合判断:进程的输出文件或者stdout缓冲超过设定时间没有任何新增内容,且EINT的CPU使用率低于3%。注意,CLAUDE的debug日志也是判断依据,日志文件大小长时间不增长,同样说明任务停摆了。
5.2 同样的思路适用于其他Node CLI Agent
Claude Code不是唯一的Node写的CLI Agent。现在很多类似的编码助手、自动化Agent都是这个架构:一个Node主进程、一堆child_process、一些stdio通信的MCP服务。这些程序的卡死套路和Claude Code如出一辙。pstack-claude整套方法论迁移过去不需要大改,只需替换进程定位时过滤的关键词。
我后来用它诊断过另一个Node Agent,那次是卡在自定义工具的等待上。栈指纹特别典型,read等待+child_process帧,往下一看果然是它spawn的Python脚本在等stdin输入。和Claude Code的场景没有本质区别。
5.3 我踩了几次坑之后的一点体会
现在我用Claude Code跑长任务时,会习惯性地把debug日志开关打开,并且养成了第一时间抓三张栈再下结论的习惯。以前遇到假死,第一反应是怀疑网络、怀疑模型、怀疑Token额度,结果经常是瞎折腾半小时找不到问题。现在改成pstack-claude流程之后,先让事实说话,三张栈和一段strace就能把问题分到对应类别里。
最后分享一个衍生小技巧。我把每一次诊断的栈文件按任务编号命名,比如task-rebuild-2025-06-11-15-30-42-dump,后面回溯历史问题时翻起来特别方便。有时候同一个卡死模式会复发,旧栈和新栈一对,马上知道上次是怎么解决的,省掉了重新分析的时间。这套习惯比抓栈本身更值钱,强烈推荐你也试试。