凌晨两点线上一个服务进程 CPU 飙到 99%客户端超时告警一片可你连它在哪个函数里忙都看不到。这种时候我最先掏出来的工具就是 pstack。pstack 是一条命令行工具作用只有一个打印某个运行中进程的所有线程的函数调用栈call stack。说白了它能把进程内部此刻每个线程正在执行哪行代码、一层层是怎么调进来的原原本本列出来。不管你是处理服务假死、死锁、高 CPU还是线程池被打满它都能在几秒内给你第一手的现场证据。这是 pstack 完整指南的上篇我按实际排查的思路来写先讲清楚 pstack 是什么、适合什么场景再看不同发行版怎么装、权限怎么开然后拆一拆它背后的原理最后放一段完整的线上实操和踩坑记录。适合后端开发、运维、SRE以及所有需要跟线上进程“隔空把脉”的人。看完这篇遇到“进程还活着但不对劲”的情况你就知道第一步该干什么。1. pstack 是什么先搞清楚它在解决什么问题1.1 一条命令看清进程内部pstack 的核心能力是给一个活着的进程拍一张“内部快照”。你想知道某个进程现在卡在哪、在等什么、哪几个线程在空转它直接把答案打印到终端上不需要改代码、不需要重启、也不需要提前埋点。我经常打一个比方看日志是看一个人的聊天记录而看堆栈是直接拍 CT。日志是间接证据告诉你“它之前做了什么”堆栈是直接证据告诉你“它现在就在这里的这一行上”。遇到假死类问题日志往往停在出事前的那一刻真正卡在哪一行只有堆栈能说清楚。实际操作里pstack 的典型依赖很少通常就是一条命令加一个进程号。它临时接管进程、读取每个线程的寄存器状态、回溯出调用链、然后立刻放手整个过程对目标进程来说是秒级的短暂停顿。1.2 四个典型场景我这些年用下来pstack 高频出场的场景基本就四个。第一服务假死、请求无响应。进程还在端口也开着但业务请求进来就像进了黑洞。这时候 pstack 一下通常能看到两种结果所有线程都堵在某把锁上或者所有工作线程都在空等某个永远不会来的信号。第二死锁。多个线程互相持有对方需要的锁谁也等不到谁。pstack 的输出里会出现多个线程都停在pthread_mutex_lock或futex_wait上而且等锁的地址互相交叉这时候基本可以判定死锁。第三高 CPU 但不知道在忙什么。很多语言的服务在 CPU 飙高时看不出来热点在哪pstack 能直接告诉你线程正在执行哪个函数。配合top -H拿到线程号再定位到对应线程的堆栈热点函数一目了然。第四线程池或连接池耗尽。现象是服务整体变慢新增的请求全部排队。pstack 能看到工作线程是不是全部卡在同一个获取任务、获取连接或者等待 IO 的动作上池子被打满的原因很快就能定位。1.3 它和 gdb、strace、jstack 的分工很多人会问有了 gdb 为什么还要 pstack我的理解是分工不同。pstack 强调的是“快、浅、不打扰”适合在第一现场快速判断方向gdb 强调的是“深、全、可交互”适合在确定方向后下断点、看变量、查内存。工具看什么典型用途侵入性pstack用户态线程调用栈快速判断进程卡在哪、忙在哪低秒级停顿gdb同一份栈但能断点调试、查看变量深挖问题根因高交互式strace系统调用及其参数看进程和内核的交互、文件网络读写高明显拖慢性能jstack / py-spy对应语言虚拟机的线程栈Java / Python 进程内部线程低eu-stack与 pstack 等价的原生实现无 gdb 环境、大进程快速回溯低所以我的习惯是问题一发生先 pstack 抓现场。搞清楚大概方向后需要深挖才上 gdb。如果用 pstack 就能直接定位就不必把 gdb 请出来毕竟生产环境上交互式调试的窗口往往只有几分钟。2. 环境准备与工具选型装哪个、用哪个、权限怎么开2.1 先确认你的系统里 pstack 是哪一种pstack 没有想象中那么“标准”。Solaris 上是原生命令Linux 上常见的是 gdb 封装脚本还有 elfutils 提供的原生实现。不同发行版、不同仓库装出来的东西行为略有差异。拿到一台机器第一件事是确认能不能用which pstack pstack -h 21 | head -5如果返回的是/usr/bin/pstack再看一眼文件内容file $(which pstack) head -30 $(which pstack)看到#!/bin/sh加一串gdb -batch -p之类的调用说明这是 gdb 封装版看到 ELF 二进制则可能是原生实现。这不只是好奇后面排查问题时你判断“为什么 pstack 这么慢”“为什么带不出 file:line”全都依赖这个版本信息。2.2 不同发行版的安装方式发行版类型安装命令说明Debian / Ubuntu 系apt-get install -y pstack或apt-get install -y gdbpstack 通常是 gdb 封装脚本CentOS / RHEL 系yum install -y gdbgdb 包内自带 pstack / gstackAlpine / 精简容器apk add gdb或apk add pstack镜像内可用体积略大elfutils 方案apt-get install -y elfutils提供原生eu-stack不依赖 gdbCentOS / RHEL 系我印象最深yum install gdb之后/usr/bin/pstack和/usr/bin/gstack会一起出现用途基本一致。Debian 系单独装pstack包装出来的也是一个几十行的小脚本本质还是调 gdb。如果没有安装条件也不想装任何东西可以直接用 gdb 自带命令代替后面 4.4 节会写这套“穷人版 pstack”。内核和 gdb 本来就是标配的话连包都不用装。提示不同版本、不同仓库的 pstack 参数略有差异。有的支持-p指定进程有的直接跟 PID。报错时先跑pstack -h或pstack -V看用法别想当然。2.3 ptrace 权限pstack 不是想做就能做pstack 能接管进程依赖的是内核的 ptrace 机制。也就是说不是谁都能对一个不相干的进程执行 pstack权限系统管得很严。现代 Linux 发行版默认启用了 Yama 安全模块通过ptrace_scope控制谁能 attach 谁。ptrace_scope 值含义0同一 uid 下任意进程都可以 attach 其他进程1常见默认只能 attach 自己的子进程root 或拥有 CAP_SYS_PTRACE 可以跨进程2仅允许 root / CAP_SYS_PTRACE3禁止常规 attach目标进程需主动调用PR_SET_PTRACER放行查看当前值cat /proc/sys/kernel/yama/ptrace_scope实际排查时容易遇到两类问题。一类是普通用户去 pstack 另一个用户的进程直接报ptrace: Operation not permitted。另一类是在容器里面哪怕你是容器里的 root也可能因为容器启动时没有授予CAP_SYS_PTRACE而失败。这两种情况不是 pstack 坏了而是权限不够。注意临时把ptrace_scope改成 0 会放松整个系统的 ptrace 限制有安全风险。生产环境上我更建议用同账号、同父进程的方式去排查或者让服务本身在出问题前就通过PR_SET_PTRACER放行诊断进程不要图省事全局关掉。2.4 选择哪个实现我的建议如果你问我日常用哪个我的答案是默认用系统自带的 pstack毕竟零成本、输出格式大家都熟。但遇到下面两种情况我会换工具。第一种是大进程、高线程数。gdb 封装版的 pstack 启动时要把 gdb 整个拉起来几百上千个线程的 attach 和回溯可能要十几秒甚至更久。这时候用eu-stack它是 elfutils 的原生实现速度明显更快输出也更简洁。第二种是纯脚本化场景。比如我要对一批进程做采样一分钟内连续跑多次gdb 版每次都有启动开销eu-stack 更适合做这种批量动作。eu-stack用法也很简单eu-stack -p 8876 # 打印进程 8876 所有线程的栈 eu-stack -t 8912 # 只打印线程 8912 的栈3. 核心原理拆解pstack 究竟是怎么拿到堆栈的3.1 剥开外壳本质是调试器的自动化批处理如果打开 Linux 上常见的 gdb 封装版 pstack 脚本你会发现核心就是一条 gdb 批处理命令逻辑完全可以手工复现gdb -q -batch -nx -p 8876 \ -ex set pagination off \ -ex thread apply all bt拆开看每一步。-p 8876让 gdb 通过 ptrace 接管目标进程set pagination off关闭分页避免输出停在半截thread apply all bt对每个线程执行btbacktrace命令把调用栈全部打出来。-batch表示执行完自动退出不进入交互界面。这里的关键是 ptrace。gdb 通过它向目标进程发送暂停信号、读取每个线程的寄存器状态、读取进程内存里的栈数据。pstack 之所以能“冻住”进程一瞬间就是这个机制在起作用。你可以把 ptrace 想象成调试器的手先按住肩膀再把内部的寄存器、内存抄一遍抄完松手走人。3.2 /proc 文件系统先把“有哪些线程”摸清楚在真正回溯栈之前pstack 先要做一件事找到目标进程下的所有线程。这一步不是通过系统调用而是读/proc文件系统。ls /proc/8876/task/ cat /proc/8876/status | grep -E Threads|State/proc/pid/task目录下每个数字都是一个线程 IDTID也叫 LWP进程内每个线程都在这里有对应的一项。gdb 里的 “Thread N (LWP xxx)” 指的就是它。除了线程列表/proc里还藏着不少排查素材。/proc/pid/status的State字段能看出进程是睡眠、运行还是卡在不可中断等待/proc/pid/wchan能告诉你进程当前阻塞在内核的哪个函数上/proc/pid/task/tid/stack在 root 下还能看到内核栈。pstack 管的是用户态栈但这些内核侧的信息常用来做交叉验证后面 4.3 节细说。3.3 栈是怎么回溯出来的拿到线程列表、暂停进程后关键的活来了怎么从线程当前的寄存器状态推出一整条调用链。以 x86_64 架构为例每个线程都有三个关键寄存器rip指向当前正在执行的指令rsp是栈指针rbp是栈基址指针。程序中每个函数调用都会在栈上留下一帧frame帧里保存着返回地址和上一个帧的地址。最简单的回溯方式是“帧指针链”。传统上编译器会用rbp寄存器和栈上的rbp值串起一条链当前rbp指向栈上的一个位置那里存着调用者的rbp一路往上走就能把调用链一条条拉出来。但现代编译器默认开了优化后经常省略帧指针也就是-fomit-frame-pointer。这时候依赖rbp链就不灵了回溯靠的是.eh_frame或.debug_frame里的展开表unwind table它们描述了怎么根据当前的rip和rsp算出上一帧的位置。这个机制更复杂对大多数普通程序是可靠的但在 JIT 代码、信号处理现场、刻意混淆过的二进制上失败率会上升。这是理解后面各种坑的基础pstack 能不能给出漂亮完整的栈取决于编译器是否保留帧指针、二进制是否带展开表、以及符号表是否健在。3.4 符号解析为什么有时全是地址和问号回溯只能得到一串内存地址pstack 要把它变成可读的函数名和文件行号靠的是符号表。进程里有几类符号来源。动态符号表.dynsym通常保存着导出函数的名字所以你会发现即使二进制被 strip 过glibc 的__pthread_cond_wait、poll这些库函数名字照样打得出。但程序自己的内部函数如果被 strip 掉了就只剩地址。静态符号表.symtab和调试信息DWARF则能提供更完整的函数名和源码行号需要在编译时保留或事后安装 debuginfo 包。看到输出里出现大量??或光秃秃的地址第一反应不应该是“pstack 坏了”而是“符号没找到”。手动补救的方法是用addr2lineaddr2line -e /usr/sbin/gw-server -f -C 0x55c1f1d04e7c把 pstack 输出的地址丢进去只要二进制带必要符号就能还原出函数名和代码位置。3.5 输出字段速读以一次实际输出为例Thread 2 (Thread 0x7f8b3f6fe700 (LWP 8903)): #0 0x00007f8b3e4a523d in __futex_abstimed_wait_common64 () at ../sysdeps/nptl/futex-internal.h:61 #1 0x00007f8b3e4a52cc in __pthread_cond_timedwait (cond0x55c1f2a1b3a0, mutex0x55c1f2a1b370, abstime...) at pthread_cond_wait.c:251 #2 0x000055c1f1d04e7c in WorkerThread::FetchQueue (this0x55c1f2493020, item...) at src/worker.cc:178 #3 0x000055c1f1d05211 in WorkerThread::RunLoop (this0x55c1f2493020) at src/worker.cc:203 #4 0x000055c1f1d09da0 in thread_entry (arg0x55c1f2493020) at src/thread_util.cc:57每一帧从左到右是帧编号、代码地址、函数名、参数如果有调试信息、源码文件与行号。#0是线程当前停住的位置越往下的帧越是“谁调用了它”。看 pstack 最重要的是看#0这个停留点futex_wait表示在等锁或等条件变量poll表示在等 IOread表示阻塞在读。下面是几个常见帧特征速查表遇到时心里就有个底关键帧大概率说明__futex_wait/__pthread_cond_wait线程在条件变量或互斥锁上等待__poll/__GI___poll参数 timeout-1事件循环阻塞等待通常是正常状态read/recv/__read_nocancel线程阻塞在 IO 上nanosleep/sched_yield主动让出 CPU也可能是在忙等pthread_mutex_lock互斥锁等待死锁的高发点malloc/__libc_malloc在堆分配上阻塞或争用4. 实操过程一个典型的“服务假死”排查实录4.1 找到目标进程和可疑线程纸上谈兵够多了来一段完整的排查流程。某个系统有个网关进程PID 8876业务方反馈请求大面积超时但进程还活着、端口也通。第一步不是打开日志而是把进程和线程的分布摸清楚。ps -eLf | grep gw-server top -H -p 8876ps -eLf里每一行是一个 LWP能看到进程里有多少线程、每个线程 CPU 占用如何。top -H -p 8876则以线程维度展示 CPU 使用率找出哪个线程在猛转。发现线程 8912 的 CPU 占用明显异常其余线程基本是 0这就是重点怀疑对象。实操心得先top -H拿到线程号再在 pstack 输出里找对应的 LWP比对着几十上百行堆栈猜要快得多。线程号就是这个排查里的锚点。4.2 跑一次 pstack看整体分布执行 pstackpstack 8876输出很长但有规律。我习惯先分类大部分工作线程如果都停在条件变量等待上__pthread_cond_timedwait说明它们在等任务本身不算异常主线程停在poll说明事件循环在正常等事件。真正可疑的是那两三个停在pthread_mutex_lock上的线程。第二次采样间隔十秒左右再跑一遍。对比两次输出如果某个线程两次都停在同一个地址、同一把锁上基本可以判定它不是运气不好碰上了锁竞争而是真的拿不到锁了。再把所有等锁线程的参数里的 mutex 地址列出来发现 A 线程等着0x55c1f2a1b3a0而 B 线程等着0x55c1f2a1b370两个地址正好互相是对方已经持有的锁。这种交叉等待的地址对就是死锁的经典证据。pstack 虽不能直接告诉你“谁持有了哪把锁”但锁地址的交叉关系已经足够说明问题。4.3 交叉验证wchan、内核栈、文件描述符pstack 给的是用户态证据内核侧的信息可以用来进一步坐实判断。cat /proc/8876/wchan for t in /proc/8876/task/*; do echo $t: $(cat $t/wchan); done cat /proc/8876/task/8912/stackwchan显示线程当前阻塞在内核的哪个函数上比如futex_wait、do_wait、pipe_read。如果 pstack 显示线程在等锁wchan 又是futex_wait两个证据就对上了。/proc/pid/task/tid/stack能看到内核栈不过在kptr_restrict开启时可能只输出一堆 0需要 root 才读得全。还有一种很常用的交叉验证当 pstack 显示线程阻塞在read上打开/proc/pid/fd看这个线程手里的文件描述符到底连着什么。ls -l /proc/8876/fd | grep socket如果线程在read一个 socket而 socket 对端又是一个长期不发数据的下游依赖问题就清晰了不是你的服务死了是你依赖的下游不吐数据。4.4 采样两次法与脚本化排查pstack 一次输出只能代表一瞬间。我处理“假死”类问题时几乎从不看单次结果而是固定间隔采两次甚至三次样。判断逻辑很简单如果两次采样中某个线程的#0地址和关键帧地址完全一致那它是“卡住”了如果地址一直在变说明线程还在往前走只是慢。采样可以写成循环脚本for i in 1 2 3; do echo sample $i at $(date %H:%M:%S) pstack 8876 sleep 5 done带时间戳的输出可以直接留档事后跟代码 diff、跟监控曲线对齐都方便。注意pstack 有侵入性attach 的瞬间目标进程会有秒级停顿。对高流量线上服务尤其线程数上千的进程别在高峰期无差别循环采样。我的原则是先 pstack 一次拿到关键线索确认必要后再补第二次绝不空跑。4.5 穷人版 pstack没有 pstack 时怎么办有些精简容器里只有 gdb没有 pstack 脚本。这时候完全可以用一条 gdb 命令替代gdb -q -batch -nx -p 8876 \ -ex set pagination off \ -ex thread apply all bt想看得更细把bt换成带参数的bt full会连局部变量、函数参数一起打出来gdb -q -batch -nx -p 8876 \ -ex set pagination off \ -ex set print thread-events off \ -ex thread apply all bt full这个版本的额外好处是可以控制栈深度比如只看前五帧-ex thread apply all bt 5遇到上千线程的大进程全量bt打出来动辄几万行限制深度能让现场分析快很多。5. 常见问题与排查技巧实录5.1 权限类问题速查现象常见原因处理思路ptrace: Operation not permittedyamaptrace_scope1且不是目标进程的父进程用 root 或 CAP_SYS_PTRACE临时调高权限要谨慎容器内同样报 Operation not permitted容器缺少 CAP_SYS_PTRACE容器启动参数加--cap-addSYS_PTRACENo such processPID 已退出先ps确认 PID退出了只能抓 core 或下次提前采样输出全??二进制 strip、无 debuginfo装 debuginfo 包用 addr2line 手动解析地址权限问题里最容易忽略的是容器。有些容器编排平台默认不给 CAP_SYS_PTRACE在容器里哪怕 root 也 attach 不了宿主上的进程。遇到这种先确认是不是容器权限问题别在 pstack 用法上浪费时间。5.2 目标进程卡在 D 状态时怎么办还有一种让 pstack 直接“失效”的情况目标进程卡在 D 状态不可中断睡眠。进程在等磁盘 IO 或底层内核操作时对信号和 ptrace 都不响应pstack attach 上去会一直挂在那里。判断方式很简单ps -o pid,stat,wchan:30,cmd -p 8876STAT 列出现D基本就是不可中断状态。这时候我一般直接放弃 pstack转去看/proc/pid/task/tid/stack的内核栈或等 IO 恢复后再试。为了避免 pstack 干等可以用 timeout 兜底timeout 5 pstack 8876超时退出总比把排查窗口浪费在等待上强。5.3 大进程、高线程数的三个实用技巧线程数上千的进程pstack 用起来要讲究方法。第一优先用eu-stack而不是 gdb 封装版启动快、attach 快输出也不差。第二用 gdb one-liner 并限制栈深度thread apply all bt 3只看每线程最上面三帧判断停留点完全够用。第三看输出时先做“线程分类”把同样等待位置的线程归成一组而不是逐行读。还有一个长期建议在你的服务代码里给线程起名字。用pthread_setname_np或各语言的线程名机制让每个线程有一个可读的名字比如worker-io-0、cache-loader。这样 pstack 输出里的线程名会直接变成这些标识几百行堆栈里找重点线程的效率会高非常多。我见过太多线上服务所有线程都叫Thread-x排查时只能靠猜。5.4 别忘了那些“看起来没问题”的输出pstack 最迷惑人的时候恰恰是输出看起来一切正常的时候。所有线程都在正常等待主线程在poll工作线程在cond_wait好像什么都没坏。但服务就是超时。这种时候要看的不是“某个线程坏了”而是整体分布是否失衡。比如所有工作线程都在等同一个条件变量而负责投喂任务的生产者线程不见了或者生产者卡在了一个很深的 IO 调用上。再比如几十个线程同时等同一把锁虽然每个都只是短暂等待但整体形成“锁拥挤”吞吐量一样会崩。单看一个线程都是正常的组合起来就是事故现场。所以我的习惯是pstack 不仅要看还要带着“谁在干嘛、谁该干嘛、谁不在”三个问题去看。把线程按角色分类跟服务的架构预期比对异常往往就藏在“一个本该在工作的线程却出现在等待队列里”这类细节中。另外提醒一句pstack 看 Java 或 Python 进程时用处要大打折扣。JVM 的线程栈大多是libjvm.so里的原生帧业务代码在 JIT 编译后并没有稳定的 ELF 符号pstack 打出来基本没法读。Java 用jstackPython 用py-spy或faulthandler才是对口的工具。这篇把 pstack 的基础原理和操作流程讲完了。我自己的习惯是问题刚发生时先 pstack 抓现场无论能不能马上看懂都先把输出存成文件事后对照代码慢慢翻。堆栈信息就是事故现场的照片错过了这个时间窗口很多线索是补不回来的。建议你找一台测试机自己写一个死循环线程和一个双线程互相抢锁的 demo各跑一遍 pstack亲眼看看“卡死”和“活着但慢”在堆栈上是两种什么形态。练过这两次线上再遇到类似问题你就不会慌了。下一篇我们接着聊 core dump 离线分析、Java 与 Python 进程的栈查看以及 pstack 和 gdb 怎么配合做根因深挖。