1. 先说为什么pstack 是我排障箱子里被严重低估的一把刀做后端开发的人多半都有过这种时刻服务进程活着端口开着但请求像堵在隧道里一样日志停在最后一行CPU 和内存看着都正常。这种假死状态最磨人而我最常用的第一把刀就是 pstack——一个把指定进程所有线程的调用栈原样打出来的命令行工具。它看起来不起眼用法一句话就能说完给定一个进程号它把当前每个线程到底停在哪一行代码、等哪个系统调用、卡在哪把锁上全部展现出来。pstack 最大的价值在于它在绝大多数情况下不会破坏现场。附加、抓栈、剥离整个过程进程只是短暂暂停不会丢连接、不会重置资源、不需要重启。对于挂着大量长连接、或者带着昂贵内存缓存的线上服务这一点几乎是压倒性的优势。我自己的经验是很多疑难故障的突破口就是第一次 pstack 输出的那几行栈帧。这篇文章会从原理讲到实战把我在生产环境里用 pstack 的经验完整整理一遍。适合正在跟线上疑难杂症较劲的后端开发者、运维同学也适合第一次听说这个名字、想搞明白它到底能干什么的新人。因为是上篇我会把原理、权限、基础用法和三个最常见场景讲透采样自动化、报告生成、跨语言进程这些更进一步的内容放到下篇再展开。1.1 一次活着但不干活的故障改变了我的排障习惯有一年晚上我收到告警某个接口的耗时从几十毫秒直接飙到几十秒。登上去看进程在、端口在、CPU 和内存都没什么异常日志只停在一行请求进来之后的某个节点之后再无下文。我当时的工具箱里只有 ps、top、lsof 这些基础工具能确认进程没死却完全不知道它内部停在哪。旁边一位同事提醒我试着用 pstack一条命令下去几十个线程的调用栈全部铺在屏幕上。我一眼就看到了关键线索几乎所有工作线程都阻塞在同一个互斥锁上而持有锁的那个线程自己卡在一个网络读取函数里。问题瞬间定位——下游服务不返回超时时间又设得太长结果锁被一个线程攥在手里不松开其他请求全部排队等待。那次之后我彻底改掉了一卡就重启的习惯。重启确实能恢复服务但锁竞争的现场、调用栈的顺序、线程间的等待关系全都丢了复盘时只能靠猜。pstack 这种先留证、再操作的思路对我后来的排障方式影响很大。1.2 这份指南适合什么人、上篇会讲什么如果你也是这样的人——遇到过进程活着但不干活的疑难杂症需要在最短时间内不重启地拿到进程内部状态或者手上管着多个微服务、经常要判断根因——那么这份指南就是为你准备的。上篇解决三件事理解 pstack 的工作原理在自己的环境里把它跑起来并读懂输出通过假死、死锁、热点定位三个实战场景把方法变成可以直接抄作业的套路。文中涉及的命令和示例输出我都尽量保留了实际排障时的原始形态方便你对照。2. pstack 的工作原理一次 ptrace 附加全线程快照2.1 从附加到剥离pstack 在一瞬间做了四件事很多人以为 pstack 是个很轻的命令其实它在极短时间内完成的事情相当完整可以拆成四步。第一步是附加。pstack 通过 ptrace 系统调用对被调试进程执行 PTRACE_ATTACH让目标进程进入停止状态。这一步本质上和 gdb 附加进程用的是同一个机制所以 pstack 对进程的影响方式和调试器一样——附加期间进程是静止的。第二步是枚举线程。pstack 读取 /proc/ /task/ 目录找出该进程下的所有线程。Linux 把线程建模为轻量级进程每个线程在 task 目录里对应一个子目录枚举因此变得很直接。这一步决定了输出里会有多少个 Thread 块。第三步是逐一抓栈。对每个线程pstack 通过 ptrace 读取寄存器状态主要是栈指针和指令指针然后从当前栈帧开始逐层向上展开。展开过程依赖二进制里的调试信息和栈展开规则这也是整个流程里技术含量最高的部分符号表是否完整、编译时是否带调试信息都直接影响抓出来的栈好不好读。第四步是剥离。所有线程的栈都抓完之后pstack 调用 PTRACE_DETACH 让进程恢复运行。绝大多数情况下这个过程是毫秒级的。但注意线程特别多、栈特别深、或者进程本身被同步问题缠住时抓栈耗时可能拉到好几秒。这段时间服务是暂停的后面我会专门讲这个坑。因为附加到剥离之间进程完全停止pstack 拿到的是一张一致的全线程快照不会出现前面的帧是 10 秒前的、后面的帧是现在的这种错乱。这个特性在对比多次采样时尤其重要。2.2 为什么有些发行版的 pstack 本质上是个 gdb 脚本不少人不清楚的是pstack 其实有两个物种。一种是用 C 写的独立小工具自己实现附加、读取、展开、符号解析的全流程另一种在很多发行版里其实就是一段封装好的 shell 脚本背后调用的还是 gdb脚本内部大致等价于执行这样一条命令gdb -p PID -batch -ex thread apply all bt这个事实意味着不同机器上 pstack 的输出格式会有差异。脚本型输出通常带着 gdb 的版本信息和交互痕迹独立型输出则更干净、更像原生命令。两种都能用但当你打算写脚本去解析输出、做自动化统计时一定要先确认当前机器上的实现是哪一种别拿同一套正则去适配所有环境。我在 4.3 节里讲的多轮采样统计就是必须先过这一关。顺带一提有些系统里还有 gstack 这类命令功能与 pstack 基本重叠只是名字和输出风格略有差异。看到它们时不用困惑思路完全相同。2.3 权限模型先过内核 ptrace 限制这一关用 pstack 时遇到的第一个拦路虎往往不是命令不存在而是权限不足。这背后是内核的 ptrace 限制机制在起作用默认值一般是 1含义是只有目标进程的父进程可以附加或者目标是由当前进程 fork 出来的子进程。换句话说你想调试一个由服务管理器拉起来的常驻服务普通用户身份直接 pstack大概率会得到 Operation not permitted。解决办法有三种。第一种是用 sudo 提权这也是最常用的在系统默认配置下具备特权的进程通常能突破这个限制。第二种是临时调低限制值例如用 sysctl 命令把 kernel.yama.ptrace_scope 改成 0但要意识到这是系统范围的放宽生产环境需要谨慎评估。第三种是调整服务的启动方式让服务成为你的子进程后再调试——这在本地开发环境里很实用调试完再把服务交还给服务管理器管。容器环境还有额外一层很多容器默认没有赋予 SYS_PTRACE 能力即便在容器内是 root附加也可能失败。要么在创建容器时显式放开这个能力要么从宿主机以 host 视角附加到对应进程。碰到输出只有一半线程或者明明 root 却附加失败这类情况先怀疑命名空间和能力配置别急着跟进程本身较劲。3. 装好、跑通、读懂输出3.1 安装与第一条命令安装没有太多悬念不同发行版差别主要在包管理方式上有的发行版可以直接安装 pstack 包有的则是把命令放在 gdb 包里装完 gdb 顺手就有了。如果某个环境的仓库里压根没有 pstack不必纠结直接跳到 3.3 节用 gdb 等价命令顶上效果一致甚至更强。装好之后最基础的用法只有一个参数——进程号pstack PID想对一个名字已知的服务下手可以先用 pgrep 找到进程号pstack $(pgrep -f my_service | head -1)输出会像下面这样每个线程一段Thread 8 (Thread 0x7f2e1a5f9700 (LWP 31241)): #0 0x00007f2e1c0d3d6c in __lll_lock_wait () from /lib/x86_64-linux-gnu/libc.so.6 #1 0x00007f2e1c0d71e7 in _L_lock_77 () from /lib/x86_64-linux-gnu/libc.so.6 #2 0x00007f2e1c0d6b5e in __GI___pthread_mutex_lock (mutex0x55a8c0b20d40) at pthread_mutex_lock.c:114 #3 0x000055a8c0a14b93 in WorkerThread::ProcessRequest () at worker.cpp:118 #4 0x000055a8c0a14273 in WorkerThread::Run () at worker.cpp:76 #5 0x00007f2e1c0cee23 in start_thread (arg0x7f2e1a5f9700) at pthread_create.c:477 #6 0x00007f2e1c2e0b5e in clone () at ../sysdeps/unix/sysv/linux/x86_64/clone.S:95如果你第一次跑就看到 Operation not permitted先别怪工具按 2.3 节的思路把用户身份和系统限制检查一遍。3.2 输出怎么读三处关键信息第一次看 pstack 输出的人容易盯着函数名发呆其实关键信息就三处。第一线程编号和 LWP。Thread 8 是进程内部的逻辑线程编号LWP 31241 是内核视角的轻量级进程号也就是线程在操作系统里的真实身份。排障时想跟日志里的线程号对上看 LWP 最可靠。第二栈顶和栈底。最上面几帧代表线程此刻正停在哪最下面几帧通常是一成不变的线程入口和系统调用参考价值不大。中间那几帧才是重点——例如上例里的 pthread_mutex_lock 帧直接告诉你线程正在等一把互斥锁锁的地址就写在参数里。第三源码文件和行号。带调试符号时每帧末尾会给出对应的源文件与行号比如 worker.cpp:118。这是定位代码最直接的路标比翻日志猜半天高效得多。另外输出里频繁出现的 futex、lll_lock_wait、cond_wait 这类名字是 glibc 底层同步原语的内部实现翻译成人话就是这个线程正在排队等锁或者正在等条件变量通知。看到它们不必慌它们是线索不是异常。3.3 没有 pstack 也能干活gdb 等价命令在精简环境里装不上 pstack 时直接手敲 gdb 一样能达到目的。我最常用的命令是这一条gdb -p PID -batch \ -ex set pagination off \ -ex thread apply all btset pagination off 禁用了 gdb 的分页提示避免输出卡在交互式翻页上thread apply all bt 的作用是对所有线程执行 backtrace。想连局部变量和参数一起看就把 bt 换成 bt fullgdb -p PID -batch \ -ex set pagination off \ -ex thread apply all bt full这条增强命令的信息量比 pstack 默认输出大得多代价是输出体积成倍膨胀。线上使用时建议直接重定向到文件再慢慢看。还要提醒一点pstack 只处理活进程。如果手头只有转储文件就换用 gdb 直接加载程序和 core 文件同样能拿到所有线程的完整调用栈分析思路和 pstack 完全一致。4. 三个高频实战场景假死、死锁、热点定位4.1 进程假死先看每个线程堵在哪回到文章开头那种进程活着但请求不返回的场景。拿到 pstack 输出后我读取信息分三步。第一步全局扫一眼所有线程的栈顶。如果绝大多数工作线程都停在 pthread_mutex_lock、futex_wait 这类同步等待帧上基本可以断定有人在持锁而且持锁时间异常长。第二步找出那个持锁线程。怎么找看栈顶不在等待帧里的线程尤其是停在 read、recv、poll、sleep 或者某个业务函数里的线程——它很可能就是抓着锁不放的那个。第三步把持锁线程的完整调用链读一遍通常立刻能知道它在等什么等下游返回、等磁盘 IO、等另一把锁。我印象很深的一次故障就是靠这个三步定位的。持锁线程停在一个远程调用等待上下游超时设了 30 秒锁被这一线程独占后面所有请求线程排成一条长队。当时如果直接重启服务虽然能暂时恢复但锁竞争的现场、栈的顺序、等待关系全没了。截图保存 pstack 输出之后写故障报告也有了第一手材料。4.2 死锁从重复等待帧里找环死锁和假死最大的区别在于假死往往是一个线程卡住、其他线程排队死锁则是两个或多个线程互相等对方形成一个环。pstack 输出的特征也很明显多个线程的栈顶全部落在锁等待帧上并且等待的锁地址彼此交叉。我排查过一个典型的 ABBA 死锁线程 A 拿着锁 L1等在锁 L2 上线程 B 拿着锁 L2等在锁 L1 上。pstack 输出里A 的栈顶是 pthread_mutex_lock(mutex0x...L2 的地址)B 的栈顶是 pthread_mutex_lock(mutex0x...L1 的地址)。两个地址互相指着对方环就浮出水面了。确定环之后下一步是翻代码看这两条线程分别以什么顺序加锁。绝大多数死锁的根因都是加锁顺序不一致同一组锁有的路径先加 L1 再加 L2有的路径先加 L2 再加 L1。修复方向也很明确统一加锁顺序或者引入带超时的锁、锁层级这些更复杂的机制。pstack 看到的是症状但这个症状已经帮你把病灶圈得很小了。4.3 轻量采样用多次 pstack 拼一张粗糙热力图现实里更多的故障不是彻底卡死而是慢。慢在哪如果怀疑某个函数消耗 CPUpstack 也能派上用场。它不像性能剖析工具那样能精确采样但胜在简单直接短时间连续抓多次统计某个函数作为栈顶出现的频次频次最高的函数基本就是热点。具体做法是写一个循环每隔一秒抓一次抓个十次二十次for i in $(seq 1 10); do pstack $(pgrep -f my_service) /tmp/stack.txt sleep 1 done然后做一次最朴素的统计把所有栈帧里的函数名列出来按出现次数排序重点看那些反复出现的面孔。我习惯把出现次数最多的前几个函数连同它们的完整调用链保留下来因为这代表采样期间 CPU 主要花在了哪条链路上。这个方法精度不高但在没有权限跑性能工具、或者只想快速确认是不是这个函数在烧 CPU时非常实用。看到同一个函数在多次采样里反复出现就值得把它从疑似名单里提出来深入排查了。5. 我在生产环境里踩过的坑5.1 符号表缺失输出变成地址流水账最扫兴的情况是pstack 跑通了但输出里全是裸地址一个函数名都看不到。原因几乎都是二进制被剥离了符号或者动态库没带调试符号。对发布构建来说这是常态毕竟精简体积能省不少成本。遇到这种情况我分两条腿走。短期上看环境里有没有对应的调试符号包可装装上再抓长期上把保留一份与发布版本对应的未剥离文件写进构建流程存到安全位置备用。如果连调试符号包都装不上就只能用工具从地址反推函数但前提是先搞清楚进程里每个模块的加载基址那就得去翻 /proc/ /maps 了操作成本高不少。所以我的切身体会是别等故障发生了才想起符号表发布时顺手留一份调试信息成本极低价值极高。5.2 附加即停顿高峰期要克制前面说了pstack 附加进程后进程是停住的。一次抓栈通常很快但对于线程数以千计的进程把所有线程的栈完整展开可能要好几秒等于服务被硬停了几秒。在请求量很大的高峰期这几秒足以引发连锁反应监控报警、上游超时重试、下游被突增请求打穿。我的建议是核心服务尽量在低峰期或者业务可接受的窗口内执行 pstack如果必须现场处置先评估线程数量再看能不能用 gdb 只抓特定线程的一部分栈而不是无差别全量抓。另一个变通办法是抓转储文件获取完整快照后再离线分析。转储同样会造成停顿但停顿是一次性的且取证更完整风险更好控制。5.3 D 状态、内核态与容器边界pstack 不是万能的。如果进程处于 D 状态不可中断睡眠常见于磁盘 IO 或内核锁等待ptrace 附加经常会一直挂起就算附加成功拿到的用户态栈也常常没有什么有效信息。遇到 D 状态进程我习惯先看 /proc/ /wchan 文件它告诉你进程在内核态等着什么函数再看 /proc/ /stack 获取内核栈。这两步通常比硬用 pstack 靠谱得多。另一个容易被忽略的边界是容器。容器里的进程只能看到自己命名空间内的 /proc如果某些目录不可见pstack 就可能枚举不出全部线程。处理方式有两种进到容器内部执行 pstack或者从宿主机以 host 视角附加到对应进程。具体选哪种取决于你的容器运行时和编排方式。总之碰到输出只有一半线程的情况先怀疑是命名空间隔离而不是进程本身出了问题。5.4 语言栈不匹配Java、Go、Python 进程别硬用最后一个坑说到底是工具选型问题。pstack 看的是操作系统线程的调用栈它对 C/C 这类编译型服务很有效但面对带运行时的高级语言进程帮助就有限了。Java 进程在操作系统层面真正在跑用户代码的线程并不多JIT 编译出来的方法和 Java 方法栈不会直接以友好形式出现在 pstack 里这种场景应该用 JVM 自带的线程转储工具而不是硬看 pstack。Go 进程同理goroutine 和系统线程是多对多的关系pstack 只能看到系统线程看不到 goroutine运行时自带的挂起转储机制反而更直接。Python 进程可以依靠标准库提供的故障处理钩子或调试器扩展来输出调用栈也比 pstack 对症。我的原则很简单动手之前先确认目标进程的运行时是什么再决定用什么工具。pstack 是一把通用好刀但好刀也要用在合适的战场上。6. 三条使用习惯收尾顺带预告下篇6.1 三条到手即用的使用习惯最后分享几个我自己养成的使用习惯都是花了代价换来的。第一抓栈输出第一时间落盘。pstack 直接打屏很爽但几十个线程的输出很容易把终端缓冲区冲掉前半段就丢了。我都是重定向到文件再分析顺便留一份作为故障现场的原始凭证后续写复盘报告也用得上。文件名里最好带上时间戳比如 stack_20251017_t1.txt回头和日志对齐时非常省事。第二抓栈要和日志时间对齐。pstack 只告诉你这一刻线程在哪。想知道它是持续卡住还是瞬时抖动最好间隔几秒抓两次或三次。如果每次结果几乎一模一样说明线程真的卡死了如果栈顶一直在变那可能只是一次偶发抖动。如果多次采样之间能看到线程状态迁移比如第一次在等锁、第二次进入了业务函数那说明它并不是死锁只是处理速度慢。这个区别在故障定性上非常关键。第三单一工具下结论前先交叉验证。pstack 指出了锁等待我还会用日志、监控指标和最近的变更记录交叉确认。排障结论宁可慢一点也不要被一次采样带偏方向。毕竟 pstack 给出的是快照解释快照还需要对业务逻辑的理解。这套交叉验证的习惯帮我挡掉了不少伪结论也让我对工具的边界认识更清楚。6.2 下篇预告采样自动化与工具链组合这篇文章写完pstack 的地基算是打好了。下篇我计划聊更工程化的内容多轮采样之后怎么自动化生成热点报告、pstack 输出跟监控告警怎么联动以及符号表和加载基址换算的具体操作。比如你可以把 pstack 的输出喂给一个简单的统计脚本算出每个函数出现的频次再推给值班群这些命令和模板下篇会给出可直接复制的版本。面对转储文件、跨语言进程这些场景时怎么组合工具链也会一并整理。如果你在实操里遇到过什么特别诡异的栈欢迎带着现场输出来交流我自己也是被各种奇怪现场教育过来的排障这件事信息交换永远比一个人硬扛高效。