1. 从一次线上卡顿说起pstack 到底解决什么问题线上服务跑着跑着突然响应变慢CPU 看着不高日志也没什么异常重启之后又能撑一阵子——这种问题最让人头疼。我最早接触 pstack就是因为一个后台服务每隔几天就出现一次“假死”进程还在端口还监听但请求就是卡住不返回。当时试过加日志、加监控都没抓到根因最后用 pstack 连续打了几次调用栈才发现是某个锁在特定条件下被长时间持有导致工作线程全部堵在等锁上。pstack 这个工具说白了就是把一个正在运行的进程的调用栈打印出来。它不是一个独立的大而全的调试器而是一个轻量级的“快照工具”——你给它一个进程号它就把这个进程当前所有线程的函数调用链展示给你看。对于排查死锁、死循环、线程阻塞、性能瓶颈这类“运行时状态”问题它几乎是成本最低、见效最快的手段之一。这篇文章适合谁看如果你写过 C/C 服务、做过后端开发、或者需要维护长时间运行的后台程序pstack 应该是你工具箱里的常备品。哪怕你平时用 Go、Java理解 pstack 的输出逻辑对看懂其他语言的栈信息也有帮助。我会从它背后的原理讲起把安装、使用、输出解读、常见坑、实战排查思路全部串一遍尽量做到你看完就能直接上手。需要先说明一点pstack 本质上是一个封装脚本它依赖系统底层的调试能力。不同发行版、不同内核版本下它的行为和可用性差异很大这也是很多人“照着教程敲却报错”的根本原因。所以第一部分我不急着给命令而是先把它的工作原理和适用边界讲清楚这样后面遇到问题你才知道往哪个方向查。1.1 pstack 的本质它其实不是一个人在战斗很多人以为 pstack 是一个编译好的二进制程序其实在大多数 Linux 发行版上pstack只是一个 shell 脚本。你可以在终端里执行which pstack然后cat一下它会发现里面通常调用了gdbGNU 调试器或者eu-stackelfutils 套件里的工具。以最常见的实现为例pstack 脚本的核心逻辑大致是这样接收一个 PID 参数然后调用 gdb让 gdb attach 到目标进程执行thread apply all bt对所有线程打印回溯最后 detach 退出。所以真正干活的底层工具是 gdbpstack 只是帮你把这套操作包装成了一条命令。这就解释了几个关键现象为什么 pstack 需要权限gdb attach 进程需要ptrace权限普通用户只能 attach 自己的进程attach 别人的进程通常要 root 或者配置ptrace_scope。为什么 pstack 会“暂停”进程gdb attach 的瞬间目标进程会被暂停stop打印完栈之后再恢复continue。所以对高并发服务频繁执行 pstack是会影响性能的。为什么有些环境没有 pstack因为它不是核心包很多精简镜像里默认不装需要手动安装 gdb 或 elfutils。理解这一层你就能明白pstack 的能力上限其实取决于底层 gdb 或 eu-stack 的能力。gdb 能做的pstack 基本都能做gdb 受限的地方pstack 也一样受限。1.2 调用栈是什么给进程拍一张“此刻在干什么”的 X 光片要读懂 pstack 的输出得先理解“调用栈”这个概念。我用一个生活化的类比把进程想象成一栋办公楼每个线程是一个正在办公的员工。员工手头的工作可能是一层层委派下来的——经理让主管做一件事主管又交给专员专员正在执行具体动作。调用栈就是记录“这个员工当前的工作是怎么一层层派下来的”。在程序里函数 A 调用函数 BB 调用 CC 正在执行那么 C 的栈帧stack frame就在最上面往下依次是 B、A。pstack 打印出来的就是每个线程从“当前正在执行的函数”一路回溯到“线程入口函数”的完整链条。一个典型的栈长这样#0 0x00007f8b2c4a1e5d in __lll_lock_wait () from /lib64/libpthread.so.0 #1 0x00007f8b2c49d9a1 in _L_lock_1000 () from /lib64/libpthread.so.0 #2 0x00007f8b2c49d7f8 in pthread_mutex_lock () from /lib64/libpthread.so.0 #3 0x0000000000401a2b in worker_process (arg0x0) at worker.c:88 #4 0x00007f8b2c497ea5 in start_thread () from /lib64/libpthread.so.0 #5 0x00007f8b2c3beb0d in clone () from /lib64/libc.so.6从下往上看线程由start_thread启动进入worker_process在第 88 行调用pthread_mutex_lock然后卡在__lll_lock_wait——这说明这个线程正在等一把互斥锁。#0是当前执行点编号越小越靠近“现在”。这里有个关键点栈顶#0告诉你进程此刻卡在哪栈的整体形状告诉你它是怎么走到这里的。排查问题时这两条信息缺一不可。只看栈顶你可能知道它在等锁但不知道是哪个业务逻辑触发的只看整体又抓不住当前的阻塞点。1.3 什么时候该用 pstack什么时候别用pstack 不是万能的它有非常明确的适用场景。用对了事半功倍用错了要么抓不到问题要么把线上服务搞出二次故障。适合用 pstack 的场景进程假死、无响应想快速知道所有线程卡在哪里。死锁排查多个线程互相等锁栈里会清晰显示等待关系。CPU 飙高定位配合top -H找到高 CPU 线程再用 pstack 看它在执行什么。偶发卡顿抓现场脚本化定时采样事后分析。死循环确认某个函数反复出现在栈顶基本可以锁定。不适合用 pstack 的场景追求极致低开销的高频采样pstack 每次 attach 都会暂停进程高频使用影响大这种场景更适合 perf 这类采样工具。没有调试符号的发布包栈里全是地址没有函数名和行号可读性极差。容器内权限受限很多容器默认禁用了 ptracepstack 直接报错。对实时性要求极高的交易类进程哪怕几十毫秒的暂停也可能造成问题需谨慎评估。提示在生产环境使用 pstack 前务必确认目标进程能承受短暂的暂停。对于核心交易链路建议先在预发环境验证或者选择业务低峰期操作。2. 环境准备让 pstack 真正跑起来很多人第一次用 pstack 就碰壁报错五花八门其实大部分问题都出在环境上。这一部分我把安装、权限、调试符号这三件事讲透这是后面所有实操的前提。环境没弄对后面看再多输出解读都是空中楼阁。2.1 安装不同发行版的正确姿势pstack 本身通常包含在gdb包里或者由elfutils提供。下面按常见发行版分别说明。Debian / Ubuntu 系列# 安装 gdbpstack 脚本通常随 gdb 一起提供 sudo apt-get update sudo apt-get install -y gdb # 如果系统没有自带 pstack 脚本可以手动创建一个 sudo tee /usr/local/bin/pstack /dev/null EOF #!/bin/sh if [ -z $1 ]; then echo Usage: pstack pid exit 1 fi exec gdb -q -nx -batch -ex thread apply all bt -p $1 2/dev/null EOF sudo chmod x /usr/local/bin/pstackCentOS / RHEL / Rocky 系列# 安装 gdb sudo yum install -y gdb # 部分版本 pstack 由 gdb 包提供验证一下 which pstack || echo 需要手动创建脚本Alpine容器常用# Alpine 默认用 musl libcgdb 支持有限推荐用 elfutils 的 eu-stack apk add --no-cache elfutils # 使用方式eu-stack -p pid这里有个经验Alpine 环境下 gdb 经常不好使因为 musl libc 和 glibc 的调试信息格式有差异gdb 可能 attach 失败或者栈信息残缺。这种情况下eu-stack是更靠谱的选择它来自 elfutils对 musl 支持更好。2.2 权限ptrace 这道坎绕不过去pstack 报错里最常见的就是权限问题典型报错是ptrace: Operation not permitted.或者Could not attach to process. If your uid matches the uid of the target process, check the setting of /proc/sys/kernel/yama/ptrace_scope.这背后是 Linux 的 Yama 安全模块在起作用。ptrace_scope有几个取值含义如下取值含义影响0经典模式任意进程可 attach 任意进程需权限1受限模式默认只能 attach 自己的子进程或 root2仅管理员只有 root 能 attach3完全禁用任何 attach 都不允许查看当前值cat /proc/sys/kernel/yama/ptrace_scope临时放开重启失效sudo sysctl -w kernel.yama.ptrace_scope0永久生效echo kernel.yama.ptrace_scope 0 | sudo tee /etc/sysctl.d/10-ptrace.conf sudo sysctl -p /etc/sysctl.d/10-ptrace.conf注意把 ptrace_scope 设为 0 会降低系统安全性意味着同用户下的进程可以互相 attach。生产环境建议保持默认的 1用 root 或 sudo 执行 pstack而不是全局放开。容器环境还有额外一层seccomp 和 capability。Docker 默认的 seccomp 配置会拦截ptrace系统调用即使容器内是 root 也会失败。解决办法是启动容器时加--cap-addSYS_PTRACE或者调整 seccomp 配置。Kubernetes 里则需要在 securityContext 里加capabilities.add: [SYS_PTRACE]。2.3 调试符号决定栈信息可读性的关键这是最容易被忽视、但对排查效率影响最大的一环。没有调试符号pstack 输出长这样#0 0x0000000000401a2b in ?? () #1 0x0000000000401b3c in ?? () #2 0x00007f8b2c497ea5 in start_thread () from /lib64/libpthread.so.0全是地址和问号等于白打。有了调试符号才会变成#0 worker_process (arg0x0) at worker.c:88 #1 start_thread () from /lib64/libpthread.so.0函数名、文件名、行号一应俱全排查效率天差地别。编译时加-g是最基本的gcc -g -O2 -o myapp myapp.c但这里有个坑-O2优化会打乱栈帧导致某些函数被内联inline栈里看不到它们行号也可能对不上。排查阶段建议用-O0 -g编译或者至少用-O2 -g -fno-omit-frame-pointer保留帧指针能让栈回溯更准确。如果程序已经发布、不方便重新编译可以单独保留调试信息文件。用objcopy把调试信息剥离到单独文件# 剥离调试信息到 myapp.debug原程序保留 objcopy --only-keep-debug myapp myapp.debug objcopy --strip-debug myapp objcopy --add-gnu-debuglinkmyapp.debug myapp这样发布的是精简后的 myapp调试时 gdb 会自动去 myapp.debug 里找符号。这个做法在正式环境很常见既减小了二进制体积又保留了排查能力。3. 基础用法与输出解读把栈信息读成故事环境弄好之后就可以正式上手了。这一部分我先讲最基本的命令用法然后重点讲怎么读输出——这是很多人卡住的地方栈打出来了但看不懂等于没打。3.1 最常用的几条命令最基础的用法就是给一个 PIDpstack 12345如果系统没有 pstack 脚本直接用 gdb 等价实现gdb -q -nx -batch -ex thread apply all bt -p 12345几个参数解释一下-q安静模式不打印版权信息-nx不加载.gdbinit避免本地配置干扰-batch批处理模式执行完自动退出-ex执行指定命令-p指定进程。如果只想看某个特定线程可以先thread apply all bt看全貌再针对性深入。gdb 交互模式下还能做更多事比如切到某个线程看局部变量gdb -p 12345 (gdb) thread apply all bt (gdb) thread 3 (gdb) bt full (gdb) info localsbt full会打印每一帧的局部变量对定位问题非常有帮助但输出量大建议先bt定位到可疑线程再bt full。3.2 读懂栈顶进程到底卡在哪栈顶#0是信息量最大的地方。我总结了几种常见的栈顶形态和它们的含义。形态一卡在锁等待#0 __lll_lock_wait () #1 _L_lock_1000 () #2 pthread_mutex_lock () #3 process_request (req0x7f...) at server.c:210这说明线程在server.c:210处尝试获取一把互斥锁但锁被别的线程占着只能等。这时候你要去找“谁持有这把锁”——通常另一个线程的栈里会有pthread_mutex_lock之后进入业务逻辑的形态。形态二卡在 IO 等待#0 __libc_recv () #1 recv () #2 read_request (fd12) at net.c:55线程在等网络数据这通常是正常的。但如果大量线程都卡在这里可能是上游没发数据或者连接泄漏导致线程池被占满。形态三卡在条件变量#0 pthread_cond_wait () #1 worker_loop () at pool.c:120线程池里的空闲线程在等任务这也是正常的。判断是否正常的关键是看有多少线程处于这个状态——如果所有工作线程都在等条件变量而任务队列里明明有任务那就是唤醒逻辑出了问题。形态四死循环#0 hash_lookup (key0x...) at hash.c:88 #1 hash_lookup (key0x...) at hash.c:88 #2 hash_lookup (key0x...) at hash.c:88同一个函数反复出现在栈里或者栈顶函数长时间不变基本可以判定是死循环或深度递归。这时候配合top -H看哪个线程 CPU 高就能精准定位。3.3 多线程栈的横向对比找出“异常的那一个”单个线程的栈只能说明它自己在干什么真正的排查价值在于把所有线程的栈放在一起对比。正常的服务线程状态应该是“百花齐放”——有的在处理请求有的在等 IO有的在等任务。如果出现“一边倒”比如 20 个线程全卡在同一把锁上那问题就很明显了。我通常的做法是把 pstack 输出保存到文件然后用脚本统计每个栈顶函数的出现次数。pstack 12345 /tmp/stack_$(date %s).txt # 统计栈顶函数分布 grep -A1 ^Thread /tmp/stack_*.txt | grep ^# | head -1更实用的做法是抓多次快照对比变化for i in $(seq 1 5); do pstack 12345 /tmp/stack_$i.txt sleep 2 done如果某个函数在 5 次快照里都出现在栈顶那它大概率就是瓶颈所在。这种“多次采样 对比”的思路比单次快照可靠得多因为单次快照可能刚好抓到进程在做正常操作的瞬间。实操心得抓栈的时候建议同时记录时间戳和当时的系统负载uptime、top摘要。事后分析时把栈信息和负载曲线对应起来能更快锁定问题发生的时间窗口。4. 实战排查三类典型问题的完整思路光讲理论不够这一部分我用三个典型场景把 pstack 的排查思路完整走一遍。这三个场景分别是死锁、CPU 飙高、线程池耗尽基本覆盖了日常遇到的大部分运行时问题。4.1 死锁排查顺着锁的持有关系找闭环死锁的典型特征是进程不响应CPU 不高但就是卡住。用 pstack 抓一次往往能直接看到“互相等待”的闭环。假设抓到这样两个线程的栈Thread 5 (Thread 0x7f8b2c... (LWP 12350)): #0 __lll_lock_wait () #1 pthread_mutex_lock () #2 update_account (acc0x...) at account.c:150 #3 transfer (from0x..., to0x...) at transfer.c:80 Thread 6 (Thread 0x7f8b2c... (LWP 12351)): #0 __lll_lock_wait () #1 pthread_mutex_lock () #2 update_account (acc0x...) at account.c:150 #3 transfer (from0x..., to0x...) at transfer.c:80两个线程都卡在update_account的加锁处说明它们在等同一把锁或者互相持有对方需要的锁。这时候需要看account.c:150附近的代码确认锁的获取顺序。经典的死锁场景是线程 A 先锁账户 1 再锁账户 2线程 B 先锁账户 2 再锁账户 1两者交叉就死锁了。解决办法通常是统一加锁顺序所有转账操作都按账户 ID 从小到大加锁。这样就不会出现交叉等待。排查死锁时pstack 的价值在于它能一次性展示所有线程的状态让你看到完整的等待图。如果只有单个线程的栈你根本不知道它在等谁。4.2 CPU 飙高定位从线程号到具体代码行CPU 飙高的排查有个标准流程pstack 是其中关键一环。第一步用top -H -p pid找到高 CPU 的线程号注意是线程号 TID不是进程号 PIDtop -H -p 12345输出里会列出每个线程的 CPU 占用找到那个 90% 以上的 TID比如 12350。第二步把 TID 转成十六进制因为栈里的 LWP 号有时是十六进制显示的printf %x\n 12350 # 输出 303e第三步用 pstack 抓栈找到对应的线程pstack 12345 | grep -A 20 LWP 12350或者直接用 gdb 切到该线程gdb -q -nx -batch -ex thread apply all bt -p 12345 | grep -A 15 303e假设抓到这样的栈#0 parse_json (str0x...) at json.c:220 #1 handle_message (msg0x...) at handler.c:95 #2 worker_loop () at pool.c:130栈顶是parse_json说明这个线程在疯狂解析 JSON。结合业务代码看json.c:220可能是解析逻辑里有低效的正则或者遇到了畸形数据导致反复重试。定位到具体行之后优化就有了方向。这里有个细节如果栈顶是系统调用如__memcpy_avx、__strlen_avx2说明瓶颈在内存操作可能是数据量太大或者拷贝次数太多。这类问题往往需要从算法层面优化而不是改某一行代码。4.3 线程池耗尽为什么请求都卡住了线程池耗尽的典型表现是新请求进来没人处理但进程本身没死。用 pstack 抓一次会看到所有工作线程都卡在某个地方没有空闲线程。Thread 1: #0 __libc_recv () ... #3 read_request () at net.c:55 Thread 2: #0 __libc_recv () ... #3 read_request () at net.c:55 Thread 3: #0 __libc_recv () ... #3 read_request () at net.c:55 ... Thread 20: #0 __libc_recv () ... #3 read_request () at net.c:5520 个工作线程全卡在read_request等数据。这说明有 20 个连接建立了但迟迟不发数据把线程池占满了。根因可能是客户端连接泄漏或者某个上游服务响应极慢。排查方向有两个一是看连接来源找出是谁占着连接不发数据二是检查线程池配置是否需要加超时机制。read_request里如果用了阻塞式recv且没设超时一个慢连接就能永久占住一个线程。提示这类问题的根治办法是给 IO 操作加超时或者改用事件驱动模型。pstack 帮你定位到“卡在等 IO”但真正的修复要在代码层面做。5. 常见问题速查与避坑经验前面讲了原理和实战这一部分我把踩过的坑整理成速查表再补充几条文档里不会写的经验。这些都是实际排查中积累的希望能帮你少走弯路。5.1 报错速查表报错信息根本原因解决办法ptrace: Operation not permittedptrace_scope 限制或权限不足用 sudo或调整 ptrace_scopeCould not attach to process容器 seccomp 拦截加--cap-addSYS_PTRACENo such processPID 不存在或已退出确认进程存活注意 PID 复用?? ()全是问号缺少调试符号重新编译加-g或保留 debug 文件Cannot access memory进程状态异常或内核限制检查进程是否处于 D 状态ptrace: No such process目标线程刚退出重试或抓取时机调整5.2 几条文档里不会写的经验经验一pstack 会暂停进程别在高峰期频繁用。我见过有人写了个脚本每秒 pstack 一次结果把线上服务拖垮了。gdb attach 的暂停时间虽然短但高频累积起来影响很大。建议采样间隔至少几秒且只在排查窗口期使用。经验二PID 复用是个隐形陷阱。你拿到一个 PID准备 pstack结果进程刚好退出、PID 被新进程复用你 attach 到的是另一个进程。所以抓栈前最好确认进程的启动时间ps -o lstart -p pid和你的预期一致再操作。经验三栈信息要结合代码看不能只看函数名。同样是卡在pthread_mutex_lock可能是正常的锁竞争也可能是死锁。区别在于等待时长和线程数量。单看一次快照很难判断多次采样对比才靠谱。经验四容器里排查优先用eu-stack。前面提过 Alpine 的问题其实很多精简镜像里 gdb 都不完整。eu-stack体积小、依赖少在容器场景下更实用。用法也简单eu-stack -p pid输出格式和 pstack 类似。经验五保留一份带符号的二进制。发布时剥离符号能减小体积但一定要把.debug文件归档保存并且版本要和发布版本严格对应。否则线上出问题你手里只有一堆地址排查会非常痛苦。5.3 让 pstack 更好用的几个小技巧第一个技巧是封装一个带时间戳和负载信息的抓栈脚本#!/bin/bash PID$1 OUTDIR${2:-/tmp/pstack} mkdir -p $OUTDIR TS$(date %Y%m%d_%H%M%S) { echo Time: $(date) echo Load: $(uptime) echo Threads: $(ls /proc/$PID/task | wc -l) pstack $PID } $OUTDIR/stack_$TS.txt 21 echo Saved to $OUTDIR/stack_$TS.txt这样每次抓栈都自带上下文事后分析不用再回忆当时的环境。第二个技巧是用watch做定时采样watch -n 5 pstack 12345 /tmp/stack_$(date %s).txt每 5 秒抓一次适合观察一段时间内的状态变化。但记得排查完及时停掉别让它一直跑。第三个技巧是结合strace交叉验证。pstack 看的是用户态调用栈strace看的是系统调用。如果栈顶是某个库函数但你不确定它在做什么系统调用可以用strace -p pid看它实际在跟内核交互什么。两者结合信息更完整。6. 从 pstack 到更系统的排查方法论pstack 是个好工具但它只是排查工具箱里的一件。真正高效的排查靠的是“工具组合 系统思路”。我在实际工作中慢慢形成了一套流程这里分享出来供你参考。第一步永远是确认现象是 CPU 高、内存涨、还是响应慢不同现象对应不同的工具组合。CPU 高优先用top -H pstack内存涨优先用pmapvalgrind响应慢优先用 pstack strace。第二步是抓取现场在问题发生时抓而不是事后补。很多问题一旦重启就消失现场抓不到后面只能靠猜。所以监控告警要配好出问题第一时间保留现场。第三步是对比分析单次快照信息有限多次采样对比才能看出趋势。我习惯至少抓 3 到 5 次间隔几秒然后对比栈顶函数的变化。第四步是验证假设根据栈信息提出假设比如“卡在锁上”然后通过代码审查、加日志、或者构造复现来验证。不要看到栈顶是锁就断定是死锁也可能是正常的锁竞争。这套流程说起来简单但每一步都有细节。pstack 的价值就在于它能在第二步“抓取现场”时用极低的成本给你一份高信息密度的快照。用好它很多看似玄学的问题都会变得有迹可循。最后分享一个我自己的习惯每次排查完一个问题我都会把当时的 pstack 输出、分析过程和最终根因整理成一份简短的记录。时间久了这份记录就成了自己的“故障模式库”。下次遇到类似的栈形态一眼就能联想到可能的原因。这个习惯坚持下来排查效率的提升是肉眼可见的。