当前存在的问题
未启用写时复制时,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()的场景,就不用拷贝父进程的这些数据结构了。