当前存在的问题
未启用写时复制时,fork()
创建子进程地址空间的流程如下:
- 动态申请子进程的页表
- 动态申请子进程的物理页面,大小和父进程的相同
- 创建
父进程虚拟地址-新物理页
的映射到子进程页表 memcpy()
将父进程所有页面拷贝到子进程地址空间下
这样做有什么问题呢? 在fork()
的常规调用环境下,fork()
之后
接的一般是exec()
类函数,即载入一个新的可执行文件,继续用父进程
的情况不多。
这样的话,上述过程中memcpy()
父进程的页面就是多余的,而且如果
父进程比较大,会非常耗时。
写时复制的优化
执行 fork()
时,不给子进程分配新的物理页,而是将父进程的页表项
完全的拷贝到子进程中,结果就是父子进程的虚拟地址指向同一个物理地址。
换句话说,这样做就不需要memcpy()
父进程所有的页面,仅仅是memcpy()
一份
父进程的页表,给子进程用。
那么是否连新页表都不申请,直接用父进程的页表?
显然是不行的,因为本质上父子进程拥有不同的地址空间, 最后都要分隔开(无论是否执行
exec()
),所以没必要 推迟页表的申请,本身不怎么耗时。但是创建线程时,确实使用同一张页表。
当然,仅设计到这步是不行的,因为按理来说父子进程是独立的,对子进程的 修改不应该影响父进程的地址空间。
所有,在 copy 完页表后,会将父子进程的所有地址空间(实际是页表项)设置 为只读属性,当父/子进程尝试修改地址空间时,触发异常,配合特定的 异常处理机制,为其创建一个新的屋里也,拷贝原来的+执行修改。
下图是对上述情况的描述,仅给出一个页面的示例,可以推广到整个地址空间:
VMA VMA
┌───────┐ │ ┌───────┐
Parent │ │ │ Parent │ │
│ │ │ │ │
├───────┤ │ ├───────┤
│ ├────┐ │ │ ├────┐
├───────┤ │ PMA Write │ ├───────┤ │ PMA
│ │ │ ┌───────┐ ────┼───► │ │ │ ┌───────┐
│ │ │ │ │ │ │ │ │ │ │
│ │ │ │ │ │ │ │ │ │ │
└───────┘ │ ├───────┤ │ └───────┘ │ ├───────┤
├────►│ │ Read │ └────►│ │ RW
┌───────┐ │ ├───────┤ only │ ┌───────┐ ├───────┤
Child │ │ │ │ │ │ Child │ │ │ │
│ │ │ │ │ │ │ │ ├───────┤
├───────┤ │ │ │ │ ├───────┤ ┌─►│ │ RW
│ ├────┘ └───────┘ │ │ ├───────┘ ├───────┤
├───────┤ │ ├───────┤ │ │
│ │ │ │ │ └───────┘
│ │ │ │ │
│ │ │ │ │
└───────┘ │ └───────┘
│
这样就完美了吗
实际上不是的,拷贝父进程的页表和vm_area_struct
就不占内存了吗?
当页表是稀疏的,vm_area_struct
的数量过多时,其本身的数据结构
就会占用很大的空间。
就 AArch64 来说,针对页表过大的问题,提供了 2M 和 1G 的巨型页(Huge Page) 可供选择,能在申请大而稀疏的页面时显著的减少页表的大小,同时也增加了 TLB 的 命中率,因为同样大小的内存大页只需要一个 TLB 表项即可。
然而,还有一种情况更加严重,若fork()
后父进程写了地址空间的内容,
如上所说就要拷贝这些物理页面,此时如果写的页面过多可能发生fork()
到exec()
那一段间隔时间里的物理内存占用极高。虽然这种情况极少发生,前提必须是内存
分配稀疏+父进程修改的内存也是稀疏的,但是并不能完全忽略这种情况。
当然,这个问题不能归根于 COW,而是fork()
带来的,fork()
应该提供参数
给那些子进程立马调用exec()
的场景,就不用拷贝父进程的这些数据结构了。