根本原因是fork复制父进程整个虚拟地址空间,包括模块代码段、常量池及已初始化全局变量;numpy、torch、cv2等模块因预分配内存或初始化全局状态易引发COW导致内存翻倍。

为什么 fork 子进程时导入模块会触发隐式内存复制
根本原因不是“导入模块”本身吃内存,而是 Python 默认用 fork 启动子进程,而 fork 会复制父进程的整个虚拟地址空间——包括已加载的模块代码段、常量池、甚至 .pyc 缓存和已初始化的全局变量。哪怕模块只被 import 一次,只要它在主进程启动后、fork 前执行过任何赋值(比如 numpy.array([1,2,3]) 赋给模块级变量),这部分数据就会被 COW(copy-on-write)机制标记为“可能要写”,导致每个 worker 进程都预留对应大小的物理页映射。
哪些模块最容易放大这个问题
以下模块在导入或首次使用时会主动分配大块内存或初始化全局状态,fork 后极易引发内存翻倍:
-
numpy:导入时可能预分配 BLAS 缓冲区;若模块级定义了np.zeros((1000, 1000)),该数组会被完整复制 -
torch:调用torch.cuda.is_available()或初始化 CUDA 上下文后,显存/锁页内存映射会进入地址空间 -
cv2(OpenCV):内部加载大量静态模型和预编译内核,部分版本还会在 import 时触发 OpenCL 初始化 - 自定义模块中含
json.load(open('config.json'))或pandas.read_csv('lookup.csv'):数据直接落进模块全局命名空间
如何验证是不是模块导入导致的内存膨胀
别猜,用 /proc/[pid]/maps 直接看子进程实际映射了什么:
- 启动一个带
num_workers=4的DataLoader,等 worker 进程起来后,用ps aux | grep python找到任意一个 worker PID - 执行
cat /proc/[PID]/maps | awk '{print $2 " " $3 " " $6}' | sort -k1,1nr | head -10,观察前几行是否出现多个超大尺寸(>10MB)的anon或python3.*映射段 - 对比主进程的同样输出:如果 worker 多出几段大小接近的匿名映射,且位置与主进程里模块初始化代码段重合,基本锁定是 fork + 模块全局状态所致
真正有效的缓解方式不是“少 import”,而是控制加载时机
关键原则:让模块导入和初始化推迟到子进程内部、且仅在需要时发生。常见做法包括:
立即学习“Python免费学习笔记(深入)”;
- 把
import全部移到 worker 函数体内,而非模块顶层 —— 这样每个子进程只加载自己用到的部分 - 用
if __name__ == '__main__':包裹主进程专属初始化逻辑(如模型加载、大配置读取),避免被 fork 继承 - 对必须全局共享的模块(如
numpy),改用spawn启动方式:torch.multiprocessing.set_start_method('spawn'),彻底规避 fork 复制 - 禁用
fork的副作用:在主进程启动前加os.environ['PYTORCH_ENABLE_MPS_FALLBACK'] = '0'(MPS 会额外触发 Metal 缓存初始化)
最隐蔽的坑是:你以为只是 import 了一个轻量模块,但它内部又 import 了 scipy 或 sklearn,而后者会在首次调用时悄悄分配几十 MB 的线程局部存储 —— 这类链式初始化在 fork 后全量复制,却很难从代码表面察觉。


















