























某天,我在 Orange Pi 上跑了一个批量处理 URL 的脚本,用 claude CLI 把网上的文章自动转成知识卡片。脚本跑了一晚上,第二天发现它好像"卡住了"——终端没有任何输出,进度也不再推进。
怎么确认它真的卡住了、又卡在哪里呢?下面是完整的排查过程。
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 停止,然后查看日志。
日志有 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 在做事件循环(event loop):等事件 → 超时 → 检查一些状态 → 回去继续等。epoll 每次返回的都是空数组 [] ,说明主事件循环上 没有事件就绪 。
线程 169080 从日志开始到结束一直卡在 read(16, ...) 上,从未返回。看到 read() 阻塞,我的第一反应是: 这个进程大概在等网络响应 。毕竟它是一个调用 API 的程序, read() 卡住最常见的原因就是网络请求发了出去,但服务端的响应迟迟没到。但这个猜测对不对呢?
小知识:什么是文件描述符(fd)?
在 Linux 中,所有 I/O 操作都通过文件描述符进行。打开一个文件、建立一个网络连接、创建一个管道,都会得到一个 fd。它就是一个整数编号(0=标准输入,1=标准输出,2=标准错误,3 以上由程序自己分配)。
fd 不仅对应普通文件,还可以对应网络 socket、管道、定时器、inotify 实例等。知道了 fd 的编号,不等于知道它的类型——我们需要到
/proc文件系统中去查看。
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 不是故障原因,我需要继续找。
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 的监控清单(搞清楚它在等谁的数据)。
先看进程一共有哪些文件描述符:
ls -la /proc/169047/fd/
输出中能看到的 fd 类型包括:
| fd | 类型 | 说明 |
|---|---|---|
进程里有三个 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 文件列出了系统所有 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 中的地址格式是 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 。
拿到了远端 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,但近 3 小时没有任何数据流动。这说明:
这就是经典的 TCP 半死连接 (half-open connection)问题。TCP 本身不会因为长时间无数据就主动关闭连接(除非开启了 TCP keepalive),如果应用层也没有设置读取超时,进程就会 无限期等待 。
在决定 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)后:
|| true 兜住错误
脚本读取的 URL 文件 /tmp/feed2mail.urls.txt 已经被另一个程序覆盖了,原始内容丢失了。但 bash 进程(138905)启动时已经把 URL 列表读进了内存中的 urls 数组。
gdb (GNU Debugger)虽然主要用来调试程序,但也能 attach 到运行中的进程,读取它的内存。
cat /proc/138905/maps | grep heap sudo gdb -batch -p 138905 \ -ex 'dump memory /tmp/bash_heap.bin 0xaaaaec588000 0xaaaaec60c000' \ -ex 'detach' 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
| 工具 | 用途 | 示例 |
|---|---|---|
这次事件暴露了几个可以改进的地方:
脚本健壮性 :可以在脚本中为每个 claude 调用加上 timeout 命令:
timeout 600 claude -p ...
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。