如果你第一次在终端里敲下pstack这个命令,多半是因为某个进程已经卡得像台坏掉的风扇:CPU 一直在转,业务却纹丝不动,日志翻遍了也看不出它执行到哪一行。我第一次在线上用 pstack,就是这样一个手足无措的深夜,依赖库、网络、磁盘全查了一遍,最后靠着一条堆栈快照才定位到问题。
作为维护 pstack 多年的从业者,我一直想把它讲透。市面上的碎片资料不少,但要么停留在“会用就行”,要么直接把 gdb 手册甩给你。这篇文章是“完整指南”的上篇,先把最核心的东西讲清楚:pstack 是什么、为什么能救急、它背后到底怎么工作、安装和上手怎么做、输出结果怎么读、以及日常最容易踩的坑。写完之后你会明白,这不是什么高深技术,但它确实值得每一个运维和后台开发者掌握。
1. pstack 到底是干嘛的,为什么生产环境离不开它
1.1 一句话定义:进程的瞬时 X 光片
pstack 做的事情,用一句话概括就是:在不打断目标进程、不重启、不埋点的情况下,把一个正在运行的进程当前所有线程的调用栈原样抓出来。你可以把它理解成给一个正在埋头干活的程序员拍一张 X 光片,照片上清清楚楚显示他此刻站在哪段代码里,是谁把他叫到这里,再往上一层层是谁调用了谁。
听起来简单,但这个能力在关键时刻是无可替代的。生活中的场景很类似:你去探病,医生不让你动病人,又想知道病人身体里发生了什么,只能用无创检查;pstack 对进程做的就是这种“无创检查”。它不修改进程的内存,不注入任何代码,只是短暂地看一眼它的执行位置。
这也决定了它的定位:它不是用来做日常代码调试的“放大镜”,而是用来做线上应急的“急救工具”。在你没有任何线索、只想快速判断进程在干什么的时候,pstack 是最直接的手段。
1.2 哪些场景下它才是主力
我总结过 pstack 最典型的四类作战场景,基本覆盖了线上事故的大半情况:
- CPU 使用率飙高,但应用层日志长时间没有输出。此时你无从判断它是死循环、忙等,还是锁竞争导致的空转。pstack 抓一次栈,栈顶立即告诉你它到底在执行什么函数。
- 服务卡死,请求全都堆积,但进程还没退出。重启容易,但重启之后现场就没了。pstack 在重启之前能给你留下最关键的证据。
- 怀疑线程卡在某个锁上。栈顶如果出现
futex_wait、pthread_mutex_lock以及对应的调用链,基本就能确认锁等待关系。 - 线上程序偶发异常,比如请求超时、线程池收缩、心跳中断,但你无法每时每刻盯着。此时在监控脚本里定时执行 pstack 抓帧是个低成本的好办法。
有人会说,现在有一堆 APM 工具、分布式追踪系统,还需要 pstack 吗?我的观点很明确:那些工具能告诉你“请求走到哪个服务、耗时多少”,但它们很难告诉你“这个进程内部的线程此刻正卡在哪个函数、哪一行”。后者恰好是 pstack 最擅长的事。
1.3 它和 gdb 到底是什么关系
很多人一看 pstack 的输出格式,会脱口而出“这不就是 gdb 的 bt 命令吗?”没错,pstack 在大部分发行版里本质上就是 gdb 的一个极薄封装。它的核心动作正是对 gdb 说:拿到目标进程的 pid,不要交互,不要打断,把所有线程的调用栈都打印出来。
这层封装的价值在哪里?主要有三点:
- 你不用在事故现场手忙脚乱地敲一长串 gdb 命令,也不会因为误操作 gdb 的交互式界面而卡住进程。
- pstack 预先设置了 batch 模式、关闭交互确认、打开分页,输出的结果干净利落,适合直接落地成文本。
- 它固定了“遍历所有线程”这个动作。手动操作 gdb 时,新手经常只看到主线程栈,漏掉真正出问题的子线程。
不过,理解了“它是 gdb 的壳”之后,也要对它的边界有清醒认识。gdb 能附加到什么程度,pstack 就能看到什么程度;gdb 受权限、内核限制、符号表缺失影响时的表现,pstack 也会原样继承。所以我在后面专门留了一节讲这些坑。
2. 核心工作原理:一次 pstack 调用的背后发生了什么
2.1 附加、遍历、离场:三个关键动作
我最初写 pstack 的时候,最纠结的点不是“怎么实现”,而是“怎么保证安全”。后来彻底想明白:把脏活累活都交给 gdb,pstack 要做到快进快出。
一次标准的 pstack 调用,背后是这样一个过程:
- pstack 启动一个 gdb 子进程,目标 pid 作为参数传入。
- gdb 通过系统提供的调试接口附加到目标进程。这一步的本质是请求内核允许自己“观察”对方,如果权限不够、或者目标进程状态异常,这一层就会失败。
- gdb 短暂暂停目标进程的所有线程,逐个抓取寄存器现场,并从栈内存中回溯出每一帧的函数调用关系。
- gdb 把回溯结果打印到标准输出,随后恢复目标进程继续运行,然后自己退出。
整个过程通常在几百毫秒到一两秒之内完成。线程数量越多、需要解析的符号越多,耗时就越长。要是你的进程里有上千个线程,一次 pstack 可能要好几秒,这时候心里就要有数:它不是完全零成本的。
有一个非常关键的细节,我反复在文档里强调:pstack 不是去读/proc/<pid>/stack那种静态文件。它实打实地接管了目标进程一小段时间。虽然在正常情况下这个接管极短暂,但如果目标进程正处在某些特殊的不可中断状态,比如正在等待磁盘 I/O,附加动作可能会卡住。这不是 pstack 的 bug,而是 ptrace 机制本身的边界。
2.2 权限问题:为什么有时候它说“不允许”
pstack 最常见的失败信息不是“进程不存在”,而是 “Operation not permitted”。这条错误背后的原因五花八门,但绝大多数逃不出下面几类:
- 目标进程属于另一个用户,而当前用户没有 root 权限。生产环境里很多服务以专用账号启动,普通开发者账号直接 pstack 过去,权限是不够的。
- 系统的 ptrace 限制策略比较严格。现代内核引入的 ptrace 访问控制,默认只允许同 uid 且是父子继承关系的进程互相 ptrace。也就是说,你从终端里直接 pstack 一个后台服务,哪怕用的是同一个 uid,也可能被拒绝。
- 容器场景下,权限隔离和内核 capabilities 不完整,导致 gdb 根本没有附加的资格。这在容器化部署流行之后尤其常见。
碰到这种情况,我的建议是:先确认自己是不是真的需要这台机器上的栈,如果需要,老老实实切换到运行该服务的管理员账号,或者在具备相应权限的容器 / 主机上执行。不要抱着侥幸心理用各种奇技淫巧绕过权限限制,那只会把现场弄得更复杂。
2.3 为什么 pstack 不自己实现栈回溯
有朋友问过我:pstack 这么简单,为什么不直接自己读线程寄存器、自己解析栈内存,绕开 gdb?这个问题问得很好,但答案也很现实。
栈回溯看起来简单,实际上极其容易出错。它要处理不同架构的寄存器规则、栈帧布局、优化编译后帧指针被省略的情况、动态库加载地址偏移、线程局部信息,还得解析 DWARF 调试信息、符号表、以及可能出现的内联函数展开。这些逻辑每一处都是复杂度和兼容性的坑。
与其自己维护一套随时可能翻车的栈回溯器,不如站在 gdb 这个巨人肩膀上。gdb 在符号解析和栈回溯上已经积累了非常久的时间,它对各种极端情况的处理远比我临时写的代码可靠。pstack 保持薄封装,最大的好处就是:gdb 升级后能解析更多格式,pstack 自动跟着受益。
牺牲也很明显:凡是 gdb 不适用的场景,比如进程崩溃现场已经无法附加、或者目标机器完全没有 gdb、或者对性能极其敏感怕附加动作影响在线服务,pstack 就帮不上忙。这些边界,使用者心里必须有数。
3. 动手实战:安装、准备示例、抓取第一份堆栈
3.1 安装 pstack:三种方式按需选择
不同阵营的 Linux 发行版对 pstack 的处理不太一样。在 RPM 系发行版里,pstack 通常是直接放在系统软件源里的,一条命令就能装:
yum install pstack在 apt 系发行版里情况略有差异,默认软件源可能没有 pstack,或者名字不叫这个。这时你可以先试试:
apt install pstack如果提示找不到包,不用着急,我给你一个更通用的办法:只要系统里有 gdb,我们自己封装一个 pstack 即可。事实上很多发行版自带的 pstack 就是一个脚本,核心也就几行。你完全可以把下面这段放进 shell 配置里:
# 简易 pstack 替代实现 pstack() { local pid="$1" if [ -z "$pid" ]; then echo "usage: pstack <pid>" return 1 fi gdb -q -batch -ex "set pagination off" -ex "thread apply all bt" -p "$pid" }这条封装里最核心的参数是-ex,后面跟的命令thread apply all bt表示“对所有线程执行堆栈回溯”,set pagination off则是关掉分页,避免输出卡在半途。实际使用时我还会额外加-ex "set print thread-events off"减少噪音,不过依赖版本较老时可以不加。
3.2 准备一个能稳定复现的实验环境
纸上得来终觉浅,我强烈建议你亲手抓一次栈。我准备了一个极简的多线程示例程序,放在你本机编译运行完全没压力:
#include <pthread.h> #include <unistd.h> #include <stdio.h> void *worker_thread(void *arg) { while (1) { usleep(100000); } return NULL; } int main(void) { pthread_t tid; if (pthread_create(&tid, NULL, worker_thread, NULL) != 0) { perror("pthread_create"); return 1; } while (1) { usleep(100000); } return 0; }编译时一定记得加调试符号,否则一会儿你只能看到一串裸地址:
gcc -g -O0 -o demo demo.c -lpthread启动它,然后拿到 pid:
./demo & pid=$! echo $pid接下来就是见证奇迹的时刻:
pstack $pid3.3 第一次留下的堆栈快照
上面命令的输出大概长这样:
Thread 2 (process 12345): #0 0x00007f8a4b4f3355 in nanosleep () from /usr/lib64/libc.so.6 #1 0x00007f8a4b4f32b0 in usleep () from /usr/lib64/libc.so.6 #2 0x00000000004007b2 in worker_thread (arg=0x0) at demo.c:9 #3 0x00007f8a4b76a1e4 in start_thread () from /usr/lib64/libpthread.so.0 #4 0x00007f8a4b41d53d in clone () from /usr/lib64/libc.so.6 Thread 1 (process 12345): #0 0x00007f8a4b4f3355 in nanosleep () from /usr/lib64/libc.so.6 #1 0x00007f8a4b4f32b0 in usleep () from /usr/lib64/libc.so.6 #2 0x000000000040060a in main () at demo.c:18 #3 0x00007f8a4b763d4a in __libc_start_main () from /usr/lib64/libc.so.6 #4 0x0000000000400529 in _start () at crt1.o这一瞬间你其实已经完成了生产排查里最关键的动作:把一个活着的进程的内部结构拍成了静态照片。我来教你怎么读这份照片。
首先是线程分组。输出里 Thread 1、Thread 2 不止是编号,每个线程后面括号里还有进程号。在多线程程序里所有线程共享同一个 pid,所以你会看到相同数字。
其次是每一帧。栈帧编号 #0 在最顶部,代表这个线程正在执行的当前函数;往下 #1、#2 是调用者,一层一层追溯到线程的起点。你可以把栈想象成一层层叠起来的盘子,最上面是现在正在做的事情,最底下是整个流程从哪里开始。
再看函数名后的源文件和行号。比如worker_thread (arg=0x0) at demo.c:9,说明此刻 worker 线程正停在 demo.c 的第 9 行,也就是usleep(100000)。这行信息就是日后判断问题最直接的锚点。
有一件事我每次分享都要强调:pstack 抓到的栈只是“这一刻”的快照。在线程循环执行的程序中,下一次抓取可能栈顶就变了。所以如果要诊断类似 CPU 飙升的问题,不要只抓一次,我一般会间隔几秒连续抓 3 到 5 次,看栈顶是否稳定停留在某个热点函数上。
3.4 封装脚本里藏的小经验
那条自定义 pstack 脚本虽然只有几行,但我在真实环境里已经用了很多年,做一些补充说明:
- 如果目标是 Java、Python、Go 这类运行时进程,原生 pstack 抓出来的往往是运行时自身代码,看不到业务层栈。Java 程序应该优先用
jstack,Go 程序则应该用发送信号或runtime/pprof的方式。pstack 的主场是 C/C++ 或同类本地程序。 - 如果目标进程名重复出现,你可以先用
pgrep -f 进程名拿到所有 pid,再逐个抓,而不是到处猜。 - 生产环境建议把输出重定向到文件,并贴上时间戳。事故分析时,时间线比栈本身更重要。
ts=$(date +%F_%H-%M-%S) pstack "$pid" > "stack_${pid}_${ts}.txt" 2>&1多留几份快照,你在复盘时就能对比状态的演变,而不是只有孤零零的一份。
4. 进阶读栈:从几行帧里挖出真正的问题
4.1 栈顶决定状态,调用链决定位置
只看栈顶函数,可以快速判断线程处于什么状态。栈顶是poll、epoll_wait、nanosleep,说明线程在等待事件或在休眠,一般不属于忙等;栈顶是某种加锁操作,比如futex_wait或pthread_mutex_lock,说明线程正试图获取锁而不得。
单独看栈顶仍然不够,调用链才是定位的关键。同样是futex_wait,它可能是业务代码在访问共享数据结构时被锁堵住了,也可能是底层内存分配器在内部锁上等待。你需要沿着 #1、#2 一直往上找,直到看到自己工程里的函数。那个函数,才是事故需要关注的“第一现场”。
我给团队培训时常举一个比喻:栈顶是汽车仪表盘上亮起的红灯,它告诉你“有故障”,但真正要修的是引擎盖下面某个零件。调用链就是通往引擎盖的路线图。
4.2 没有调试符号时,怎么剥离出地址背后的信息
生产环境里许多二进制出于安全和瘦身考虑,是不带-g调试信息的,抓出来的栈可能长成这样:
#0 0x00007f8a4b4f3355 in nanosleep () from /usr/lib64/libc.so.6 #1 0x00007f8a4b4f32b0 in __sleep30 () #2 0x000055c7abc03a82 in ?? () #3 0x000055c7abc03957 in ?? ()看到一堆??很绝望,但还没到完全没救的程度。关键思路是:地址本身可以被解析。
第一步,查一下目标程序的加载基址,可以从/proc/<pid>/maps里看。假设可执行文件映射起始是0x000055c7abc03000,那么栈里那个0x000055c7abc03a82在文件内的偏移就是:
0x000055c7abc03a82 - 0x000055c7abc03000 = 0xa82接着用地址转换工具把它还原成函数名:
addr2line -e /path/to/program -f 0xa82输出可能给你一个函数名,甚至还能给出行号。前提是程序文件里还保留了符号表。如果符号表也被剥离了,那我只能遗憾地告诉你:只能看到偏移,没法直接知道函数名。这时候更现实的办法是找编译时的构建版本和符号文件,在开发环境重新解析。
我额外说一个生产经验:不要等到事故发生了才想到符号表。构建流水线里把每次发布的二进制和符号文件一起归档,哪怕程序本身不带调试信息,符号文件也能在你事后需要时救命。这几分钟的准备,能省掉现场抢救的大量时间。
4.3 多线程栈之间怎么找锁等待
线上服务卡死,最常见的元凶之一就是死锁。pstack 输出里的多个线程栈,恰好能帮你把死锁关系画出来。用文字描述我的判断流程:
先找所有处于futex_wait或pthread_mutex_lock状态的线程,然后看这些线程的调用链。如果线程 A 拿着锁 L1 等锁 L2,线程 B 拿着锁 L2 等锁 L1,输出的调用链上必然能看到不同线程的加锁位置互相咬合。此时把这两个栈并排放在一起,锁的关系基本就清楚了。
比如看到线程 A 的链:thread_func -> lock(B) -> wait,线程 B 的链:thread_func2 -> lock(A) -> wait,那几乎可以断定是 ABBA 型的经典死锁。虽然我们不会在这里手动画流程图,但我建议你直接在纸上把锁名和线程名连线,一目了然。
处理死锁的手段各有不同,可能是加锁顺序统一、超时机制、或者改用读写锁,这些是另一个话题。但你要记住,pstack 的价值是让你用极低成本拿到死锁现场的第一手证据,这份证据比任何人的推测都有说服力。
5. 高频问题与避坑手册:我替你踩过的雷
5.1 问题速查表
我把这些年遇到的 pstack 问题整理成了一张速查表,照着对号入座即可:
| 现象 | 最常见原因 | 处理思路 |
|---|---|---|
| Operation not permitted | ptrace 权限受限或非同一用户 | 切换到目标进程的运行账号或具备权限的容器执行 |
| 进程不存在或已退出 | pid 写错或进程恰好崩溃 | 用ps -ef、pgrep重新确认 pid |
抓的栈里全是?? | 二进制没有调试符号 | 用addr2line配合 maps 映射解析,或找归档符号文件 |
| Java 进程抓不到业务栈 | pstack 只能看本地栈 | Java 用jstack,并考虑结合jcmd分析 |
| 执行后长时间卡住 | 目标进程处于不可中断睡眠状态 | 先确认进程状态是否为 D,必要时借助其它内核手段 |
| 容器里抓不到外部的进程 | 容器 pid namespace 与 host 隔离 | 进入目标容器的上下文执行,或在宿主机用对应权限操作 |
| 输出很长,终端刷屏 | 线程数量很多 | 重定向到文件,用split或grep分块查看 |
这张表不是背出来的,而是我一次次在群里看别人踩坑后积攒出来的。最核心的教训其实只有一条:先确认进程状态和权限,再抱怨工具不行。
5.2 D 状态进程:pstack 的致命盲区
别人问我很长时间:“我的进程卡住是 D 状态,为什么 pstack 执行后一直不出结果?”这个问题值得单独讲。
D 状态也叫不可中断睡眠,通常意味着进程正在等待内核 I/O 完成,比如磁盘读写、NFS 挂载卡住。在这种状态下,进程无法响应用户态的调试信号,gdb 附加进去就可能阻塞住,pstack 也就跟着卡住。
所以我的建议是:看到ps输出中进程状态为 D,先不要对 pstack 抱太大期望。此时你更应该关注系统层的 I/O 问题,比如iostat、dmesg、存储设备状态。如果系统崩溃严重,可能连 pstack 都无法执行,那时需要的是内核转储、kdump 之类的机制,而不是再在用户态死磕。
反过来讲,如果进程状态是 R(运行中)或 S(可中断睡眠),pstack 的大多数问题都出在人,而不是工具本身。
5.3 别在事故现场反复抓同一片废墟
有个习惯我必须提醒:拿到一份栈之后不要 “趁热” 反复用 gdb 做各种手工命令,频繁附加会加剧对进程的扰动。尤其是在高并发服务上,额外停顿时长可能引发连锁超时。
正确做法是快照驱动:抓一次,落盘,分析;如果证据不够,再抓第二次,但间隔至少几秒。相比一股脑在交互式 gdb 里折腾十几分钟,几次干净利落的快照对在线业务友好得多。
这也是我当初做 pstack 时最在意的一点:仪式感不重要,快才是美德。
5.4 符号表、符号表、还是符号表
栈信息有没有价值,一半取决于符号表。没有符号表,pstack 输出只能停留在内核函数和动态库层面,你自己的业务代码全是??。所以我看到的线上事故复盘,几乎无一例外地和符号表缺失有关。
要解决也不复杂,构建时不要丢弃符号文件,哪怕发布时不给二进制加-g,也要保留一份带符号的副本。不少团队会用单独的 debug 包,把符号文件归档进制品库。等到需要分析,下载对应版本符号,配合addr2line或 gdb 的set debug-file-directory就能恢复可读性。这套流程前期成本很低,事故时价值极高。
6. 写在“上篇”的最后,以及接下来你会用到什么
pstack 的完整指南到这里,只是把地基打牢了。你现在清楚了它是什么、什么时候用、内部原理是怎样的、怎么安装、怎么抓取第一份栈、怎么读懂每一帧,也知道了那些常见的坑和正确的使用习惯。在掌握了这些之后,实际上你已经能应付绝大多数线上“进程卡住但不知道卡在哪”的场景了。
我在多年运维和排查过程中越来越深的一个体会是:工具的价值不在于它有多复杂,而在于它能不能在最要紧的时刻以最低成本给你一条线索。pstack 恰恰就是这样一个工具。它没有炫目的界面,没有庞大生态,但它却和核心调试机制紧紧绑在一起。每一行输出背后,都有 ptrace 的介入、gdb 符号解析的功劳,以及操作系统对调试权限的某种微妙平衡。
如果你继续往深了走,接下来值得拓展的方向包括:怎么结合/proc/<pid>/maps把地址偏移换算成动态库里的具体函数、怎么用一次 pstack 抓取与分析多进程服务集群、怎么把栈快照进一步转换成火焰图、以及在服务端编程里怎么用栈信息反向优化代码。这些内容适合作为这篇指南的下篇,我已经在整理了。
最后分享一个我自己的习惯:不要等到出事了才想起 pstack。平时在测试环境、在压测的时候,随手对一个“看起来正常”的进程抓一次栈,存成基线。等哪天进程真的异常了,你再抓一份和基线对照,那种“原来它现在卡在这里,而正常时候它是停在另一个位置”的对比,往往会让你少熬一个大夜。命令行很短,习惯却很值钱。