我要提问
ARTICLE DETAIL

资讯详情

前沿编程新知与开发实战干货的深度解读。

pstack完整指南:原理、实战与避坑,一键诊断进程卡死和CPU飙升

pstack完整指南:原理、实战与避坑,一键诊断进程卡死和CPU飙升 如果你第一次在终端里敲下pstack这个命令多半是因为某个进程已经卡得像台坏掉的风扇CPU 一直在转业务却纹丝不动日志翻遍了也看不出它执行到哪一行。我第一次在线上用 pstack就是这样一个手足无措的深夜依赖库、网络、磁盘全查了一遍最后靠着一条堆栈快照才定位到问题。作为维护 pstack 多年的从业者我一直想把它讲透。市面上的碎片资料不少但要么停留在“会用就行”要么直接把 gdb 手册甩给你。这篇文章是“完整指南”的上篇先把最核心的东西讲清楚pstack 是什么、为什么能救急、它背后到底怎么工作、安装和上手怎么做、输出结果怎么读、以及日常最容易踩的坑。写完之后你会明白这不是什么高深技术但它确实值得每一个运维和后台开发者掌握。1. pstack 到底是干嘛的为什么生产环境离不开它1.1 一句话定义进程的瞬时 X 光片pstack 做的事情用一句话概括就是在不打断目标进程、不重启、不埋点的情况下把一个正在运行的进程当前所有线程的调用栈原样抓出来。你可以把它理解成给一个正在埋头干活的程序员拍一张 X 光片照片上清清楚楚显示他此刻站在哪段代码里是谁把他叫到这里再往上一层层是谁调用了谁。听起来简单但这个能力在关键时刻是无可替代的。生活中的场景很类似你去探病医生不让你动病人又想知道病人身体里发生了什么只能用无创检查pstack 对进程做的就是这种“无创检查”。它不修改进程的内存不注入任何代码只是短暂地看一眼它的执行位置。这也决定了它的定位它不是用来做日常代码调试的“放大镜”而是用来做线上应急的“急救工具”。在你没有任何线索、只想快速判断进程在干什么的时候pstack 是最直接的手段。1.2 哪些场景下它才是主力我总结过 pstack 最典型的四类作战场景基本覆盖了线上事故的大半情况CPU 使用率飙高但应用层日志长时间没有输出。此时你无从判断它是死循环、忙等还是锁竞争导致的空转。pstack 抓一次栈栈顶立即告诉你它到底在执行什么函数。服务卡死请求全都堆积但进程还没退出。重启容易但重启之后现场就没了。pstack 在重启之前能给你留下最关键的证据。怀疑线程卡在某个锁上。栈顶如果出现futex_wait、pthread_mutex_lock以及对应的调用链基本就能确认锁等待关系。线上程序偶发异常比如请求超时、线程池收缩、心跳中断但你无法每时每刻盯着。此时在监控脚本里定时执行 pstack 抓帧是个低成本的好办法。有人会说现在有一堆 APM 工具、分布式追踪系统还需要 pstack 吗我的观点很明确那些工具能告诉你“请求走到哪个服务、耗时多少”但它们很难告诉你“这个进程内部的线程此刻正卡在哪个函数、哪一行”。后者恰好是 pstack 最擅长的事。1.3 它和 gdb 到底是什么关系很多人一看 pstack 的输出格式会脱口而出“这不就是 gdb 的 bt 命令吗”没错pstack 在大部分发行版里本质上就是 gdb 的一个极薄封装。它的核心动作正是对 gdb 说拿到目标进程的 pid不要交互不要打断把所有线程的调用栈都打印出来。这层封装的价值在哪里主要有三点你不用在事故现场手忙脚乱地敲一长串 gdb 命令也不会因为误操作 gdb 的交互式界面而卡住进程。pstack 预先设置了 batch 模式、关闭交互确认、打开分页输出的结果干净利落适合直接落地成文本。它固定了“遍历所有线程”这个动作。手动操作 gdb 时新手经常只看到主线程栈漏掉真正出问题的子线程。不过理解了“它是 gdb 的壳”之后也要对它的边界有清醒认识。gdb 能附加到什么程度pstack 就能看到什么程度gdb 受权限、内核限制、符号表缺失影响时的表现pstack 也会原样继承。所以我在后面专门留了一节讲这些坑。2. 核心工作原理一次 pstack 调用的背后发生了什么2.1 附加、遍历、离场三个关键动作我最初写 pstack 的时候最纠结的点不是“怎么实现”而是“怎么保证安全”。后来彻底想明白把脏活累活都交给 gdbpstack 要做到快进快出。一次标准的 pstack 调用背后是这样一个过程pstack 启动一个 gdb 子进程目标 pid 作为参数传入。gdb 通过系统提供的调试接口附加到目标进程。这一步的本质是请求内核允许自己“观察”对方如果权限不够、或者目标进程状态异常这一层就会失败。gdb 短暂暂停目标进程的所有线程逐个抓取寄存器现场并从栈内存中回溯出每一帧的函数调用关系。gdb 把回溯结果打印到标准输出随后恢复目标进程继续运行然后自己退出。整个过程通常在几百毫秒到一两秒之内完成。线程数量越多、需要解析的符号越多耗时就越长。要是你的进程里有上千个线程一次 pstack 可能要好几秒这时候心里就要有数它不是完全零成本的。有一个非常关键的细节我反复在文档里强调pstack 不是去读/proc/pid/stack那种静态文件。它实打实地接管了目标进程一小段时间。虽然在正常情况下这个接管极短暂但如果目标进程正处在某些特殊的不可中断状态比如正在等待磁盘 I/O附加动作可能会卡住。这不是 pstack 的 bug而是 ptrace 机制本身的边界。2.2 权限问题为什么有时候它说“不允许”pstack 最常见的失败信息不是“进程不存在”而是 “Operation not permitted”。这条错误背后的原因五花八门但绝大多数逃不出下面几类目标进程属于另一个用户而当前用户没有 root 权限。生产环境里很多服务以专用账号启动普通开发者账号直接 pstack 过去权限是不够的。系统的 ptrace 限制策略比较严格。现代内核引入的 ptrace 访问控制默认只允许同 uid 且是父子继承关系的进程互相 ptrace。也就是说你从终端里直接 pstack 一个后台服务哪怕用的是同一个 uid也可能被拒绝。容器场景下权限隔离和内核 capabilities 不完整导致 gdb 根本没有附加的资格。这在容器化部署流行之后尤其常见。碰到这种情况我的建议是先确认自己是不是真的需要这台机器上的栈如果需要老老实实切换到运行该服务的管理员账号或者在具备相应权限的容器 / 主机上执行。不要抱着侥幸心理用各种奇技淫巧绕过权限限制那只会把现场弄得更复杂。2.3 为什么 pstack 不自己实现栈回溯有朋友问过我pstack 这么简单为什么不直接自己读线程寄存器、自己解析栈内存绕开 gdb这个问题问得很好但答案也很现实。栈回溯看起来简单实际上极其容易出错。它要处理不同架构的寄存器规则、栈帧布局、优化编译后帧指针被省略的情况、动态库加载地址偏移、线程局部信息还得解析 DWARF 调试信息、符号表、以及可能出现的内联函数展开。这些逻辑每一处都是复杂度和兼容性的坑。与其自己维护一套随时可能翻车的栈回溯器不如站在 gdb 这个巨人肩膀上。gdb 在符号解析和栈回溯上已经积累了非常久的时间它对各种极端情况的处理远比我临时写的代码可靠。pstack 保持薄封装最大的好处就是gdb 升级后能解析更多格式pstack 自动跟着受益。牺牲也很明显凡是 gdb 不适用的场景比如进程崩溃现场已经无法附加、或者目标机器完全没有 gdb、或者对性能极其敏感怕附加动作影响在线服务pstack 就帮不上忙。这些边界使用者心里必须有数。3. 动手实战安装、准备示例、抓取第一份堆栈3.1 安装 pstack三种方式按需选择不同阵营的 Linux 发行版对 pstack 的处理不太一样。在 RPM 系发行版里pstack 通常是直接放在系统软件源里的一条命令就能装yum install pstack在 apt 系发行版里情况略有差异默认软件源可能没有 pstack或者名字不叫这个。这时你可以先试试apt install pstack如果提示找不到包不用着急我给你一个更通用的办法只要系统里有 gdb我们自己封装一个 pstack 即可。事实上很多发行版自带的 pstack 就是一个脚本核心也就几行。你完全可以把下面这段放进 shell 配置里# 简易 pstack 替代实现 pstack() { local pid$1 if [ -z $pid ]; then echo usage: pstack pid return 1 fi gdb -q -batch -ex set pagination off -ex thread apply all bt -p $pid }这条封装里最核心的参数是-ex,后面跟的命令thread apply all bt表示“对所有线程执行堆栈回溯”set pagination off则是关掉分页避免输出卡在半途。实际使用时我还会额外加-ex set print thread-events off减少噪音不过依赖版本较老时可以不加。3.2 准备一个能稳定复现的实验环境纸上得来终觉浅我强烈建议你亲手抓一次栈。我准备了一个极简的多线程示例程序放在你本机编译运行完全没压力#include pthread.h #include unistd.h #include stdio.h void *worker_thread(void *arg) { while (1) { usleep(100000); } return NULL; } int main(void) { pthread_t tid; if (pthread_create(tid, NULL, worker_thread, NULL) ! 0) { perror(pthread_create); return 1; } while (1) { usleep(100000); } return 0; }编译时一定记得加调试符号否则一会儿你只能看到一串裸地址gcc -g -O0 -o demo demo.c -lpthread启动它然后拿到 pid./demo pid$! echo $pid接下来就是见证奇迹的时刻pstack $pid3.3 第一次留下的堆栈快照上面命令的输出大概长这样Thread 2 (process 12345): #0 0x00007f8a4b4f3355 in nanosleep () from /usr/lib64/libc.so.6 #1 0x00007f8a4b4f32b0 in usleep () from /usr/lib64/libc.so.6 #2 0x00000000004007b2 in worker_thread (arg0x0) at demo.c:9 #3 0x00007f8a4b76a1e4 in start_thread () from /usr/lib64/libpthread.so.0 #4 0x00007f8a4b41d53d in clone () from /usr/lib64/libc.so.6 Thread 1 (process 12345): #0 0x00007f8a4b4f3355 in nanosleep () from /usr/lib64/libc.so.6 #1 0x00007f8a4b4f32b0 in usleep () from /usr/lib64/libc.so.6 #2 0x000000000040060a in main () at demo.c:18 #3 0x00007f8a4b763d4a in __libc_start_main () from /usr/lib64/libc.so.6 #4 0x0000000000400529 in _start () at crt1.o这一瞬间你其实已经完成了生产排查里最关键的动作把一个活着的进程的内部结构拍成了静态照片。我来教你怎么读这份照片。首先是线程分组。输出里 Thread 1、Thread 2 不止是编号每个线程后面括号里还有进程号。在多线程程序里所有线程共享同一个 pid所以你会看到相同数字。其次是每一帧。栈帧编号 #0 在最顶部代表这个线程正在执行的当前函数往下 #1、#2 是调用者一层一层追溯到线程的起点。你可以把栈想象成一层层叠起来的盘子最上面是现在正在做的事情最底下是整个流程从哪里开始。再看函数名后的源文件和行号。比如worker_thread (arg0x0) at demo.c:9说明此刻 worker 线程正停在 demo.c 的第 9 行也就是usleep(100000)。这行信息就是日后判断问题最直接的锚点。有一件事我每次分享都要强调pstack 抓到的栈只是“这一刻”的快照。在线程循环执行的程序中下一次抓取可能栈顶就变了。所以如果要诊断类似 CPU 飙升的问题不要只抓一次我一般会间隔几秒连续抓 3 到 5 次看栈顶是否稳定停留在某个热点函数上。3.4 封装脚本里藏的小经验那条自定义 pstack 脚本虽然只有几行但我在真实环境里已经用了很多年做一些补充说明如果目标是 Java、Python、Go 这类运行时进程原生 pstack 抓出来的往往是运行时自身代码看不到业务层栈。Java 程序应该优先用jstackGo 程序则应该用发送信号或runtime/pprof的方式。pstack 的主场是 C/C 或同类本地程序。如果目标进程名重复出现你可以先用pgrep -f 进程名拿到所有 pid再逐个抓而不是到处猜。生产环境建议把输出重定向到文件并贴上时间戳。事故分析时时间线比栈本身更重要。ts$(date %F_%H-%M-%S) pstack $pid stack_${pid}_${ts}.txt 21多留几份快照你在复盘时就能对比状态的演变而不是只有孤零零的一份。4. 进阶读栈从几行帧里挖出真正的问题4.1 栈顶决定状态调用链决定位置只看栈顶函数可以快速判断线程处于什么状态。栈顶是poll、epoll_wait、nanosleep说明线程在等待事件或在休眠一般不属于忙等栈顶是某种加锁操作比如futex_wait或pthread_mutex_lock说明线程正试图获取锁而不得。单独看栈顶仍然不够调用链才是定位的关键。同样是futex_wait它可能是业务代码在访问共享数据结构时被锁堵住了也可能是底层内存分配器在内部锁上等待。你需要沿着 #1、#2 一直往上找直到看到自己工程里的函数。那个函数才是事故需要关注的“第一现场”。我给团队培训时常举一个比喻栈顶是汽车仪表盘上亮起的红灯它告诉你“有故障”但真正要修的是引擎盖下面某个零件。调用链就是通往引擎盖的路线图。4.2 没有调试符号时怎么剥离出地址背后的信息生产环境里许多二进制出于安全和瘦身考虑是不带-g调试信息的抓出来的栈可能长成这样#0 0x00007f8a4b4f3355 in nanosleep () from /usr/lib64/libc.so.6 #1 0x00007f8a4b4f32b0 in __sleep30 () #2 0x000055c7abc03a82 in ?? () #3 0x000055c7abc03957 in ?? ()看到一堆??很绝望但还没到完全没救的程度。关键思路是地址本身可以被解析。第一步查一下目标程序的加载基址可以从/proc/pid/maps里看。假设可执行文件映射起始是0x000055c7abc03000那么栈里那个0x000055c7abc03a82在文件内的偏移就是0x000055c7abc03a82 - 0x000055c7abc03000 0xa82接着用地址转换工具把它还原成函数名addr2line -e /path/to/program -f 0xa82输出可能给你一个函数名甚至还能给出行号。前提是程序文件里还保留了符号表。如果符号表也被剥离了那我只能遗憾地告诉你只能看到偏移没法直接知道函数名。这时候更现实的办法是找编译时的构建版本和符号文件在开发环境重新解析。我额外说一个生产经验不要等到事故发生了才想到符号表。构建流水线里把每次发布的二进制和符号文件一起归档哪怕程序本身不带调试信息符号文件也能在你事后需要时救命。这几分钟的准备能省掉现场抢救的大量时间。4.3 多线程栈之间怎么找锁等待线上服务卡死最常见的元凶之一就是死锁。pstack 输出里的多个线程栈恰好能帮你把死锁关系画出来。用文字描述我的判断流程先找所有处于futex_wait或pthread_mutex_lock状态的线程然后看这些线程的调用链。如果线程 A 拿着锁 L1 等锁 L2线程 B 拿着锁 L2 等锁 L1输出的调用链上必然能看到不同线程的加锁位置互相咬合。此时把这两个栈并排放在一起锁的关系基本就清楚了。比如看到线程 A 的链thread_func - lock(B) - wait线程 B 的链thread_func2 - lock(A) - wait那几乎可以断定是 ABBA 型的经典死锁。虽然我们不会在这里手动画流程图但我建议你直接在纸上把锁名和线程名连线一目了然。处理死锁的手段各有不同可能是加锁顺序统一、超时机制、或者改用读写锁这些是另一个话题。但你要记住pstack 的价值是让你用极低成本拿到死锁现场的第一手证据这份证据比任何人的推测都有说服力。5. 高频问题与避坑手册我替你踩过的雷5.1 问题速查表我把这些年遇到的 pstack 问题整理成了一张速查表照着对号入座即可现象最常见原因处理思路Operation not permittedptrace 权限受限或非同一用户切换到目标进程的运行账号或具备权限的容器执行进程不存在或已退出pid 写错或进程恰好崩溃用ps -ef、pgrep重新确认 pid抓的栈里全是??二进制没有调试符号用addr2line配合 maps 映射解析或找归档符号文件Java 进程抓不到业务栈pstack 只能看本地栈Java 用jstack并考虑结合jcmd分析执行后长时间卡住目标进程处于不可中断睡眠状态先确认进程状态是否为 D必要时借助其它内核手段容器里抓不到外部的进程容器 pid namespace 与 host 隔离进入目标容器的上下文执行或在宿主机用对应权限操作输出很长终端刷屏线程数量很多重定向到文件用split或grep分块查看这张表不是背出来的而是我一次次在群里看别人踩坑后积攒出来的。最核心的教训其实只有一条先确认进程状态和权限再抱怨工具不行。5.2 D 状态进程pstack 的致命盲区别人问我很长时间“我的进程卡住是 D 状态为什么 pstack 执行后一直不出结果”这个问题值得单独讲。D 状态也叫不可中断睡眠通常意味着进程正在等待内核 I/O 完成比如磁盘读写、NFS 挂载卡住。在这种状态下进程无法响应用户态的调试信号gdb 附加进去就可能阻塞住pstack 也就跟着卡住。所以我的建议是看到ps输出中进程状态为 D先不要对 pstack 抱太大期望。此时你更应该关注系统层的 I/O 问题比如iostat、dmesg、存储设备状态。如果系统崩溃严重可能连 pstack 都无法执行那时需要的是内核转储、kdump 之类的机制而不是再在用户态死磕。反过来讲如果进程状态是 R运行中或 S可中断睡眠pstack 的大多数问题都出在人而不是工具本身。5.3 别在事故现场反复抓同一片废墟有个习惯我必须提醒拿到一份栈之后不要 “趁热” 反复用 gdb 做各种手工命令频繁附加会加剧对进程的扰动。尤其是在高并发服务上额外停顿时长可能引发连锁超时。正确做法是快照驱动抓一次落盘分析如果证据不够再抓第二次但间隔至少几秒。相比一股脑在交互式 gdb 里折腾十几分钟几次干净利落的快照对在线业务友好得多。这也是我当初做 pstack 时最在意的一点仪式感不重要快才是美德。5.4 符号表、符号表、还是符号表栈信息有没有价值一半取决于符号表。没有符号表pstack 输出只能停留在内核函数和动态库层面你自己的业务代码全是??。所以我看到的线上事故复盘几乎无一例外地和符号表缺失有关。要解决也不复杂构建时不要丢弃符号文件哪怕发布时不给二进制加-g也要保留一份带符号的副本。不少团队会用单独的 debug 包把符号文件归档进制品库。等到需要分析下载对应版本符号配合addr2line或 gdb 的set debug-file-directory就能恢复可读性。这套流程前期成本很低事故时价值极高。6. 写在“上篇”的最后以及接下来你会用到什么pstack 的完整指南到这里只是把地基打牢了。你现在清楚了它是什么、什么时候用、内部原理是怎样的、怎么安装、怎么抓取第一份栈、怎么读懂每一帧也知道了那些常见的坑和正确的使用习惯。在掌握了这些之后实际上你已经能应付绝大多数线上“进程卡住但不知道卡在哪”的场景了。我在多年运维和排查过程中越来越深的一个体会是工具的价值不在于它有多复杂而在于它能不能在最要紧的时刻以最低成本给你一条线索。pstack 恰恰就是这样一个工具。它没有炫目的界面没有庞大生态但它却和核心调试机制紧紧绑在一起。每一行输出背后都有 ptrace 的介入、gdb 符号解析的功劳以及操作系统对调试权限的某种微妙平衡。如果你继续往深了走接下来值得拓展的方向包括怎么结合/proc/pid/maps把地址偏移换算成动态库里的具体函数、怎么用一次 pstack 抓取与分析多进程服务集群、怎么把栈快照进一步转换成火焰图、以及在服务端编程里怎么用栈信息反向优化代码。这些内容适合作为这篇指南的下篇我已经在整理了。最后分享一个我自己的习惯不要等到出事了才想起 pstack。平时在测试环境、在压测的时候随手对一个“看起来正常”的进程抓一次栈存成基线。等哪天进程真的异常了你再抓一份和基线对照那种“原来它现在卡在这里而正常时候它是停在另一个位置”的对比往往会让你少熬一个大夜。命令行很短习惯却很值钱。
返回列表