你有没有遇到过这种情况:线上服务进程明明还活着,端口也通,请求却全部卡住不返回。CPU 不高,日志没有异常,内存也不见涨,就像一个人睁着眼睛但已经没有反应了。我第一次处理这种“假死”问题时,试遍了常规手段,最后靠 pstack 一条命令把现场拉了回来。pstack 是 Linux 上打印进程线程调用栈的命令,它能在不重启、不打断服务的前提下,把目标进程所有线程的栈快照一次性输出,排查死锁、定位长时间阻塞非常靠谱。这篇指南先讲清楚 pstack 是什么、底层怎么工作、怎么安装、怎么看输出,再用一个实际死锁案例带你走一遍完整排查流程。
1. pstack 是什么,为什么调试时离不开它
1.1 一次线上假死引发的工具选型
那是一个用 C++ 写的网关服务,某个版本上线后,用户反馈请求大面积超时。我登录服务器一看,进程还在,PID 也正常,监听端口没断,但是业务线程似乎全部卡在一个地方。当时的第一反应是上 GDB,可生产环境的 GDB 交互式操作风险不小,而且每次 attach 之后都要手动输入一堆命令,面对几百个线程,效率很低。
就是在那个场景下,同组的同事提醒我:先别折腾 GDB,试试 pstack。我执行了pstack PID,几秒钟内在终端刷出了当前所有线程的调用栈,很快就看到两个线程分别停在pthread_mutex_lock上,而且互相拿着对方下一步要获取的锁。一瞬间就明白了,这是典型的死锁。
从那之后,pstack 就成了我排查线上服务“假死”问题的默认第一动作。它的定位很朴素:只做“线程栈快照”这一件事,不修改变量、不打断流程、不搞复杂交互,所以上手成本极低。更关键的是,它把“现场抓取”这个动作变得足够轻,轻到可以放心在生产环境用。
不过,pstack 也不是银弹。它适合回答“当前线程停在哪、调用链是什么”,但回答不了“为什么 CPU 这么高、系统调用哪一步慢”。所以这篇指南会把 pstack 的边界也讲清楚,让你知道什么时候该用它,什么时候该换别的工具。
1.2 pstack 的底层工作原理
pstack 的核心机制是 Linux 下的 ptrace 系统调用。ptrace 是调试器的基础能力,GDB 也是基于它工作的。pstack 执行的动作可以拆成下面几步:
- 遍历
/proc/PID/task目录,拿到进程内所有线程的 TID; - 对每个 TID 执行
ptrace(PTRACE_ATTACH),让目标线程暂时停下来; - 读取线程的寄存器和栈内存,结合二进制文件的符号表,把地址还原成函数名和行号;
- 输出完成后执行
PTRACE_DETACH,让线程继续运行。
整个过程非常快,通常只有几十毫秒到几百毫秒,取决于线程数量和符号表大小。这也是它敢用于生产环境的原因:虽然会让进程产生一个极短的停顿,但相比 GDB 手工 attach、detach 的流程,pstack 的侵入性已经控制到很小。
我用一个生活里的类比:进程像是一个正在赶路的人,pstack 是让他突然站住几秒,拍一张 X 光片,拍完立刻继续跑。你损失的是几秒时间,拿到的是内部骨骼结构图。对于死锁、阻塞这类“静态问题”,一张快照往往就足够下结论。
还要强调的是权限问题。ptrace 不是随便谁都能用的:普通用户只能 attach 自己启动的进程,或者被明确授权的子进程;要 attach 其他用户的进程,必须用 root 或具备CAP_SYS_PTRACE能力。后面我会专门讲权限相关的报错怎么处理。
1.3 什么时候会想到用 pstack
pstack 适合的场景,我整理成一张表:
| 场景 | 为什么用 pstack | 更好的替代方案 |
|---|---|---|
| 进程假死、请求卡住 | 快速看线程卡在哪,判断是等锁还是等 IO | GDB 交互式分析,但成本更高 |
| 死锁 | 直接看到两个线程互相等待锁 | 配合 GDB 看锁持有者更精确 |
| 长时间阻塞在下游调用 | 栈顶停在 read/poll/recv 等调用 | strace 看系统调用耗时 |
| 多线程服务偶发性卡顿 | 连续采样,对比两次栈变化 | 配合 coredump 做离线分析 |
| Java 服务线程卡死 | pstack 只能打 JVM 的 C 栈,不方便 | jstack 直接打 Java 方法栈 |
反过来,pstack 不适合做 CPU 热点分析。热点函数需要采样多次,用 perf、perf top 这类工具更对口。pstack 更适合回答“现在这一刻,程序内部的执行状态是什么”,而不是“过去一段时间哪个函数最烧 CPU”。
2. 安装与基本使用:不同发行版的快速上手
2.1 安装 pstack 的几种方式
在主流 Linux 发行版里,pstack 不一定默认安装。常见的安装方式如下:
# RHEL / CentOS 7,需要先启用 EPEL yum install -y epel-release yum install -y pstack # CentOS 8 / Rocky Linux / AlmaLinux dnf install -y epel-release dnf install -y pstack # Ubuntu / Debian apt update && apt install -y pstack如果你所在的系统源里没有 pstack 包,还有一个非常实用的替代方案:直接用 GDB 的批处理模式。pstack 本质上就是“打印所有线程的 backtrace”,而 GDB 恰好能做这件事,命令如下:
gdb -p PID -batch -ex "thread apply all bt"这条命令不需要交互,执行完自动退出,输出格式和 pstack 非常接近。我甚至见过某些发行版的 pstack 就是一个包装脚本,内部就是调这条 GDB 命令。所以就算没有现成的 pstack,你也不会卡住。
也有源码编译安装的方式,但没必要展开,因为大多数情况下包管理器已经足够。真要编译,无非是拉源码、make && make install,但生产环境里我一般倾向少折腾编译依赖,能装二进制就用二进制。
2.2 命令格式与核心参数
pstack 的命令行参数少得可怜:
pstack <PID> pstack -V核心用法就是跟一个进程号。有的版本支持-v或者-a,但不同发行版实现不同,不需要记太多。真正要记住的是:先确认 PID 正确,再执行命令。
执行后的输出大致长这样:
Thread 1 (Thread 0x7f1c5ffd6700 (LWP 14251)): #0 0x00007f1c5edf3c00 in __GI___lll_lock_wait () #1 0x00007f1c5edf56b7 in __GI___pthread_mutex_lock () #2 0x000055b3e26d0524 in thread_b () #3 0x000055b3e26d06a8 in std::thread::_Impl<std::thread::_Invoker<std::tuple<void (*)()> > >::_M_run () #4 0x00007f1c5ed94c8e in start_thread () #5 0x00007f1c5edc2a1c in clone ()我来拆解一下输出里的信息:
Thread 1是 pstack 对线程的编号,不一定是 PID;- 括号里的
LWP 14251才是真正的轻量级进程 ID,也就是线程在系统里的标识; #0是当前执行点,#1是调用者,逐步往上就是整条调用链;- 行尾如果是空括号,说明没有行号信息,通常是因为编译时没加调试符号;
- 函数名后面跟着的
start_thread、clone这类底层函数,表示线程入口和内核创建线程的调用。
不要只看第一行,栈的上层函数往往才是业务代码真正所在的位置。比如#0停在__lll_lock_wait代表等锁,但你要看清楚#2是哪个业务函数触发的,才能真正定位到问题代码。
2.3 权限与安全限制提前确认
实际使用 pstack,很多新手第一次都卡在权限上。Linux 内核里有一个安全模块叫 Yama,它通过 ptrace_scope 控制 ptrace 的使用范围。查看当前值:
cat /proc/sys/kernel/yama/ptrace_scope常见取值含义:
| 值 | 含义 | 对 pstack 的影响 |
|---|---|---|
| 0 | 无限制,任何进程都能 attach 任何进程 | 方便,但安全风险高 |
| 1 | 只能 attach 子进程,或者具备 CAP_SYS_PTRACE 的进程 | 默认值,root 和同用户子进程可用 |
| 2 | 仅 root 且具备 CAP_SYS_PTRACE 才可用 | 普通用户大概率报错 |
| 3 | 内核层限制,不允许任何外部修改 | 比较少见,一般不用考虑 |
如果遇到Operation not permitted,先别急,按这个顺序排查:
- 确认是不是同一个用户启动的进程;
- 确认是否 root 执行;
- 查看 ptrace_scope 的值;
- 是否容器环境内缺少
SYS_PTRACE能力。
临时把 ptrace_scope 改成 0 可以解决权限问题,但不建议在没有安全评估的情况下长期开着,尤其是多用户服务器。正确做法是尽量用目标进程的用户身份去执行 pstack,或者放容器时显式添加对应能力。
3. 核心实操:用 pstack 定位死锁与线程卡死
3.1 构造一个可复现的多线程死锁 Demo
理论说一百遍不如实际看一遍。这里我写一个非常简单的 C++ 死锁程序,方便你在自己环境里验证 pstack 的威力。
#include <iostream> #include <thread> #include <mutex> #include <chrono> std::mutex m1, m2; void thread_a() { std::lock_guard<std::mutex> lock1(m1); std::this_thread::sleep_for(std::chrono::milliseconds(100)); std::lock_guard<std::mutex> lock2(m2); } void thread_b() { std::lock_guard<std::mutex> lock2(m2); std::this_thread::sleep_for(std::chrono::milliseconds(100)); std::lock_guard<std::mutex> lock1(m1); } int main() { std::thread a(thread_a); std::thread b(thread_b); a.join(); b.join(); return 0; }thread_a 先拿 m1,再等 m2;thread_b 先拿 m2,再等 m1。两个线程各自跑 100 毫秒后,就会形成交叉等待,这正是死锁最常见的形态。
编译时一定要加-g,否则 pstack 只能看到地址,看不到函数名:
g++ -std=c++11 -g demo.cpp -o demo -pthread运行后,用pgrep demo找到 PID:
./demo & pgrep demo这时程序已经死锁,两个线程永远停在锁等待中,非常适合拿来练手。
3.2 从 pstack 输出解读死锁现场
对上面这个 Demo 执行pstack PID,输出类似这样:
Thread 2 (Thread 0x7f1c5ffd6700 (LWP 14251)): #0 0x00007f1c5edf3c00 in __GI___lll_lock_wait () #1 0x00007f1c5edf56b7 in __GI___pthread_mutex_lock () #2 0x000055b3e26d0524 in thread_b () #3 0x000055b3e26d06a8 in std::thread::_Impl<std::thread::_Invoker<std::tuple<void (*)()> > >::_M_run () #4 0x00007f1c5ed94c8e in start_thread () #5 0x00007f1c5edc2a1c in clone () Thread 1 (Thread 0x7f1c5cbe6640 (LWP 14250)): #0 0x00007f1c5edf3c00 in __GI___lll_lock_wait () #1 0x00007f1c5edf56b7 in __GI___pthread_mutex_lock () #2 0x000055b3e26d04f6 in thread_a () #3 0x000055b3e26d06a8 in std::thread::_Impl<std::thread::_Invoker<std::tuple<void (*)()> > >::_M_run () #4 0x00007f1c5ed94c8e in start_thread () #5 0x00007f1c5edc2a1c in clone ()看到这里,判断死锁只需要三个步骤:
#0位置相同,都是__lll_lock_wait,说明两个线程都停在锁等待上;- 往上看
#2,一个是 thread_a,一个是 thread_b,说明业务入口不同; - 结合代码逻辑,thread_a 持有 m1 等 m2,thread_b 持有 m2 等 m1,交叉等待成立。
这个例子告诉我们两条经验:第一,pstack 输出不需要所有行都看懂,关键是看栈顶和业务函数层;第二,死锁现场往往有对称性,两个线程的栈会呈现出“互相等”的结构,多线程越多越明显。
3.3 除了死锁,还能看出哪些问题
pstack 的价值不只在死锁。我把常见的线程卡点整理成这样几种类型:
- 栈顶停在
read、recv、poll、select这类调用,说明线程在等网络 IO,通常是下游响应慢或 socket 阻塞; - 栈顶停在
pthread_cond_wait或者cv.wait,说明在等条件变量,要检查是不是其他线程忘了 notify; - 栈顶停在
malloc或free,可能是内存分配器锁竞争,甚至多个线程在分配器内部互相等待; - 栈顶停在
nanosleep或定时等待,说明线程主动休眠,需要结合业务逻辑判断是否正常。
区分“长时间阻塞”和“瞬间调度”有一个很实用的方法:连续做两次 pstack 采样,间隔 0.1 到 0.5 秒。如果两次栈顶位置一模一样,那基本可以断定线程长时间停留;如果每次都不一样,说明线程还在运行,只是采样时恰好停在某个地方。
4. 实战技巧:排查线上问题的完整流程
4.1 快速定位问题进程的方法
拿到一台“假死”服务器,我的标准流程是这样的:
第一步,用top看整体负载,找到 CPU 不高的嫌疑进程。注意,死锁和 IO 阻塞往往 CPU 不高,所以不能只盯高 CPU。
第二步,用ps -T -p PID或top -H -p PID看进程内线程状态。在top -H里,你会看到线程的 PID 和 CPU 使用率,如果所有线程 CPU 都是 0,而进程又不响应,多半是卡死。
第三步,执行 pstack 拿到线程栈快照。如果线程很多,建议先输出到文件再分析,避免终端被刷屏。
我常用的连续采样命令是:
for i in 1 2 3; do pstack $PID > /tmp/stack.$i.txt sleep 0.1 done三份快照之间的差异非常关键。如果三份的栈都一样,说明进程已经硬卡住;如果栈在变化但业务无进展,可能是某个循环在空转。对比采样文件时,我用 diff 或直接看调用链中的函数名变化,很快能判断问题的“动态”还是“静态”。
4.2 pstack 与其它工具组合使用
pstack 是很好用的点工具,但排查复杂问题经常需要和其他工具配合,才能把一张静态快照串成完整故事。
| 工具 | 定位问题 | 与 pstack 配合方式 |
|---|---|---|
| GDB | 变量值、锁持有者、内存布局 | pstack 确定函数范围后,GDB 深入检查 |
| gstack | 类似 pstack 的实现 | 输出更依赖 GDB,可以互相验证 |
| strace | 系统调用耗时和错误码 | pstack 看到阻塞在 read,strace 看卡在哪个 fd |
| perf / perf top | CPU 热点和采样分析 | pstack 看静态阻塞,perf 看性能热点 |
| jstack | Java 线程堆栈 | Java 服务优先用 jstack,pstack 仅作补充 |
举一个组合排查的例子:某个服务卡住,但 CPU 不高。pstack 输出显示大量线程停在recvfrom,说明在等网络数据。此时再用strace -p PID -f -e trace=network跟踪几秒,就能看到是不是某个 socket 一直没有消息返回,进而联想到下游依赖超时。
如果 pstack 显示栈顶停在malloc,可以配合perf record做分配器采样,看锁竞争来源;如果是 CPU 飙高但进程偶尔不响应,则不要用 pstack,直接用perf top找热点函数。工具之间不是竞争关系,而是互补关系。
4.3 容器与高并发场景下的注意事项
现在很多服务都跑在容器里,pstack 的使用环境比裸机更复杂,有几个坑需要注意。
第一个坑是能力限制。容器默认不一定允许 ptrace,即使你在容器里以 root 身份运行,也可能遇到Operation not permitted。此时需要在容器定义时添加SYS_PTRACE能力,或者临时提升权限,否则 pstack 无法 attach。
第二个坑是 PID namespace。在容器内看到的 PID,和宿主机上的 PID 不一样。如果你在宿主机执行 pstack,要使用宿主机的 PID;如果在容器内执行,要用容器内看到的 PID。不要换算错,否则 attach 到的不是你想要的进程。
第三个坑是 attach 的短暂停顿。高并发服务对延迟很敏感,pstack 每次 attach 都会让目标进程所有线程停一下。虽然通常只有几十毫秒,但在极高 QPS 下也会产生影响。我的建议是不要在流量高峰期频繁采样,最好一次拿够信息,或者先录几分钟数据再做离线分析。
第四个坑是 Java 服务。很多人看到 Java 线程卡住,下意识用 pstack,结果得到一堆 JVM 底层 C 栈,如GCTaskThread、VMThread,根本看不到业务方法。Java 应用请优先使用 jstack,pstack 只能作为 JVM 自身状态排查的补充。
4.4 如何判断是否需要重启
拿到 pstack 快照后,最需要考虑的问题是:到底要不要重启?很多团队一到线上假死就直接重启,省事,但重启等于销毁了事故现场。后续复盘没有证据,问题第二次出现还是要靠猜。
我的建议是,在任何重启之前,至少留一份 pstack 输出。哪怕当时看不懂,等恢复后也能慢慢分析。实际经验里,很多看起来“像死锁”的问题并不是真的死锁,而是连接池耗尽、线程池拒绝任务、下游超时导致阻塞。这些情况重启可能暂时恢复,但根因没解决,过几天又犯。
如果 pstack 明确看到两个或多个线程在锁上交叉等待,那大概率是真死锁,重启是合理的止损手段,但重启后必须找出加锁顺序不一致的代码。如果栈顶停在网络调用,重启前最好把上下游依赖检查一遍,否则重启只是治标不治本。
5. 常见问题与避坑指南
5.1 pstack: Operation not permitted
这是最常遇到的报错。原因通常是权限不足。排查步骤:
# 1. 确认当前用户 whoami # 2. 确认目标进程用户 ps -o user,pid,cmd -p PID # 3. 查看 ptrace 限制 cat /proc/sys/kernel/yama/ptrace_scope如果 ptrace_scope 是 1,普通用户只能 attach 自己的子进程。如果你不是该进程用户,必须用 sudo 或切换用户。临时放宽限制:
echo 0 | sudo tee /proc/sys/kernel/yama/ptrace_scope这句命令只在当前内核会话有效,重启后恢复。不要图省事长期改成 0,尤其多人共用的服务器,安全风险很大。
5.2 输出是空的或者看不到函数名
pstack 返回了内容,但只有地址,没有函数名,这种情况也很常见。根本原因是目标二进制没有符号表。
编译时加上-g可以保留调试符号。发布版本通常还会加-fno-omit-frame-pointer保留帧指针,否则优化后栈回溯信息会丢失。
如果线上二进制已经 strip 过,你还可以用同版本的带符号二进制,手动将 pstack 得到的地址映射到符号:
addr2line -e /path/to/binary -f 0x000055b3e26d0524addr2line可以把地址解析成函数名和文件行号。前提是你手头有相同编译产物的带符号文件,否则价值有限。
看到大量??的时候,还有一个可能是 PIE(位置无关可执行文件)加载基地址变化导致地址无法直接解析。这种情况建议换 GDB 连上去,用 GDB 自动处理重定位,比手动换算靠谱。
5.3 为什么 pstack 在一些新版系统上是脚本
有些发行版没有提供独立的 pstack 二进制,而是用一个脚本调用 GDB 来实现。这解释了为什么有些机器装了 pstack 包,执行时却报错说找不到 GDB。
遇到这种情况,顺带安装 GDB 就能解决,或者直接用我前面写的 GDB 批处理命令:
gdb -p PID -batch -ex "thread apply all bt"没有必要纠结到底是脚本还是二进制,输出结果和排查思路完全一致。但你要知道这层关系,不然在精简镜像里很容易被 “No such file or directory” 这类诡异报错卡住。
5.4 现场已经变了,怎么保存证据链
排查事故的时候,尤其是处理完问题准备复盘,最怕的是现场信息不全。我希望你把 pstack 输出当成事故现场的第一份证据,而不仅仅是临时看一眼的东西。
推荐用这个组合命令保存现场:
date ps -ef | grep $PID cat /proc/$PID/status pstack $PID > $(date +%Y%m%d_%H%M%S)_stack.txt采样时间、进程状态、线程数量、完整栈输出都在一个时间点记录下来,后续分析问题、写事故报告都够用。如果服务是多线程重灾区,pstack 输出可能上万行,直接丢终端完全没有可读性,一定要重定向到文件再处理。
另外,连续采样时文件名里的时间戳要精确到秒,这样对比多次采样时才能区分先后顺序,避免把不同时刻的栈当成同一时刻来推断。
这篇先写到这里。我自己的使用习惯是,不管什么服务假死,先别急着重启,先连续抓两份 pstack,哪怕暂时看不懂,也先把证据拿住。很多线上事故其实并不需要重启,可能只是下游超时、线程池打满、锁顺序写错,盲目重启只会把最关键的现场毁掉。pstack 的成本低、风险可控,是每个后端工程师都应该熟练掌握的工具。下一篇我会接着聊如何结合 addr2line 和 GDB 做更深入的栈分析,以及在大型多线程服务里怎么过滤噪音,快速从几千个线程里找到真正被卡住的那一个。