<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>死锁 on Zero Day Notes</title>
        <link>https://blog.5772447.xyz/tags/%E6%AD%BB%E9%94%81/</link>
        <description>Recent content in 死锁 on Zero Day Notes</description>
        <generator>Hugo -- gohugo.io</generator>
        <language>zh</language>
        <lastBuildDate>Mon, 02 Nov 2026 09:00:00 +0800</lastBuildDate><atom:link href="https://blog.5772447.xyz/tags/%E6%AD%BB%E9%94%81/index.xml" rel="self" type="application/rss+xml" /><item>
            <title>fork 撞上多线程：子进程启动即死锁</title>
            <link>https://blog.5772447.xyz/posts/1112ecdc/</link>
            <pubDate>Mon, 02 Nov 2026 09:00:00 +0800</pubDate>
            <guid>https://blog.5772447.xyz/posts/1112ecdc/</guid>
            <description>&lt;h2 id=&#34;问题背景&#34;&gt;&lt;a href=&#34;#%e9%97%ae%e9%a2%98%e8%83%8c%e6%99%af&#34; class=&#34;header-anchor&#34;&gt;&lt;/a&gt;问题背景&#xD;&#xA;&lt;/h2&gt;&lt;p&gt;公司有一个图像处理服务，Python 写的，对外提供缩略图、水印和格式转换接口。请求进来后，耗时操作交给 &lt;code&gt;multiprocessing.Pool&lt;/code&gt; 里的 8 个子进程去做，主进程负责收发请求和返回结果。这套结构在 Python 3.8 上跑了两年，一直稳定。&lt;/p&gt;&#xA;&lt;p&gt;上个月做两件事：一是把运行时升级到 Python 3.11，二是接入了 Prometheus，在主进程里起了一个后台线程，每 15 秒推送一次指标。上线后压测正常，但运行一周后开始出现“任务提交后永远不返回”的情况：接口超时，进程池里的子进程还活着，却不再处理任何任务，只能重启服务恢复。&lt;/p&gt;&#xA;&lt;h2 id=&#34;故障现象&#34;&gt;&lt;a href=&#34;#%e6%95%85%e9%9a%9c%e7%8e%b0%e8%b1%a1&#34; class=&#34;header-anchor&#34;&gt;&lt;/a&gt;故障现象&#xD;&#xA;&lt;/h2&gt;&lt;ul&gt;&#xA;&lt;li&gt;偶发，通常发生在流量较高、后台线程频繁推送指标的时候。&lt;/li&gt;&#xA;&lt;li&gt;出问题时接口大量超时，&lt;code&gt;Pool.apply_async&lt;/code&gt; 提交的任务没有结果，回调也不触发。&lt;/li&gt;&#xA;&lt;li&gt;主进程正常，子进程也都还在，&lt;code&gt;ps&lt;/code&gt; 看状态是 &lt;code&gt;S&lt;/code&gt;，CPU 接近零。&lt;/li&gt;&#xA;&lt;li&gt;子进程的日志停在“worker started”之后再无输出，连任务开始的日志都没有。&lt;/li&gt;&#xA;&lt;li&gt;重启服务立即恢复，但过几天又会出现；压测时如果只跑进程池、不开后台线程，几乎复现不了。&lt;/li&gt;&#xA;&lt;li&gt;故障率随并发上升，8 个 worker 可能同时卡住，也可能只卡一两个。&lt;/li&gt;&#xA;&lt;/ul&gt;&#xA;&lt;h2 id=&#34;排查过程&#34;&gt;&lt;a href=&#34;#%e6%8e%92%e6%9f%a5%e8%bf%87%e7%a8%8b&#34; class=&#34;header-anchor&#34;&gt;&lt;/a&gt;排查过程&#xD;&#xA;&lt;/h2&gt;&lt;p&gt;第一步确认卡在哪里。用 &lt;code&gt;strace -f -p &amp;lt;子进程&amp;gt;&lt;/code&gt; 跟了一会儿，发现它停在 &lt;code&gt;futex(0x..., FUTEX_WAIT_PRIVATE, 2, NULL)&lt;/code&gt;，没有任何读写系统调用。这说明子进程在等一把用户态锁，不是卡在计算，也不是在等 IO。&lt;/p&gt;&#xA;&lt;p&gt;第二步看 Python 栈。子进程里提前注册了 &lt;code&gt;faulthandler.register(signal.SIGUSR1)&lt;/code&gt;，给它发信号后拿到所有线程的栈：主线程停在 &lt;code&gt;logging&lt;/code&gt; 模块内部，具体位置是获取日志锁的地方。也就是说，子进程启动后第一件事是打日志，而日志锁已经被别人占着，并且永远不会释放。&lt;/p&gt;&#xA;&lt;p&gt;第三步查锁的归属。父进程里除了主线程，还有一个 Prometheus 推送线程，它也会写日志。&lt;code&gt;fork&lt;/code&gt; 只复制调用 fork 的那个线程，其他线程在子进程里直接消失，但它们当时持有的锁会被原样复制过去——锁的状态是“已加锁”，而持有它的线程已经不存在了。子进程一旦去拿这把锁，就永远等不到释放。&lt;/p&gt;&#xA;&lt;p&gt;第四步做最小复现。写一个二十行的脚本：主线程起一个后台线程，后台线程拿到一把锁后休眠；主线程 &lt;code&gt;os.fork()&lt;/code&gt;，子进程尝试拿同一把锁。子进程必然卡死。把锁换成 &lt;code&gt;logging&lt;/code&gt; 的锁，现象完全一致：子进程第一条日志就打不出来。概率低是因为 fork 必须恰好发生在另一个线程持有日志锁的瞬间，高并发下这个窗口被不断撞到。&lt;/p&gt;&#xA;&lt;p&gt;第五步对照版本与代码。Python 3.12 起，多线程进程里调用 &lt;code&gt;fork&lt;/code&gt; 会给出 &lt;code&gt;DeprecationWarning&lt;/code&gt;；3.14 更把 Linux 上的默认启动方式改成了 &lt;code&gt;forkserver&lt;/code&gt;。但这套服务跑在 3.11，没有任何提示，代码里也没显式指定 start method，默认就是 fork。升级前没有这个后台线程，所以问题一直潜伏着；加了线程之后，窗口才被打开。&lt;/p&gt;&#xA;&lt;p&gt;第六步验证修复。把进程池改成 &lt;code&gt;multiprocessing.get_context(&amp;quot;spawn&amp;quot;).Pool(8)&lt;/code&gt;，spawn 会启动全新的解释器、重新 import 模块，不继承父进程的锁；同时在入口加上 &lt;code&gt;if __name__ == &amp;quot;__main__&amp;quot;&lt;/code&gt; 保护，确保子进程不会重复执行启动逻辑。改完在“8 线程压测 + 后台线程持续写日志”的条件下跑了两万次任务，没有再出现卡死，代价是进程池启动慢了约 1 秒，对长驻服务可以接受。&lt;/p&gt;&#xA;&lt;h2 id=&#34;解决方案&#34;&gt;&lt;a href=&#34;#%e8%a7%a3%e5%86%b3%e6%96%b9%e6%a1%88&#34; class=&#34;header-anchor&#34;&gt;&lt;/a&gt;解决方案&#xD;&#xA;&lt;/h2&gt;&lt;ol&gt;&#xA;&lt;li&gt;&lt;strong&gt;显式指定 spawn 或 forkserver&lt;/strong&gt;：&lt;code&gt;ctx = multiprocessing.get_context(&amp;quot;spawn&amp;quot;)&lt;/code&gt;，用 &lt;code&gt;ctx.Pool()&lt;/code&gt; 创建进程池；对启动速度敏感的场景可以用 &lt;code&gt;forkserver&lt;/code&gt;，它从一个干净的单线程服务进程 fork，既有安全性又比 spawn 快。&lt;/li&gt;&#xA;&lt;li&gt;&lt;strong&gt;保证 fork 发生在所有线程之前&lt;/strong&gt;：如果暂时不能改启动方式，至少把进程池的创建放到最前面，等池子建好再启动后台线程。顺序反过来，就等于主动制造锁继承的风险。&lt;/li&gt;&#xA;&lt;li&gt;&lt;strong&gt;补上入口保护&lt;/strong&gt;：spawn/forkserver 会重新 import 主模块，必须用 &lt;code&gt;if __name__ == &amp;quot;__main__&amp;quot;:&lt;/code&gt; 包住启动逻辑，否则子进程会递归创建进程池。&lt;/li&gt;&#xA;&lt;li&gt;&lt;strong&gt;检查参数可序列化&lt;/strong&gt;：spawn 通过 pickle 传参，lambda、打开的文件句柄、数据库连接都不能直接传；把任务参数收敛成简单类型，或在子进程内重新建立连接。&lt;/li&gt;&#xA;&lt;li&gt;&lt;strong&gt;把警告变成错误&lt;/strong&gt;：测试环境用 &lt;code&gt;python -W error::DeprecationWarning&lt;/code&gt; 跑一遍，Python 3.12+ 会把“多线程进程 fork”直接暴露成失败，而不是等到线上偶发。&lt;/li&gt;&#xA;&lt;li&gt;&lt;strong&gt;压测覆盖组合场景&lt;/strong&gt;：不要只压进程池或只压线程，要按真实形态同时开后台线程和进程池，并持续足够长的时间，让 fork 撞上锁的概率显现出来。&lt;/li&gt;&#xA;&lt;/ol&gt;&#xA;&lt;h2 id=&#34;根因分析&#34;&gt;&lt;a href=&#34;#%e6%a0%b9%e5%9b%a0%e5%88%86%e6%9e%90&#34; class=&#34;header-anchor&#34;&gt;&lt;/a&gt;根因分析&#xD;&#xA;&lt;/h2&gt;&lt;p&gt;直接根因是 &lt;strong&gt;在多线程父进程里调用 fork 创建进程池；fork 只复制当前线程，其他线程持有的锁在子进程里保持“已加锁”状态，子进程第一次写日志时等待一把永远无人释放的锁，启动即死锁&lt;/strong&gt;。&lt;/p&gt;&#xA;&lt;p&gt;更深一层，是把进程和线程当成了可以随意混用的两种并发工具。fork 的语义是“复制当前执行流”，不是“复制整个进程的所有线程”；锁是状态，不是代码，复制过去的是一把打不开的锁。Python 长期把 fork 作为 Linux 默认启动方式，让这个陷阱隐藏得很好，直到多线程、日志、高并发三个条件同时满足才暴露。&lt;/p&gt;&#xA;&lt;p&gt;还有一层是升级与变更的验证盲区。升级 Python、增加后台线程，两件事单独看都没问题，压测也过了；但压测没有覆盖“后台线程正在写日志时 fork”的时间窗口，功能测试更不会去构造这种竞态。问题不是没测，而是测的形态和线上不一致。&lt;/p&gt;&#xA;&lt;h2 id=&#34;预防措施&#34;&gt;&lt;a href=&#34;#%e9%a2%84%e9%98%b2%e6%8e%aa%e6%96%bd&#34; class=&#34;header-anchor&#34;&gt;&lt;/a&gt;预防措施&#xD;&#xA;&lt;/h2&gt;&lt;ul&gt;&#xA;&lt;li&gt;服务里有线程时，进程池一律使用 spawn 或 forkserver，禁止默认 fork；在代码规范里写成硬性条款。&lt;/li&gt;&#xA;&lt;li&gt;进程池必须在任何线程启动之前创建；启动顺序写进模块注释，代码评审时检查。&lt;/li&gt;&#xA;&lt;li&gt;CI 用 &lt;code&gt;-W error::DeprecationWarning&lt;/code&gt; 跑测试，Python 3.12+ 能提前拦住多线程 fork。&lt;/li&gt;&#xA;&lt;li&gt;压测脚本固定包含“后台线程 + 进程池”组合，并统计任务排队时间、worker 重启次数，而不只看 QPS。&lt;/li&gt;&#xA;&lt;li&gt;给进程池加可观测性：任务从提交到完成的耗时分布、活跃 worker 数、卡住超过阈值的任务数，出现异常先告警再自动重建池。&lt;/li&gt;&#xA;&lt;/ul&gt;&#xA;&lt;h2 id=&#34;总结&#34;&gt;&lt;a href=&#34;#%e6%80%bb%e7%bb%93&#34; class=&#34;header-anchor&#34;&gt;&lt;/a&gt;总结&#xD;&#xA;&lt;/h2&gt;&lt;p&gt;fork 复制的是“当前这一个线程”，不是整个进程；父进程里别的线程连同它们手里的锁，在子进程里只剩下一个上锁的状态。多线程和进程池放在一起时，真正危险的不是代码写错，而是运行时默认值替你选了一个不安全的组合。把启动方式显式写出来，把创建顺序固定下来，把警告当错误对待，这类偶发死锁才会从“过几天挂一次”变成“根本跑不起来”。&lt;/p&gt;&#xA;&lt;h2 id=&#34;延伸阅读&#34;&gt;&lt;a href=&#34;#%e5%bb%b6%e4%bc%b8%e9%98%85%e8%af%bb&#34; class=&#34;header-anchor&#34;&gt;&lt;/a&gt;延伸阅读&#xD;&#xA;&lt;/h2&gt;&lt;ul&gt;&#xA;&lt;li&gt;&lt;a class=&#34;link&#34; href=&#34;https://blog.5772447.xyz/posts/15f31637/&#34; &gt;Popen 未读管道：备份脚本卡在 64KB&lt;/a&gt;&lt;/li&gt;&#xA;&lt;li&gt;&lt;a class=&#34;link&#34; href=&#34;https://blog.5772447.xyz/posts/5255de1b/&#34; &gt;asyncio 接口集体超时：同步调用阻塞事件循环&lt;/a&gt;&lt;/li&gt;&#xA;&lt;/ul&gt;&#xA;</description>
        </item></channel>
</rss>
