操作系统可以设置任何它希望的“写时复制”策略,但通常它们都会执行相同的操作(即执行最有意义的操作)。
大致上,在类POSIX系统(linux、BSD、OSX)中,有四个感兴趣的区域(您称之为段):
data
(
int x = 1;
所在的位置)、
bss
(
int y
所在的位置)、
sbrk
(这是Heap/Malloc),以及
stack
。
当执行
fork
时,操作系统会为子进程设置一个新的页面映射,该映射与父进程共享所有页面。然后,在父进程和子进程的页面映射中,所有页面均标记为只读。
每个页面映射还具有引用计数,表示有多少进程共享该页面。在进行分叉之前,refcount将为1,在此之后,它将为2。
现在,当
任一进程尝试写入R/O页面时,它会收到页面故障。操作系统将看到这是为了“写时复制”,将为该进程创建一个私有页面,从共享的页面中复制数据,标记该页面为可写,并恢复它。
它还会减少refcount。如果refcount现在再次变为1,则操作系统将标记另一个进程中的页面为可写且非共享[这消除了另一个进程中的第二个页面故障——仅因为此时操作系统知道该另一个进程应该再次自由地编写未被破坏的内容]。这种加速可能取决于操作系统。
实际上,
bss
部分获得了更多的特殊处理。在其初始页面映射中,所有页面都映射到包含所有零的
单个页面(也称为“零页”)。映射标记为只读。因此,
bss
区域可以达到几GB大小,但仅占用单个物理页面。这个单一的特殊的零页在所有进程的
所有bss
部分之间共享,无论它们是否彼此有
任何关系。
因此,进程可以从该区域中的任何页面读取,并得到它所期望的:零。只有当进程尝试写入这样的页面时,相同的写时复制机制才会启动,进程会获得一个私有页面,映射会调整,然后进程将被恢复。现在,它可以自由地编写页。
再次强调,操作系统可以选择其策略。例如,在进行分叉后,共享
大多数堆栈页面可能更有效,但是会从堆栈指针寄存器的值确定“当前”页面的私有副本开始。当子进程执行
exec
系统调用时,内核必须撤销在
fork
期间执行的大部分映射操作(降低引用计数),释放子进程的映射等,并恢复父进程的原始页面保护(即除非再次
fork
,否则它将不再共享其页面)。
虽然不属于您最初的问题,但还有一些相关的活动可能会引起兴趣,例如
按需加载 [页面]和
按需链接 [符号]在
exec
系统调用之后。
当进程执行
exec
时,内核会进行上述清理,并读取可执行文件的一小部分以确定其对象格式。主要格式是ELF,但可以使用内核理解的任何格式(例如,OSX可以使用ELF [如果我没有记错],但它也有其他格式)。
对于ELF,可执行文件有一个特殊部分,它给出了到被称为“ELF解释器”的共享库的完整FS路径,通常为
/lib64/ld.linux.so
。
内核使用内部形式的
mmap
,将其映射到应用程序空间,并为可执行文件本身设置映射。大多数内容都标记为R/O页面和“未出现”。
在我们继续之前,我们需要谈论页面的“后备存储”。也就是说,如果发生页面故障并且我们需要从磁盘加载页面,则来自何处。对于堆/ malloc,这通常是交换磁盘[也称为分页磁盘]。
在linux下,它通常是添加系统时安装的类型为“linux swap”的分区。当写入页面以释放一些物理内存时,必须将其写入其中。请注意,第一节中的页面共享算法仍然适用。
无论如何,当可执行文件首次
映射到内存中时,其后备存储都是文件系统中的可执行文件。
因此,内核将应用程序的程序计数器设置为指向ELF解释器的起始位置,并将控制权转移到该位置。
ELF解释器进行业务处理。每次它尝试执行一个未加载但已映射的部分自身[“代码”页]时,页面故障会发生并从后备存储(例如ELF解释器的文件)加载该页,并将映射更改为R/O但
当前存在。
这适用于ELF解释器,共享库和可执行文件本身。ELF解释器现在将使用mmap将libc映射到应用程序空间中(再次受需求加载的限制)。如果ELF解释器必须修改代码页面以便重新定位符号(或尝试写入任何以文件为后备存储的页面,例如数据页面),则会发生保护错误,内核将页面的后备存储从磁盘上的文件更改为交换磁盘上的页面,调整保护,然后恢复应用程序。
内核还必须处理这样一种情况:ELF解释器(例如)正在尝试写入(比方说)一个尚未加载的数据页面(即它必须首先加载它,然后再将后备存储更改为交换磁盘)。
然后,ELF解释器使用libc的部分内容来帮助完成初始链接活动。它重新定位了最少量的内容以允许它完成工作。
然而,对于大多数其他共享库,ELF解释器并没有重新定位所有符号。它将查看可执行文件,并使用mmap为可执行文件需要的共享库创建映射(即当您执行ldd可执行文件时所看到的内容)。
这些到共享库和可执行文件的映射可以被认为是“段”。
每个共享库中都有指向解释器的符号跳转表。但是,ELF解释器只做最小限度的更改。
只有当应用程序尝试调用给定函数的跳转入口(这就是您可能看到的GOT等内容)时才会发生重定位。跳转条目将控制权转移回解释器,解释器定位符号的实际地址并调整GOT,使其现在直接指向符号的最终地址,并重新调用,从而现在可以调用真正的函数。在随后对同一给定函数的调用中,它现在直接执行。
这被称为“按需链接”。
所有这些mmap活动的副产品是经典的sbrk系统调用几乎没有用处。它很快就会与一个共享库内存映射冲突。
因此,现代的libc不使用它。当malloc需要更多内存时,它会从匿名mmap请求更多内存,并跟踪哪些分配属于哪个mmap映射(即如果释放了足够的内存来组成整个映射,则free可以执行munmap)。因此,总之,我们同时进行“写时复制”、“按需加载”和“按需链接”。这看起来很复杂,但可以使
fork
和
exec
快速、顺畅地执行。这增加了一些复杂性,但额外的开销仅在需要时(“按需”)进行。
因此,程序启动时不会出现大的延迟/抖动,而是在程序生命周期中根据需要分散处理开销活动。