暗无天日

=============>DarkSun的个人博客

排查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_addressrem_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 的小端序表示,对应 IP 119.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 小时没有任何数据流动。这说明:

  1. 进程发送了约 2MB 的 API 请求(完整到达服务端)
  2. 服务端回了约 572KB 数据后,传输中断了
  3. 中间的网络设备(NAT/防火墙)可能丢弃了这个长时间空闲连接的状态
  4. 由于没收到 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 pipefailset -e 会让脚本在任何命令返回非零时立即退出。但脚本中有这样的写法:

output=$(claude ...) || true
#                    ^^^^^^^^

|| true 的作用是:即使 claude 命令失败,表达式整体也会返回成功( true 的返回值是 0)。这样 set -e 就不会触发,脚本不会退出。

所以,kill 掉卡住的 claude 进程(169047)后:

  1. claude 收到信号退出
  2. 子 shell 也退出(非零)
  3. || true 兜住错误
  4. 脚本继续处理下一个 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?://...' :用正则匹配 URL
  • sort -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 到运行中的进程 导出内存、查看变量

反思与改进

这次事件暴露了几个可以改进的地方:

  1. 应用层超时 :claude CLI 应该给 API 请求设置合理的读取超时(如 5-10 分钟),避免死连接无限挂起
  2. TCP keepalive :开启 TCP keepalive 可以让内核自动检测死连接
  3. 脚本健壮性 :可以在脚本中为每个 claude 调用加上 timeout 命令:

    timeout 600 claude -p ...  # 最多等 10 分钟
    
  4. URL 文件保护 :脚本应该在读入 URL 后立即备份,或者用管道传递而非依赖临时文件
异闻录