1. 一次真实的卡死现场:Claude Code 不动了,我却靠 pstack 打开了新思路
先说结论:如果你也在 Linux 或 WSL2 里面跑 Claude Code,遇到过“界面没崩、终端里最后一行还在闪烁、但怎么按回车都没反应”的情况,那 pstack 这套工具值得你花十分钟了解。它是 Linux 上最老的进程堆栈查看工具之一,作用就一句话:把一个进程当前所有线程的执行现场直接打印出来。配合 Claude Code 这种以 Node.js 为底座、日常又经常挂着一堆 MCP 子进程的 AI 编码工具,它尤其能救急。
1.1 那个下午的现场:没有报错、没有崩溃,就是卡住一整晚
我自己遇到过一次很典型的故障。下午三点多,Claude Code 正在改一个多文件的 TypeScript 项目,我让它重构某个模块,命令发出去之后,终端里出现了一条黄色的编辑中提示,然后就再也没有下文了。我试过按 Esc、按 Ctrl+C、切到别的终端,全部无效。ps 杀掉进程、重新进对话目录再恢复会话,倒是能回到旧状态,但刚才这次任务已经没救了。
最让人崩溃的是,系统日志里什么都没有,Claude Code 自己的日志目录里也没有任何 error,只有一条正常的请求记录和一个正常结束的 token。进程没崩溃,说明它还活着,但它卡在某个地方不做任何事。这个时候如果直接 kill 掉,等于放弃了一次现场调查的机会。
后来我养成了一个习惯:任何“没崩但不动”的进程,先别急着杀掉,先拿 pstack 照一眼。
所谓 pstack,就是打印线程栈的工具。Linux 下很多发行版自带,也可以单独装。你可以把它理解成给进程拍一张“内部合照”:每个线程此刻正在执行什么函数、卡在哪个系统调用上,全部一目了然。它不会暂停进程,也不影响正在运行的任务,非常适合用来收集卡死现场。
1.2 为什么是老牌工具 pstack,而不是直接重启
遇到卡死,大部分人的第一反应是“重启”。但重启解决的只是表面症状,它没法回答“为什么会卡”。如果你用的是 Claude Code 这种真金白银按 token 计费的工具,一次卡死就意味着那段任务的输出可能已经永久丢失,下次同样的问题还会再犯。
pstack 的定位非常准:它只解决“进程到底在等什么”这一个问题。比如你的进程卡住了,你能不能回答出来它是在等网络包、等子进程、等磁盘 IO、等锁,还是在无限重试?知道了这一个问题的答案,后面 80% 的修复方案都能顺着往下推。
它对 Claude Code 尤其友好,因为 Claude Code 本质上是一个跑在 Node.js 上的进程,而 Node 进程的绝大部分时间都在事件循环里等回调。它不像那些纯计算型的程序一样能把 CPU 跑满,更多时候就是“正在 sleep / 正在 poll / 正在 read”。pstack 打印出来的栈,能直接定位到它到底在哪一个系统调用上睡过去了。
1.3 为什么这套东西值得单独起一个项目名叫 pstack-claude
“pstack-claude”这个项目名,我当时起得很随意,就是把我日常排查 Claude Code 卡死问题的脚本、常看的栈特征、对比记录都收到一个目录里。后来用着用着发现,它已经不只是几条 pstack 命令,而是一套可以复用的排查流程:
- 第一步,抓堆栈快照;
- 第二步,对照符号特征判断等待类型;
- 第三步,结合 Node 日志、网络连接、子进程状态交叉验证;
- 第四步,把结论沉淀成一个特征库,下次再遇到类似卡死,一眼就能识别。
这篇文章就把这套流程完整拆开,从 pstack 的原理到我在 Claude Code 场景里踩过的坑、总结的特征,全部讲清楚。内容偏实操,你跟着步骤做一遍,至少能学会“拿到一份 pstack 输出后到底该怎么看”。
2. 先搞清楚 pstack 到底看到了什么:原生栈和 JS 栈之间的距离
很多人在用 pstack 的第一分钟就会受挫:拿到输出的确是一大串函数名,但全都是v8::internal::...、uv__...、read()、poll()这种底层符号,完全找不到任何和 TypeScript、Prompt、模型调用相关的字眼。这不是工具坏了,而是你还没理解 Node 进程的机制。
2.1 底层原理很简单:它拿到的是内核视角的执行现场
pstack 的工作方式本质上依赖 Linux 的进程调试接口,它会短暂地 attach 到目标进程,让每个线程停下来记录当前正在执行的指令位置,然后沿着栈帧往回回溯,打印出一串调用链。这个调用链不会包含你的 JS 业务逻辑,它只反映“此刻线程真正在跑的机器指令属于哪个函数”。
这有点像你去医院给设备拍片子。片子能显示的是骨头的轮廓和密度,不会直接告诉你“这个人的工作压力来自老板”,但骨头上的疲劳性裂纹能反过来证明他最近确实很累。pstack 就是那台拍片机,它不解释原因,只提供证据。
所以 pstack 输出的第一行通常不是main,而是某个系统调用,比如read、epoll_wait、futex_wait。这些符号才是真正能说明问题的部分。
2.2 V8 的双层巴士结构:你在栈上看到的其实只是巴士底盘
Claude Code 跑在 Node.js 上,而 Node.js 内部由两部分组成:一部分是 V8 引擎,负责解释和编译 JavaScript;另一部分是 libuv,负责提供事件循环、线程池和文件 IO。你的业务代码会先被 V8 编译成机器码,然后由 libuv 摆在合适的线程上运行。
当进程阻塞时,V8 本身并不会一直占着 CPU,它会把执行权交回给 libuv,然后 libuv 再把控制权交给内核。于是 pstack 打印出来的栈大概分三种:
- 系统调用栈:比如
read()、write()、epoll_wait(),说明进程正在等 IO; - libuv 相关栈:比如
uv__io_poll()、uv__work_submit(),说明事件循环在等任务; - V8 内部栈:比如
v8::internal::...,说明有一批 JavaScript 代码正在被解释或执行,但大多数卡死场景里这种栈反而是少数。
记住这个分层,你就能明白为什么 pstack 里看不到generateDoc()这样的名字。如果你的确需要看 JS 层调用栈,pstack 做不到,应该用node --inspect配合调试器,或者用 llnode 加载 Node 的调试符号来解析,这部分我在第 3 章的进阶玩法里会讲。
2.3 什么时候不该用 pstack,换别的工具更合适
再好的工具也有边界。以下情况我不建议用 pstack:
- 你是 macOS 用户,那 pstack 在原生 macOS 上并不存在,需要装 gdb 或用
sample命令; - 你想看的是 JavaScript 层的函数调用关系,而不是原生线程栈;
- 你怀疑的是内存泄漏而不是阻塞,那应该优先看堆快照;
- 你对目标进程没有 ptrace 权限,跑 pstack 会直接报错。
这里我给你一张我自己常用的工具选择表,免得每次排查都从零开始:
| 工具 | 适用场景 | 我主要用它看什么 |
|---|---|---|
| pstack / gstack | 进程卡死、无响应,快速看一眼 | 各线程卡在哪个系统调用/底层函数 |
| gdb | 需要深入调查变量、内存、寄存器 | 附加到进程后交互式调试 |
| llnode | Node 进程的深度排障 | JS 调用栈、堆对象、可疑引用 |
| node --inspect | JS 层逻辑可疑但不卡死 | CDP 协议抓 JS 函数调用栈 |
| perf | 性能瓶颈、CPU 时间分布 | 采样热点函数、火焰图数据 |
pstack-claude 这个项目在最早期其实只维护了一张表和一串命令。后来我发现,大多数 Claude Code 卡死都属于“系统调用层面的等待”,所以 pstack 的出镜率最高,其他工具只是偶尔补位。
3. 核心实操:怎么装、怎么抓、怎么把一份栈读懂
到了动手环节。你不需要对 Linux 内核有多深的理解,按下面的步骤来,基本能完成一次完整的现场取证。
3.1 安装 pstack:不同发行版还是有区别的
Debian / Ubuntu 系最省事:
sudo apt-get update sudo apt-get install -y pstack如果你所在的环境里没有 pstack 这个包,装 gdb 也能得到等价工具 gstack,它是一个由 gdb 实现的脚本,用法几乎一模一样:
sudo apt-get install -y gdb gstack <pid>RHEL / CentOS 系一般通过 gdb 提供:
sudo yum install -y gdbFedora 用 dnf 同理。装完后先验一下工具能不能用,挑一个你知道肯定活着的进程测试:
sudo pstack 1如果能打出一串栈帧,说明工具本身没问题。如果提示没有权限,再确认你是不是真的加了 sudo,以及目标进程是不是你的用户启动的。
3.2 抓取堆栈的三种姿势:快照、连续采样、多进程比对
第一步是找到 Claude Code 的主进程号。注意别用pgrep claude,因为命令行的进程名可能是 node,真正的入口脚本可能是 cli.js。我建议用下面这条命令来找:
ps -ef | grep -i claude | grep -v grep这时候会看到若干进程,Claude Code 的主进程一般是一个 node 进程,命令行里带着类似cli.js的路径,或者带着当前工作目录的参数。把它的 PID 摘出来,接着做快照:
sudo pstack <pid> > /tmp/claude_stack_1.txt一份快照只有一瞬间的证据,很多时候不够。因为阻塞可能不是永久的,而是每隔几十秒触发一次。这时候我建议连续采样:
for i in 1 2 3 4 5; do sudo pstack <pid> > /tmp/claude_stack_$i.txt sleep 2 done连续抓五份之后,对比一下第一份和最后一份。如果栈完全一致,说明进程真的死等在同一位置;如果栈在变化,说明它其实还是有动作的,只是某个回调的重试逻辑走入了死循环或异常分支。
还有一种情况值得注意:Claude Code 会拉起来 MCP 子进程,比如用 npx 启动某个 MCP server。这时候光看主进程不够,你也得查看子进程的栈。子进程和父进程的 PID 关系可以用以下命令梳理:
ps -ef --forest | grep -A 20 -B 2 cli.js看到有父子关系的 node 进程后,挨个都抓一份 pstack,再对照着看父进程是等子进程输出,还是子进程自己在等什么资源。
3.3 读懂输出:我整理的一张关键符号对照表
这是整个 pstack-claude 项目里最值钱的部分。刚接触 pstack 的时候我面对几十行符号完全懵,后来把常见现象整理成表,再碰见类似栈,基本扫一眼就能判断方向:
| 栈中反复出现的符号或系统调用 | 含义 | 下一步诊断动作 |
|---|---|---|
read/recvfrom/tcp_recvmsg | 正在等待网络数据 | 用 tcpdump 或 ss 看连接状态 |
epoll_wait/uv__io_poll | 事件循环空闲 | 说明当前没有待处理回调,多对比两次采样 |
futex_wait/sys_futex | 在等待锁,可能死锁 | 看是否有多个线程卡在同一个锁地址 |
clock_nanosleep/__sleep/sleep | 在主动退避重试 | 多半是自动升级或请求重试逻辑 |
read(pipe)/uv__read | 在等子进程输出 | 优先检查 MCP 子进程是否存活 |
tty_write/ioctl/write | 往终端写数据被阻塞 | 检查终端缓冲,换非终端运行方式试验 |
SSL_*/OPENSSL相关符号 | 在 TLS 握手或加解密阶段 | 抓包看是否卡在 TLS 协商 |
拿到这份表之后,你就把 pstack 从“一串看不懂的名字”变成了“一套可以推理的线索”。不过我也要提醒一句:看到单个符号不要急着下结论,至少结合三份快照和进程状态再判断。
3.4 把栈翻译成人话:三个真实的判断案例
案例 A:卡在子进程等待。
栈里反复出现wait4、read(pipe)、uv__read,同时ps --forest里能看到一个名叫npx的子进程,那我基本判断是 Claude Code 拉起某个 MCP 服务器时,这个子进程长时间没有输出。对应到用户感知,就是每次启动 Claude Code 或设置工具时,终端会卡十几秒甚至更久。
案例 B:日志里出现 auto-update failed,进程并不完全卡死。
Claude Code 的自动升级机制会在启动时检查新版本。如果 npm 前缀目录没有写权限,它会报Auto-update failed: no write permission to npm prefix,然后进入重试状态。pstack 上表现为反复出现clock_nanosleep和access类系统调用,每次间隔很短。表层现象是“启动变慢”,底层原因是权限配置不正确。解决之后,整个启动过程畅通无阻。
案例 C:模型请求发出去了,但响应一直没回来。
主栈卡在recvfrom或 TLS 相关函数,同时网络连接状态显示 ESTABLISHED,但数据包不再增加。这种情况大概率是上游服务迟迟没有返回,或者本地代理层把响应挂住了。pstack 至少能帮你确认“不是自己的业务代码死循环,而是等待外部响应”。
4. 我在 Claude Code 里遇到的四种卡死姿势,以及对应的 pstack 特征
工具在手,更重要的还是经验。这一章把我在实际使用 Claude Code 过程中遇到的高频卡死场景整理成一份特征库,每个场景都给出 pstack 的表现和修复思路。
4.1 场景一:MCP 子进程拉起后停住,整个会话被拖死
MCP(Model Context Protocol)服务器是 Claude Code 和外部工具沟通的桥梁。很多人会配置一些通过 npx 启动的 MCP server,比如文件系统、数据库、浏览器自动化工具。正常情况下 Claude Code 启动时会自动拉起这些服务器,但如果某个服务器安装不完整、npx 拉包特别慢、或者服务器内部自己在等交互输入,问题就来了。
用户看到的现象是:启动 Claude Code 或调用某个工具时,会话立刻失去响应,终端停在那里一动不动。pstack 的表现非常典型:
- 父进程栈里出现
read(pipe)或者wait4; - 子进程栈里出现
epoll_wait或其他等待逻辑; ss -tlnp能看到子进程监听了一个端口但没有任何连接进来。
我的处理套路是:先不着急杀 Claude Code,单独执行这个子进程的启动命令,看它能不能独立跑起来。比如配置里写的是npx -y @some/mcp-server,那我就在终端单独跑一遍这条命令,观察它是否正常初始化。如果单独跑也要卡,那就是服务器本身的问题,和 Claude Code 无关。
4.2 场景二:启动时的自动升级和 npm 权限冲突
这个坑在真实用户里出现频率很高。Claude Code 每次启动都会检查新版本,而自动升级逻辑本质上是要写全局 npm 目录。如果你的 Node.js 是装在系统目录里的,npm 全局前缀可能是/usr/local或/usr/lib/node_modules,普通用户根本没有写入权限。于是启动流程变成:
- Claude Code 被唤起;
- 后台检查新版本;
- 发现需要升级;
- 尝试写入 npm 全局目录;
- 失败;
- 进入短等待重试;
- 再失败。
整个过程会让进程“看起来卡住”,但并没有真正死锁。pstack 的特征是:主线程栈反复出现clock_nanosleep和access、openat之类的文件权限检查调用,而且连续几次采样之间栈会变化,因为它在循环重试。
修复思路也很简单,把 npm 全局前缀改成用户目录下的可写目录:
npm config set prefix ~/.npm-global export PATH=$HOME/.npm-global/bin:$PATH改完之后重启 Claude Code,升级流程能正常走通,启动时的卡顿感也会明显消失。
4.3 场景三:集成终端里的 TTY 背压
这个坑最隐蔽,也最容易被误判为网络问题。在 VSCode 的集成终端里跑 Claude Code,Claude Code 会输出大量带颜色的日志、编译器输出、状态表格。终端本身是一个 PTY,而 PTY 的缓冲区是有上限的。如果输出洪峰一次性灌进来,终端来不及消费,进程往 stdout 写数据的系统调用就会阻塞。
这时候拿 pstack 抓进程,栈里会出现和 write、tty、ioctl 相关的符号。注意,它和网络请求完全无关,但用户感知就是“整个会话卡住了,打字也没反应”。
验证方法不复杂:把输出重定向到文件跑一次,如果换成重定向之后不再卡,那问题基本就锁定了 TTY。解决方式也简单,要么在 VSCode 里加大终端缓冲,要么不要使用集成终端,改用独立的终端窗口,或者把 Claude Code 的日志输出级别调低一点,减少非关键内容。
4.4 场景四:上游模型服务长时间无响应
还有一种最让人焦虑的情况:任务看起来还在继续,请求发出去了,但迟迟没有增量输出。这时候 pstack 里网络相关的符号会非常扎眼,常见的是recvfrom、poll、epoll_wait混合出现,同时网络连接状态是 ESTABLISHED。
我的经验是先观察两分钟,连续抓三次 pstack。如果每次栈都停在网络等待上,那就把重心转向网络层,用ss -tp确认连接目标地址和端口,再用 tcpdump 看有没有数据包流动。没有数据包流动,说明请求可能还没到上游,或者到了但上游没有回包;有数据包但服务无法处理,那就更可能是上游的模型服务超时或限流。
这里不讨论任何复杂的网络环境和绕行方案,只说一句朴素的建议:如果你的本地网络到目标服务本身不稳定,那就需要先把自己的网络环境调稳,而不是反复重试。
5. 从一次排障到日常巡检:把 pstack-claude 沉淀成一套顺手脚本
单次排障只能解决眼前的问题,真正提升效率的是把这套方法工具化。我最后把 pstack 相关的常用操作写成了一套简单脚本,既适合临时用,也适合交给团队共享。
5.1 一条巡检命令:批量抓取所有相关进程的堆栈
下面的脚本适合放在.bashrc或者独立脚本文件里:
#!/bin/bash # 自动查找所有和 claude 相关的 node 进程,依次抓取堆栈 PID_LIST=$(ps -ef | grep -E "cli\.js|claude-code|@anthropic" | grep -v grep | awk '{print $2}') if [ -z "$PID_LIST" ]; then echo "没有找到 claude 相关进程" exit 1 fi for PID in $PID_LIST; do echo "========= PID: $PID =========" sudo pstack "$PID" 2>/dev/null || echo "[ERROR] 抓取 PID $PID 的堆栈失败" done保存成脚本后,在卡死现场只需要执行一次,就会输出所有 Claude Code 相关进程的完整堆栈。如果你希望做定时采样,可以在外面套一个循环:
for round in 1 2 3; do sudo bash claude_stack_scan.sh > /tmp/stack_round_$round.txt sleep 3 done连续三轮采样结果对比,基本就能判断是“死等”还是“慢重试”。我把这套采集脚本收到 pstack-claude 项目目录里,遇到新问题就只更新符号对照表,命令本身几乎不用改。
5.2 进阶搭配:perf 和 llnode 怎么和 pstack 互补
pstack 的短板是看不到 JS 层调用关系,所以当你判断出阻塞不在底层、而是某个 JS 回调有问题时,需要进一步上更强的手段。
perf 是 Linux 下的性能分析工具,它可以通过采样构建调用图。配合 Node.js 的--perf-basic-prof-only-functions启动参数,V8 会额外生成一个 symbol map 文件,perf 就能把原生栈和 JS 函数名对应起来。启动 Claude Code 时可以这样加环境变量测试:
NODE_OPTIONS="--perf-basic-prof-only-functions" claude想抓取样例时再用 perf 执行:
sudo perf record -F 99 -p <pid> -g -- sleep 10 sudo perf report --no-children这样一个耗时任务卡住时,你能直接看到消耗时间最多的究竟是在读文件、走网络还是跑正则。
llnode 则更适合用来抓 JS 堆栈。它是针对 Node 的 lldb 插件,支持v8 bt这类命令打印 JavaScript 调用栈。安装配置步骤比 pstack 繁琐一点,但一旦遇到需要精确理解 JS 层执行顺序的场景,它比 pstack 远更准确。我的建议是:pstack 先摸方向,perf 和 llnode 再深入,三级排查思路组合起来用。
5.3 三句经验总结:踩过几次坑后才真正懂得的事
第一件,抓堆栈之前想一下“值不值得”。如果只是偶发卡顿而且重来一次代价不高,直接重启就够了,不必每次都做完整取证。但如果是高复现率的卡死,那就一定要把现场留着,反复采样,把特征库补全。
第二件,pstack 输出一定要结合时间。单次堆栈只是快照,它只能告诉你“这一刻在哪”。分析卡死问题至少要两次采样,最好间隔两三秒,观察栈是否固定。固定不变说明真死锁或者死等待,不断变化说明进程还在干活,只是某个循环没有收敛。
第三件,不要把 MCP 子进程忘掉。Claude Code 卡死十次里有三四次是底层 MCP 服务器惹的祸。抓完主进程栈,再花一分钟看一下子进程栈,很多疑难杂症立刻迎刃而解。
做这套 pstack-claude 之前,我遇到卡死多半是跟着感觉试各种姿势,浪费不少时间。现在流程固定下来,从抓栈到出结论基本在十分钟之内。想复现这套方法的人,不需要照抄我的目录结构,只要记住两个原则:先备份现场,再对症下药;先抓底层栈,再深入 JS 层。工具不在多,用得熟才是关键。