> try/except 挡不住段错误,signal.alarm 送不进 C 代码——进程池会静默卡死

Research Tools Advanced 2026-10-12 04:23 2026-10-12
#python #multiprocessing #timeouts #signals #native-code #debugging

一个用 multiprocessing.Pool 批量解析 PDF 的流水线,撞到两种 native 层失效, 两道看起来很标准的防护都不起作用。

// TABLE_OF_CONTENTS

结论先行

一个用 multiprocessing.Pool 批量解析 PDF 的流水线,撞到两种 native 层失效, 两道看起来很标准的防护都不起作用。

① 段错误的 worker 会把整个进程池静默卡死。 某本 1014 页的书让 native 解析库直接段错误(exit 139)。 Python 的 try/except 捕不到 —— 进程已经没了。

worker 死了不返回结果 → for r in pool.imap_unordered(...) 永远阻塞 → 整批卡死 30 分钟,CPU 全闲、日志一行不出、没有任何报错。

怎么确认是"卡死"而不是"慢":采 CPU ticks。

for pid in $workers; do awk '{print $14, $15}' /proc/$pid/stat; done   # 8 秒后再取一次

全部 0 增量,系统 95% idle,同时有两个子进程停在 futex_do_wait。 慢的话至少有一个 worker 在烧 CPU。

② signal.alarm 管不住 native 代码。 另一本 677 页的书在同一个库上 跑了一个多小时没结束,其余 21 本早就完了。第一版修法是在 worker 里挂 signal.alarm(900) —— 不起作用。到点了那本书还在跑: Python 的信号处理只在解释器执行字节码时才有机会跑, 而它卡在 C 代码里,信号根本送不进去。

还试过按体积挑"病态的书",实测否掉了:那本慢的是 82 KB/页, 而 130 KB/页、108 KB/页的两本都几十秒跑完。 体积和耗时不相关,按体积卡会误伤一堆正常的书。

为什么

Python 的三道常用防护,各有一个它够不到的层:

防护 够得到 够不到
try/except Python 异常 段错误、abort()、OOM kill —— 进程直接没了
signal.alarm 卡在字节码里的循环 卡在 C 调用里 —— 信号排队等解释器回来
按输入特征预筛 可预测的病态输入 耗时与任何易得特征都不相关的输入

共同点:它们都假设控制权还在解释器手里。 第三方 native 库在进程池里运行时,这个假设在最需要防护的那一刻正好不成立 —— 既不会抛异常,也收不到信号。

于是唯一还成立的位置是外层、跨进程、由另一个还活着的进程持有的超时。 imap_unordered 的默认行为是无限等待,这在 worker 可能消失的前提下 是个死锁:收集方等一个永远不会来的结果,而它等的那个进程已经不存在了。

按体积预筛失败这件事也值得单记:当你想用一个易得的特征去预测一个贵的量时, 先去量一下它们的相关性。 这里是 82 vs 130 vs 108 KB/页三个点 就足以否掉这个判据 —— 比实现一套预筛规则便宜得多。

怎么用

收结果一定要带超时,不要裸 for r in pool.imap_unordered(...):

it = pool.imap_unordered(work, tasks)
while True:
    try:
        r = it.next(timeout=PER_DOC_TIMEOUT)
    except mp.TimeoutError:
        break          # 把这批还 running 的退回 pending,交给下一轮
    except StopIteration:
        break

超时后的处理要接到状态表上:退回 pending,试满 MAX_ATTEMPTS 自然进隔离区。这才是有重试语义的流水线本来该有的行为 —— 超时不是"这次失败",是"这个任务需要换一条路"。

超时值从实测最慢的样本定,留 1.5 倍余量。 这个项目最初定 5000s (实测最慢 3318s),后来收到 1200s —— 因为同时加了降级路径, 不再需要等那个病态样本跑完。超时值和降级路径要一起定: 没有降级路径时超时必须宽,有了之后才能收紧。

超时的处置是降级,不是丢弃。 超时时往错误字段记一笔 "超时:下次走纯文本",重试时读这个标记直接降级成逐页取文本。 那本书降级后 10 秒跑完,丢的只是标题层级,而切块阶段还有两级 fallback 兜底 —— 整本丢掉比降级严重得多。

首选是让它根本不崩。 那本段错误的书是 reflowable 的 epub, 本来就有完整内嵌书签(实测 155~969 条),不需要走那条会崩的 markdown 路径;改成逐页取文本之后 4 秒跑完两本(原来卡死 30 分钟), 页锚点还更精确。超时是兜底,换路径是修复。

"卡死 vs 慢"要有一个 8 秒就能出结论的判别法。 /proc/<pid>/stat 的第 14、15 列是 utime / stime(时钟滴答)。 两次采样零增量 = 卡死;有增量 = 慢。 配 cat /proc/<pid>/wchan 或 py-spy dump --pid 看阻塞在哪。 这比"再等等看"便宜,也比加日志便宜 —— 卡死的进程不会打日志。

进程池里跑第三方 native 库,必须假设 worker 会崩。

出处

~/Tools/booksearch/docs/RAG_PLAN.md §12.6「段错误的 worker 会把整个 进程池静默卡死」(exit 139、CPU ticks 判别法、futex_do_wait、 it.next(timeout=...) 的修法、4 秒 vs 30 分钟)与 §16.5 「一本书拖住整批一小时:signal 管不住 native 代码」 (signal.alarm(900) 无效、82/130/108 KB per page 的体积反例、 PER_DOC_TIMEOUT 5000s → 1200s、降级后 10 秒)。