排查Linux进程"卡死"实战:从strace到gdb全流程
目录
故障现象
某天,我在 Orange Pi 上跑了一个批量处理 URL 的脚本,用 claude CLI 把网上的文章自动转成知识卡片。脚本跑了一晚上,第二天发现它好像"卡住了"——终端没有任何输出,进度也不再推进。
怎么确认它真的卡住了、又卡在哪里呢?下面是完整的排查过程。
排查过程
用 strace 查看进程在干什么
strace 是 Linux 下排查进程问题的神器,它能追踪一个进程正在调用的所有系统调用(system call)。系统调用是程序向内核请求服务的接口,比如读文件、写网络数据、等待定时器等。
先用 ps 找到卡住的进程 PID,然后 attach 上去:
sudo strace -f -p 169047 -o /tmp/169047.log
-f:追踪子进程和子线程-p 169047:attach 到 PID 为 169047 的进程-o /tmp/169047.log:将输出写入日志文件
等了几秒钟后按 Ctrl+C 停止,然后查看日志。
分析 strace 输出
日志有 7049 行,初看很吓人,但关键是找到规律。先看前面几行:
169080 read(16, <unfinished ...> 169059 epoll_pwait2(13, <unfinished ...> 169047 epoll_pwait2(4, <unfinished ...> 169048 futex(...) = -1 ETIMEDOUT (Connection timed out) ...
每一行的格式是: 线程ID 系统调用(参数) = 返回值
这里有多个不同的数字(169047、169048、169049……),它们是什么?同一个进程可以有多个 线程 ,每个线程有自己的 ID(TID),但它们共享同一个进程的内存和文件描述符。
初步归类
过滤掉重复的 sched_yield (让出 CPU)和 futex (线程同步)后,我发现了这些有意义的操作:
| 线程 | 系统调用 | 含义 |
|---|---|---|
| 169047 (主线程) | epoll_pwait2(4, [], ...) 反复超时返回空 |
事件循环在等事件,但什么都没来 |
| 169080 | read(16, ...) 从第1行卡到最后一行 |
一直阻塞在读操作上 |
| 169049-169053 | 大量 sched_yield |
工作线程在空转 |
| 169059 | epoll_pwait2(13, ...) 周期性返回事件 |
有事件到来,但…… |
| 169057-169061 | 周期性 statx 检查 .git 文件 |
正常的文件监控轮询 |
主线程 169047 在做事件循环(event loop):等事件 → 超时 → 检查一些状态 → 回去继续等。epoll 每次返回的都是空数组 [] ,说明主事件循环上 没有事件就绪 。
线程 169080 从日志开始到结束一直卡在 read(16, ...) 上,从未返回。看到 read() 阻塞,我的第一反应是: 这个进程大概在等网络响应 。毕竟它是一个调用 API 的程序, read() 卡住最常见的原因就是网络请求发了出去,但服务端的响应迟迟没到。但这个猜测对不对呢?
小知识:什么是文件描述符(fd)?
在 Linux 中,所有 I/O 操作都通过文件描述符进行。打开一个文件、建立一个网络连接、创建一个管道,都会得到一个 fd。它就是一个整数编号(0=标准输入,1=标准输出,2=标准错误,3 以上由程序自己分配)。
fd 不仅对应普通文件,还可以对应网络 socket、管道、定时器、inotify 实例等。知道了 fd 的编号,不等于知道它的类型——我们需要到
/proc文件系统中去查看。
确认 fd 16 的类型:不是网络 socket
ls -la /proc/169047/fd/16
输出:
lr-x------ 1 orangepi orangepi 64 /proc/169047/fd/16 -> anon_inode:inotify
fd 16 是一个 inotify 实例!inotify 是 Linux 的文件系统事件监控机制,用来监听文件变化(比如文件被修改、创建、删除)。 read() 在 inotify 上阻塞是 设计上就是这样的 ——它只在有文件变化时才返回数据,没有变化时阻塞等待是天经地义的事。
但是,一个 inotify 线程阻塞,并不能解释整个进程"卡住"的现象。这个线程只是一个后台的文件监控器,它的职责就是安静地等文件变化。进程之所以"卡住",是因为它无法完成自己的核心任务——处理 URL。所以 fd 16 不是故障原因,我需要继续找。
转向线程 169059:epoll fd 13 只收到定时器事件
strace 日志中,线程 169059 也在做一个 epoll_pwait2 。单独提取它的记录:
grep '169059' /tmp/169047.log
输出:
169059 epoll_pwait2(13, <unfinished ...>
169059 <... epoll_pwait2 resumed>[{events=EPOLLIN, data=...}], ...) = 1
169059 read(14, "\1\0\0\0\0\0\0\0", 8) = 8
169059 epoll_pwait2(13, <unfinished ...>
169059 <... epoll_pwait2 resumed>[{events=EPOLLIN, data=...}], ...) = 1
169059 read(14, "\1\0\0\0\0\0\0\0", 8) = 8
169059 epoll_pwait2(13, <unfinished ...>
...(重复多次)
线程 169059 在反复做一件事: epoll_pwait2(13, ...) → 返回 1 个事件 → read(14, ...) → 回到 epoll_wait 。
注意一个规律:每次 epoll 返回后,线程读的都是 fd 14 (后面可以看到这是个定时器)。也就是说,线程在重复等待 epoll fd 13 上的事件。那 epoll fd 13 到底监控了哪些 fd?我们需要查两样东西:进程的完整 fd 列表(搞清楚每个 fd 是什么),以及 epoll fd 13 的监控清单(搞清楚它在等谁的数据)。
查看 fd 全貌和 epoll 监控清单
先看进程一共有哪些文件描述符:
ls -la /proc/169047/fd/
输出中能看到的 fd 类型包括:
| fd | 类型 | 说明 |
|---|---|---|
| 0 | /dev/pts/1 | 标准输入(终端) |
| 1, 2, 11, 12 | pipe | 管道(stdout/stderr 重定向) |
| 3, 9 | /dev/urandom | 随机数生成器 |
| 4 | anon_inode:[eventpoll] | 主事件循环的 epoll 实例 |
| 5, 7, 8 | anon_inode:[timerfd] | 定时器 |
| 6 | anon_inode:[eventfd] | 事件通知 |
| 10 | /proc/169047/statm | 进程内存统计 |
| 13, 32 | anon_inode:[eventpoll] | 另外两个 epoll 实例 |
| 16 | anon_inode:inotify | 文件系统监控 |
| 28 | socket:[616763] | 唯一的网络 socket! |
进程里有三个 epoll 实例(fd 4、fd 13、fd 32)。查看 epoll fd 13 的监控清单:
cat /proc/169047/fdinfo/13
输出:
tfd: 28 events: 19 data:2611c3c00a0 ← 网络 socket tfd: 15 events: 80000019 data:2611c0a0100 ← eventfd tfd: 14 events: 19 data:2611c0a00c0 ← timerfd
/proc/PID/fdinfo/EPOLL_FD 中的 tfd 字段就是被监控的目标 fd 编号。epoll fd 13 同时监控了三个 fd:fd 28(网络 socket)、fd 15(eventfd)、fd 14(定时器)。
strace 中只看到 fd 14(定时器)触发,fd 28 和 fd 15 都没有。但 fd 15 不触发并不奇怪——它是一个 eventfd ,这是一种线程间内部通知机制,只有当其他线程主动往里写数据时才会触发。如果内部没有需要通知的事情,它安静待着是正常的。
而 fd 28 就不同了。它是 网络 socket ,是进程与外部世界通信的唯一通道。这个进程是 claude CLI,它向 API 服务器发出了请求,正在等响应。socket 长时间不触发,意味着服务端的响应数据迟迟没到——这才是进程"卡住"的根本原因。
检查网络连接状态
找到了网络 socket fd 28,它的 inode 号是 616763。接下来要搞清楚这个 socket 连接的是谁、状态如何。
从 /proc/net/tcp 找到连接详情
/proc/net/tcp 文件列出了系统所有 TCP 连接。我们需要从中找到 inode 为 616763 的那一条:
cat /proc/169047/net/tcp
输出很长,但关键的一行是:
sl local_address rem_address st tx_queue:rx_queue ... inode ... 13: 191FA8C0:CF72 33551777:01BB 01 00000000:00000000 ... 616763 ...
inode 616763 匹配上了!但 local_address 和 rem_address 都是十六进制,怎么看懂?
解码 /proc/net/tcp 的地址格式
/proc/net/tcp 中的地址格式是 IP地址:端口 ,其中 IP 地址是 十六进制、小端序 编码的。以远端地址 33551777:01BB 为例:
远端 IP: 33551777 → 每 2 位拆成一个字节: 33 55 17 77 → 反转字节序: 77 17 55 33 → 119.23.85.51 远端端口: 01BB → 十六进制转十进制 → 443 (即 HTTPS)
同理,解码本地地址 191FA8C0:CF72 :
本地 IP: 191FA8C0 → 19 1F A8 C0 → 反转: C0 A8 1F 19 → 192.168.31.25 本地端口: CF72 → 53106
所以这个连接是:本机 192.168.31.25:53106 → 远端 119.23.85.51:443
小知识:为什么要反转字节序?
/proc/net/tcp中的 IP 地址使用 网络字节序 (大端序)存储,但在 x86/ARM 等 小端序 机器上,内核为了存储效率,按小端序输出十六进制。所以33551777实际上是0x77175533的小端序表示,对应 IP119.23.85.51。简单记忆: 把十六进制字符串每两位切一刀,然后倒着拼回去,再转成十进制 IP 。
用 ss 查看连接的详细状态
拿到了远端 IP 后,用 ss 命令查看这个连接的详细信息:
ss -ti dst 119.23.85.51
-t:只显示 TCP 连接-i:显示详细信息(RTT、超时、收发字节数等)dst 119.23.85.51:只看连接到这个 IP 的
输出中最关键的字段:
ESTAB 0 0 192.168.31.25:53106 119.23.85.51:https bytes_sent:2114416 bytes_acked:2114417 bytes_received:586087 lastsnd:10621788 lastrcv:10621508
解读:
| 指标 | 值 | 含义 |
|---|---|---|
| 状态 | ESTABLISHED | TCP 连接看起来还活着 |
| bytes_sent / bytes_acked | ~2MB / ~2MB | 请求已完整发送并被服务端确认 |
| bytes_received | ~572KB | 收到了部分响应 |
| lastsnd | 10,621,788 ms ≈ 2.95 小时 | 最后一次发送数据是近 3 小时前 |
| lastrcv | 10,621,508 ms ≈ 2.95 小时 | 最后一次接收数据也是近 3 小时前 |
真相大白:TCP 半死连接
连接的状态是 ESTABLISHED,但近 3 小时没有任何数据流动。这说明:
- 进程发送了约 2MB 的 API 请求(完整到达服务端)
- 服务端回了约 572KB 数据后,传输中断了
- 中间的网络设备(NAT/防火墙)可能丢弃了这个长时间空闲连接的状态
- 由于没收到 TCP RST 或 FIN,进程端以为连接还活着,一直在等
这就是经典的 TCP 半死连接 (half-open connection)问题。TCP 本身不会因为长时间无数据就主动关闭连接(除非开启了 TCP keepalive),如果应用层也没有设置读取超时,进程就会 无限期等待 。
恢复过程
问题 1:能不能安全 kill?
在决定 kill 之前,需要了解进程之间的关系:
ps -ef --forest | grep -A5 url-to-cards
bash url-to-cards.sh (138905) ← 主脚本
└── bash (169046) ← command substitution 子 shell
└── claude (169047) ← 卡住的 claude 进程
脚本开头设置了 set -euo pipefail , set -e 会让脚本在任何命令返回非零时立即退出。但脚本中有这样的写法:
output=$(claude ...) || true # ^^^^^^^^
|| true 的作用是:即使 claude 命令失败,表达式整体也会返回成功( true 的返回值是 0)。这样 set -e 就不会触发,脚本不会退出。
所以,kill 掉卡住的 claude 进程(169047)后:
- claude 收到信号退出
- 子 shell 也退出(非零)
|| true兜住错误- 脚本继续处理下一个 URL
问题 2:原始 URL 列表丢失了
脚本读取的 URL 文件 /tmp/feed2mail.urls.txt 已经被另一个程序覆盖了,原始内容丢失了。但 bash 进程(138905)启动时已经把 URL 列表读进了内存中的 urls 数组。
用 gdb 从进程内存恢复数据
gdb (GNU Debugger)虽然主要用来调试程序,但也能 attach 到运行中的进程,读取它的内存。
# 先找到堆内存的地址范围 cat /proc/138905/maps | grep heap # 输出: aaaaec588000-aaaaec60c000 rw-p ... [heap] # 用 gdb 导出堆内存 sudo gdb -batch -p 138905 \ -ex 'dump memory /tmp/bash_heap.bin 0xaaaaec588000 0xaaaaec60c000' \ -ex 'detach' # 从导出的内存中搜索 URL strings /tmp/bash_heap.bin | grep -oE 'https?://[^[:space:]>"]+' | sort -u
dump memory:将指定地址范围的内存写入文件strings:从二进制数据中提取可打印字符串grep -oE 'https?://...':用正则匹配 URLsort -u:去重排序
成功恢复了 189 个 URL !
小知识:为什么要从堆(heap)恢复?
进程的内存分为多个区域:
- 代码段(text) :存放程序指令
- 数据段(data) :存放全局变量
- 堆(heap) :动态分配的内存(bash 变量存在这里)
- 栈(stack) :函数调用栈
bash 的变量(包括数组)存储在堆内存中,所以我们从堆区域提取数据。
总结
排查流程回顾
进程卡住了
│
├─ 1. strace -f 看进程在做什么系统调用
│ → 发现多个线程:主线程 epoll 空转,线程 169080 read 阻塞,线程 169059 epoll 周期触发
│
├─ 2. ls -la /proc/PID/fd/16 看 fd 16 指向什么
│ → 是 inotify(文件监控),阻塞是正常行为,排除
│
├─ 3. 回到 strace,细看线程 169059
│ → epoll_pwait2(13) 每次返回后都读 fd 14(定时器),从不读 fd 28(网络 socket)
│
├─ 4. cat /proc/PID/fdinfo/13 确认 epoll 监控清单
│ → epoll fd 13 监控了 fd 28(网络 socket),但 socket 从未触发事件
│
├─ 5. ss -ti 查看网络连接状态
│ → lastsnd/lastrcv 约 3 小时前 → TCP 半死连接
│
├─ 6. 分析代码确认 kill 安全(|| true 兜底)
│
└─ 7. gdb dump memory 恢复丢失的数据
→ 从堆内存中提取出 189 个 URL
工具速查
| 工具 | 用途 | 示例 |
|---|---|---|
strace -f -p PID |
追踪进程的系统调用 | 看进程卡在哪个 syscall |
ls -la /proc/PID/fd/ |
查看进程打开的所有文件描述符 | 确认 fd 是 socket、pipe 还是 inotify |
cat /proc/PID/fdinfo/N |
查看 fd 的详细信息 | 看 epoll 监控了哪些 fd |
cat /proc/PID/maps |
查看进程内存布局 | 找到堆地址范围 |
ss -ti |
查看 TCP 连接详情 | 看 RTT、超时、最后收发时间 |
ps -ef --forest |
查看进程树 | 理解父子进程关系 |
gdb -batch -p PID |
attach 到运行中的进程 | 导出内存、查看变量 |
反思与改进
这次事件暴露了几个可以改进的地方:
- 应用层超时 :claude CLI 应该给 API 请求设置合理的读取超时(如 5-10 分钟),避免死连接无限挂起
- TCP keepalive :开启 TCP keepalive 可以让内核自动检测死连接
脚本健壮性 :可以在脚本中为每个 claude 调用加上
timeout命令:timeout 600 claude -p ... # 最多等 10 分钟
- URL 文件保护 :脚本应该在读入 URL 后立即备份,或者用管道传递而非依赖临时文件