☰
pstack 原理、用法与实战:快速定位进程卡顿与死锁
2026/10/12 7:29:13 网站建设 项目流程

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/pstack

CentOS / 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 locals

bt 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:55

20 个工作线程全卡在read_request等数据。这说明有 20 个连接建立了但迟迟不发数据,把线程池占满了。根因可能是客户端连接泄漏,或者某个上游服务响应极慢。

排查方向有两个:一是看连接来源,找出是谁占着连接不发数据;二是检查线程池配置,是否需要加超时机制。read_request里如果用了阻塞式recv且没设超时,一个慢连接就能永久占住一个线程。

提示:这类问题的根治办法是给 IO 操作加超时,或者改用事件驱动模型。pstack 帮你定位到“卡在等 IO”,但真正的修复要在代码层面做。

5. 常见问题速查与避坑经验

前面讲了原理和实战,这一部分我把踩过的坑整理成速查表,再补充几条文档里不会写的经验。这些都是实际排查中积累的,希望能帮你少走弯路。

5.1 报错速查表

报错信息根本原因解决办法
ptrace: Operation not permittedptrace_scope 限制或权限不足用 sudo,或调整 ptrace_scope
Could not attach to process容器 seccomp 拦截加--cap-add=SYS_PTRACE
No 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" 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 输出、分析过程和最终根因整理成一份简短的记录。时间久了,这份记录就成了自己的“故障模式库”。下次遇到类似的栈形态,一眼就能联想到可能的原因。这个习惯坚持下来,排查效率的提升是肉眼可见的。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询