线上遇到服务假死是最让人头皮发麻的故障之一。负载看着正常进程还活着但业务就是不动日志也不输出。这时候你手里可能只有 ps 和 top它们能告诉你“它卡了”却说不清“卡在哪一行代码”。我遇到过太多次这种现场而能把问题从“现象”变成“答案”的工具排在最前面的就是 pstack。这篇是 pstack 完整指南的上半部分主要讲清楚三件事为什么这个工具在排障时不可替代怎么把它正确装好并避开权限坑以及它内部到底是怎么工作的。同时我会带你把最常用的几个场景跑一遍最后用一个完整的线上假死案例复盘整个定位链路。适合所有搞服务端开发和运维的人哪怕你之前完全没用过 gdb也能照着思路走完一遍。1. 为什么是 pstack进程一卡最该抓的就是它先说个很常见的误区很多人以为 pstack 就是“打印堆栈”的小工具用起来跟看日志差不多。实际上它解决的是日志和监控系统永远覆盖不到的那类问题——当代码已经没有机会输出任何信息时它还能强制从外部“撬开”进程看一眼内部。1.1 ps/top 只告诉你“它卡了”pstack 告诉你“卡在哪”每次线上故障第一波操作永远是从 top 和 ps 开始。进程 CPU 跑满还是处于 D 状态还是完全无响应这些信息能帮你划分排查方向但也就到此为止了。真正要动手改代码、修问题的时候你必须知道当前这个进程到底停在了哪个函数、哪个调用链上。举个例子一个服务线程卡住top 里只能看到某个线程 CPU 接近 100%你不知道它是在自旋锁里死转还是在某个无底循环里处理脏数据。这时候对进程执行一次 pstack栈顶会直接告诉你在哪一行。如果连续执行两次还能看出栈顶是否在变化——不变大概率死锁或阻塞在变那就是活循环。这种判断成本极低一条命令而已。要注意的是pstack 得到的是进程某一瞬间的快照。它不适合用来做长时间的性能分析也不适合找“平均耗时”这种统计指标。它的定位就是故障发生时最快速度把现场凝固下来。1.2 一套工具三件事用户态堆栈、线程分布、函数调用链真正用熟 pstack 之后你会发现它其实同时干了三件事。第一打印用户态堆栈。这是最核心的功能每个线程当前执行到的函数调用链都会列出来。第二展示线程分布。多线程服务里线程集中在哪个调用点往往就是锁竞争或者连接池耗尽的地方。第三通过栈上的函数名还原出完整的调用链。配合源码行号和参数很多问题一眼就能定位。我常用的一个判断方法是看所有线程是不是都压在同一个锁的入口函数上。如果是基本可以断定是死锁或者极端锁竞争如果不是那就得看哪个线程的栈异常再顺着往下查。1.3 pstack 和 gstack、gdb 到底什么关系这里必须把关系讲清楚否则后面原理部分你会看得一头雾水。很多 Linux 发行版里的 pstack本质上就是一个封装了 gdb 的 shell 脚本。它做的事情等价于你用 gdb attach 到进程然后执行 thread apply all bt 来抓取所有线程的调用栈。gstack 也是类似的包装只是参数细节略有不同。直接用 gdb 完全可以实现同样的效果为什么要多一层 pstack因为 gdb 是交互式的你要记住一堆命令还要处理 attach 提示、断点设置这些杂音。pstack 把这些全部封装成一条命令输出格式清爽适合在紧张的故障处理时用。你可以把它理解成“给 gdb 的堆栈抓取功能做了个一键封装”。正因为底层是 gdb所以 pstack 也继承了 gdb 的能力边界它能抓动态链接库里的函数能解析符号但前提是进程里有对应的符号信息。没有符号信息的二进制的函数名会变成一串地址这也解释了为什么有时候 pstack 输出看起来特别“原始”。2. 把 pstack 搞到手三种安装路径与取舍工具虽小装起来却有讲究。不同发行版、不同场景安装方式直接决定你后续能不能用、好不好用。2.1 发行版包管理器安装如果你的机器是常见的 Debian/Ubuntu 或者 CentOS/RHEL 系包管理器里通常都有现成的 pstack。Debian/Ubuntu 上执行sudo apt-get install -y pstackCentOS/RHEL 上执行sudo yum install -y pstack装完之后直接跑pstack --help或者pstack 某个pid验证。这种方式的好处是省事依赖关系由系统帮你处理升级也方便。坏处是版本可能偏老而且有些发行版为了精简把 pstack 做成了 gdb 的软链封装输出格式跟标准实现有细微差别。如果你只是日常排障这个差别基本无感如果要做二次开发或者定制输出就得考虑源码安装了。2.2 从源码构建选项、依赖与验证有些场景必须自己构建比如目标机器的 glibc 版本比较老新版本 pstack 二进制跑不起来比如你想给 pstack 加自定义输出格式又比如你需要在离线环境内网部署没法直接 yum、apt。从源码构建 pstack 的核心依赖只有一个gdb 的开发头文件和库。因为 pstack 要调用 gdb 的 attach 机制来抓取线程栈这个能力是通过链接 gdb 库实现的。不同版本的 pstack 对 gdb 版本的兼容性不一样我自己踩过的坑是gdb 版本太新旧版 pstack 源码编译报错gdb 版本太老新版 pstack 又可能用上了新接口。构建步骤大体是这样的# 安装依赖 sudo apt-get install -y gdb libgdb-dev build-essential # 解压源码包 tar -xzf pstack-version.tar.gz cd pstack-version # 配置并编译 ./configure --prefix/usr/local make sudo make install安装完成后验证一下pstack --version能看到版本号输出说明链接成功。再用一个实际进程测试确认能正常打印栈才算真正可用。这里有个容易忽略的细节make check里通常会带一组测试用例会 fork 一个子进程然后抓它的栈。建议构建完顺手跑一遍尤其当你改了 configure 参数或者用了非标准的 gdb 路径时这个测试特别能暴露问题。2.3 环境核弹ptrace 权限与内核参数安装好了不代表就能用。pstack 抓进程栈底层要通过 ptrace 附加到目标进程。这里有两个最常见的拦路虎。第一个是内核的 ptrace 权限限制。很多发行版默认开启了kernel.yama.ptrace_scope1意思是非 root 用户只能 attach 到自己启动的子进程。如果你用普通用户去 pstack 别人的进程会看到类似 “Operation not permitted” 的报错。解决方法是切换到 root或者把目标进程的父进程关系理清再或者临时调低 ptrace_scopesudo sysctl -w kernel.yama.ptrace_scope0这个参数改完立即生效但重启会恢复。生产环境不建议永久改成 0属于扩大攻击面的操作除非你能接受这个风险。第二个是容器环境。容器里跑 pstack宿主机上的权限和容器内的配置可能不一致经常出现 attach 上了但读不到 /proc 信息的情况。这类问题一般要通过给容器加--privileged或者挂载宿主 proc 来解决具体取决于你的运行平台。3. 它凭什么看得见进程的内部原理拆解很多工具你会用就行但 pstack 是少数“不懂原理就很容易误判”的工具。因为它输出的栈信息不同情况下代表的意义完全不同。3.1 用户态堆栈调试器 attach 全线程抓取先看最常见的情况用户态堆栈。pstack 附加到进程后会遍历这个进程的所有线程对每个线程获取当前的寄存器和栈指针然后通过符号表把栈上的地址翻译成函数名和行号。这个过程不是简单的“读文件”。它需要暂停目标线程的执行读取线程的栈内存再恢复。所以 pstack 本身会对目标进程产生短暂影响。绝大多数情况下这个影响以毫秒计不会造成什么问题。但如果你的进程有严格的实时性要求或者正处于高负载状态抓取瞬间可能放大延迟。我看过有人连续对同一个进程执行十几次 pstack每次间隔零点几秒想观察栈的变化。这种做法不是不行但要注意每次 attach 都会让进程短暂停顿高频操作对高并发服务是有扰动的。更好的做法是间隔一到两秒抓两三次取关键差异而不是疯狂刷屏。3.2 内核态堆栈/proc/ /stack 的另一种读法Linux 还有一个内核态栈的读取路径就是/proc/pid/stack。这个文件里记录的是进程当前在内核态的执行路径比如正在等某个锁、正在读某个文件、正在做内存回收。pstack 本身并不直接依赖这个文件但我在排查问题时经常两个配合用用户态栈告诉你“业务代码卡在哪”内核态栈告诉你“内核里卡在哪”。比如用户态栈显示线程在 read 系统调用上内核态栈可能会进一步显示它阻塞在某个具体的内核等待队列上。读取这个文件需要 root 权限而且内核要开启相应配置。在多数生产环境里这个文件是可读的但内容偏底层没有内核经验的人看到一堆内核函数名会头大。我的建议是先看用户态栈用户态栈解决不了时再来看内核态栈。3.3 符号表解析为什么有的行是函数名有的只是一串地址用 pstack 时最常被问到的就是为什么有时候输出特别友好函数名、行号一应俱全有时候却只有十六进制地址完全看不出是啥原因在符号表。编译器在生成二进制时如果带了调试信息pstack 就能把地址翻译成函数名和行号。如果没带二进制里只有动态符号表甚至什么都没有那 pstack 就无能为力了。所以生产环境的二进制最好保留调试符号或者至少保留一份对应的符号文件。我见过不少团队为了让包变小编译时加了-s或者 strip 掉符号结果线上出问题时 pstack 打出来的全是地址还得专门去对照符号表换算白白浪费了黄金排障时间。如果不方便带调试信息还有一个折中方案把 strip 下来的符号文件存档出问题时用 gdb 加载符号文件再配合 pstack 的地址输出来还原函数名。4. 上手实操三个最常用的场景原理讲完开始动手。我挑三个出现频率最高的场景每个都会给到可以直接复制的命令。4.1 单进程瞬间快照最简单的情况一个进程疑似卡住想看看它现在在干嘛。假设进程 PID 是 12345pstack 12345输出类似这样Thread 1 (process 12345): #0 0x00007f846e9a207b in poll () from /lib/x86_64-linux-gnu/libc.so.6 #1 0x00007f846e78d254 in epoll_wait ... #2 0x0000000000405a34 in main ...看到poll或者epoll_wait在栈顶说明这个线程正在等事件是正常的空闲状态。如果栈顶是某个业务处理函数或者某个锁的加锁函数才说明真的卡住了。单进程场景最忌讳的是拿到一次输出就下结论。我一般会隔两秒再抓一次对比两次输出。如果两次栈完全一致那可能是阻塞如果栈顶在变说明它在持续执行某些逻辑只是你不知道它在干嘛而已。第二次抓取的成本很低但信息量大很多。4.2 多线程应用抓取输出的结构多线程服务的输出会复杂一些。一个典型的输出会包含多个线程块Thread 7 (Thread 0x7f1234abcd00 (LWP 23456)): #0 ... ... Thread 6 (Thread 0x7f1234bcde00 (LWP 23455)): #0 ...每一块代表一个线程块标题里的 LWP 是线程 ID后面是它的调用栈。看多线程输出时抓三个重点。第一哪个线程栈顶是异常的正常的线程要么在等待事件要么在空闲轮询栈顶基本是 poll、futex、nanosleep 这类。第二有没有多个线程的栈几乎一模一样这种情况经常是锁竞争或者资源池耗尽大家都挤在同一个入口。第三有没有线程卡在锁相关函数上比如 pthread_mutex_lock、pthread_rwlock_rdlock这些都需要重点看。还有一种情况值得注意很多线程同时卡在同一个锁上但持锁线程的栈却看不出异常甚至看起来很正常。这时候你别被假象迷惑问题往往不在这把锁本身而在持锁线程之前做了什么耗时操作。线程栈只能告诉你“现在卡在哪”要回答“为什么卡在这”需要结合代码逻辑继续向下查。4.3 自旋、死锁、等待如何从栈上快速辨认这是 pstack 排障里最有意思的一部分。三种状态栈上的表现完全不同。自旋栈顶通常在一个循环里比如sched_yield、cpu_relax或者某个空循环。连续抓两次栈顶会变因为它在不停转。自旋常见于自旋锁和某些 busy-wait 的代码里。死锁涉及多个线程和多个锁典型表现是线程 A 拿着锁 1 等锁 2线程 B 拿着锁 2 等锁 1。从 pstack 输出里看就是两个线程互相卡在对方的锁上。这种一定要把每个线程的锁持有关系列出来才看得清楚。等待最常见栈顶一般是futex、poll、read、nanosleep这类函数。等待本身不代表有问题关键要看等的是什么。比如等一个永远不会有结果的回调那才是问题所在。我自己的习惯是拿到栈之后先在脑子里画一个“线程-函数-锁”的关系图。不需要多精美能搞清楚谁在等谁就行。这比直接翻代码快得多。5. 一次线上“假死”的完整复盘理论讲再多不如看一次真实的定位过程。这个案例是之前帮某团队排查的现象很典型。5.1 现场与第一反应某服务突然没有响应外部请求全部超时。进程还在CPU 和内存看起来都正常日志停在某个时间点之后再无输出。按照我的习惯第一件事不是重启而是先保留现场。pstack 15823输出里大部分线程都堆在一个函数上pthread_mutex_lock。这已经是个强烈的信号——锁竞争或者死锁。我没有停在这一次抓取上隔了两秒又抓了一次结果一模一样栈完全没有变化。到这里基本可以排除锁竞争了。锁竞争虽然也会让大量线程堆在锁上但持锁线程通常过一会儿就会释放线程会动起来。如果两次抓取之间所有线程都纹丝不动那基本就是死锁。5.2 从输出到结论锁等待的完整链路确认死锁方向后我开始逐个看输出里的线程块。关键线索在其中一个线程的栈它持有一把锁又在这把锁的持有期间调用了另一个加锁操作。而这个加锁操作等待的锁恰好被另一个线程持有那个线程又因为某种原因永远无法释放这把锁。从 pstack 输出看整个过程就是线程 A 等锁 L2但 L2 被线程 B 持有线程 B 在等线程 A 持有的锁 L1而 L1 在线程 A 等其他锁的代码路径上一直没有释放。两条等待关系一拼形成一个环。代码层面也验证了这个判断。某段逻辑在异常分支里提前返回时忘了释放锁。正常情况下这个分支不触发但不巧有一个极端输入触发了它。于是线程 B 带着锁 L2 提前返回后面又去抢 L1形成了死锁环。这种问题靠日志很难发现因为日志只记录到进入异常分支之前之后就是一片空白而 pstack 恰恰能把空白处的真相挖出来。5.3 修复与验证修复方案不复杂在异常分支里补上锁释放。但验证过程不能马虎。我让团队先在测试环境用一个脚本模拟极端输入把之前的死锁路径触发出来确认 pstack 能看到锁等待环。修复后再次执行同样的模拟pstack 输出里不再有线程堆在pthread_mutex_lock上服务也能正常响应了。这个案例里pstack 的价值不是“找到了一个 bug”而是把定位时间从小时级压缩到了分钟级。没有它你可能得一步步看代码、猜分支、加日志、复现问题而那可能需要好几天。这次复盘之后我给自己定了一条规矩任何核心服务的上线清单里都必须包含一条“确认 pstack 能正常抓栈”。这条看起来不起眼却能在关键时刻让你少走很多弯路。最后再说一个实战里的个人体会pstack 用完记得把捕获的栈信息连同时间点一起保存下来。很多时候你当下看不懂某个栈但结合后续日志和代码变更回头再看会发现新的线索。故障现场的原始数据永远比事后回忆值钱得多。至于 pstack 下半部分我会重点写几个进阶玩法怎么用 pstack 分析启动即崩溃的进程、怎么把 C 符号还原成可读名称、怎么跟 perf、gdb 配合做更深入的调查。这些内容留到下一篇。