想象一下,公司里新来一名员工(子进程)。行政部没有立刻为他印一整套厚厚的新手册,而是让他先和旁边的老员工(父进程)共用同一本手册,并在手册扉页上标注“借阅人数 +1”。只有当新员工拿出笔要写笔记(首次写入)的那一刻,行政部才紧急复印全新的一页交给他专用——这一页从此只属于他。这套“先共享、要改才复印”的规则,正是操作系统创建进程时最精妙的一招:写时复制(Copy On Write,COW)。
本篇文章将带你以“图书复印馆”的视角,追踪进程的完整一生:从 fork 递交入职申请(系统调用入口),到档案室逐项复印档案(copy_process),到共享手册正文(COW 与页表复制),再到换岗换全新手册(execve),最后离职销毁手册(exit 回收)。看懂这条链路,你就掌握了现代操作系统进程管理的核心骨架。
对 AI 从业者而言,理解 fork/execve 与 COW,能让你真正看懂容器镜像的分层共享、PyTorch DataLoader 的多进程 Worker、以及大模型推理时的内存复用——这些 AI 基础设施的性能优化,骨子里全是“延迟复制”的智慧。
目录
可视化演示
本节配有交互动效(进入视口后自动加载,约 3KB)。
系统调用入口:递交入职申请
新员工入职的第一步,是走到人事窗口(系统调用接口)递上一张“入职申请”。这张申请就是 fork()。员工在工位上(用户态)不能直接闯进档案室(内核态)拿东西,必须通过这扇专门的窗口,把申请递进去,再由人事专员转交给后台处理。这扇窗口在 CPU 上对应一条 syscall 指令:它会触发一次“陷入(trap)”,把 CPU 从用户态切换到内核态,并在系统调用表中查找对应的处理函数。
人事窗口后台的专员叫 do_fork。它先核对申请单上要复制哪些资源(内存、文件表、信号等),再向下转交。而这张申请单有一个有趣的“双重回执”规则:它返回给父进程一个“新员工工号”(子进程 PID),返回给子进程一个“0 表示我是新来的”,如果申请失败则返回负数错误码。同一个函数,在两个进程里拿到不同的返回值,这就是 fork 最著名的“一次调用、两次返回”。
人事窗口受理后,在登记簿上记下新员工的工号(PID),申请单被转交到档案室。新员工现在正式进入了“待建档”状态——接下来就看档案室如何复印他的手册了。
- 子进程分支子进程从函数返回时拿到 0,表示“我是新创建的那个进程”。
- 父进程分支父进程拿到的是子进程的 PID,继续执行原有代码流。
- 失败分支当进程数超限或内存不足时返回负值。
描述符复制:逐项复印员工档案
档案室收到申请后,要为新员工复制一整套“档案”。这套档案的封面(task_struct 进程描述符)记录了姓名、状态、优先级等基本信息;内页则登记了员工能访问的各种资源:内存手册(mm_struct)、打开的文件(files_struct)、当前目录(fs_struct)、信号处理方式(sighand_struct)等。复制整个档案的总工作量,就是逐项复印这些部分所需时间之和。
内核中负责这项工作的主管理员叫 copy_process。它就像一个流水线班长,依次调用 copy_task(复印封面)、copy_mm(复印内存档案)、copy_files(复印文件表)、copy_fs、copy_sighand……不过流水线有个聪明的策略:并非每样东西都硬复印。文件表、信号处理这些“公共文档”常常采用共享引用计数的方式——大家一起看同一份文件,只在需要修改时才真正拆分。
档案室把封面和目录复制完毕后,突然意识到一个关键问题:整本手册的正文纸张(物理内存页)如果也全部复印,成本太高!于是管理层拍板:正文纸张先不印,父子员工共用原件。这个“共用手册”的规则,正是下一节的主角——写时复制。
copy_process 的复制总开销等于各项资源复制开销之和,内核优化方向就是尽量把某些项从“复制”改成“共享”。- 复制任务描述符复印员工档案封面,建立子进程的调度与状态信息。
- 复制内存描述符
copy_mm复印内存档案,并准备页表目录。 - 共享文件与信号表文件描述符表、信号处理器采用共享引用,降低开销。
写时复制:共享手册的智慧
公司新规生效了:新员工入职时,不再整本复印手册,而是由档案室发一张“共用手册凭证”。父子两人共享同一叠正文纸张,但有一个铁律:任何人想拿笔在手册上写笔记,必须先把这一页复印成自己的专属页,然后才能动笔。这个规则就叫写时复制(Copy On Write,COW)。它把“复制成本”从入职时刻,推迟到了第一次动笔的时刻。
在内核里,fork 调用 copy_mm 后并不会复制物理内存页,而是进入 copy_page_range,把父进程页表里的映射关系逐条抄给子进程——子页表和父页表指向同一批物理页。同时,内核会把所有共享页的页表项(PTE)里的“可写位”清除,标记为只读。物理页本身也有一个“借阅人数”计数器(引用计数 refcount),每多一个进程共享就加一。
有了“共用手册”的规则,入职成本骤降。但这样一来,手册的目录(页表)就成了关键——它必须被精确复制,才能让两个人找到同一叠纸。下一节我们看看档案室如何“只复印目录,不印正文”。
- 复制页表项子进程 PTE 直接继承父进程 PTE 的映射,物理页不复制。
- 清除写权限把共享 PTE 标记为只读,让任何写入都必须先经过缺页异常。
- 递增引用计数物理页的共享人数加一,为后续回收提供依据。
页表复制:只复印目录不印正文
手册翻开第一页是目录索引:它告诉读者“第 3 章在第 50 页”。在进程的内存世界里,这个目录就是页表。为了让父子进程共享正文纸张,档案室必须把整个目录完整地抄一份给子进程——但纸上的正文一页都不用印。这就是页表复制的精髓:只复制“映射关系”,不复制“数据本身”。
现代 CPU 的页表不是一张大表,而是像图书馆一样的多层索引结构。以 x86-64 为例,虚拟地址被切分成几段:最前面的位用来查顶层目录(PGD),接着查中间目录(PMD),再查页表项(PTE),最后的低位是页内偏移。copy_page_range 就是沿着这套层级逐层往下走,把叶子层的 PTE 一条条复制到子进程的目录里,同时应用 COW 规则。
目录复印完成后,父子两人都能顺着目录找到同一叠正文纸。然而共享有共享的风险——万一其中一个人开始乱涂乱画怎么办?别急,正是前面设置的“只读”标记,会让任何动笔行为都惊动管理员。这就是缺页异常登场的时机。
- 地址切分64 位虚拟地址被拆成多层索引和页内偏移。
- 逐级查表从 PGD 开始逐级读取物理地址,检出下一级目录位置。
- 到达页框PTE 中保存的物理页框号加上偏移,得到最终物理地址。
execve替换:换一本全新手册
前面讲的都是“复制旧手册”。但如果员工转岗到 AI 算法组,旧手册里的销售话术、财务流程全都没用了——这时候需要直接换一本全新手册。这个动作对应的系统调用就是 execve。它和 fork 截然相反:fork 是“复印归档”,execve 是“废旧立新”。
execve 的执行路径像一场大扫除:内核先调用 flush_old_exec 释放旧的内存描述符和旧页表,把旧手册连同目录、纸张一起归还档案室;然后创建全新的 mm_struct,读取可执行文件的 ELF 格式头,把代码段、数据段、栈段逐一映射到新地址空间;最后从零搭建一套全新的页表,重置程序计数器并跳转到新程序的入口。父子之间此前共享的一切物理页,在这一刻被彻底切断。
新手册启用,旧手册化为纸浆。这也解释了为什么 shell 里 fork + execve 总是成对出现:fork 让 COW 机制下几乎不复制任何物理页,execve 再彻底抛弃这些映射。两者配合,既高效又干净。不过,如果某位员工真的要在共享手册上写第一笔,缺页异常就该上场了。
- 释放旧空间
flush_old_exec销毁旧 mm 和旧页表,归还共享页。 - 创建新映射依据 ELF 程序头建立代码段、数据段、栈的虚拟映射。
- 重建页表为新映射重新建立完整页表,重置执行入口。
缺页异常:动笔前的临时复印
员工翻开共享手册,提起笔想在页边写一行笔记——只听“咔”一声,笔被锁住,册页纹丝不动。因为这一页被标记为“只读共享”。员工只好举手报告管理员:“这页纸是共享的,我要动笔!”管理员核查共享人数后,立刻复印一页新的纸交给他,此后这笔账就归他一个人管。这套“举手—复印—放行”的流程,就是缺页异常(Page Fault)处理。
CPU 在执行写入指令时发现 PTE 没有写权限,会触发缺页异常,陷入内核的 do_page_fault。异常处理程序顺着虚拟地址找到对应页表项,判断这页是否属于 COW 页——条件是有映射、可读但不可写,且物理页引用计数大于 1。满足条件,就分配一个新物理页(alloc_page),把旧页内容拷贝过去(copy_page),更新子进程的 PTE 指向新页并恢复写权限,同时把旧页的引用计数减一。如果引用计数恰好是 1,说明没人跟你共享了,直接把 PTE 改为可写即可,连拷贝都省了。
管理员把新复印件交给员工,员工终于写下属于自己的笔记。从此父进程改父进程的,子进程改子进程的,互不干扰。但新问题来了:当员工离职时,他手里的手册——以及那些曾经共享过的纸张——该怎么处理?这就是 exit 的回收艺术。
- 判断共享人数检查物理页引用计数是否大于 1,决定是否需要复制。
- 分配并拷贝分配新物理页,把旧页内容逐字节复制过去。
- 更新 PTE新页映射到子进程页表,恢复写权限,旧页计数减一。
exit回收:离职时的纸张清理
员工离职那天,要把员工手册交回档案室并在扉页的“借阅人数”栏减一。如果这本手册还有人共用,档案室只做登记,并不销毁——毕竟其他同事还在看呢。只有当借阅人数归零,手册才能被碎纸销毁。这个“最后一人离开才销毁”的机制,就是 exit 路径上的资源回收逻辑。
进程调用 exit 后进入 do_exit,沿路释放文件、信号等资源,并调用 exit_mm 把手上的内存描述符交还给内核。exit_mm 最终走到 mmput:它先递减内存描述符的引用计数,只有当计数归零,说明没有任何进程再引用这枚 mm,才真正调用 mmput 尾部逻辑销毁页表、回收所有物理页。COW 中共享的物理页同理——每个页框有自己的引用计数,父子各自退出时各减一次,减到零才回收到伙伴系统。
至此,一个进程走完了“入职复印—共享手册—动笔复印—换新手册—离职销毁”的完整一生。COW 的延迟复制思想贯穿始终:申请时少复印、共用时少占用、动笔时才真正分配、离职时等最后一人走才销毁。这四个“延迟”,共同成就了现代操作系统高效创建进程的秘密。
- 递减引用
mmput先把内存描述符的引用计数减一。 - 判断归零检查计数是否为 0,判断是否还有共享者。
- 真正销毁归零后释放页表、归还全部物理页给内存管理子系统。
AI 应用场景
PyTorch DataLoader 的多进程 Worker
阶段:数据加载与预处理
解决的问题:训练大型数据集时,若每个 Worker 都复制一份完整数据,内存会瞬间爆炸。传统做法把数据整体复制到每个进程,代价高昂;PyTorch 利用 fork 创建 Worker,依靠 COW 让多个 Worker 共享同一份数据集物理页,只有 Worker 各自写入增强后的样本时才触发复制,极大降低内存开销。
具体案例:在 ImageNet 规模的数据集上训练 ResNet,设置 num_workers=8 时,若用 COW 的 fork 启动方式,8 个 Worker 共享父进程加载的数据缓存页,内存占用从“8 倍数据量”降到“1 倍数据量 + 少量写复制”,训练吞吐显著提升。
Docker 容器镜像的分层共享
阶段:模型部署与环境构建
解决的问题:多个 AI 服务容器共享同一基础镜像(如 PyTorch 镜像)时,如果不做共享,每个容器都要存储一份完整镜像,磁盘开销巨大。镜像存储驱动(如 overlayfs)正是 COW 思想的直接应用:只读镜像层被所有容器共享,容器启动时不复制任何文件,只有写入时才在容器层复制出可写副本。
具体案例:在 GPU 集群上同时启动 50 个推理容器,基础镜像 5GB。借助 overlayfs 的写时复制,共享层只存一份,每个容器仅占用少量可写层空间,总磁盘占用从 250GB 下降到约 10GB。
大模型推理的多进程权重加载
阶段:模型加载与内存优化
解决的问题:多路推理服务进程加载同一个 70B 大模型权重时,若每路都读入独立内存,显存/内存需求量翻倍。通过 mmap 以 MAP_PRIVATE 方式映射权重文件,多个进程共享同一批物理页;只有做 LoRA 微调等需要原地改写权重时,才触发 COW 复制——推理场景几乎只读,命中率极高。
具体案例:vLLM 等多进程推理框架用共享内存 + COW 映射权重文件,4 路服务同时加载 Qwen-70B,峰值内存从“4 倍模型体积”降至“1 倍模型体积 + 页表开销”,单卡可服务路数显著增加。
Python 演示
"""写时复制(COW)机制迷你模拟器演示 fork 后父子进程共享物理页,子进程首次写入触发缺页复制。""" class Frame: """物理页帧:保存一页数据,记录有几个页表项引用它""" def __init__(self, value=0): self.value = value self.refs = 1 # 引用计数 class Process: """进程:用字典模拟页表,虚拟页号 -> PTE 字典""" def __init__(self, pid): self.pid = pid self.pt = {} def read(self, vpage): return self.pt[vpage]["frame"].value def write_to(self, vpage, value): pte = self.pt[vpage] if pte["ro"]: # CPU 写只读页 -> 触发 COW 缺页异常 old = pte["frame"] new = Frame(old.value) # 分配新物理页并拷贝内容 old.refs -= 1 # 旧页引用计数减一 pte["frame"] = new pte["ro"] = False print(f" [进程{self.pid}] COW缺页:分配新页并拷贝数据={new.value}") pte["frame"].value = value print(f" [进程{self.pid}] 写入 虚拟页{vpage} = {value}") def fork(self): """创建子进程:共享物理帧,PTE 全部标记只读""" child = Process(self.pid * 10 + 1) for vpage, pte in self.pt.items(): pte["frame"].refs += 1 # 共享帧,引用计数 +1 child.pt[vpage] = {"frame": pte["frame"], "ro": True} pte["ro"] = True # 父进程 PTE 也变只读 print(f" [进程{self.pid}] fork 出子进程{child.pid},物理帧共享、PTE只读") return child def main(): parent = Process(1) parent.pt[0] = {"frame": Frame(100), "ro": False} parent.pt[1] = {"frame": Frame(200), "ro": False} print("1) 初始:父进程有两个虚拟页 0 和 1") child = parent.fork() print("2) fork 之后:") print(f" 父进程{parent.pid}: 页0={parent.read(0)} 页1={parent.read(1)}") print(f" 子进程{child.pid}: 页0={child.read(0)} 页1={child.read(1)}") print("3) 子进程修改页 0:") child.write_to(0, 999) print("4) 写后检查:") print(f" 父进程{parent.pid}: 页0={parent.read(0)}(不受影响)") print(f" 子进程{child.pid}: 页0={child.read(0)}(已修改)") print(f" 页1 仍是共享帧,父子都能读到:{child.read(1)}") if __name__ == "__main__": main()
old.refs -= 1触发 COW 后,旧物理页的共享人数减少,为将来回收做准备。child.pt[vpage] = {"frame": pte["frame"], "ro": True}子进程页表项指向父亲同一帧,但强制只读,这是 COW 的核心。if pte["ro"]:模拟 CPU 写入只读页时触发缺页异常,进入复制流程。new = Frame(old.value)分配新物理页并把旧页内容拷贝过去,对应内核的alloc_page+copy_page。pte["frame"] = new; ro = False更新 PTE 指向新页并恢复写权限,之后子进程可以随意改写。
常见误区
误区我以为调用 fork 后,父子进程各自拥有一份完整的内存副本,所以 fork 是很重、很慢的操作——复制几 GB 内存得花好几秒。
正解fork 只复制页表目录项,物理内存页通过 COW 共享。子进程的页表和父进程指向同一批物理页,只是被标记为只读。只有某方首次写入某页时,才真正分配新页并复制那 4KB。所以 fork 通常非常快,与进程总内存大小关系不大。
为何常见:教材常把 fork 描述为“复制进程”,初学者容易望文生义,以为物理内存全部复制,忽略了页表和物理页是两回事。
误区我以为写时复制只在 fork 的瞬间生效一次:fork 时把页变只读,子进程第一次写时复制完,后面就全部正常了。
正解COW 是一个持续机制。每次 fork 都会对共享页重新设置只读并递增引用计数;同一个物理页可能被多个进程共享,任何一方首次写入都会触发 COW。每次 fork 后,父子双方又各自处于“只读共享”状态,下一次写入仍需缺页复制。
为何常见:把“第一次写入”误解为“全局只有一次”,忽略了 COW 是逐页、逐次生效的延迟复制机制。
误区我觉得 fork 之后马上就加载新程序了,所以 fork 和 execve 是一回事,执行 fork 就会运行新程序。
正解fork 和 execve 是两步独立操作。fork 创建当前进程的副本,子进程继续执行和父进程几乎一样的代码;execve 才用全新程序映像替换地址空间。shell 执行命令时先 fork 再 execve:fork 得到的子进程马上调用 execve,把自己变成目标程序,父进程则继续等待。
为何常见:日常使用中 fork 和 execve 总是成对出现、间隔极短,初学者容易把它们当成一次调用的两个阶段。
误区我以为 fork 后父子进程的内存就彻底分家了,父进程写什么、子进程写什么互不影响,所以内存是“一分为二”的。
正解fork 后父子进程的内存最初是共享的(同一批物理页),只因 PTE 被置为只读而“看起来”分离。只有某一方写入某页,该页才被复制分离;从未被写入的页(如只读代码段、常量数据)会一直共享到进程退出。所以“隔离”是写时渐进完成的,不是瞬间完成的。
为何常见:人们把“虚拟地址空间不同”等同于“物理内存不同”,忽略了页表映射可以指向同一个物理页。
误区我以为进程调用 exit 后,它的所有内存会立刻归还系统,页表和物理页瞬间销毁。
正解exit 只是开始回收流程,是否真正释放取决于引用计数。COW 共享的物理页被多个进程页表引用,一个进程退出只会让引用计数减一;只有当计数归零,即最后一个引用者退出时,物理页才真正释放。mmput 对内存描述符的回收同样是“计数归零才算数”。
为何常见:直觉上“人走茶凉”,但共享资源必须等所有人都离场才能收拾,否则会误伤仍在使用的进程。
面试题
Q1. fork() 在子进程中的返回值是?
- A.0← 正确答案
- B.子进程自己的 PID
- C.父进程的 PID
- D.-1
答案:A。fork 一次调用两次返回:子进程返回 0,父进程返回子进程 PID。这就像入职申请的两张回执,新员工拿到"0"表示我是新来的,老员工拿到新工号。B、C 是父进程或相反场景的返回值,D 是失败场景。
Q2. COW 的全称和核心思想是什么?
- A.Copy On Write,延迟物理页复制、首次写入才复制← 正确答案
- B.Create Once Write,只创建一次写入多次
- C.Convert Old Wait,转换旧进程等待
- D.Cache On Write,写入时缓存
答案:A。COW 即 Copy On Write(写时复制),指 fork 后父子共享物理页,只有一方写入该页时才真正分配并复制。这像共用手册、动笔才复印。B、C、D 都是对首字母缩写的错误杜撰。
Q3. fork 后,父子进程实现共享物理页的关键操作是?
- A.清除页表项的 present 位
- B.清除页表项的 user 位
- C.清除页表项的 write 位← 正确答案
- D.清除页表项的 dirty 位
答案:C。fork 后要把共享页的 write 位(可写位)清除,PTE 变为只读。这样任何一方的写入都会触发缺页异常进入 COW 流程。present 位表示页是否存在,user 位表示是否用户态可访问,dirty 位表示页是否被写过,都与共享只读机制无关。
Q4. 子进程 execve 成功后,原来与父进程共享的物理页会怎样?
- A.继续和父进程共享
- B.被释放回收,映射替换为新程序映像← 正确答案
- C.迁移到交换分区
- D.原样复制一份给子进程
答案:B。execve 会释放旧 mm 和旧页表,按新 ELF 文件重新建立地址空间,因此旧共享物理页被归还。就像换新手册后旧手册全部销毁。共享关系在 execve 后彻底切断,不会再被父进程的旧页引用。
Q5. 子进程首次写入共享页时,CPU 会触发什么事件?
- A.系统调用
- B.缺页异常← 正确答案
- C.时钟中断
- D.软中断
答案:B。写入只读 PTE 会让 CPU 触发缺页异常(Page Fault),陷入内核的 do_page_fault 处理。系统调用是主动陷入内核的指令,时钟中断是定时器事件,软中断用于内核异步任务——它们都不是 COW 触发的机制。
Q6. COW 缺页处理中,新分配的物理页需要做什么?
- A.全部清零
- B.从旧物理页拷贝数据← 正确答案
- C.从磁盘加载
- D.从 swap 换入
答案:B。COW 缺页的目标是“复制一份可写的副本”,因此新页必须拷贝旧页内容,保证写入前的数据一致。清零、磁盘加载、swap 换入分别对应匿名页分配、文件映射和换页场景,不是 COW 的主路径。
Q7. 物理页引用计数为 1 时触发写缺页,内核的正确做法是?
- A.分配新页并拷贝内容
- B.直接把 PTE 改为可写,免去拷贝← 正确答案
- C.将页表项清零
- D.触发 OOM 终止进程
答案:B。引用计数为 1 说明没有其他进程共享这一页,写操作不会影响别人,因此只需把 PTE 改回可写,无需分配和拷贝。这是 COW 中常见的优化分支。分配拷贝只在 refcount > 1 时执行,C、D 更是无稽之谈。
Q8. 以下哪些是 fork 后 COW 机制中会发生的事件?
- A.物理内存页暂时不复制,父子共享← 正确答案
- B.共享页的 PTE 被设置为只读← 正确答案
- C.物理页引用计数递减
- D.父子共享物理页直到某一方写入← 正确答案
答案:ABD。fork 后物理页共享不复制(A 正确),PTE 置只读(B 正确),引用计数递增而非递减(C 错误),直到任意一方写入才触发 COW 分离(D 正确)。这就像两个员工共用手册,任何一方动笔前都保持共享。
Q9. COW 缺页异常处理时,内核可能执行哪些操作?
- A.分配一个新的物理页← 正确答案
- B.将旧物理页内容复制到新页← 正确答案
- C.更新 PTE 指向新页并恢复写权限← 正确答案
- D.把整张页表重新复制一遍
答案:ABC。COW 缺页分为两步:若 refcount > 1,分配新页并拷贝数据(A、B),然后更新 PTE 指向新页、置可写(C)。若 refcount = 1 则只做 C。页表在 fork 时已复制过,COW 缺页无需再复制整张页表(D 错误)。
Q10. 关于 fork+execve 组合使用,以下说法正确的是?
- A.fork 后立即 execve,子进程地址空间被替换,COW 复制大量发生
- B.execve 释放旧 mm 后,子进程继续与父进程共享物理页
- C.fork 后立即 execve 时,COW 机制能避免大部分无用复制← 正确答案
- D.execve 会保留 fork 产生的所有 COW 映射
答案:C。fork 创建的子进程如果马上 execve,旧地址空间被完全替换,父进程的 COW 共享页根本不会被写入,因此几乎不发生物理页复制(C 正确)。A 说“大量复制”错误;B、D 说 execve 后仍共享旧页,与 execve 释放旧 mm 的事实矛盾。这就是 shell 高效启动进程的底层原因。