Linux 进程地址空间 写时复制

当前存在的问题

未启用写时复制时,fork()创建子进程地址空间的流程如下:

  1. 动态申请子进程的页表
  2. 动态申请子进程的物理页面,大小和父进程的相同
  3. 创建父进程虚拟地址-新物理页的映射到子进程页表
  4. 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()的场景,就不用拷贝父进程的这些数据结构了。


创建于: 2023-05-08T09:51:49, Lastmod: 2023-09-24T18:08:59