资讯动态

gstack命令实战:快速抓取线程栈定位线上死锁与CPU飙升问题

发布时间:2026/10/13 15:57:34 来源:尧图企业网站定制
大概所有做过线上故障排查的人都有过这种时刻服务没有崩却突然像睡着一样不响应CPU 被某个进程死死占住日志里一片空白的死循环线程卡住谁都不释放锁。这时候我最常翻出来的工具不是调试器的大全套而是一个名字很短的小命令gstack。gstack 是 GNU/Linux 发行版中很常见的栈跟踪工具它能把指定进程所有线程的当前调用栈打印出来。这句话听起来不稀奇但关键时刻它能回答运维和开发最关心的那个问题这一秒程序到底在执行什么下面我从为什么用、怎么用、踩过哪些坑三个角度展开所有内容几乎都能直接拿到你自己的服务器上复现。1. gstack 的本质一条命令背后的 gdb 脚本逻辑1.1 为什么需要 gstack 这么轻的命令先说一下我最初接触 gstack 的场景。那是一个没有统一 APM 平台的时期服务卡死后进到生产环境第一反应当然是查日志、看监控但日志停在上一次正常请求进程还在那里喘气内存不高、磁盘不忙就是不动。这时候能用的手段其实很少要么直接重启让业务恢复但 bug 现场就没了要么上 gdb 交互式调试可在生产环境慢慢敲命令是件很奢侈的事gdb 一旦挂上去其他同事盯着你压力比故障本身还大。gstack 的价值就在于“快”。它不需要你打开一个交互式调试会话不需要记一长串 gdb 指令执行完就退出。一条命令一个 PID几秒钟内把整个进程所有线程的调用栈全部吐出来。这种轻量级特性决定了它天生适合应急场景——线上故障发生时你的脑子可能是懵的记不住复杂命令但 gstack 这个命令名和用法足够简单简单到可以刻进肌肉记忆。它也不是什么冷门工具。只要系统里装了 gdb很多时候 gstack 就跟着一起安装了。Debian/Ubuntu 的 gdb 包、RHEL/CentOS 的 gdb 包通常都会附带 gstack有些发行版还会附带 pstack。这大大降低了使用门槛不用额外引入一套监控体系也不用改业务代码直接对现有进程下手。1.2 拆开放大看gstack 实际做的事情说到本质gstack 并不是一个独立的调试器。它就是一个把 gdb 包装起来的 shell 脚本脚本的逻辑十分直白根据/proc/pid/exe找到目标进程对应的可执行文件然后用 gdb attach 到这个进程上执行thread apply all bt把每个线程的 backtrace 打出来最后 detach 退出。如果你手边某台机器没有 gstack但装了 gdb完全可以用一条等价的命令模拟出同样的效果gdb -q -p pid \ -ex set pagination off \ -ex thread apply all bt \ -ex detach \ -ex quit这行命令基本就是 gstack 脚本的核心。set pagination off是为了防止输出过长时 gdb 停下来等待用户按回车这在非交互式场景下是致命的thread apply all bt则是遍历当前进程里的全部线程逐个打印栈回溯。理解了这个原理你就能明白一个很重要的副作用gdb attach 到进程时会通过 ptrace 短暂地暂停目标进程。整个过程通常只有几秒但如果目标进程线程很多、内存很大这个停顿时间会被拉长到肉眼可见的程度。打个比方gstack 就像是给一台正在高速运转的机器拍 X 光片拍片的瞬间机器会被固定一下快门落下后继续运转。大多数服务受不了这一下但真正的关键业务、严格要求延迟的服务就需要评估一下这个快门时间了。后面我会专门讲这个坑。2. 该出手才出手gstack 与 pstack、jstack 的分工2.1 三种典型故障现场gstack 不是一个需要天天用的工具但碰到下面三类情况我会第一时间想到它。第一种是 CPU 被拉满但业务没崩。死循环、自旋锁、某个入口被异常流量打爆都可能造成单核或整机 CPU 飙高。此时你只看到进程名列前茅却不知道它在执行哪一段代码。配合top -H找到肇事线程的 LWP线程 ID再执行gstack pid看这个线程的栈顶函数基本就能锁定代码位置。这是 gstack 最高光的场景。第二种是服务无响应疑似死锁或长期阻塞。两个线程各自持有锁等待对方释放或者某个线程卡在 IO 上永远等不到数据日志不会告诉你只有线程栈能显示它们各自停在哪个系统调用上。特别是当你看到两个线程的栈里一个停在futex_wait另一个停在持锁后的后续逻辑里死锁的证据链就出来了。第三种是程序没有崩溃也没有日志但行为诡异。比如内存池异常增长、连接的 worker 全部消失、定时任务不再触发。这种问题加日志排查看运气因为下次不一定能复现。gstack 是即时采样抓到的是这一秒的真实状态。我在这种情况下会连着抓两三次间隔几秒对比线程栈的变化判断是稳定阻塞还是随机抖动。2.2 拿 gstack 跟一群“同类”比一比很多人一开始会把 gstack、pstack、jstack 混在一起这几个名字确实容易让人晕。这里直接给一张我用下来的对比表工具适用对象获取方式输出内容我的使用判断gstackC/C、Go、Rust 等原生线程gdb 包附带系统线程栈、LWP 内地址线上原生进程首选pstack同上部分发行版单独提供同样基于 ptrace 抓栈看到 pstack 就用本质接近 gstackjstack / jcmdJava 进程JDK 自带Java 方法栈、锁信息、GC 细节纯 Java 问题优先用快且安全jstack gstack 组合Java 里嵌了 JNI/第三方 native分别安装Java 栈 原生线程栈遇到 JVM 内 native 卡死时的王牌组合py-spyPython 进程pip 安装Python 帧栈不需要 GDB采样开销低但只认 Python 栈gstack 和 pstack 的区别其实没那么大。在很多系统里它们指向同样的实现思路pstack 这个名字更老Solaris 时代就有gstack 则侧重于 GNU 工具链的配套提供。实际使用中我基本看机器上哪个装了就用哪个输出格式几乎一样。真正需要分辨的是“什么时候用 gstack什么时候用 jstack”。如果你面对的是一个纯 Java 服务那 jstack 是更合适的选择它拿到的 Java 方法栈信息更丰富还能看到锁对象和 JVM 内部状态。但如果这个 Java 进程通过 JNI 调了 C/C 库或者你怀疑是 GC 线程、JIT 编译线程出了问题jstack 就完全看不到 native 栈了。这时候反而要切回 gstack从操作系统视角看一遍 JVM 的原生线程究竟停在哪。反过来gstack 对纯 Java 业务逻辑也是瞎的它只看到一堆JavaCalls::call_helper之类的 JVM 内部符号无法还原 Java 代码层次。所以这两者不是替代关系是互补关系。3. 现场实操从安装到读懂第一份线程栈输出3.1 环境和安装在你决定执行 gstack 之前先确认机器上有这个命令。which gstack pstack --help如果没找到优先考虑安装 gdb 包# Debian / Ubuntu sudo apt-get install -y gdb # RHEL / CentOS sudo yum install -y gdb装完再确认which gstack。有些精简镜像只装了 gdb没带 gstack 脚本这时候别慌直接用我之前写的 gdb 等价命令即可。然后拿到目标进程的 PID。我最常用的两种方式pidof your_server_name ps -ef | grep your_server_name | grep -v grep不建议你用pidof拿多个结果时直接套进 gstack进程一多输出会乱。先肉眼确认你要抓的是哪个进程再动手。3.2 拿到 PID跑第一条 gstack假设目标 PID 是 12345执行gstack 12345 /tmp/stack_$(date %Y%m%d_%H%M%S).txt注意一定要把输出重定向到文件不要直接打在终端上。线程一多终端滚动会让人复盘时找不到头尾而且会拖慢整个操作节奏。存成文件后可以随时翻查也可以直接传给同事一起看。第一次看到 gstack 输出时你可能会被一堆#0、#1吓到但其实结构非常固定。一份典型的输出长这样Thread 0x7f0c9a6fe700 (LWP 12345): #0 0x00007f0c9d7d3f0d in epoll_wait () from /lib64/libc.so.6 #1 0x0000557e1a2b9c10 in EventLoop::Run() at event_loop.cc:45 #2 0x0000557e1a2ba8e5 in main at main.cc:18逐行解释一下Thread 0x7f0c9a6fe700是 gdb 给线程起的内部标识括号里的LWP 12345才是操作系统线程号。#0表示当前正在执行的函数也就是栈顶。epoll_wait表示这个线程正在等待内核事件。#1、#2是调用它的上级函数。读的顺序应该从下往上先看入口再看当前停点。看栈的时候我习惯先看#0是不是一个正常的等待点。如果线程停到epoll_wait、nanosleep、read这种通常是正常睡眠如果停到某个业务函数、锁相关的pthread_mutex_lock、或者重复出现的malloc相关内部调用那就很可疑了。3.3 抓连续快照判断问题类型单次 gstack 输出只能说明执行到哪但无法说明是“慢”还是“卡死”。我的经验是间隔几秒连续抓两次尽可能在同一批线程上比较。gstack 12345 /tmp/stack_1st.txt sleep 5 gstack 12345 /tmp/stack_2nd.txt如果同一个 LWP 两次都停在几乎相同的栈顶且中间的函数调用链完全一致说明这个线程确实在某种稳定状态里要么是死锁要么是长时间阻塞。如果两次栈顶变化很大那说明线程还在跑问题可能是忙等或者调度异常。这两种情况的后续处理方向完全不同先定性很重要。4. 一次线上高 CPU 故障的完整排查实录4.1 现象单核被拉满请求大量超时拿一次真实发生过的故障来说。某个 C 网关服务在一次例行发布后监控发现 CPU 使用率比平时高了 30%随后十几分钟内请求超时率明显上升。业务方第一反应是回滚版本但回滚也要走流程而且大家都不确定是不是发布引入的。现场的状态是进程活着端口监听正常Accept 也正常就是请求处理不过来了。我上去之后没有看代码先执行了两条命令top找到进程 PID然后top -Hp pid定位到具体线程。top # 找到进程 PID 为 26220 top -Hp 26220 # 看到 LWP 26234 这一行 CPU 接近 95%这个 LWP 26234 就是嫌疑线程。注意top -Hp输出的 PID 列实际上显示的是线程 TID也就是 LWP跟 gstack 括号里那个 LWP 能对上。4.2 用 gstack 锁死嫌疑线程紧接着执行sudo gstack 26220 /tmp/gateway_stack.txt然后从文件里找 LWP 26234 对应的块grep -n LWP 26234 -A 40 /tmp/gateway_stack.txt关键输出大概是这样的Thread 0x7f7ebc006700 (LWP 26234): #0 0x00007f7ebf6ec5ed in __lll_lock_wait_private () from /lib64/libc.so.6 #1 0x00007f7ebf6ea0e4 in _L_lock_1016 () from /lib64/libc.so.6 #2 0x00007f7ebf6e81e7 in malloc_consolidate () from /lib64/libc.so.6 #3 0x00007f7ebf6ecf2e in _int_free () from /lib64/libc.so.6 #4 0x0000557f2a3c2d10 in std::__detail::_List_node_base::_M_hook (std::__detail::_List_node_base*) at /usr/include/c/.../list.tcc:... #5 0x0000557f2a3c8b7a in UserSessionManager::CleanExpiredSessions() at session_manager.cc:118 #6 0x0000557f2a3cbbc5 in TimerQueue::Run() at timer_queue.cc:92 #7 0x0000557f2a3ca210 in worker_thread_main(void*) at main.cc:221这一看就非常清楚了LWP 26234 本来应该是一个定时执行清理任务的线程现在它陷在CleanExpiredSessions()函数里不断进行链表的删除操作。中间那层_M_hook和malloc_consolidate表明这个清理动作卡在了内存管理上而不是真正的业务逻辑。我马上让开发打开session_manager.cc的 118 行附近。代码里是一个std::listUserSession的遍历删除逻辑循环里删掉当前过期节点后没有使用erase返回的新迭代器而是继续使用原来的迭代器往前推进。在某些情况下失效迭代器的后续操作陷入了解析链表节点的循环导致这个线程一直往复在处理同一批节点。4.3 修复后验证修复方法就是标准做法遍历删除时把erase的返回值赋值给迭代器。// 修复前 for (auto it sessions_.begin(); it ! sessions_.end(); it) { if (it-expired) { sessions_.erase(it); } } // 修复后 auto it sessions_.begin(); while (it ! sessions_.end()) { if (it-expired) { it sessions_.erase(it); } else { it; } }发布后再次用top -Hp观察LWP 26234 的 CPU 降到了 2% 以下整体请求超时率在几分钟内恢复正常。4.4 复盘为什么 gstack 比加日志更快定位这个故障如果不用 gstack典型排查路径是看监控、找开发看代码、加日志、等复现、再分析。整个流程可能一两个小时就过去了。而 gstack 从执行到定位到具体函数前后只有几分钟。我在复盘时经常讲一句话线程栈是程序在事故瞬间的“现场照片”日志只是它之前留下的脚印。很多性能问题不会主动打日志也不会崩溃你只能靠抓现行。gstack 是最简单的抓现行方式比 GDB 完整调试、比 perf 采样、比埋点都更接近“无侵入”的状态。这次故障还有一个值得说的细节gstack 执行时进程会有可感知的停顿但网关服务在外面有负载均衡和健康检查短暂停顿不会造成请求失败。如果你负责的是那种完全没有兜底的 TCP 长连接服务抓栈前最好评估一下停顿的影响挑业务低谷操作。5. gstack 实战中的隐藏坑与破解办法5.1 ptrace 权限把第一次 gstack 挡在门外新手最容易遇到的是 gstack 执行后没有输出反而报一句ptrace: Operation not permitted。这句话的根源在 Linux 内核的 Yamaptrace_scope机制。默认情况下它等于 1意味着一个普通用户只能 attach 到“自己启动的子进程”上不能轻易 attach 到其他用户或其他会话的进程。如果你是用普通用户执行 gstack 去看 root 启动的服务几乎必然被拒。解决办法也很直接用 sudo 执行。sudo gstack 26220 /tmp/stack.txt如果 sudo 后还是报同样的错误再看一下内核参数cat /proc/sys/kernel/yama/ptrace_scope非必要情况下我不建议为了一次排查就把ptrace_scope改成 0因为这相当于放开了对 ptrace 的系统级限制安全性会下降。更合理的做法是确认运行用户再决定是用 root 还是用服务同名的用户去执行 gstack。如果服务本身就是 root 启动的那就必须 sudo如果服务是普通业务用户跑的就在那个用户下执行 gstack。5.2 符号丢失后怎么抢救生产环境为了减少体积经常会把二进制文件 strip 一下去掉符号表。这时候 gstack 输出里会出现一堆??Thread 0x7f0c9a6fe700 (LWP 12345): #0 0x00007f0c9d7d3f0d in ?? () #1 0x0000557e1a2b9c10 in ?? () #2 0x0000557e1a2ba8e5 in ?? ()遇到这种情况先别急着放弃。重点是提前做好准备每次发布都保留一份带调试符号的二进制副本。我司的发布脚本会把-g编译出的 unstripped 版本和 stripped 版本一起归档存到固定的目录。拿到符号副本后可以借助 gdb 直接加载它再 attach 进程让它用符号文件里的信息解析栈地址gdb -q /path/to/app.debug -p pid \ -ex set pagination off \ -ex symbol-file /path/to/app.debug \ -ex thread apply all bt \ -ex detach \ -ex quit没有符号副本时也可以靠/proc/pid/maps里的加载地址和addr2line做换算但操作复杂且容易算错 PIE 基址实战价值不高。我想强调的是gstack 再厉害也要配合规范的构建产物管理否则现场照片可能是模糊的。5.3 高线程数进程和 attach 停顿的取舍我见过有人在 Java 业务上直接跑 gstack结果因为进程有上千个线程thread apply all bt输出的栈信息量大到文件几十 MBattach 和 detach 的过程也拖了几秒。这不是 gstack 本身的问题而是使用方式不够精细。在 attach 之前先用这个命令看下进程的线程规模grep Threads: /proc/pid/status如果 Threads 数量超过两三百我会更倾向用 gdb 直接控制只抓部分线程栈并限制栈帧深度gdb -q -p pid \ -ex set pagination off \ -ex info threads \ -ex thread apply 1 bt 10 \ -ex thread apply 2 bt 10 \ -ex detach \ -ex quitbt 10表示每个线程只显示 10 层调用栈足够看到调用链全貌又能显著减少输出量。如果你明确知道肇事线程的 LWP可以直接在 gdb 里用thread 编号先切到那个线程再bt这样 attach 开销最小对线上服务也最友好。需要明确的是gstack 本身不适合频繁、大批量地跑。把多个线程抓全必然会伴随更高的暂停时间。生产环境建议提前跟团队约定好遇到高线程数进程优先用 gdb 直接控制而不是无脑 gstack 全量输出。5.4 容器和 systemd 环境下的附加问题容器化之后gstack 遇到的新问题是容器里经常没有 gdb你在容器内执行不了在宿主机上 attach 容器内进程又受到命名空间和 ptrace 权限双重限制。我的经验是分两步。第一步在宿主机上确认进程确实可见ps -ef | grep your_service第二步如果需要进入容器所在 PID 命名空间执行 gstack可以用nsenter进入对应进程的命名空间sudo nsenter -t host_pid -p进入后就能看到一个接近容器内的进程视图再执行 gstack。如果 nsenter 不可用那就在宿主机上直接对容器进程 gstack但要确保宿主机端的 ptrace 限制打开到足够权限。systemd 服务如果开了ProtectSystem、RestrictAddressFamilies、NoNewPrivileges等限制也可能让 attach 失败这种情况优先看服务端安全配置必要的时候在排查窗口内临时放宽查完恢复。6. 三个很顺手但总被忽略的 gstack 用法6.1 用 gdb -batch 保持同样的效果但多三个控制维度gstack 脚本为了保证通用性参数很少但我经常需要更细的控制比如只要固定线程、限制栈帧深度、关闭分页。这时候直接使用 gdb batch 模式等价命令比改脚本更可控。gdb -q -batch \ -ex set pagination off \ -ex thread apply all bt 20 \ -p pid一个很实用的场景是抓线程栈的同时把每个线程的寄存器也带出来。遇到汇编级问题单纯看函数不够需要结合info registers或者frame切换后的参数值。gstack 做不到但 gdb batch 可以灵活组合命令。6.2 用两次快照对比判断“真死锁”还是“慢调用”前面提过连续抓两次栈这里具体展开一下。死锁有一个很典型的特征第二次抓栈时相关线程栈顶与第一次完全一致函数地址一字不差。这个“完全一致”本身就是强证据。gstack 12345 /tmp/s1.txt sleep 10 gstack 12345 /tmp/s2.txt diff /tmp/s1.txt /tmp/s2.txtdiff 结果越小说明线程状态越稳定。如果线程栈完全相同十有八九是死锁或不可中断睡眠如果 diff 很大说明它一直在动问题更可能是忙等或循环。这套方法简单、可复现比单独看一次输出更可靠。6.3 用 gstack 抓完栈后配合 gcore 保留完整现场gstack 只能给你看不能给你继续深挖的素材。如果栈信息指向了某个可疑问题但你又不想立刻重启进程可以考虑在抓完栈之后顺手生成一份 core dumpgcore -o /tmp/your_service.core pidgcore 也会让进程短暂停顿但它产出的核心转储文件包含了进程当时完整的内存和寄存器状态。之后你可以放心地把进程重启掉在本地慢慢分析这个 core用 gdb 打开甚至回头看某一块内存里的数据。gstack 负责“拍快照”gcore 负责“留样本”两者搭配是线上问题保留现场的最佳组合之一。我自己的习惯是把 gstack 写进服务器故障手册并且在一开始top -Hp之前就想好输出要重定向到哪个目录。很多时候团队会选择直接重启进程来恢复日志却没有任何报错我们失去的不只是现场还有解开 bug 的最快线索。gstack 解决不了所有问题但它能保证你在事故发生时留下第一张清晰的“现场照片”。下一次服务卡住的时候不妨先冷静下来给进程拍个照再决定要不要重启。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑