为什么多线程程序里 fork 会死锁?fork 安全是怎么回事?
简化版
fork() 会复制整个进程的内存(含所有锁的当前状态),但只把调用 fork() 的那一个线程带到子进程里**——其他线程在子进程中「凭空消失」。危险就出在这里:如果 fork 发生的那一刻,另一个线程正持有某把锁,那么子进程里这把锁会永远处于「已锁定」状态**——因为持有它的线程根本不存在,没人会来解锁。子进程只要碰到这把锁(哪怕只是调用一次 logging.info()、print()、或者向数据库连接池要连接)就会永久死锁。这类 bug 极其阴险:不必现、无日志、进程静默卡住。同类问题还有一大批被复制却处于不一致状态的资源:已建立的数据库/HTTP 连接(父子进程同时用同一个 socket 会互相串数据)、随机数种子(子进程生成完全相同的随机序列)、打开的文件偏移量(父子共享,互相覆盖)、以及 OpenMP/BLAS 线程池(numpy 在 fork 后再调用可能直接挂死)。macOS 更严格——系统库(Objective-C 运行时)在 fork 后调用非 async-signal-safe 的函数会直接崩溃,所以 Python 3.8 起 macOS 的 multiprocessing 默认启动方式已改为 spawn。**Python 3.12 起,在多线程程序里调用 fork 会发出 DeprecationWarning;3.14 起 Linux 上 multiprocessing 的默认启动方式也从 fork 改成了 forkserver。**核心记忆:fork 只带走当前线程、却复制了所有锁的状态;多线程 + fork = 定时炸弹;用 spawn/forkserver,或在创建任何线程之前就 fork 完。
详细版
fork 复制了什么、没复制什么:
| 资源 | fork 后的状态 | 风险 |
|---|---|---|
| 内存(变量、对象) | 写时复制(COW) 的副本 | 引用计数导致 COW 很快失效 |
| 线程 | ❌ 只保留调用 fork 的那一个 | 其他线程凭空消失 |
| 锁(Lock/RLock) | ✅ 复制当前状态(含”已锁定”) | ⚠️ 子进程里永久死锁 |
| 文件描述符 | 复制(共享同一个打开文件表项) | 父子共享偏移量、互相覆盖 |
| socket / DB 连接 | 复制 fd | 两端同时读写 → 协议错乱 |
| 随机数种子 | 完全相同 | 父子生成一样的随机序列 |
| 信号处理器 | 保留 | —— |
| 原子性/一致性 | ❌ 不保证 | 数据结构可能停在中间状态 |
import os, threading, time, multiprocessing as mp
# ① ★经典死锁复现★
lock = threading.Lock()
def holder():
with lock:
time.sleep(5) # ★持有锁 5 秒★
threading.Thread(target=holder, daemon=True).start()
time.sleep(0.1) # 确保锁已被持有
pid = os.fork()
if pid == 0: # 子进程
print("子进程尝试获取锁...")
with lock: # ★永久阻塞★:锁是"已锁定"状态,
print("永远到不了这里") # 而持有它的线程在子进程里根本不存在
os._exit(0)
# ② 更隐蔽的版本:你根本没写 lock,但 logging 内部有
import logging
threading.Thread(target=lambda: [logging.info("x") for _ in range(10**6)],
daemon=True).start()
pid = os.fork()
if pid == 0:
logging.info("子进程") # ★可能永久卡住★(logging 的内部锁被复制成已锁定)
os._exit(0)
# ③ ★随机数种子被复制★
import random
def child_random():
print(os.getpid(), random.random())
for _ in range(3):
p = mp.Process(target=child_random) # fork 方式下
p.start()
# → 三个子进程打印★完全相同★的随机数!
# ✓ 解决:子进程里 random.seed() / 用 os.register_at_fork 重新播种
# (numpy 的 default_rng 每次新建则没问题;multiprocessing 对 random 有部分处理)
# ④ ★数据库/HTTP 连接被复制★
conn = create_db_connection() # 父进程里建好
p = mp.Process(target=lambda: conn.execute("SELECT 1")) # ★fork 后子进程复制了 fd★
p.start()
# → 父子进程同时用同一个 TCP 连接 → ★协议错乱、连接被服务端断开、数据错乱★
# ✓ 铁律:★连接绝不跨 fork 使用★,子进程里重新建立
# ⑤ 官方推荐的解法:换启动方式
mp.set_start_method("spawn") # ★全新解释器,什么都不继承(最安全)★
mp.set_start_method("forkserver") # ★fork 一个干净的服务进程,再由它 fork★
# 或者按上下文:
ctx = mp.get_context("spawn")
p = ctx.Process(target=work)
# ⑥ os.register_at_fork:在 fork 前后做修复
os.register_at_fork(
before=lambda: pool_lock.acquire(), # ★fork 前先拿到锁(保证一致状态)★
after_in_parent=lambda: pool_lock.release(),
after_in_child=lambda: (pool_lock.release(), reset_pool(), random.seed()),
)
⚠️ 三个必须记住的机制:①
fork只把「调用它的那一个线程」带到子进程——其他线程直接消失,但它们的副作用留下了:持有的锁仍是「已锁定」、写了一半的数据结构仍是半成品、等待队列里的记录仍在。子进程一旦碰到这些就死锁或读到损坏数据。② 危险的锁往往不是你自己写的:logging有内部锁、requests/urllib3的连接池有锁、内存分配器有锁、导入系统有锁——所以「我没在多线程里用锁」这句话完全不构成安全保证,任何一次logging.info()都可能卡死子进程。③ POSIX 规定:fork之后、exec之前,子进程只能调用 async-signal-safe 的函数——Python 解释器本身远远达不到这个要求。multiprocessing的fork方式之所以「大多数时候能用」,纯粹是因为绝大多数程序在 fork 时恰好没有别的线程持有关键锁;一旦你的程序里有后台线程(心跳、日志上报、监控 agent、gRPC 客户端),这就成了一个概率性的定时炸弹。
完整版教学
一、fork 到底做了什么
fork() 的语义:★复制当前进程,得到一个几乎一模一样的子进程★
父进程:pid = os.fork() → 返回子进程的 pid(>0)
子进程:pid = os.fork() → 返回 0
→ 两个进程从★同一行代码★继续往下执行
复制了什么:
✓ 整个地址空间(★写时复制 COW★:初期共享物理页,谁写谁触发复制)
✓ 所有文件描述符(★指向同一个打开文件表项 → 共享偏移量★)
✓ 信号处理器、环境变量、工作目录、umask
✓ ★所有内存里的对象和它们的状态★——包括锁的"已锁定/未锁定"标志
★ 没有复制什么(关键):
✗ ★其他线程★!只有调用 fork 的那一个线程存活
→ POSIX 明确规定:fork 后子进程只有一个线程(调用者的副本)
✗ 挂起的信号、定时器、文件锁
┌─────────────── 父进程 ───────────────┐ ┌──── 子进程 ────┐
│ 主线程 ── 调用 fork ─────────────────┼─────►│ 主线程(副本) │
│ 线程 B ── 持有 lock,正在写文件 ─────┼──✗ │ ★不存在★ │
│ 线程 C ── 等在 queue.get() ──────────┼──✗ │ ★不存在★ │
│ lock 状态 = 已锁定 ────────────────────┼─────►│ lock = ★已锁定★│
│ 半成品的数据结构 ──────────────────────┼─────►│ ★半成品★ │
└──────────────────────────────────────┘ └────────────────┘
★ 这张图就是全部问题的根源:
"线程没了,但它们制造的中间状态留下了,而且没人能收拾"
为什么 fork 在单线程程序里是安全的:
单线程时,调用 fork 的那一刻程序状态是★自洽的★
(没有别的线程正在修改任何东西)
→ 子进程拿到的是一个完整、一致的快照
→ 这也是 fork 设计之初的假设(1970 年代还没有线程)
fork + exec 才是它的经典用法:
pid = os.fork()
if pid == 0:
os.execv("/bin/ls", ["ls"]) # ★立刻用新程序替换掉整个地址空间★
→ exec 之后所有继承来的烂摊子都被丢弃了 → 安全
→ 这就是 subprocess 的做法(★所以 subprocess 是 fork 安全的★)
★ 危险的是"fork 之后继续跑 Python 代码"(multiprocessing 的 fork 方式)
fork() 的语义是「复制当前进程」,但要精确理解它复制了什么、没复制什么。复制的包括整个地址空间(写时复制)、所有文件描述符(指向同一个打开文件表项,因此共享偏移量)、以及内存里所有对象的当前状态——包括锁的「已锁定/未锁定」标志。没有复制的是其他线程:POSIX 明确规定 fork 后子进程只有一个线程(调用者的副本)。上面那张图就是全部问题的根源:线程没了,但它们制造的中间状态留下了,而且没人能收拾——锁停在「已锁定」、数据结构停在半成品。fork 在单线程程序里是安全的,因为调用那一刻的状态是自洽的(这也是 1970 年代设计 fork 时的假设,当时还没有线程)。同理,fork + exec 的经典用法也是安全的——exec 会用新程序替换整个地址空间,继承来的烂摊子全被丢弃(这正是 subprocess 的做法,所以 subprocess 是 fork 安全的)。真正危险的是「fork 之后继续执行 Python 代码」,也就是 multiprocessing 的 fork 启动方式。
二、锁被复制成「已锁定」:死锁是怎么发生的
时间线(★把这个过程讲清楚就答对了一半★):
t0 主线程:准备 fork
线程 B:with lock: 开始写日志(★获得锁★)
t1 主线程:os.fork()
→ 内存被复制,★lock 的状态"已锁定"也被复制到子进程★
→ 子进程里★没有线程 B★
t2 子进程:logging.info("hello")
→ logging 内部 with self.lock:
→ 这把锁是"已锁定"状态
→ ★等待一个永远不会到来的 release★ → 永久阻塞
★ 关键点:锁没有"所有者已死"的检测机制
pthread_mutex 不知道持有者是谁、更不知道它已经不存在了
→ 只能永远等下去
哪些地方藏着你没注意到的锁(★这份清单是重点★):
① ★logging★:每个 Handler 都有一把锁(★最常见的死锁源★)
② ★print / sys.stdout★:IO 缓冲有锁
③ ★import 系统★:导入锁(子进程里 import 可能卡住)
④ ★内存分配器★:pymalloc / malloc 内部有锁 → ★连创建对象都可能卡住★
⑤ requests / urllib3 的连接池锁
⑥ 数据库驱动的连接池锁(SQLAlchemy、psycopg2)
⑦ ★OpenMP / BLAS / MKL 的线程池★(numpy、scipy、sklearn)
→ fork 后调用 numpy 运算可能直接挂死(OpenBLAS 有已知问题)
⑧ 第三方 SDK 的后台线程(APM agent、gRPC、Kafka 客户端、Sentry)
★ 结论:★你几乎不可能确认"fork 那一刻没有任何锁被持有"★
尤其是接入了 APM/监控 agent 的生产服务——它们默认就起后台线程
真实故障的典型现象(★面试可讲★):
- 子进程"卡住不动",没有日志、没有异常、CPU 占用为 0
- ★只在生产环境偶发★(本地测试时后台线程恰好没持有锁)
- 加了日志反而更容易复现(因为 logging 本身就是死锁源)
- py-spy dump 看到子进程卡在 acquire() 上 → ★这是最快的定位手段★
POSIX 的官方立场:
★fork 之后、exec 之前,子进程只能调用 async-signal-safe 的函数★
(write、_exit 等一小撮)
→ Python 解释器做的事(分配内存、创建对象、导入模块)★全都不满足★
→ 所以"fork 后继续跑 Python"在标准层面就是未定义行为,
能用只是因为大多数时候运气好
死锁的时间线是本题的核心:fork 那一刻线程 B 正持有 logging 的锁 → 内存被复制,「已锁定」状态一起复制到子进程 → 子进程里没有线程 B → 子进程调用 logging.info() 时等待一个永远不会到来的 release。关键在于锁没有「所有者已死」的检测机制——pthread_mutex 不知道持有者是谁、更不知道它已经不存在了。那份「藏着锁的地方」清单是重点:logging(最常见的死锁源)、print/stdout 缓冲、import 系统、内存分配器(连创建对象都可能卡住)、连接池、OpenMP/BLAS 线程池(fork 后调用 numpy 可能直接挂死)、以及各种 SDK 的后台线程(APM agent、gRPC、Kafka、Sentry)。结论很有冲击力:你几乎不可能确认「fork 那一刻没有任何锁被持有」,尤其是接入了监控 agent 的生产服务。典型故障现象是「子进程卡住、无日志、无异常、CPU 为 0,且只在生产偶发」,最快的定位手段是 py-spy dump 看子进程卡在 acquire() 上。
三、除了锁,还有一堆被复制的「烂摊子」
① ★网络连接 / 数据库连接(fd 被复制)★
父进程:conn = psycopg2.connect(...) # TCP 连接已建立
fork 后:父子进程持有★同一个 socket★
→ 两边都往里写 → ★请求交织、协议错乱★
→ 两边都读 → 各自读到对方的响应
→ 服务端看到"一个连接上有两个客户端"→ 通常直接断开
✓ 铁律:★连接不跨 fork★
- fork 前不建连接(懒加载,子进程首次使用时才建)
- 或 os.register_at_fork(after_in_child=重建连接池)
- SQLAlchemy: engine.dispose() in child;psycopg2 连接池同理
- Gunicorn 的 preload_app=True 就是这个坑的经典来源
② ★随机数种子相同★
fork 后父子的 random 模块状态完全一样
→ 三个 worker 生成★一模一样★的随机数(UUID、抽样、退避时间、A/B 分流全废)
✓ 子进程里 random.seed()(无参数=用系统熵重新播种)
✓ numpy:np.random.default_rng() 在子进程里重新创建,
或用 SeedSequence.spawn() 生成互不相关的子种子(★推荐★)
③ ★文件偏移量共享★
父子进程写同一个 fd → ★共享同一个偏移量★
→ 交替写入会互相覆盖(除非用 O_APPEND,它的"定位+写"是原子的)
✓ 日志文件用 O_APPEND 模式打开;或子进程重新打开文件
④ ★缓冲区被复制★
f.write("父进程") # ★还在缓冲区里,没落盘★
fork()
→ 父子进程各有一份缓冲区 → ★这句话被写了两次★
✓ fork 前 sys.stdout.flush() / 所有文件 flush
⑤ ★线程池 / 事件循环状态★
ThreadPoolExecutor:worker 线程没了,但队列里还有任务、内部状态是"运行中"
→ 子进程里 submit 会永远等不到结果
asyncio 事件循环:fork 后子进程里的 loop 是损坏的
✓ 子进程里重建,别继承
⑥ ★OpenMP / BLAS 线程池(数值计算的重灾区)★
numpy/scipy 底层的 OpenBLAS、MKL 会起自己的线程池
→ fork 后子进程里这些线程没了,但线程池状态还在 → ★调用矩阵运算直接挂死★
✓ 用 spawn;或 fork 前设 OMP_NUM_THREADS=1(★常见的临时缓解手段★)
✓ joblib/loky 之所以默认用 loky(基于 spawn/forkserver)就是为了绕开这个
⑦ ★macOS 的特殊限制(★必须知道★)★
Objective-C 运行时和很多系统框架在 fork 后调用会★直接崩溃★
(报 "objc[xxxx]: +[__NSCFConstantString initialize] may have been in progress
in another thread when fork() was called")
→ macOS 上 CoreFoundation/网络相关的库 fork 后基本不可用
✓ ★Python 3.8 起 macOS 上 multiprocessing 默认改为 spawn★
✓ 临时绕过(★仅调试用★):OBJC_DISABLE_INITIALIZE_FORK_SAFETY=YES
除了锁,还有一整批被复制的「烂摊子」。网络和数据库连接最典型:fd 被复制后父子进程持有同一个 socket,两边同时读写会导致协议错乱、服务端直接断开——铁律是「连接不跨 fork」(Gunicorn 的 preload_app=True 是这个坑的经典来源)。随机数种子相同会让所有 worker 生成一模一样的随机序列(UUID、抽样、退避时间全废)。文件偏移量共享会让父子写入互相覆盖(除非用 O_APPEND)。缓冲区被复制会让 fork 前没 flush 的内容被写两遍。OpenMP/BLAS 线程池是数值计算的重灾区——numpy 底层的 OpenBLAS 在 fork 后调用矩阵运算可能直接挂死(joblib 默认用基于 spawn 的 loky 正是为了绕开它)。macOS 的限制最严格:Objective-C 运行时在 fork 后调用会直接崩溃(那句著名的 may have been in progress in another thread when fork() was called 报错),所以 Python 3.8 起 macOS 的 multiprocessing 默认就是 spawn。
四、三种启动方式与版本变化
multiprocessing 的三种 start method:
┌────────────┬──────────────────────────┬────────┬──────────┬────────────┐
│ │ 原理 │ 速度 │ 继承状态 │ 安全性 │
├────────────┼──────────────────────────┼────────┼──────────┼────────────┤
│ fork │ 直接复制父进程 │ ★最快★ │ ★全部★ │ ⚠️ 多线程下危险│
│ spawn │ ★启动全新解释器★+ 重新 import│ 最慢 │ 几乎不继承│ ★最安全★ │
│ forkserver │ 先 fork 一个★干净的★服务进程│ 中等 │ 很少 │ 安全 │
│ │ 之后由它来 fork 工作进程 │ │ │ │
└────────────┴──────────────────────────┴────────┴──────────┴────────────┘
forkserver 的巧妙之处:
在★程序刚启动、还没有创建任何线程时★先 fork 出一个"服务进程"
→ 这个服务进程是干净的单线程状态
→ 之后每次要新进程,都由它 fork(★而不是由已经多线程化的主进程 fork★)
→ 兼顾了 fork 的速度和安全性
★ 版本变化时间线(面试能说出来是加分项):
Python 3.8 ★macOS 默认改为 spawn★(因为 Objective-C 运行时的崩溃问题)
Python 3.12 ★在多线程程序里调用 os.fork() 会发 DeprecationWarning★
("This process is multi-threaded, use of fork() may lead to deadlocks")
Python 3.14 ★Linux 上 multiprocessing 的默认 start method 从 fork 改为 forkserver★
(macOS/Windows 保持 spawn)
→ ★官方态度已经很明确:fork 在多线程环境下是不被推荐的★
spawn 的代价(★要知道才能做权衡★):
① 慢:每个子进程都要★重新启动解释器 + 重新 import 所有模块★
实测:fork ~1ms,spawn ~50~200ms(依赖越多越慢)
② ★参数必须可 pickle★:lambda、闭包、局部定义的类、打开的文件对象都传不了
→ 报错 "Can't pickle <function <lambda>>"
③ ★必须有 if __name__ == "__main__" 保护★
否则子进程重新 import 主模块时会再次执行创建进程的代码 → ★无限递归创建进程★
④ 全局状态不继承:模块级的配置、缓存、已建立的连接都要重新初始化
选择建议:
Linux + 单线程 + 追求启动速度 + 数据量大(想利用 COW)→ fork(★但要确认真的没有线程★)
★有任何后台线程(含 APM/日志/监控 agent)★ → ★forkserver 或 spawn★
跨平台代码 / 用了 numpy、数据库连接、网络客户端 → ★spawn★(最安全)
需要频繁创建进程且依赖多 → forkserver(折中)
multiprocessing 的三种启动方式各有取舍:fork 最快但在多线程下危险、spawn 最安全但最慢(要重启解释器并重新 import)、forkserver 是巧妙的折中——它在程序刚启动、还没创建任何线程时先 fork 出一个干净的单线程「服务进程」,之后每次要新进程都由这个干净的服务进程去 fork,兼顾了速度和安全。版本变化的时间线值得记住:3.8 起 macOS 默认改为 spawn(Objective-C 崩溃问题)、3.12 起多线程程序里调用 os.fork() 会发 DeprecationWarning、3.14 起 Linux 上默认启动方式从 fork 改为 forkserver——官方态度已经很明确。选 spawn 要知道三个代价:慢(实测 fork 约 1ms、spawn 约 50~200ms)、参数必须可 pickle(lambda 和闭包传不了)、必须有 if __name__ == "__main__" 保护(否则子进程重新 import 主模块时会再次执行创建进程的代码,无限递归创建进程)。
五、fork 安全的编程守则
★ 守则一:能不用 fork 就不用
mp.set_start_method("spawn", force=True) # 程序最开始设置一次
# 或 ctx = mp.get_context("forkserver");ctx.Process(...)
★ 必须在创建任何进程之前设置,且只能设置一次★
★ 守则二:非用 fork 不可时,在"程序最早期"就 fork 完
正确顺序:
① 进程启动
② ★立刻创建好所有需要的子进程 / 进程池★(此时还是单线程,状态干净)
③ 再启动线程、建连接、初始化 SDK
✗ 错误顺序:先跑起来(起了一堆线程和连接),运行中再 fork
★ 守则三:用 os.register_at_fork 修复必须修复的东西
os.register_at_fork(
before=..., # ★fork 前★在父进程执行(★先拿锁,保证一致状态★)
after_in_parent=..., # fork 后在父进程执行(释放锁)
after_in_child=..., # ★fork 后在子进程执行(重建状态)★
)
典型用途:
after_in_child=lambda: (
random.seed(), # ★重新播种★
db_pool.reset(), # ★丢弃继承来的连接★
logging_lock._release_save(), # (框架内部通常已经处理)
metrics.reinit(),
)
★ 标准库自己就用它:logging 在 3.7+ 已经注册了 fork 处理器来重置内部锁
→ 所以现代 Python 里 logging 的 fork 死锁比以前少了,但★第三方库不一定★
★ 守则四:连接、句柄、线程池一律"子进程里重建"
✗ 父进程建好连接池 → fork → 子进程直接用
✓ 懒加载:连接池在★首次使用时★创建 → 子进程自然会建自己的
✓ 或显式:after_in_child 里 dispose 掉继承来的
★ 守则五:fork 前 flush 所有缓冲
sys.stdout.flush(); sys.stderr.flush()
for f in open_files: f.flush()
→ 否则缓冲区被复制,同一段内容被写两遍
★ 守则六:诊断手段
- ★py-spy dump --pid <子进程 pid>★:一眼看出卡在 acquire() 上
- faulthandler.register(signal.SIGUSR1):发信号打印所有线程栈
- 复现时用 PYTHONFAULTHANDLER=1
- 3.12+ 直接看 DeprecationWarning("process is multi-threaded")
- 排除法:把 start method 改成 spawn,问题消失 → ★基本确认是 fork 安全问题★
Gunicorn / uWSGI 的实践(★Web 服务的高频场景★):
preload_app=True → ★master 先加载应用(可能建了连接、起了线程)再 fork worker★
优点:省内存(COW 共享代码)、启动快
风险:★继承了连接和线程状态★
✓ 必须配合 post_fork hook 在子进程里重建:
def post_fork(server, worker):
db.engine.dispose() # 丢弃继承的连接池
random.seed()
preload_app=False → 每个 worker 自己 import 和初始化(★更安全,内存多一些★)
fork 安全的六条守则里,最重要的是前两条:能不用 fork 就不用(在程序最开始 mp.set_start_method("spawn", force=True)),非用不可就在程序最早期 fork 完——正确顺序是「进程启动 → 立刻创建好所有子进程/进程池(此时还是单线程、状态干净)→ 再启动线程和建连接」。os.register_at_fork 是修复的标准工具,它的 before/after_in_parent/after_in_child 三个钩子分别在 fork 前后执行,典型用途是在子进程里重新播种随机数、丢弃继承来的连接池、重建指标客户端(标准库的 logging 从 3.7 起自己就用它重置内部锁,所以 logging 的 fork 死锁比以前少了,但第三方库不一定)。诊断手段里最有效的是 py-spy dump --pid <子进程>(一眼看出卡在 acquire())和排除法(改成 spawn 后问题消失就基本确认)。Web 服务场景要特别注意 Gunicorn 的 preload_app=True——它让 master 先加载应用(可能已建连接、起了线程)再 fork worker,必须配合 post_fork hook 在子进程里重建连接池和重新播种。
六、和 COW 内存优化的取舍
fork 的一个"卖点":★写时复制(COW)能省内存★
父进程加载了一个 10GB 的模型/数据集 → fork 出 8 个 worker
→ 理论上:8 个进程共享同一份物理内存,总占用仍是 ~10GB
→ 用 spawn 的话:每个子进程都要重新加载 → ★80GB★
★ 但在 CPython 里,这个优势会大打折扣:
① ★引用计数破坏 COW★
子进程只要"读"一个对象(遍历列表、访问属性),
就会修改它的 ob_refcnt → ★触发所在内存页的复制★
→ 遍历一遍 10GB 的数据 ≈ 把 10GB 全部复制一遍
② ★GC 的标记也会写对象头★(分代 GC 在对象上打标记)
✓ 缓解手段:
- ★gc.freeze()★(3.7+):fork 前调用,把当前所有对象移到"永久代",
GC 不再扫描它们 → ★显著减少 COW 失效★
常见组合:gc.disable() → 加载数据 → gc.freeze() → fork → 子进程 gc.enable()
- 大数组用 numpy/mmap/shared_memory(★数据在 Python 对象之外★,
不受引用计数影响 → COW 真正有效)
- 只读数据用 multiprocessing.shared_memory 显式共享
★ 结论:想靠 fork + COW 共享大对象,要么用 gc.freeze,
要么把数据放进 numpy 数组/共享内存这类"引用计数管不到"的地方
真实场景的权衡:
┌──────────────────────────────┬──────────────────────────────┐
│ 场景 │ 建议 │
├──────────────────────────────┼──────────────────────────────┤
│ ML 推理服务,模型 10GB │ fork + ★gc.freeze★,但★确保★ │
│ 需要多进程共享 │ fork 时还没有任何线程 │
│ Web 服务(Gunicorn/uWSGI) │ preload + ★post_fork 重建连接★ │
│ │ 或干脆 preload_app=False │
│ 数据处理(numpy/pandas) │ ★spawn/forkserver★(BLAS 线程池)│
│ 通用任务并行 │ ★forkserver★(3.14 的新默认) │
│ 跨平台工具 │ ★spawn★ │
└──────────────────────────────┴──────────────────────────────┘
★ 一句话总结取舍:
fork 快且省内存,但★要求 fork 那一刻进程是"干净的单线程状态"★;
一旦有后台线程、连接、C 库线程池,它就从"优化"变成"定时炸弹"。
→ ★现代做法:要么在最早期 fork(保证干净),要么用 forkserver/spawn★
fork 的经典卖点是写时复制能省内存(父进程加载 10GB 模型后 fork 8 个 worker,理论上总占用仍是 10GB,而 spawn 要 80GB)。但在 CPython 里这个优势会大打折扣:引用计数会破坏 COW——子进程只要「读」一个对象(遍历列表、访问属性)就会修改它的 ob_refcnt,从而触发所在内存页的复制,遍历一遍 10GB 数据约等于把 10GB 全复制一遍;分代 GC 在对象上打标记也有同样效果。缓解手段是 gc.freeze()(3.7+):fork 前把当前所有对象移到「永久代」让 GC 不再扫描它们,标准组合是「gc.disable() → 加载数据 → gc.freeze() → fork → 子进程 gc.enable()」;或者把大数据放进 numpy 数组、mmap、shared_memory 这类「引用计数管不到」的地方,让 COW 真正生效。最终取舍一句话:fork 快且省内存,但要求 fork 那一刻进程是「干净的单线程状态」;一旦有后台线程、连接或 C 库线程池,它就从优化变成定时炸弹。
记忆钩子:「★fork 只把『调用它的那一个线程』带到子进程,却复制了所有锁的当前状态★——这一句话就是全部问题的根源:线程没了,但它们制造的中间状态留下了,而且★没人能收拾★。于是如果 fork 那一刻另一个线程正持有某把锁,子进程里这把锁就★永远是已锁定★(锁没有『所有者已死』的检测机制),子进程一碰它就永久死锁。★危险的锁往往不是你写的★:logging(最常见)、print/stdout 缓冲、import 系统、★内存分配器(连创建对象都可能卡住)★、连接池、★OpenMP/BLAS 线程池(fork 后调 numpy 可能直接挂死)★、以及 APM/Sentry/gRPC 等 SDK 的后台线程——所以『我没用锁』完全不构成安全保证。典型故障是★子进程卡住、无日志、无异常、CPU 为 0、只在生产偶发★,最快定位手段是 py-spy dump 看它卡在 acquire()。除了锁还有一堆烂摊子:★连接的 fd 被复制★(父子共用一个 socket → 协议错乱,铁律是『连接不跨 fork』,Gunicorn 的 preload_app 是经典坑)、★随机数种子相同★(所有 worker 生成一样的随机序列)、文件偏移量共享、fork 前没 flush 的缓冲被写两遍。★macOS 因为 Objective-C 运行时会直接崩溃,3.8 起默认就是 spawn★;★3.12 起多线程里 fork 会发 DeprecationWarning,3.14 起 Linux 默认改成 forkserver★——官方态度已经很明确。三种启动方式:fork 最快但危险、spawn 最安全但要重启解释器(★参数必须可 pickle、必须有 if name == ‘main’ 否则无限递归创建进程★)、★forkserver 是折中★(在还没有线程时先 fork 一个干净的服务进程,之后由它来 fork)。守则:★能不用 fork 就不用;非用不可就在程序最早期、还没起任何线程时 fork 完★;用 os.register_at_fork(after_in_child=…) 重新播种和重建连接池。最后,fork 的 COW 省内存优势会被★引用计数★破坏(读对象就改 refcnt → 触发页复制),要用 ★gc.freeze()★ 或把数据放进 numpy/shared_memory。」
七、常见误区与追问
- 误区:我的代码里没用
threading,所以fork是安全的。 「没有显式创建线程」不等于「进程是单线程的」。大量常见组件会默默启动后台线程:APM/监控 agent(Datadog、New Relic、Sentry 的上报线程)、gRPC 客户端、Kafka/RabbitMQ 客户端的心跳线程、concurrent.futures的线程池、某些 HTTP 客户端的连接管理线程、以及 OpenMP/BLAS(numpy、scipy 一 import 就可能起线程池)。更麻烦的是 Web 框架和 WSGI 服务器本身也可能有线程。验证方法很简单:threading.active_count()和threading.enumerate()在 fork 前打印一下——生产环境里的结果往往让人意外。Python 3.12 起,在多线程程序里调用os.fork()会直接发出DeprecationWarning,这是最省事的自查手段。 - 误区:fork 死锁是小概率事件,测试没问题就可以上线。 它确实是概率性的(取决于 fork 那一刻是否恰好有线程持有锁),但这正是它危险的地方:本地测试时后台线程负载低、几乎不持有锁,所以不复现;生产环境流量大、日志频繁、监控 agent 活跃,触发概率大幅上升。而且故障表现极其不友好——子进程静默卡住,没有异常、没有日志、CPU 占用为 0,监控上只看到「worker 数量在减少」或「任务再也不完成」,排查时甚至会因为「加日志」而更容易触发(
logging本身就是最常见的死锁源)。POSIX 的立场其实很清楚:fork 之后、exec 之前只能调用 async-signal-safe 的函数,而 Python 解释器做的一切(分配内存、创建对象、导入模块)都不满足——「能用」只是运气,不是保证。 - 误区:把数据库连接(或
requests.Session)在父进程建好,fork 后子进程直接用能省事。 这会造成父子进程持有同一个 socket 文件描述符:两边同时发请求,字节流会交织在一起,服务端看到的是一个连接上混着两个客户端的请求 → 协议解析错乱、响应被发给错误的一方、连接被服务端直接断开;数据库场景还可能导致事务状态错乱。铁律是「连接不跨 fork」:要么改成懒加载(连接池在首次使用时才创建,子进程自然会建自己的),要么用os.register_at_fork(after_in_child=...)显式丢弃并重建(SQLAlchemy 用engine.dispose())。Gunicorn 的preload_app=True正是这个坑的经典来源——它让 master 先完整加载应用(往往已经建好了连接池)再 fork worker,必须配合post_forkhook 重建。 - 误区:
multiprocessing的进程都是独立的,随机数当然也是独立的。 用fork启动方式时,子进程会完整继承父进程的随机数状态——于是所有 worker 生成完全相同的随机序列。后果可能很严重:随机采样得到重复样本、指数退避的重试时间完全同步(造成周期性的流量尖刺)、A/B 分流全部落到同一组、生成的临时文件名冲突。(multiprocessing对标准库random做了一些处理,但numpy的全局随机状态、第三方库自己维护的随机源都不在其列。)解法:子进程里调用random.seed()(无参数即用系统熵重新播种),numpy 用np.random.default_rng()在子进程内新建,或者更规范地用SeedSequence.spawn()为每个 worker 生成互不相关的子种子(可复现且统计独立)。用spawn启动方式则天然没有这个问题。 - 误区:改用
spawn就万事大吉了,直接把set_start_method("spawn")加上就行。spawn确实最安全,但有三个必须处理的代价:① 慢——每个子进程都要重新启动解释器并重新 import 所有模块(实测 fork 约 1ms、spawn 约 50~200ms,依赖越重越慢),频繁创建进程的场景会明显变慢;② 参数必须可 pickle——lambda、闭包、局部定义的类、已打开的文件对象、数据库连接都传不了,会报Can't pickle <function <lambda>>,需要改成模块级函数 + 可序列化的参数;③ 必须有if __name__ == "__main__":保护——因为子进程会重新 import 主模块,没有这个保护就会再次执行创建进程的代码,导致无限递归地创建进程(Windows 上早就必须这么写,Linux 用户切到 spawn 时经常踩)。此外模块级的全局状态、缓存、已初始化的配置都不会被继承,需要在子进程里重新初始化。折中方案是forkserver(3.14 起 Linux 的新默认)。 - 追问:
forkserver为什么比fork安全,又比spawn快? 它的巧妙之处在于**「什么时候 fork」。forkserver会在程序刚启动、还没有创建任何线程和连接的时候**,先 fork 出一个专门的「服务进程」——这个服务进程处于干净的单线程状态,没有持有任何锁、没有连接、没有 C 库线程池。之后每当需要新的工作进程,请求都发给这个服务进程,由它来 fork(而不是由已经多线程化、状态复杂的主进程 fork)。这样既避开了「fork 一个多线程进程」的所有危险,又保留了 fork 的速度优势(不需要像 spawn 那样重启解释器)。代价是:工作进程继承的是服务进程的状态,所以主进程后来 import 的模块、修改的全局变量不会被继承(这一点和 spawn 类似),参数同样需要可 pickle。Python 3.14 起把它设为 Linux 上multiprocessing的默认启动方式,正是因为这个「安全性接近 spawn、速度接近 fork」的平衡。 - 追问:
os.register_at_fork的三个钩子分别在什么时候执行,典型用途是什么? 它注册三类回调:before在 fork 之前、父进程里执行——典型用途是先获取所有关键锁,确保 fork 发生时这些资源处于一致状态(这样父子进程拿到的都是「锁被当前线程持有」的确定状态);after_in_parent在 fork 之后、父进程里执行——通常是释放before里获取的锁;after_in_child在 fork 之后、子进程里执行——这是最常用的一个,用来修复继承来的状态:random.seed()重新播种、db_pool.dispose()丢弃继承的连接、重置指标客户端和 trace 上下文、重建线程池、清空继承来的任务队列。标准库自己就在用它:logging从 3.7 起注册了 fork 处理器来重置各 Handler 的内部锁,这也是为什么现代 Python 里 logging 导致的 fork 死锁比以前少了——但第三方库不一定做了同样的事,所以自己用到的连接池、SDK 客户端仍需手动处理。 - 追问:既然 fork 有 COW 能省内存,为什么在 Python 里效果不理想? 因为 CPython 的引用计数会破坏写时复制。COW 的原理是「父子进程初期共享同一批物理内存页,只有当某一方写入某页时才复制该页」。但在 CPython 里,「读」一个对象也会写内存——每次访问对象都要增减它的
ob_refcnt(引用计数字段就在对象头部),于是子进程只要遍历一遍那个 10GB 的列表,就会把几乎所有相关内存页都触发复制,内存占用迅速逼近「每个子进程各一份」;分代 GC 在对象头上打标记也有同样效果。缓解手段有两类:①gc.freeze()(3.7+)——在 fork 前调用,把当前所有对象移入「永久代」,GC 不再扫描和标记它们,标准组合是「gc.disable()→ 加载大数据 →gc.freeze()→ fork → 子进程按需gc.enable()」;② 把数据放到「引用计数管不到的地方」——numpy 数组的数据缓冲区、mmap、multiprocessing.shared_memory,这些底层内存不带对象头,COW 能真正生效(这也是 ML 服务共享大模型权重的标准做法)。
八、加强记忆
fork 只把「调用它的那一个线程」带到子进程,却复制了所有锁的当前状态——这一句话就是全部问题的根源:线程没了,但它们制造的中间状态留下了,而且没人能收拾。于是只要 fork 那一刻另一个线程正持有某把锁,子进程里这把锁就永远处于「已锁定」(锁没有「所有者已死」的检测机制),子进程一碰它就永久死锁。危险的锁往往不是你自己写的:logging(最常见的死锁源)、print/stdout 缓冲、import 系统、内存分配器(连创建对象都可能卡住)、连接池、OpenMP/BLAS 线程池(fork 后调用 numpy 可能直接挂死)、以及 APM/Sentry/gRPC/Kafka 等 SDK 的后台线程——所以「我没用锁」完全不构成安全保证(用 threading.active_count() 查一下往往会意外)。典型故障是子进程卡住、无日志、无异常、CPU 为 0、只在生产偶发,最快的定位手段是 py-spy dump 看它卡在 acquire()。除了锁还有一堆烂摊子:连接的 fd 被复制(父子共用同一个 socket → 协议错乱,铁律是「连接不跨 fork」,Gunicorn 的 preload_app=True 是经典坑,必须配 post_fork 重建)、随机数种子相同(所有 worker 生成一样的随机序列)、文件偏移量共享、fork 前没 flush 的缓冲被写两遍。macOS 因为 Objective-C 运行时会直接崩溃,3.8 起默认就是 spawn;3.12 起多线程里调用 fork 会发 DeprecationWarning,3.14 起 Linux 默认改为 forkserver——官方态度已经很明确。三种启动方式:fork 最快但危险、spawn 最安全但要重启解释器(参数必须可 pickle、必须有 if __name__ == "__main__" 否则无限递归创建进程)、forkserver 是折中(在还没有线程时先 fork 一个干净的服务进程,之后由它来 fork)。守则:能不用 fork 就不用;非用不可就在程序最早期、还没起任何线程时 fork 完;用 os.register_at_fork(after_in_child=...) 重新播种、重建连接池。最后,fork 的 COW 省内存优势会被引用计数破坏(读对象也会改 ob_refcnt → 触发页复制),要用 gc.freeze() 或把数据放进 numpy/shared_memory 这类引用计数管不到的地方。