☰
pstack实战:用线程调用栈快速定位进程假死、死锁与热点
2026/10/11 19:54:36 网站建设 项目流程

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 (mutex=0x55a8c0b20d40) 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 (arg=0x7f2e1a5f9700) 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 bt"

set pagination off 禁用了 gdb 的分页提示,避免输出卡在交互式翻页上;thread apply all bt 的作用是对所有线程执行 backtrace。想连局部变量和参数一起看,就把 bt 换成 bt full:

gdb -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(mutex=0x...L2 的地址),B 的栈顶是 pthread_mutex_lock(mutex=0x...L1 的地址)。两个地址互相指着对方,环就浮出水面了。

确定环之后,下一步是翻代码,看这两条线程分别以什么顺序加锁。绝大多数死锁的根因都是加锁顺序不一致:同一组锁,有的路径先加 L1 再加 L2,有的路径先加 L2 再加 L1。修复方向也很明确:统一加锁顺序,或者引入带超时的锁、锁层级这些更复杂的机制。pstack 看到的是症状,但这个症状已经帮你把病灶圈得很小了。

4.3 轻量采样:用多次 pstack 拼一张粗糙热力图

现实里更多的故障不是彻底卡死,而是"慢"。慢在哪?如果怀疑某个函数消耗 CPU,pstack 也能派上用场。它不像性能剖析工具那样能精确采样,但胜在简单直接:短时间连续抓多次,统计某个函数作为栈顶出现的频次,频次最高的函数基本就是热点。

具体做法是写一个循环,每隔一秒抓一次,抓个十次二十次:

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 的输出喂给一个简单的统计脚本,算出每个函数出现的频次,再推给值班群;这些命令和模板,下篇会给出可直接复制的版本。面对转储文件、跨语言进程这些场景时怎么组合工具链,也会一并整理。如果你在实操里遇到过什么特别诡异的栈,欢迎带着现场输出来交流,我自己也是被各种奇怪现场教育过来的;排障这件事,信息交换永远比一个人硬扛高效。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询