1. 从一次线上卡顿说起pstack 到底能帮你看到什么线上服务突然变慢CPU 不高、内存正常、日志也没报错但接口响应时间从 50ms 涨到了 2s。这种场景下很多人第一反应是加日志、重启、扩容但往往治标不治本。我遇到过好几次类似情况最后都是靠pstack定位到某个线程卡在一把锁上或者某个函数调用栈异常深导致栈溢出。pstack这个工具看起来简单——就是打印进程的线程栈——但真正用好了它能帮你省下大量排查时间。这篇文章面向的是日常需要排查线上问题、做性能分析、或者单纯想搞清楚自己程序到底在干什么的开发者。不管你是刚接触 Linux 调试工具的新手还是已经用过gdb、perf的老手pstack都值得放进你的工具箱。它不需要你重新编译程序不需要加任何埋点只要进程还在跑一条命令就能把当前所有线程的调用栈打出来。这篇文章会从pstack的基本原理讲起拆解它背后的实现机制然后给出完整的实操步骤、参数选择依据、常见问题排查方法以及我在实际使用中踩过的坑和总结的技巧。读完你就能直接在自己的环境里复现并且知道什么场景下该用它、什么场景下该换别的工具。2. pstack 的核心原理与适用边界2.1 pstack 本质上做了什么pstack不是一个独立的二进制程序大多数 Linux 发行版上它其实是一个 shell 脚本内部调用的是gdb。你执行pstack pid的时候它实际做的事情是启动一个gdb进程attach 到目标进程上然后对每个线程执行thread apply all bt最后 detach 并退出。所以pstack的输出格式和gdb的bt命令完全一致只是它帮你自动化了这个过程。理解这一点很重要因为它决定了pstack的能力边界。gdb能看到的栈信息pstack就能看到gdb看不到的pstack也看不到。比如如果程序没有调试符号pstack打出来的就是一堆地址你需要自己用addr2line或者nm去翻译。再比如如果进程处于不可中断睡眠状态D 状态gdbattach 可能会失败或者卡住pstack自然也会受影响。注意有些发行版把pstack做成了独立的二进制比如某些嵌入式环境或者定制系统。这种情况下它的行为可能略有不同但核心逻辑仍然是读取目标进程的栈内存并做 unwind。你可以用file $(which pstack)确认一下你系统上的pstack到底是什么。2.2 为什么选择 pstack 而不是其他工具排查线程栈问题的工具不止pstack一个常见的有gdb、perf、eu-stack、cat /proc/pid/stack等。我选pstack的理由主要有三个第一它足够简单一条命令就能拿到所有线程的栈不需要记复杂的gdb命令第二它对目标进程的侵入性相对可控attach 和 detach 的过程很快不会像gdb交互式调试那样长时间挂起进程第三它的输出格式通用几乎所有 Linux 开发者都能看懂。但pstack也有明显的短板。它依赖gdb所以如果目标环境没有装gdbpstack就用不了。它默认只能打印用户态栈看不到内核态的信息。它也不能像perf那样做采样和火焰图只能给你某一个瞬间的快照。所以我的习惯是快速定位用pstack深度分析用perf交互式调试用gdb三者配合使用。2.3 什么场景下 pstack 最有用根据我的经验pstack在以下几类场景中特别有效死锁排查多个线程互相等待锁pstack能直接看到每个线程卡在哪个锁的等待上配合源码就能定位到具体的锁对象。线程卡死某个线程长时间不推进pstack连续打几次对比栈帧变化就能判断是卡在 IO、锁还是死循环。性能抖动服务偶尔变慢pstack在变慢的瞬间抓几次往往能抓到某个不常见的调用路径。栈溢出递归太深导致栈溢出pstack能看到异常深的调用栈直接定位到递归函数。第三方库问题调用第三方库时行为异常pstack能帮你确认到底进了哪个库函数。反过来如果问题是 CPU 持续高负载、内存泄漏、或者需要精确的耗时分布pstack就不是最佳选择应该优先考虑perf、valgrind或者heaptrack这类工具。3. 环境准备与 pstack 安装实操3.1 确认系统是否自带 pstack很多发行版默认就带了pstack你可以先执行which pstack看看。如果返回了路径比如/usr/bin/pstack那直接用就行。如果没有再考虑安装。需要注意的是有些系统上pstack可能被放在/usr/sbin或者/usr/local/bin下which不一定能找到可以用find / -name pstack 2/dev/null搜一下。另外即使pstack存在也要确认它依赖的gdb是否可用。执行gdb --version如果能正常输出版本号说明环境基本没问题。如果gdb不存在pstack执行时会报错提示找不到gdb。3.2 安装 gdb 和 pstack在 Debian/Ubuntu 系上安装命令是sudo apt-get update sudo apt-get install gdb在 RHEL/CentOS 系上sudo yum install gdb在 Alpine 上sudo apk add gdb安装完gdb之后pstack通常也会一起被装上因为很多发行版把pstack放在gdb包或者gdb的推荐依赖里。如果装完gdb还是没有pstack可以手动创建一个脚本。内容如下#!/bin/sh if [ $# -ne 1 ]; then echo Usage: $0 pid exit 1 fi gdb -q -nx -batch -ex thread apply all bt -p $1 2/dev/null保存为/usr/local/bin/pstack然后chmod x /usr/local/bin/pstack即可。这个脚本比某些发行版自带的pstack更干净去掉了多余的输出只保留栈信息。3.3 权限与安全限制pstack需要 attach 到目标进程这要求你有足够的权限。普通用户只能 attach 到自己启动的进程如果要 attach 其他用户的进程需要root权限或者相应的ptrace能力。在容器环境里还需要注意ptrace相关的 seccomp 或 apparmor 配置有些容器默认禁止ptrace会导致pstack失败。提示如果你在容器里执行pstack报ptrace: Operation not permitted先检查容器的securityContext或者docker run参数确认没有禁用SYS_PTRACE能力。生产环境里如果确实需要排查可以临时给容器加上这个能力排查完再移除。另外有些系统开启了yama保护限制ptrace的范围。你可以用cat /proc/sys/kernel/yama/ptrace_scope查看当前值。0 表示无限制1 表示只能 attach 子进程2 表示只能 attach 同用户且同会话的进程3 表示完全禁止。如果值是 1 或 2而你又不是目标进程的父进程就可能 attach 失败。临时调整可以用echo 0 | sudo tee /proc/sys/kernel/yama/ptrace_scope但生产环境不建议长期改成 0。4. pstack 完整实操流程与参数解析4.1 基本用法与输出解读最简单的用法就是pstack pid。比如目标进程 PID 是 12345执行pstack 12345输出会类似这样Thread 3 (Thread 0x7f8a2c3d1700 (LWP 12348)): #0 0x00007f8a2d4b5f47 in epoll_wait () from /lib64/libc.so.6 #1 0x0000000000401a2b in event_loop () at server.c:120 #2 0x0000000000401b3c in worker_thread (arg0x0) at server.c:200 #3 0x00007f8a2d6b6e25 in start_thread () from /lib64/libpthread.so.0 #4 0x00007f8a2d3f434d in clone () from /lib64/libc.so.6 Thread 2 (Thread 0x7f8a2cbd2700 (LWP 12347)): #0 0x00007f8a2d4b5f47 in epoll_wait () from /lib64/libc.so.6 #1 0x0000000000401a2b in event_loop () at server.c:120 #2 0x0000000000401b3c in worker_thread (arg0x0) at server.c:200 #3 0x00007f8a2d6b6e25 in start_thread () from /lib64/libpthread.so.0 #4 0x00007f8a2d3f434d in clone () from /lib64/libc.so.6 Thread 1 (Thread 0x7f8a2d8b5740 (LWP 12345)): #0 0x00007f8a2d4b5f47 in epoll_wait () from /lib64/libc.so.6 #1 0x0000000000401a2b in event_loop () at server.c:120 #2 0x0000000000401b3c in main (argc1, argv0x7ffd3c4e5a88) at server.c:300每一段对应一个线程Thread 3是线程编号括号里的LWP 12348是轻量级进程 ID也就是内核视角的线程 ID。下面的#0到#4是栈帧#0是当前执行位置数字越大表示调用层级越深。如果编译时带了-g选项你会看到函数名、文件名和行号如果没有就只能看到地址和库名。4.2 连续采样与对比分析单次pstack只是一个快照很多时候你需要连续打几次对比栈帧的变化。我通常用这样的循环for i in $(seq 1 5); do echo Sample $i pstack 12345 sleep 1 done这样能拿到 5 秒内的 5 个快照。如果某个线程的栈一直不变说明它卡住了如果栈在变化说明它在正常执行。对比的时候重点关注#0和#1帧这两个帧最能反映当前状态。还有一种做法是把输出重定向到文件然后用diff对比pstack 12345 /tmp/stack1.txt sleep 2 pstack 12345 /tmp/stack2.txt diff /tmp/stack1.txt /tmp/stack2.txtdiff的输出会直接告诉你哪些线程的栈发生了变化哪些没变。这个方法在排查死锁时特别有用因为死锁的线程栈通常完全不变。4.3 结合调试符号还原真实调用栈如果程序编译时没有加-gpstack输出里只有地址比如0x0000000000401a2b。这时候你需要用addr2line来翻译addr2line -e /path/to/binary -f -C 0x0000000000401a2b-e指定可执行文件-f输出函数名-C做符号 demangle。如果地址属于某个动态库你需要用对应的库文件来翻译比如addr2line -e /lib64/libc.so.6 0x00007f8a2d4b5f47。提示生产环境的二进制文件最好保留一份带调试符号的版本但不要部署到线上。排查问题时用带符号的版本去翻译地址既能拿到完整信息又不会因为符号文件增大部署体积。可以用objcopy --only-keep-debug把调试信息单独抽出来再用objcopy --add-gnu-debuglink关联回去。4.4 参数选择与性能影响pstack本身没有太多参数但底层gdb的行为会影响性能和稳定性。默认情况下gdbattach 会暂停目标进程的所有线程读取完栈信息后再恢复。这个暂停时间通常在毫秒级但如果进程线程很多、栈很深可能会达到几十毫秒甚至上百毫秒。对于延迟敏感的服务这个暂停可能造成请求超时。如果你担心影响可以改用eu-stack它是 elfutils 包里的工具不需要暂停进程直接读取栈内存。用法是eu-stack -p pid输出格式和pstack类似。但eu-stack对调试符号的依赖更强没有符号时可能只能打出地址。另一个选择是perf record -g -p pid -- sleep 5然后perf report。这种方式是采样对进程影响更小但拿到的是统计结果不是精确的某一时刻快照。我的建议是如果只是偶尔排查pstack完全够用如果要在高负载生产环境频繁抓栈优先考虑eu-stack或perf。5. 常见问题与排查技巧实录5.1 pstack 执行报错怎么办最常见的报错是ptrace: Operation not permitted。原因通常是权限不足或者ptrace_scope限制。排查步骤先确认当前用户是否有权限 attach 目标进程用ps -o user -p pid看进程属主然后检查ptrace_scope的值最后确认容器或安全模块是否禁用了ptrace。另一个常见报错是No such process这通常是因为 PID 写错了或者进程在你执行命令的瞬间退出了。可以先用ps -p pid确认进程还在。还有一种是gdb: command not found说明系统里没有gdb需要先安装。5.2 输出全是地址没有函数名这是没有调试符号的典型表现。解决办法有两个一是用带-g重新编译但生产环境通常不允许二是用addr2line手动翻译或者用gdb加载符号文件后再看。如果你有单独的调试符号文件可以在gdb里用symbol-file加载然后再执行thread apply all bt。还有一种情况是函数名被 strip 掉了但动态符号表还在。这时候pstack可能只能看到库函数名看不到你自己的函数名。可以用nm -D或者readelf -s确认符号表状态。5.3 栈信息不完整或明显异常有时候pstack打出来的栈只有一两帧或者栈帧之间明显不连续。这通常是因为栈被破坏或者编译器做了优化比如尾调用优化、帧指针省略。解决办法是编译时加上-fno-omit-frame-pointer这样栈帧会保留帧指针unwind 更可靠。但生产环境重新编译成本高所以更实际的做法是结合perf或者eu-stack交叉验证。如果栈里出现了??说明对应地址没有找到符号信息。可能是 JIT 生成的代码也可能是某个没有符号的第三方库。这时候只能靠地址范围和调用上下文来推断。5.4 高频调用导致进程卡顿前面提到过pstack会暂停进程。如果你在短时间内频繁执行pstack比如每秒一次持续几分钟目标进程可能会因为反复暂停恢复而出现明显延迟。我踩过一次坑在一个 QPS 很高的服务上连续跑了 100 次pstack结果监控显示 P99 延迟飙升。后来改成每 5 秒一次并且用eu-stack替代问题就消失了。所以我的经验是生产环境抓栈频率不要高于每 2 秒一次如果必须高频优先用eu-stack或perf抓栈时间尽量避开业务高峰。5.5 常见问题速查表问题现象可能原因排查方法解决建议ptrace: Operation not permitted权限不足或 ptrace_scope 限制检查进程属主和 ptrace_scope用 root 或调整 ptrace_scopeNo such processPID 错误或进程已退出ps -p pid确认重新获取 PID输出全是地址缺少调试符号file查看是否 stripped用 addr2line 翻译或加载符号文件栈帧不完整栈破坏或编译优化检查编译选项加-fno-omit-frame-pointer重编译进程卡顿频繁 attach 导致暂停观察延迟监控降低频率或改用 eu-stackgdb: command not found未安装 gdbwhich gdb安装 gdb6. 几个真实场景的排查思路6.1 死锁场景两个线程互相等待有一次线上服务完全无响应日志停止输出CPU 接近 0。我执行pstack后看到两个线程的栈分别是Thread 2: #0 __lll_lock_wait () #1 pthread_mutex_lock () #2 update_config () at config.c:88 #3 handle_request () at server.c:150 Thread 3: #0 __lll_lock_wait () #1 pthread_mutex_lock () #2 reload_config () at config.c:120 #3 timer_thread () at timer.c:60两个线程都卡在pthread_mutex_lock上一个在update_config一个在reload_config。看源码发现update_config先拿锁 A 再拿锁 B而reload_config先拿锁 B 再拿锁 A典型的 AB-BA 死锁。定位到之后改成统一的加锁顺序就解决了。这个案例里pstack的价值在于它直接告诉了我两个线程分别卡在哪个函数的哪一行不需要我去猜。如果没有pstack我可能要先加日志、重启、复现整个过程至少多花半小时。6.2 线程卡死场景IO 等待还是死循环另一个案例是某个工作线程不再处理任务但进程还在跑。pstack连续打三次发现该线程的栈一直是#0 0x00007f8a2d4b5f47 in epoll_wait () #1 0x0000000000401a2b in event_loop () at server.c:120栈完全不变说明它卡在epoll_wait上也就是没有事件到来。这本身不是 bug但结合业务逻辑发现上游应该发事件却没发问题出在上游。如果栈是变化的比如在某个循环里反复执行那就是死循环。所以连续采样对比是区分 IO 等待和死循环的关键手段。6.3 性能抖动场景偶发慢请求的根因服务 P99 延迟偶尔飙高但平均延迟正常。我在延迟飙高的瞬间抓了几次pstack发现某个线程偶尔会出现在malloc里#0 malloc () #1 std::string::_M_mutate () #2 process_request ()结合代码发现这个请求路径里有一个大字符串拼接每次都会触发内存分配。在内存紧张时malloc会变慢导致偶发延迟。后来改成预分配缓冲区问题就消失了。这个案例说明pstack不仅能排查故障还能辅助性能优化。7. 我个人的使用心得与建议pstack这个工具我用了很多年最大的体会是它简单到容易被忽视但关键时刻能救命。我的建议是把它加入你的日常排查清单和top、iostat、netstat放在一起。平时可以不用但一旦遇到线程卡死、死锁、响应慢这类问题第一时间想到它。另外不要只依赖pstack。它给的是快照不是全貌。配合perf做采样、配合strace看系统调用、配合gdb做交互式调试才能形成完整的排查能力。还有一点生产环境的二进制尽量保留调试符号哪怕不部署到线上也要存档。否则真出问题时你只能对着一堆地址干瞪眼。最后分享一个小技巧如果你经常需要抓栈可以写一个脚本自动连续采样、自动对比、自动高亮变化的栈帧。这样排查效率会高很多。我自己写过一个简单的版本核心逻辑就是循环调用pstack然后用awk提取每个线程的#0帧对比前后差异。这个脚本后来成了我们团队排查线程问题的标配工具。