1. 从一次线上卡顿说起:pstack 到底解决什么问题
线上服务跑着跑着突然响应变慢,CPU 看着不高,日志也没什么异常,重启之后又能撑一阵子——这种问题最让人头疼。我最早接触 pstack,就是因为一个后台服务每隔几天就出现一次“假死”:进程还在,端口还监听,但请求就是卡住不返回。当时试过加日志、加监控,都没抓到根因,最后用 pstack 连续打了几次调用栈,才发现是某个锁在特定条件下被长时间持有,导致工作线程全部堵在等锁上。
pstack 这个工具,说白了就是把一个正在运行的进程的调用栈打印出来。它不是一个独立的大而全的调试器,而是一个轻量级的“快照工具”——你给它一个进程号,它就把这个进程当前所有线程的函数调用链展示给你看。对于排查死锁、死循环、线程阻塞、性能瓶颈这类“运行时状态”问题,它几乎是成本最低、见效最快的手段之一。
这篇文章适合谁看?如果你写过 C/C++ 服务、做过后端开发、或者需要维护长时间运行的后台程序,pstack 应该是你工具箱里的常备品。哪怕你平时用 Go、Java,理解 pstack 的输出逻辑,对看懂其他语言的栈信息也有帮助。我会从它背后的原理讲起,把安装、使用、输出解读、常见坑、实战排查思路全部串一遍,尽量做到你看完就能直接上手。
需要先说明一点:pstack 本质上是一个封装脚本,它依赖系统底层的调试能力。不同发行版、不同内核版本下,它的行为和可用性差异很大,这也是很多人“照着教程敲却报错”的根本原因。所以第一部分我不急着给命令,而是先把它的工作原理和适用边界讲清楚,这样后面遇到问题你才知道往哪个方向查。
1.1 pstack 的本质:它其实不是一个人在战斗
很多人以为 pstack 是一个编译好的二进制程序,其实在大多数 Linux 发行版上,pstack只是一个 shell 脚本。你可以在终端里执行which pstack然后cat一下它,会发现里面通常调用了gdb(GNU 调试器)或者eu-stack(elfutils 套件里的工具)。
以最常见的实现为例,pstack 脚本的核心逻辑大致是这样:接收一个 PID 参数,然后调用 gdb,让 gdb attach 到目标进程,执行thread apply all bt(对所有线程打印回溯),最后 detach 退出。所以真正干活的底层工具是 gdb,pstack 只是帮你把这套操作包装成了一条命令。
这就解释了几个关键现象:
- 为什么 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 调用函数 B,B 调用 C,C 正在执行,那么 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 (arg=0x0) 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 这类采样工具。
- 没有调试符号的发布包:栈里全是地址,没有函数名和行号,可读性极差。
- 容器内权限受限:很多容器默认禁用了 ptrace,pstack 直接报错。
- 对实时性要求极高的交易类进程:哪怕几十毫秒的暂停也可能造成问题,需谨慎评估。
提示:在生产环境使用 pstack 前,务必确认目标进程能承受短暂的暂停。对于核心交易链路,建议先在预发环境验证,或者选择业务低峰期操作。
2. 环境准备:让 pstack 真正跑起来
很多人第一次用 pstack 就碰壁,报错五花八门,其实大部分问题都出在环境上。这一部分我把安装、权限、调试符号这三件事讲透,这是后面所有实操的前提。环境没弄对,后面看再多输出解读都是空中楼阁。
2.1 安装:不同发行版的正确姿势
pstack 本身通常包含在gdb包里,或者由elfutils提供。下面按常见发行版分别说明。
Debian / Ubuntu 系列:
# 安装 gdb,pstack 脚本通常随 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 libc,gdb 支持有限,推荐用 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 自己的子进程,或 root |
| 2 | 仅管理员 | 只有 root 能 attach |
| 3 | 完全禁用 | 任何 attach 都不允许 |
查看当前值:
cat /proc/sys/kernel/yama/ptrace_scope临时放开(重启失效):
sudo sysctl -w kernel.yama.ptrace_scope=0永久生效:
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-add=SYS_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 (arg=0x0) 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-debuglink=myapp.debug myapp这样发布的是精简后的 myapp,调试时 gdb 会自动去 myapp.debug 里找符号。这个做法在正式环境很常见,既减小了二进制体积,又保留了排查能力。
3. 基础用法与输出解读:把栈信息读成故事
环境弄好之后,就可以正式上手了。这一部分我先讲最基本的命令用法,然后重点讲怎么读输出——这是很多人卡住的地方,栈打出来了,但看不懂,等于没打。
3.1 最常用的几条命令
最基础的用法就是给一个 PID:
pstack 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 (req=0x7f...) at server.c:210这说明线程在server.c:210处尝试获取一把互斥锁,但锁被别的线程占着,只能等。这时候你要去找“谁持有这把锁”——通常另一个线程的栈里会有pthread_mutex_lock之后进入业务逻辑的形态。
形态二:卡在 IO 等待
#0 __libc_recv () #1 recv () #2 read_request (fd=12) at net.c:55线程在等网络数据,这通常是正常的。但如果大量线程都卡在这里,可能是上游没发数据,或者连接泄漏导致线程池被占满。
形态三:卡在条件变量
#0 pthread_cond_wait () #1 worker_loop () at pool.c:120线程池里的空闲线程在等任务,这也是正常的。判断是否正常的关键是看有多少线程处于这个状态——如果所有工作线程都在等条件变量,而任务队列里明明有任务,那就是唤醒逻辑出了问题。
形态四:死循环
#0 hash_lookup (key=0x...) at hash.c:88 #1 hash_lookup (key=0x...) at hash.c:88 #2 hash_lookup (key=0x...) 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 (acc=0x...) at account.c:150 #3 transfer (from=0x..., to=0x...) at transfer.c:80 Thread 6 (Thread 0x7f8b2c... (LWP 12351)): #0 __lll_lock_wait () #1 pthread_mutex_lock () #2 update_account (acc=0x...) at account.c:150 #3 transfer (from=0x..., to=0x...) 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,不是进程号 PID):
top -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 (str=0x...) at json.c:220 #1 handle_message (msg=0x...) 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 permitted | ptrace_scope 限制或权限不足 | 用 sudo,或调整 ptrace_scope |
Could not attach to process | 容器 seccomp 拦截 | 加--cap-add=SYS_PTRACE |
No such process | PID 不存在或已退出 | 确认进程存活,注意 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" 2>&1 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;内存涨优先用pmap+valgrind;响应慢优先用 pstack + strace。
第二步是抓取现场:在问题发生时抓,而不是事后补。很多问题一旦重启就消失,现场抓不到,后面只能靠猜。所以监控告警要配好,出问题第一时间保留现场。
第三步是对比分析:单次快照信息有限,多次采样对比才能看出趋势。我习惯至少抓 3 到 5 次,间隔几秒,然后对比栈顶函数的变化。
第四步是验证假设:根据栈信息提出假设,比如“卡在锁上”,然后通过代码审查、加日志、或者构造复现来验证。不要看到栈顶是锁就断定是死锁,也可能是正常的锁竞争。
这套流程说起来简单,但每一步都有细节。pstack 的价值,就在于它能在第二步“抓取现场”时,用极低的成本给你一份高信息密度的快照。用好它,很多看似玄学的问题都会变得有迹可循。
最后分享一个我自己的习惯:每次排查完一个问题,我都会把当时的 pstack 输出、分析过程和最终根因整理成一份简短的记录。时间久了,这份记录就成了自己的“故障模式库”。下次遇到类似的栈形态,一眼就能联想到可能的原因。这个习惯坚持下来,排查效率的提升是肉眼可见的。