fork 的写时复制为什么在 Python 里省不了内存?
简化版
fork() 的写时复制(COW)本意是:父子进程先共享同一批物理内存页**,只有当某一方写入某个页时,内核才复制那一页。理论上「父进程加载 10GB 模型 → fork 8 个 worker」应该总共只占 10GB。但在 CPython 里这个优势会大幅失效,原因是引用计数:每个 Python 对象的头部都有一个 ob_refcnt 字段,而**「读」一个对象也要修改它**——遍历列表、访问属性、把对象作为参数传递,全都会 ob_refcnt++ 然后 --。这是对内存的写操作,会立刻触发所在页的复制。由于对象在内存里是混杂排布的(一个 4KB 的页里可能躺着几十个对象),子进程只要碰过其中任何一个,整页 4KB 就被复制了——遍历一遍 10GB 的数据结构,几乎等于把 10GB 全部复制一遍。分代 GC 的标记同样会写对象头(它在垃圾回收时会修改对象的 GC 状态位),进一步加速 COW 失效。两个有效的对策:① gc.freeze()(3.7+)——在 fork 前把当前所有对象移入「永久代」,让 GC 不再扫描它们,标准组合是「加载数据 → gc.freeze() → fork」;② 把数据放到「引用计数管不到的地方」——numpy 数组的数据缓冲区、mmap、multiprocessing.shared_memory,这些底层内存没有对象头,COW 才能真正生效。另外要注意测量陷阱:RSS 会把共享页在每个进程里各算一遍,看起来「内存翻了 8 倍」其实未必——要看 PSS/USS 才准。核心记忆:读对象也会写内存(refcnt),所以 COW 在 Python 里靠不住;对策是 gc.freeze() 或用 numpy/共享内存。
详细版
为什么 COW 会失效:
| 操作 | 是否修改内存 | 是否触发页复制 |
|---|---|---|
x = big_list[0] | ✅ ob_refcnt++ | ✅ 复制该对象所在页 |
for item in data: | ✅ 每个元素都 ++/— | ✅ 几乎复制全部页 |
len(data) | ❌(只读 ob_size) | ❌ |
obj.attr | ✅ 属性对象 refcnt++ | ✅ |
| 分代 GC 扫描 | ✅ 修改 GC 头 | ✅ |
arr[0](numpy 数组元素) | ❌ 数据在缓冲区里,无对象头 | ❌ 真正共享 |
import os, gc, time, sys
# ① ★演示:读一遍数据就把内存"读没了"★
def rss_mb(pid=None):
"""读 /proc/<pid>/status 的 VmRSS(Linux)"""
p = pid or os.getpid()
for line in open(f"/proc/{p}/status"):
if line.startswith("VmRSS:"):
return int(line.split()[1]) / 1024
data = [str(i) for i in range(5_000_000)] # ★约 350MB 的小对象★
print("父进程 RSS:", rss_mb())
pid = os.fork()
if pid == 0:
print("子进程刚 fork:", rss_mb()) # ★很小(共享父进程的页)★
total = 0
for s in data: # ★只是"读"★
total += len(s)
print("子进程遍历后:", rss_mb()) # ★暴涨到接近父进程的量级★
os._exit(0)
os.waitpid(pid, 0)
# ② ★gc.freeze():显著减少 COW 失效★
gc.disable() # 加载期间先关掉 GC(避免加载过程中反复扫描)
data = load_huge_data() # 加载大对象
gc.freeze() # ★把当前所有对象移入"永久代",GC 不再扫描★
gc.enable()
for _ in range(8):
if os.fork() == 0:
# 子进程:GC 不会去扫描/标记那些冻结的对象 → ★少了一大批写操作★
work(data)
os._exit(0)
# ③ ★真正有效的共享:numpy(数据在对象之外)★
import numpy as np
arr = np.zeros(10**8, dtype=np.float64) # ★800MB,但只有一个 PyObject 头★
pid = os.fork()
if pid == 0:
s = arr.sum() # ★读 8 亿个元素,只碰了一个对象的 refcnt★
print("子进程 RSS:", rss_mb()) # ★几乎没涨★
os._exit(0)
# ④ shared_memory:显式共享(跨 spawn 也能用)
from multiprocessing import shared_memory
shm = shared_memory.SharedMemory(create=True, size=arr.nbytes)
buf = np.ndarray(arr.shape, dtype=arr.dtype, buffer=shm.buf)
buf[:] = arr[:]
# 子进程:shared_memory.SharedMemory(name=shm.name) 附加 → ★零拷贝★
# ★用完必须 shm.close() + shm.unlink(),否则泄漏★
# ⑤ ★测量陷阱:RSS 会重复计算共享页★
# ps/top 看到 8 个进程各 2GB ≠ 用了 16GB
# 看 PSS(按共享比例分摊)才准:
# grep Pss /proc/<pid>/smaps_rollup
# 或 total: awk '/Pss/{s+=$2} END{print s/1024" MB"}' /proc/*/smaps
⚠️ 三个必须理解的点:① 「读」对象也是「写」内存——
ob_refcnt就在对象头部(PyObject 结构的第一个字段),任何一次引用(赋值、传参、遍历、加进容器)都会先++后--,这是货真价实的内存写入,内核不会区分「你只是在读逻辑数据」。② COW 的粒度是「页」不是「对象」——一页通常 4KB,而 Python 的小对象(int、str、小tuple)只有几十字节,一页里挤着几十上百个对象;子进程只要碰了其中任意一个,整页都会被复制。所以「我只访问了 10% 的数据」并不意味着只复制 10% 的内存——对象在页内混杂排布,实际复制比例远高于访问比例。③gc.freeze()解决的是 GC 那部分写入,不解决引用计数——它把对象移入「永久代」,让分代 GC 不再扫描和标记它们(GC 扫描时会修改对象的 GC 头,那也是写),能显著减少失效,但只要子进程真的去遍历数据,引用计数照样会触发复制。要彻底解决只能让数据「不是 Python 对象」。
完整版教学
一、COW 的原理与理想效果
fork 后的内存布局(理想情况):
父进程虚拟地址空间 ──┐
├──► ★同一批物理内存页★(标记为只读)
子进程虚拟地址空间 ──┘
任何一方尝试写某页:
→ 触发缺页异常(page fault)
→ 内核复制该页
→ 两个进程各自持有一份,各写各的
★ 好处:fork 本身是 O(1) 的(只复制页表,不复制数据)
→ 这也是 fork 比 spawn 快 100 倍的原因
理想场景:★预加载 + fork 的经典模式★
父进程:加载 10GB 的模型/词表/索引
fork 出 8 个 worker
→ 理论上:8 个进程共享这 10GB → ★总占用仍是 ~10GB★
→ 如果用 spawn:每个子进程各自加载 → ★80GB★
这就是 Gunicorn 的 preload_app、ML 推理服务、
以及各种"master 加载 + worker fork"架构的理论依据
★ 但在 CPython 里,实测往往是:
8 个 worker 跑一会儿之后 → 总内存逼近 ★60~80GB★
→ COW "失效"了
→ 本题要解释的就是"为什么失效"和"怎么办"
先明确一个前提:★只有 fork 才有 COW★
fork → 有 COW(Linux/macOS)
★spawn★ → 全新进程,★重新 import、重新加载,完全没有共享★
forkserver→ 有 COW,但共享的是"服务进程"的状态(很干净、数据不在里面)
Windows → 只有 spawn(没有 fork)
→ 所以"靠 COW 省内存"这件事★只在 Linux + fork 下成立★
COW 的原理是:fork 后父子进程的页表指向同一批物理内存页并标记为只读,任何一方写入时才触发缺页异常并复制那一页。这让 fork 本身是 O(1) 的(只复制页表不复制数据),也是它比 spawn 快百倍的原因。理想场景就是**「预加载 + fork」**:父进程加载 10GB 模型后 fork 8 个 worker,理论上总占用仍是 10GB(用 spawn 则要 80GB)——这是 Gunicorn 的 preload_app、ML 推理服务等架构的理论依据。但实测往往是跑一会儿之后总内存逼近 60~80GB,COW「失效」了。开始分析前先明确一个前提:只有 fork 才有 COW——spawn 是全新进程、重新 import 和加载,完全没有共享;forkserver 虽有 COW 但共享的是干净的服务进程状态(数据不在里面);Windows 只有 spawn。所以「靠 COW 省内存」只在 Linux + fork 下成立。
二、引用计数:读操作也在写内存
CPython 对象的内存布局:
┌──────────────────────────────────┐
│ ob_refcnt (8 字节) ★引用计数★ │ ← ★每次引用都要改它★
│ ob_type (8 字节) 类型指针 │
│ ...对象自己的数据... │
└──────────────────────────────────┘
★ 关键:ob_refcnt 就在对象头部,和数据放在同一块内存里
哪些"看起来只是读"的操作会修改 refcnt:
x = data[0] ★获取元素 → refcnt++★(x 引用它)
for item in data: ★每次迭代 → item 引用当前元素 → ++ 然后 --★
func(obj) ★传参 → ++;函数返回 → --★
obj.attr ★取属性 → 属性对象 ++/--★
if obj in container: ★比较过程中会临时引用★
str(obj) / len(obj) ★参数引用 → ++/--★
→ ★几乎任何 Python 操作都在改引用计数★
唯一"真读"的情况很少:
- 直接读 C 层的字段(如 list 的 ob_size,len() 走的就是这条路,但
★len(x) 本身仍然要引用 x★)
- numpy 数组的元素访问(★数据在缓冲区,不是 PyObject★)
★ 一个算例,说明失效有多彻底:
data = [str(i) for i in range(5_000_000)] # 500 万个小字符串
内存布局:
- 一个 list 对象(存 500 万个指针,约 40MB)
- ★500 万个 str 对象★(每个约 50~60 字节,共约 280MB)
- 这些 str 对象散布在很多个 4KB 的页上
→ 每页约能装 ★70 个★ str 对象
子进程执行 for s in data: total += len(s)
→ 每个 str 都被引用一次 → ★每个对象所在的页都被写★
→ 280MB / 4KB = 7 万个页,全部被复制
→ ★子进程的 RSS 从 ~5MB 涨到 ~320MB★
→ 8 个 worker → ★2.5GB★(而不是理想中的 320MB)
★ 更糟的是:即使只遍历 10% 的元素,
因为对象在页内★混杂排布★(不是按访问顺序聚集的),
也可能碰到 ★60~90% 的页★ → 实际复制远超访问比例
失效的根本原因是 ob_refcnt 就在对象头部、和数据在同一块内存里,而几乎任何 Python 操作都在改引用计数:取元素、遍历、传参、访问属性、比较——全都会先 ++ 后 --,这就是对内存的写入。那个算例很有冲击力:500 万个小字符串约 280MB,散布在约 7 万个 4KB 的页上(每页挤着约 70 个对象);子进程只要 for s in data: 遍历一遍,每个对象所在的页都被写,7 万个页全部被复制,RSS 从 5MB 涨到 320MB,8 个 worker 就是 2.5GB 而不是理想中的 320MB。更糟的是即使只遍历 10% 的元素——因为对象在页内是混杂排布的(不按访问顺序聚集),也可能碰到 60~90% 的页,实际复制比例远高于访问比例。
三、GC 也在偷偷写内存
CPython 的分代 GC 做了什么:
① 遍历"可能有循环引用"的容器对象(list/dict/set/自定义类实例)
② ★在对象的 GC 头里修改状态★(把 refcnt 副本减去内部引用、打标记)
③ 移动对象在代际链表之间(★修改链表指针★——也是写)
★ 每个 GC 跟踪的对象前面有一个 PyGC_Head:
┌──────────────────────────┐
│ _gc_next (链表指针) │ ← ★GC 会改★
│ _gc_prev (链表指针 + 标记)│ ← ★GC 会改★
├──────────────────────────┤
│ ob_refcnt / ob_type ... │
└──────────────────────────┘
→ 子进程只要触发一次 GC(哪怕根本没碰你的数据)
→ ★所有被跟踪对象的页都会被写★ → COW 全线失效
★ 这就是为什么"子进程什么都没做,内存也在涨"
★ gc.freeze()(Python 3.7 引入,专为这个场景设计):
gc.freeze()
→ 把当前所有被 GC 跟踪的对象★移到"永久代"(permanent generation)★
→ ★GC 从此不再扫描它们★(除非 gc.unfreeze())
→ 于是子进程的 GC 不会去写这些对象的 GC 头
→ ★COW 保持有效的比例大幅提高★
★ 标准用法(顺序很重要):
import gc
gc.disable() # ① 加载期间关掉 GC(避免反复扫描拖慢加载)
data = load_huge() # ② 加载大数据
gc.collect() # ③ ★清理一次垃圾(别把垃圾也冻结了)★
gc.freeze() # ④ ★冻结:移入永久代★
gc.enable() # ⑤ 重新开启 GC(新对象仍会被正常回收)
fork_workers() # ⑥ 现在再 fork
★ Instagram 的著名实践:
他们用这套方法(外加禁用部分 GC)把 Django 服务的内存占用降了约 ★1/3★,
这也是 gc.freeze 被加进标准库的直接推动力
★ 局限(必须说清楚):
① ★只解决 GC 那部分写入,不解决引用计数★
→ 子进程真去遍历数据时,refcnt 照样触发复制
② 被冻结的对象★永远不会被回收★(即使成了垃圾)→ 只适合"确实要长期存活"的数据
③ 要在 ★fork 之前★ 调用才有意义
除了引用计数,分代 GC 也在偷偷写内存:它会遍历所有被跟踪的容器对象,修改它们的 GC 头(PyGC_Head 里的链表指针和标记位)——所以子进程只要触发一次 GC,哪怕根本没碰你的数据,所有被跟踪对象的页也会被写、COW 全线失效。这就解释了「子进程什么都没做,内存也在涨」的现象。gc.freeze()(3.7+)正是为此设计:把当前所有被跟踪的对象移入「永久代」,GC 从此不再扫描它们。标准用法的顺序很重要:gc.disable() → 加载数据 → gc.collect() 先清一次垃圾(别把垃圾也冻结了) → gc.freeze() → gc.enable() → 再 fork。这套方法出自 Instagram 的著名实践(他们借此把 Django 服务内存降了约 1/3,也直接推动了 gc.freeze 进入标准库)。但要说清它的局限:只解决 GC 那部分写入、不解决引用计数,而且被冻结的对象永远不会被回收,只适合确实要长期存活的数据。
四、怎么测量:RSS 会骗你
★ 三个内存指标(必须分清):
RSS (Resident Set Size) 进程占用的物理内存
★共享页会在每个进程里各算一遍★
PSS (Proportional Set Size) 共享页按进程数★平摊★
(一页被 4 个进程共享 → 每个算 1/4)
USS (Unique Set Size) ★只属于该进程的私有内存★
(进程退出能释放多少)
★ 经典误判:
8 个 worker 用 top 看每个 2GB → "总共 16GB?"
实际:如果 COW 有效,共享部分只有一份 → ★PSS 总和可能只有 3GB★
→ ★永远用 PSS 判断真实占用★
怎么看(Linux):
# 单个进程的汇总(★最方便★)
grep -E "^(Rss|Pss|Private)" /proc/<pid>/smaps_rollup
# 所有 Python 进程的 PSS 总和
for p in $(pgrep -f python); do
awk '/^Pss:/{s+=$2} END{print s}' /proc/$p/smaps 2>/dev/null
done | awk '{t+=$1} END{print t/1024 " MB (PSS total)"}'
# 工具
smem -k -P python # ★直接给出 USS/PSS/RSS 三列★
ps_mem.py # 按程序聚合,自动处理共享
Python 里读:
def mem_kb(pid=None, key="Pss"):
p = pid or os.getpid()
total = 0
with open(f"/proc/{p}/smaps_rollup") as f:
for line in f:
if line.startswith(key):
total += int(line.split()[1])
return total
★ 做对照实验的正确方法:
① 父进程加载数据,记 RSS_parent
② fork 一个子进程,★立刻★记子进程 RSS(应该很小 → COW 生效)
③ 子进程遍历数据,再记 RSS(★看涨了多少 → COW 失效了多少★)
④ 加上 gc.freeze() 重跑,对比 ③ 的数值
⑤ 换成 numpy/共享内存重跑,再对比
★ 典型结果(500 万个 str,约 280MB):
子进程刚 fork: ~5 MB
遍历后(无 freeze): ~320 MB ← ★几乎全复制★
遍历后(有 freeze): ~300 MB ← ★只省下 GC 那部分★
改用 numpy 数组: ~8 MB ← ★真正共享★
其他内存观测手段:
tracemalloc Python 层的分配追踪(★看不到 COW 层面的事★)
memory_profiler 逐行内存(同样是 Python 视角)
★/proc/<pid>/smaps★ ★唯一能看清共享/私有页的地方★
测量是这个话题最容易被误导的部分。三个指标必须分清:RSS(物理内存,共享页在每个进程里各算一遍)、PSS(共享页按进程数平摊)、USS(只属于该进程的私有内存)。经典误判就是:8 个 worker 用 top 看每个 2GB 就以为用了 16GB,而如果 COW 有效,共享部分只有一份、PSS 总和可能只有 3GB——判断真实占用永远要看 PSS(/proc/<pid>/smaps_rollup 或 smem -k -P python)。做对照实验的正确方法是四步:父进程加载后记 RSS → fork 后立刻记子进程 RSS(应该很小,说明 COW 生效)→ 子进程遍历数据后再记(看涨了多少就是失效了多少)→ 分别加上 gc.freeze() 和换成 numpy 重跑对比。典型结果很说明问题:500 万个字符串(280MB),子进程刚 fork 时 5MB、遍历后 320MB(几乎全复制)、加 gc.freeze 后 300MB(只省下 GC 那部分)、改用 numpy 数组则只有 8MB(真正共享)。
五、真正能共享大数据的方案
核心思路:★让数据"不是 Python 对象"★——没有对象头就没有引用计数写入
① ★numpy 数组(最常用)★
一个 10 亿元素的 float64 数组 = ★1 个 PyObject 头 + 8GB 裸数据缓冲区★
→ 子进程读元素只碰缓冲区(★纯读,不写★),不改任何 refcnt
→ COW ★真正有效★
arr = np.load("big.npy", mmap_mode="r") # ★配合 mmap 更省★
★ 但要小心:如果你把数组元素转成 Python 对象(arr.tolist()、遍历生成 int),
又回到了老问题
② ★np.memmap / mmap 模块(文件映射)★
arr = np.memmap("data.bin", dtype="float32", mode="r", shape=(N,))
→ 数据在★页缓存★里,多个进程天然共享同一份
→ ★甚至不需要 fork★:spawn 出来的进程也能映射同一个文件
→ 操作系统自动管理换入换出,超出内存的数据也能处理
★ 最省心的大数据共享方案
③ ★multiprocessing.shared_memory(3.8+)★
from multiprocessing import shared_memory
shm = shared_memory.SharedMemory(create=True, size=nbytes)
arr = np.ndarray(shape, dtype=dt, buffer=shm.buf)
# 子进程:SharedMemory(name=shm.name) 附加 → ★零拷贝★
★ 必须显式 close() + unlink(),否则 /dev/shm 泄漏
★ 优点:spawn 模式也能用(不依赖 fork)
★ 缺点:只适合定长的二进制数据,要自己管理布局和生命周期
④ ★外部存储 / 数据库★
把数据放进 Redis / SQLite(WAL 模式)/ 本地服务
→ 进程间天然共享,代价是每次访问有 IPC 开销
✓ 适合:数据要更新、要跨机器、访问不密集
⑤ ★Arrow / 列式格式★
pyarrow 的内存格式支持零拷贝共享(Plasma / IPC)
→ 大数据分析场景的标准方案
★ 各方案对比:
┌────────────────┬────────┬────────────┬──────────────────┐
│ 方案 │ 共享效果│ 需要 fork? │ 适合 │
├────────────────┼────────┼────────────┼──────────────────┤
│ 纯 Python 对象 │ ★差★ │ 是 │ ——(就是本题的坑)│
│ + gc.freeze │ 中等 │ 是 │ Web 服务预加载 │
│ numpy 数组 │ ★好★ │ 是 │ 数值数据 │
│ ★np.memmap★ │ ★最好★ │ ★否★ │ ★大文件、超出内存★│
│ shared_memory │ ★最好★ │ ★否★ │ 定长二进制、跨 spawn│
│ Redis/DB │ 好 │ 否 │ 需更新、跨机器 │
└────────────────┴────────┴────────────┴──────────────────┘
★ 一个实用的判断:
数据是"一堆 Python 小对象"(dict/list/str)→ ★COW 救不了你★
→ 要么改成 numpy/Arrow 的紧凑表示,要么放外部存储
数据是"一大块数值"→ ★numpy + memmap 就完美解决★
真正能共享大数据的思路只有一条:让数据「不是 Python 对象」——没有对象头就没有引用计数写入。numpy 数组是最常用的(10 亿元素的数组只有一个 PyObject 头 + 8GB 裸缓冲区,子进程读元素纯粹是读、不改任何 refcnt,COW 真正有效;但注意 arr.tolist() 或遍历生成 Python int 又会回到老问题)。np.memmap/mmap 是最省心的方案——数据在页缓存里天然被多进程共享,甚至不需要 fork(spawn 出来的进程也能映射同一个文件),还能处理超出内存的数据。shared_memory(3.8+) 适合定长二进制数据且跨 spawn 可用,但必须显式 close() + unlink() 否则 /dev/shm 泄漏。判断很实用:数据是「一堆 Python 小对象」时 COW 救不了你,只能改成 numpy/Arrow 的紧凑表示或放外部存储;数据是「一大块数值」时 numpy + memmap 就完美解决。
六、实践清单与决策
★ 预加载 + fork 的正确姿势(ML 服务 / Web 服务):
① 判断数据形态
一大块数值 → ★numpy/memmap★(首选,COW 天然有效)
一堆小对象 → 考虑改造成紧凑表示;改不了就上 gc.freeze
② 加载阶段
gc.disable()
data = load()
gc.collect() # ★先清垃圾★
gc.freeze() # ★再冻结★
gc.enable()
③ ★fork 时机:越早越好★
- 此时还没有后台线程(避免 fork 安全问题,见对应专题)
- 此时数据刚加载完、还没被大量访问过
④ 子进程里避免"全量遍历" Python 对象
- 用索引访问需要的部分,而不是 for 全表扫
- 需要全量处理的,改用 numpy 向量化
⑤ 测量验证:★用 PSS 而不是 RSS★
★ 什么时候干脆放弃 COW:
✗ 数据会被子进程大量随机访问 → COW 必然失效,不如直接 memmap
✗ 有后台线程(fork 不安全)→ 用 spawn + shared_memory
✗ 跨平台(Windows 没有 fork)→ 用 memmap / shared_memory
✗ 数据需要更新 → 用共享内存或外部存储
★ 判断准则:★如果无法保证"子进程只碰一小部分数据",就别指望 COW★
★ Gunicorn / uWSGI 的实际配置:
preload_app = True # ★master 先加载,worker fork★
+ post_fork hook 里重建连接(★见 fork 安全专题★)
+ 应用启动时 gc.freeze()
→ 典型收益:内存降低 20~40%(取决于代码/数据的比例)
★ 但如果 worker 会大量访问那些预加载的 Python 对象,收益会被吃掉
★ 常见误判排查表:
┌────────────────────────────┬──────────────────────────────┐
│ 现象 │ 原因 │
├────────────────────────────┼──────────────────────────────┤
│ fork 后内存立刻翻倍 │ ★看的是 RSS(重复计算共享页)★│
│ 子进程"什么都没做"内存却涨 │ ★GC 扫描写了 GC 头★ │
│ 遍历一次数据内存就翻倍 │ ★引用计数触发 COW★ │
│ gc.freeze 后仍然涨很多 │ ★freeze 不解决 refcnt★ │
│ numpy 数组没问题、list 就爆 │ ★数据在对象外 vs 在对象内★ │
│ spawn 模式完全没省内存 │ ★spawn 本来就没有 COW★ │
└────────────────────────────┴──────────────────────────────┘
一句话决策:
★"想靠 fork+COW 共享内存"的前提是:数据要么是 numpy 这类
『裸缓冲区』,要么子进程几乎不去碰它;
只要子进程要遍历大量 Python 对象,COW 就一定会失效。★
实践上,预加载 + fork 的正确姿势是五步:先判断数据形态(一大块数值直接用 numpy/memmap;一堆小对象考虑改造成紧凑表示,改不了才上 gc.freeze)→ 加载阶段用 gc.disable() → 加载 → gc.collect() → gc.freeze() → gc.enable() 的顺序 → fork 时机越早越好(此时还没有后台线程,也避免了 fork 安全问题)→ 子进程里避免全量遍历 Python 对象(用索引访问需要的部分,或改用 numpy 向量化)→ 用 PSS 而不是 RSS 验证。什么时候该干脆放弃 COW:子进程会大量随机访问数据、有后台线程、需要跨平台、或数据需要更新——这些情况直接用 memmap/shared_memory 更实在。Gunicorn 的 preload_app=True 配合 gc.freeze() 典型能降 20~40% 内存,但如果 worker 会大量访问那些预加载的 Python 对象,收益会被吃掉。最终决策一句话:只要子进程要遍历大量 Python 对象,COW 就一定会失效。
记忆钩子:「fork 的写时复制本意是『父子先共享物理页、谁写谁复制』,理论上加载 10GB 后 fork 8 个 worker 总共还是 10GB。★但在 CPython 里会大幅失效,根因是引用计数★——
ob_refcnt就在对象头部,而★『读』一个对象也要改它★(取元素、遍历、传参、访问属性全都 ++ 再 —),这是货真价实的内存写入。更要命的是 ★COW 的粒度是页(4KB)而不是对象★,Python 小对象只有几十字节、★一页挤着几十上百个★,碰一个就复制整页——所以『只访问了 10% 的数据』可能碰到 60~90% 的页。算例:500 万个 str 约 280MB,子进程只是 for 遍历一遍,RSS 就从 5MB 涨到 320MB。★分代 GC 也在偷偷写★(扫描时修改 PyGC_Head 的链表指针和标记位),所以子进程『什么都没做』内存也会涨。两个对策:①★gc.freeze()(3.7+)★把当前对象移入永久代让 GC 不再扫描,标准顺序是 ★gc.disable() → 加载 → gc.collect()(先清垃圾)→ gc.freeze() → gc.enable() → 再 fork★(Instagram 靠这套降了约 1/3 内存,也是它进标准库的推动力),★但它只解决 GC 那部分、不解决 refcnt★,且冻结的对象永不回收;②★让数据不是 Python 对象★——numpy 数组(10 亿元素只有一个对象头 + 裸缓冲区)、★np.memmap(数据在页缓存里,连 fork 都不需要,spawn 也能共享)★、shared_memory(跨 spawn 可用但要记得 close+unlink)。★测量陷阱:RSS 会把共享页在每个进程各算一遍★,8 个 worker 各 2GB 不等于 16GB——★要看 PSS★(/proc/pid/smaps_rollup 或 smem)。最后记住:★只有 fork 才有 COW,spawn 完全没有★;判断准则是『只要子进程要遍历大量 Python 对象,COW 就一定失效』。」
七、常见误区与追问
- 误区:fork 之后子进程的 RSS 很快涨到和父进程差不多,说明 COW 根本没生效。 要先排除测量误差:RSS 会把共享页在每个进程里各算一遍——8 个 worker 各显示 2GB 并不意味着用了 16GB,如果那 2GB 中大部分是共享的,PSS 总和可能只有 3GB。所以判断 COW 是否生效必须看 PSS(共享页按进程数平摊)或 USS(进程私有部分),可以读
/proc/<pid>/smaps_rollup或用smem -k -P python。当然,排除测量误差之后,COW 在 CPython 里确实会大幅失效(引用计数 + GC 都在写内存)——但这两件事要分开判断,否则你可能在一个 COW 其实工作良好的场景里白折腾。 - 误区:只要子进程不修改数据,COW 就能一直保持有效。 关键在于**「不修改数据」和「不写内存」不是一回事**。CPython 对象的
ob_refcnt字段就在对象头部、和数据在同一块内存里,而任何一次引用都会修改它:x = data[0]会++、for item in data:每轮都++再--、把对象传给函数、访问属性、放进另一个容器、做in判断——全都是对内存的写入。内核只看「这一页被写了没有」,不关心你在语义上只是「读」。所以子进程哪怕只是把大列表遍历一遍求个和,也会让几乎所有相关页被复制。唯一真正的只读访问是「数据不在 Python 对象里」的情况(numpy 数组的元素、mmap的字节、shared_memory的缓冲区)。 - 误区:调用
gc.freeze()之后 COW 就不会失效了。gc.freeze()只解决 GC 那一部分写入——它把当前所有被 GC 跟踪的对象移入「永久代」,使分代 GC 不再扫描它们(GC 扫描会修改PyGC_Head里的链表指针和标记位,那是实实在在的写)。它完全不影响引用计数:子进程只要真的去遍历那些对象,ob_refcnt照样会被修改、页照样会被复制。实测中它的收益取决于「GC 写入」和「refcnt 写入」的比例——对于「加载后基本不动、子进程很少访问」的数据(比如 Web 服务预加载的代码对象、配置、模板),收益可以很可观(Instagram 报告过约 1/3 的内存下降);但对于「子进程要全量遍历」的数据,加不加freeze差别很小。另外要记住两个前提:必须在 fork 之前调用,以及冻结的对象永远不会被回收(所以要先gc.collect()清掉垃圾再 freeze)。 - 误区:把大数据存成 Python 的
list或dict,用 fork 共享给子进程能省内存。 恰恰是这种形态最救不了。一个包含 500 万个字符串的列表,实际上是「一个 list 对象(500 万个指针)+ 500 万个独立的 str 对象」——这些小对象散布在成千上万个 4KB 的页上,每页挤着几十个,子进程碰任何一个就复制整页。而且对象在页内是按分配顺序混杂排布的,不会按你的访问模式聚集,所以「只访问 10% 的数据」可能触及 60~90% 的页。正确做法是改变数据的表示形式:数值数据用 numpy 数组(一个对象头 + 一整块裸缓冲区)、字符串表用 Arrow 的列式格式或 numpy 的定长字符串数组、键值查找用mmap+ 自建索引或嵌入式 KV(LMDB)——让「数据」和「Python 对象」解耦,COW 才有意义。 - 误区:用
spawn或在 Windows 上,也可以靠 COW 省内存。 COW 是fork的特性——spawn会启动一个全新的 Python 解释器、重新 import 所有模块、重新执行加载逻辑,父子进程之间没有任何页共享,内存必然是 N 份。forkserver虽然底层用 fork,但它 fork 的是那个干净的服务进程(你的大数据通常不在里面),所以同样共享不到。Windows 根本没有 fork,multiprocessing只能用 spawn。这意味着:任何依赖「预加载 + fork 共享」的架构都是 Linux/macOS 特定的,跨平台方案必须改用np.memmap(多进程共享同一份页缓存,不依赖 fork)或multiprocessing.shared_memory(显式共享,spawn 也能用)——这也是它们比 COW 更值得推荐的原因:可移植、可控、且不会被引用计数破坏。 - 追问:为什么说 COW 的粒度是「页」会让问题变得更严重? 因为内核复制内存的最小单位是一个页(通常 4KB),而 Python 的小对象只有几十字节。一个
str对象大约 50~60 字节,意味着一个 4KB 的页里可能装着 70 个左右的对象;一个小int更小,一页能装上百个。子进程只要引用了其中任意一个对象(让它的ob_refcnt变化),整个 4KB 页就会被复制,连同那 69 个你根本没碰过的对象一起。这就是「访问比例」和「复制比例」严重不成正比的原因。更糟的是 CPython 的对象分配是按分配顺序、从 arena 里连续切出来的,不会按你的访问模式聚集 ——你想访问的那 10% 的对象很可能均匀散布在所有页上。这也解释了为什么 numpy 效果好:8GB 的数组是一整块连续缓冲区,页里装的全是纯数据、没有任何需要修改的元信息。 - 追问:
np.memmap为什么比 fork + COW 更值得推荐? 四个原因。① 不依赖 fork:数据在页缓存里,任何进程只要mmap同一个文件就能共享同一份物理内存——spawn 出来的进程、甚至完全独立启动的进程都能共享,因此跨平台可用、也不受 fork 安全问题(锁、连接、线程)的困扰。② 不会被引用计数破坏:映射区里是纯数据,没有对象头,读取不产生任何写入。③ 内存可以超过物理内存:操作系统按需换入换出,处理 100GB 的文件也不会 OOM(而 fork+COW 要求数据先完整装进内存)。④ 生命周期简单:不用像shared_memory那样手动unlink,进程退出后页缓存由内核管理。代价是数据要先落到文件(或用/dev/shm上的文件兼顾速度),以及随机访问会触发缺页 IO(首次访问较慢,之后走页缓存就很快)。对「一次生成、多进程只读」的大数组,np.load(..., mmap_mode="r")几乎是最优解。 - 追问:Instagram 那套 GC 优化具体做了什么,为什么有效? 他们的 Django 服务用「master 预加载 + fork worker」的模式,发现内存并没有像预期那样被共享。分析后定位到两个原因:① 分代 GC 在子进程里扫描时会修改所有被跟踪对象的 GC 头,导致大量页被复制;② 引用计数同样在写。他们的做法分两步:先在加载完成后禁用 GC(服务的对象生命周期相对可控,用引用计数回收足够),再把「加载完成时已存在的所有对象」标记为不再参与 GC——这正是后来被标准化为
gc.freeze()的能力(Python 3.7 引入,Instagram 的实践是直接推动力)。效果是内存占用下降约三分之一。要理解它为什么有效:Django 服务预加载的主要是「代码对象、类、模板、配置」这类加载后几乎不再被修改、也很少被完整遍历的数据——对它们来说,GC 扫描才是 COW 失效的主因,冻结之后收益就很明显。反过来,如果你的场景是「子进程要全量遍历一个大列表」,gc.freeze()帮不上多少忙。
八、加强记忆
fork 的写时复制本意是「父子先共享物理页、谁写谁复制」,理论上加载 10GB 后 fork 8 个 worker 总共还是 10GB。但在 CPython 里会大幅失效,根因是引用计数——ob_refcnt 就在对象头部,而**「读」一个对象也要修改它**(取元素、遍历、传参、访问属性全都先 ++ 后 --),这是货真价实的内存写入,内核不会区分你在语义上只是读。更要命的是 COW 的粒度是页(4KB)而不是对象:Python 小对象只有几十字节,一页挤着几十上百个,碰一个就复制整页——所以「只访问了 10% 的数据」可能触及 60~90% 的页。算例:500 万个 str 约 280MB,子进程只是 for 遍历一遍,RSS 就从 5MB 涨到 320MB。分代 GC 也在偷偷写(扫描时修改 PyGC_Head 的链表指针和标记位),所以子进程「什么都没做」内存也会涨。两个对策:① gc.freeze()(3.7+) 把当前对象移入永久代让 GC 不再扫描,标准顺序是 gc.disable() → 加载 → gc.collect()(先清垃圾)→ gc.freeze() → gc.enable() → 再 fork(Instagram 靠这套降了约 1/3 内存,也是它进标准库的直接推动力),但它只解决 GC 那部分、不解决引用计数,而且冻结的对象永不回收;② 让数据「不是 Python 对象」——numpy 数组(10 亿元素只有一个对象头 + 裸缓冲区)、np.memmap(数据在页缓存里,连 fork 都不需要,spawn 也能共享,还能超出物理内存)、shared_memory(跨 spawn 可用,但要记得 close() + unlink())。测量陷阱:RSS 会把共享页在每个进程里各算一遍,8 个 worker 各 2GB 不等于 16GB——要看 PSS(/proc/<pid>/smaps_rollup 或 smem -k -P python)。最后记住两条:只有 fork 才有 COW,spawn 完全没有(Windows 也没有 fork);判断准则是「只要子进程要遍历大量 Python 对象,COW 就一定会失效」。