pstack完全指南:从原理到实战排查进程卡死与死锁

发布时间:2026/10/15 11:57:28
pstack完全指南:从原理到实战排查进程卡死与死锁
作为一个长期跟线上问题打交道的开发者我越来越依赖 pstack 这个命令。不管是排查线程卡死、定位死锁还是确认某个进程到底卡在哪个调用栈里pstack 都是第一个会被我想起的工具。这篇文章是 PStack 项目的作者写的 pstack 完整指南上我会从原理讲到实操把那些 man 手册里不会写、但实际排查中必须知道的东西一并整理出来。pstack 不是什么新概念它本身是 Linux 下查看进程调用栈的命令。传统实现通常是一个脚本底层调用 gdb 进入批处理模式打印出指定进程所有线程的调用栈。它解决的核心问题非常直接当一个进程没有任何业务日志输出、看起来像卡住的时候你总得知道它到底卡在了哪里。适合刚入门 Linux 排查的新手也适合已经在生产环境摸爬滚打多年、想系统梳理一遍底层逻辑的老手。我写 PStack 这个项目的初衷就是因为在真实环境里被传统 pstack 折腾过太多次附加太慢、权限老报错、堆栈动不动就乱码。所以这篇指南会把工具本身的局限和替代方案讲清楚而不是只给一套背命令的口诀。1. 为什么 pstack 值得一次完整梳理1.1 一个命令背后解决的真实问题刚接触 Linux 服务端开发的时候我最常听到的一句话是“进程 hung 住了”。什么叫 hung 住业务接口不返回日志不刷新CPU 占用率却是满的或者干脆是 0。这种情况最棘手的地方在于你没有任何现场可以用程序就像一个黑盒一样沉默。pstack 恰恰是用来撬开这个黑盒的。它的核心价值就一句话把你进程当前所有线程正在执行的位置以函数调用栈的形式输出。有了调用栈你就能立刻判断出大概的嫌疑范围。比如线程卡在futex_wait多半是锁竞争或者死锁卡在read/write多半是在等待 IO卡在一个 CPU 密集型函数里那可能真的是死循环。我记得有一次排查某个状态机服务进程正常响应但有个核心业务线程每隔几小时就消失一次。加日志、看监控都查不到最后就是用 pstack 抓了三次栈发现线程总在同一个哈希桶遍历函数里循环顺着这个线索找到了一个极端情况下会漏掉指针推进的递归分支。没有栈那个问题我大概率要排查几天。所以 pstack 不是锦上添花的工具它是线上故障的第一现场记录仪。尤其对于没有集成 perf、没有完善的 APM 系统、或者你根本来不及上监控的老旧系统一条 pstack 命令就是最朴素也最可靠的现场采集手段。1.2 已有工具与 PStack 的定位差异很多人会问gdb attach就能打印栈为什么要单独学 pstack区别在两点速度和操作代价。传统 pstack 脚本本质上还是gdb -p PID -batch -ex thread apply all bt的封装。gdb 本身是一个重量级调试器启动时要加载符号文件、初始化各类模块附加到进程时会暂停所有线程。如果这个进程有几百个线程那么从附加到打印完成可能需要几十秒甚至分钟级别的时间。在生产环境里这就是妥妥的故障扩大化。PStack 的定位是做一个更轻量、更可控的栈采集工具。它直接基于 ptrace 系统调用读取目标进程的线程寄存器与栈内存不依赖 gdb也就是不需要启动完整的调试器同时支持超时退出、指定线程、采集次数降低对生产进程的侵入性。它还针对多架构做了适配x86_64、ARM64 都能用。还有一个常见场景是内核栈。传统 pstack 只打印用户态栈而/proc/PID/stack可以看内核态栈。但要拿到它需要 root 权限而且输出是内核函数符号很多运维同学看着一头雾水。PStack 把用户态和内核态的采集方式做了整合在权限允许时能一次给出两个视角。这篇指南主要讲的是 pstack 这个通用主题但我会穿插说明哪些地方 PStack 做了改进方便你判断在什么场景下值得换工具。2. pstack 的核心工作原理2.1 从 /proc 到 ptrace它靠什么拿到调用栈如果你只想知道怎么用不看原理也完全可以。但如果你要用好它、应对各类报错原理这关绕不过去。传统 pstack 之所以能拿到所有线程的栈前提是 Linux 在/proc文件系统里暴露了线程信息。以/proc/PID/task目录为例每个子目录代表一个线程。真正的调用栈信息来自两处寄存器内容尤其是栈指针 SP、程序计数器 PC、帧指针 FP和栈内存。但要读取别的进程的寄存器光靠 procfs 还不够难度也不小所以传统实现选了一条更省事的路调用 gdb让 gdb 通过 ptrace 完成读取并借助调试信息做符号化。ptrace 是 Linux 上调试器的基础机制。当一个调试进程对目标进程执行PTRACE_ATTACH后目标进程会进入停止状态并成为调试进程的 tracee。此时调试进程可以读取它的寄存器、读写它的内存、设置断点甚至可以单步执行。gdb 的pstack模式就是在这个状态下执行thread apply all bt逐线程完成栈回溯。但要特别注意ptrace_attach 本身会发送SIGSTOP让目标进程停止整个过程中目标进程的每一个线程都会被暂停。这意味着对于某些高实时性的服务哪怕只抓几百毫秒也可能造成接口抖动。这也是我强调生产环境慎用 gdb 附加的原因。现代 Linux 还有一个对 ptrace 权限的限制机制叫ptrace_scope。它由/proc/sys/kernel/yama/ptrace_scope控制除了设置为 0 时允许任意进程互相附加默认情况下只能附加直接子进程或显式调用PR_SET_PTRACER声明的进程。这也是为什么很多人在生产机器上执行 pstack 会看到 attach 失败而实际上 swap 到同一用户的 shell 下再执行却可能成功。下面实例章节我会给具体排查办法。2.2 用户态栈回溯与内核栈回溯的差异用户态栈回溯依赖的是被调试进程的调用栈。这里有个常被误解的点栈本身并不是一串带符号的信息它只是保存在内存里的一组返回地址和局部变量。要把地址翻译成funcA() - funcB()这样的可读形式必须借助符号表和调试信息。通常的做法是根据目标进程访问到的ELF文件里的.symtab/.dynsym段获得函数名再结合.eh_frame或 DWARF 调试信息计算每一帧对应的调用帧地址。也就是说如果程序是用strip处理过的、或者编译时去掉了.eh_frame那么回溯出来的栈可能是返回地址 偏移量甚至没法回溯。内核栈则完全不同。/proc/PID/stack这个入口直接由内核维护它反映的是进程当前在内核态的执行路径。注意这里面显示的是函数符号地址不是偏移量所以内核必须配置CONFIG_STACKTRACE且通常只有 root 能读取。它最有用的场景是当进程阻塞在系统调用、或者在内核路径中卡死时你能看到它在内核里钻到了哪个函数。我见过一个网络服务表现为某个连接线程突然无法收发数据pstack 用户态栈显示线程正在执行sendmsg但看不到更多信息。配合/proc/PID/stack才看到它阻塞在内核的某个锁等待函数上。这就是用户态栈和内核栈结合使用的价值所在。2.3 符号化的关键编译选项与调试信息既然栈回溯最终依赖符号信息那么一个很实际的问题就是线上二进制往往被 strip 过或者只是 release 编译怎么办首先函数名其实有两套来源。一套是动态符号表.dynsym它即使在 strip 之后也会保留因为动态链接器需要使用它来解析外部符号另一套是完整符号表.symtab通常会被 strip 删掉。大多数情况下我们自己程序的代码段符号会被放进.symtab被 strip 后 pstack 就只能看到地址。但如果你依赖系统的动态库动态库的.dynsym一般是完整的所以 glibc 里的futex_wait、malloc这些符号大概率还是能出来的。其次编译器优化对栈回溯影响很大。如果程序在编译时使用-fomit-frame-pointer许多古老的回溯方式会失效因为函数不再把帧指针 FP 保存到固定寄存器。这时必须依赖.eh_frame或者 DWARF 里的回溯规则。所以你在线上看到的很多栈只有最外层几帧不是工具坏掉了而是信息真的不在。一个实用的经验如果二进制是自己做的最好保留一份和发布版本完全一致的调试符号文件例如通过objcopy --only-keep-debug单独抽出排查时手动将地址换算成函数名。PStack 在设计中支持加载外部符号文件和addr2line对接就是为了处理这个场景。3. 实操让 pstack 真正跑起来3.1 安装、权限与快速上手直接讲传统 pstack 的安装。大多数 Linux 发行版都能通过包管理器直接安装Debian/Ubuntu 系执行apt-get install pstackCentOS/RHEL 系执行yum install pstack。需要注意这个软件包在很多发行版里就是 gdb 的一个包装脚本装好后你执行file $(which pstack)会看到它是一个 shell 脚本。如果是 PStack 这类自研工具安装方式通常是直接放置编译好的二进制到/usr/local/bin然后给执行权限。它的用法和执行 pstack 很接近pstack -p 12345这条命令会把进程 12345 的所有线程栈一次性打印出来。与 gdb pstack 不同的是PStack 支持更多控制性参数例如# 只采集指定线程例如 12345 进程的 12349 线程 pstack -p 12345 -t 12349 # 限制整体采集时间超过 10 秒强制退出 pstack -p 12345 --timeout 10s # 连续采集 3 次每次间隔 2 秒 pstack -p 12345 -n 3 -i 2s为什么会需要超时和连续采集因为栈抓一次有时候不能说明问题。一个线程可能在瞬间从futex_wait被唤醒然后又进入read一次快照造成误判。连续采样能够看趋势比如断言它总是停在某个函数这比单张快照要靠谱得多。这些设计都是我在实际采集卡死进程时体会到的硬需求。权限上PStack 和传统 pstack 都要求你有足够的 ptrace 权限。用 root 执行当然最直接但运维同学要记住即使你是 root如果容器或者安全模块限制了SYS_PTRACEcapability同样会失败。后面常见问题里我会详细列排查命令。3.2 一份输出带来的判读思路来看一个典型输出这是类似真实输出的示意具体地址和符号名按环境会不同Thread 8 (Thread 0x7f1a0f7ff700 (LWP 23532)): #0 futex_wait (addr0x7f1a0c0b9d20, ...) #1 do_futex_wait.constprop #2 __pthread_cond_timedwait #3 CondVar::TimedWait #4 TaskRunner::IdleLoop #5 ThreadEntry #6 start_thread #7 clone看到这样的栈可以马上建立判断线程停留在futex_wait相关的等待上上面是条件变量等待pthread_cond_timedwait再往上是业务层TaskRunner::IdleLoop说明它是一个等待唤醒的工作线程处于正常空闲状态并没有卡死。再比如这样Thread 1 (Thread ...): #0 __lll_lock_wait #1 __pthread_mutex_lock #2 WorkQueue::Push #3 Dispatcher::Dispatch #4 main线程在__lll_lock_wait等锁外层是WorkQueue::Push。此时如果其他线程没有对应的解锁动作基本可以确认锁竞争点在这里。要把死锁坐实你还需要抓所有线程的栈找到谁持有这把锁且不再释放。如果另一线程持锁后停在别的地方两边的栈正好互相印证这就完成了死锁判断。我在读 pstack 输出时有一个习惯先看所有线程的#0帧统计它们集中在哪些内核/库函数上。如果几十个线程集体卡在futex_wait大概率是锁竞争激烈如果只有一个线程卡在异常位置其他线程都正常则重点看这个线程的执行路径。只看单线程的原始输出很容易被误导全局列表才是关键。3.3 与 /proc 目录配合使用的冷门技巧pstack 给的是用户态栈但它不是孤岛。你要判断一个进程是否真的死锁只看栈不够还要结合进程状态和系统调用来验证。一个常用组合是# 查看线程状态和内核栈 cat /proc/PID/task/*/stat cat /proc/PID/task/*/stack # 查看当前正在执行的系统调用 cat /proc/PID/task/*/syscall/proc/PID/task/TID/syscall能告诉你当前线程正卡在哪个系统调用上。比如输出里显示read系统调用号为0参数有 fd你就能知道它在等待 socket 可读。配合 pstack 输出里的read帧判断就非常扎实。还有一个我经常用的场景进程 CPU 占用率很高但 pstack 显示线程栈都在正常的业务函数中没有明显等待。这时候可以对同一进程连续抓几次栈对比不同时刻 PC 寄存器所处的位置是否一致。一致则说明这里大概率是死循环不一致则需要进一步用 perf 采样确认热点函数。4. 常见问题与排查技巧实录4.1 权限问题Could not attach 的完整排查链路这是 pstack 使用中遇到频率最高的问题报错形式通常是Could not attach to process或者Operation not permitted。我整理了一条排查链路照着走就能定位。第一确认 ptrace_scope 的限制cat /proc/sys/kernel/yama/ptrace_scope如果值为 1 或更高那么非 root 用户只能附加自己的子进程。临时放宽可以执行echo 0 | sudo tee /proc/sys/kernel/yama/ptrace_scope注意这个改动重启后会失效而且会降低系统安全性只建议在隔离调试机上进行。更安全的方式是设置/proc/PID/attr/runstate或者在目标进程中调用prctl(PR_SET_PTRACER, ...)但生产环境很少能改代码所以临时放宽 ptrace_scope 是最常见的做法。第二检查目标进程的用户归属。传统 pstack 脚本用 gdb 附加目标进程时当前用户必须拥有 PTRACE_ATTACH 权限。最省事的办法是用与目标进程相同的系统用户执行或者直接 sudo 成 root。第三容器环境要特别注意 capability。如果目标进程运行在 Docker 容器中宿主机上直接执行 pstack 可能看到的是宿主机 PID namespace 外的视图。你需要先确认 PID namespace通常使用nsenter -t TID -m -p进入对应 namespace 后再执行 pstack或者给容器追加--cap-addSYS_PTRACE。没有SYS_PTRACE这个 capability容器内的调试工具无法附加到非子进程上。这就引出一个经验在生产 Docker 镜像里默认不带 pstack 和 gdb遇到问题时现场安装又很慢。我建议在每个基础镜像里预装一个静态编译的 pstack 工具哪怕只是 busybox 加一个配套二进制排查时能节省大量时间。4.2 抓栈时卡住工具比目标进程还慢怎么办传统 pstack 抓一个几百线程的进程时经常会出现命令半天不返回的情况。这是 gdb 附加并准备回溯所有线程时的正常现象但也确实让人抓狂。如果你连接着一个卡死的进程执行 pstack而被调试方的不稳定状态波及到调试器可能整个命令都 hang 住而不是输出正常结果。解决办法有几个。第一给 pstack 命令本身加超时比如在 shell 里用timeout 10s pstack -p PID。这样即使 gdb 卡住10 秒后也会被强杀。第二使用直接基于 ptrace 实现的工具比如 PStack它对单次采集有内部超时限制并且只会为目标进程的线程做一次快照不会加载历史断点和可执行文件内部状态。第三如果实在只能用 gdb可以先gdb -p PID进入交互模式再用set timeout 30配置超时再执行相应命令这比裸跑 batch 模式要可控。另一个卡住的原因是在某些内核版本下读取/proc/PID/task/TID/stat或/proc/PID/stack也会因目标进程的页表状态异常而短暂阻塞。这种情况比较罕见但如果你给 pstack 加了超时它就不会堵死你的操作终端。我真实踩过的一个坑是在strace跟踪着目标进程时执行 pstackptrace 会产生冲突。因为一个进程同时被两个调试器一类的机制跟踪内核会拒绝新的附加。这时候必须先把 strace 停掉再执行 pstack。这个冲突在某些内核版本中表现为Device or resource busy我看到这个报错的第一反应不是去查权限而是先去ps ef看有没有别人正 attach 着该进程。4.3 符号乱码与栈回溯不完整有时候 pstack 打出来的栈是这个样子#0 0x00007f1a0c2d3a #1 0x00007f1a0c2d3f #2 0x0000562f2ab3cde全是裸地址完全无法阅读。原因通常有两个目标二进制被 strip或者动态库的符号表缺失。此时不要马上认为工具没用用下面这套组合拳可以救回来。首先确认目标进程的 maps 信息看可执行文件和动态库的加载基址cat /proc/PID/maps | head -20然后把 pstack 输出的地址换算成模块内偏移。例如裸地址是0x00007f1a0c2d3amaps 里看到libfoo.so的起始地址是0x00007f1a0c000000那么偏移量就是python3 -c print(hex(0x00007f1a0c2d3a - 0x00007f1a0c000000))得到偏移后用addr2line -e libfoo.so -f 0x2d3a就能还原函数名和行号。前提是你手头有这个版本的动态库对应的调试符号文件。如果没有可以尝试eu-stack或者gdb在加载了本地 debuginfo 后重新回溯。栈回溯不完整还有一个常见原因是 frame pointer 优化。编译时如果使用-O2 -fomit-frame-pointer古老的基于 ebp/rbp 回溯法会在第一个没有 frame pointer 的函数处断裂。现代工具用.eh_frame可以绕过去但.eh_frame被 strip 时也会丢失。所以我编译发布版本时有一条硬性要求如果希望线上还能用 pstack 定位至少保留一个独立的.eh_frame段或者把调试信息抽离成独立包随发布物一起归档。这个成本很低收益却很大。4.4 一次死锁定位的完整复盘最后分享一个完整的排查案例用来把前面所有招式串起来。某次模拟压测中一个数据同步服务的某个线程组停止响应。我用 PStack 连续采集了三次间隔 2 秒发现线程 A 每次都以__lll_lock_wait为栈顶进入ConcurrentQueue::Pop线程 B 则以futex_wait等待条件变量进入ConcurrentQueue::Push中的cv.wait。看起来各等各的并没有明显互斥。关键在第三次输出里线程 A 的栈上有中间帧Consumer::ProcessBatch - BatchPool::Release - Batch::DecRef而线程 B 的栈上有Producer::Enqueue - BatchPool::Acquire - PoolMutex.lock。把两者拼起来生产者取批次块时要锁PoolMutex消费者释放批次块时又要拿同一把锁但消费者手里还正占着另一个已释放批次块的内部锁导致生产者等锁消费者等条件变量最终形成闭环。从结果看pstack 本身没有直接告诉我死锁结论但它提供了两个互锁线程的完整路径。我结合代码里BatchPool::Acquire与Batch::DecRef的处理顺序才得出死锁成立。所以用 pstack 时不要指望一条命令给你答案它给的是证据链条判断需要人来做。这里还要说一个容易踩的坑有些死锁根本不是两个线程直接互持一把锁而是三个线程构成循环等待。这时单看两张栈很可能误判。我建议在怀疑死锁时把进程的全部线程栈打印成一个文件然后逐个标记每个线程顶层锁等待的是哪把锁、它之前已经持有哪把锁画一个锁序图。这个动作在文本层面就能做不需要什么高级工具但能挡住大部分自以为是死锁实锤的误判。这篇上篇的内容到这里我自己重新过了一遍仍然觉得确确实实应该把原理和基础放在前面。工具是越用越顺手的但前提是你知道它在做什么、它可能在哪些条件下失效。下一篇我打算把批量进程采集、自动化分析死锁的具体脚本、以及内核栈和用户态栈交叉验证的实战展开写。到时候我们可以直接从真实案例开始动手。