我对“ 写时复制 ”的理解是:“每个人在写入之前都有一个相同数据的共享副本,然后进行复制”。
操作系统可以设置它所希望的任何“写入复制”策略,但通常它们都是做同样的事情(即最有意义的)。
松散地说,对于类似POSIX的系统(linux,BSD,OSX),有四个区域(你正在调用段)感兴趣: data
( int x = 1;
去), bss
( int y
去), sbrk
(这是堆/ malloc)和stack
当一个fork
完成后,操作系统为共享父代的所有页面的孩子建立一个新的页面映射。 然后,在父母和孩子的页面地图中,所有的页面都被标记为只读。
每个页面映射还有一个引用计数,指示有多少进程共享该页面。 在fork之前,refcount是1,之后是2。
现在,当任一进程尝试写入R / O页面时,将会出现页面错误。 操作系统会看到这是“写入时复制”,将为该进程创建一个专用页面,从共享中复制数据,将该页面标记为该进程可写,然后恢复。
它也会降低人数。 如果refcount现在是[再次] 1,操作系统会将另一个进程中的页面标记为可写和非共享[这消除了另一个进程中的第二个页面错误 – 仅仅因为此时操作系统知道另一个过程应该可以自由地再乱写]。 这个加速可能取决于操作系统。
实际上, bss
部分得到了更加特别的处理。 在它的初始页面映射中,所有页面都被映射到一个包含所有零(也就是“零页”)的页面。 映射标记为R / O。 所以, bss
区域的大小可能是千兆字节,只能占用一个物理页面。 这个单独的,特殊的,零页面在所有进程的所有 bss
部分之间共享,而不管它们是否相互之间有任何关系。
因此,一个进程可以从该地区的任何页面读取,并得到它所期望的:零。 只有当进程试图写入这样一个页面时,同样的写入机制拷贝,进程得到一个私人页面,映射被调整,并且进程被恢复。 它现在可以自由地写入页面,因为它认为合适。
操作系统再次可以选择其策略。 例如,在fork之后,共享大多数堆栈页面可能更有效率,但是从栈指针寄存器的值确定的“当前”页面的私有副本开始。
当一个exec
系统调用完成后,内核必须撤消fork
[bumping refcounts]期间完成的大部分映射,释放子映射等,并恢复父页的原始页面保护(即不再分享它的网页,除非它做了另一个fork
)
虽然不是你原来的问题的一部分,但是有一些相关的活动可能会引起人们的兴趣,比如按需加载 [页面]和按需连接一个exec
系统调用。
当进程执行一个exec
,内核会执行上面的清理,并读取一小部分可执行文件来确定其对象格式。 主要格式是ELF,但任何内核可以使用的格式都可以使用(例如,OSX可以使用ELF [IIRC],但也有其他格式]。
对于ELF,可执行文件有一个特殊的部分,它提供了一个完整的FS路径,称为“ELF解释器”,它是一个共享库,通常是/lib64/ld.linux.so
。
内核使用内部形式的mmap
将映射到应用程序空间,并为可执行文件本身设置映射。 大多数事情被标记为R / O页面和 “不存在”。
在我们进一步讨论之前,我们需要谈论一个页面的“支持存储”。 也就是说,如果发生页面错误,我们需要从磁盘加载页面,来自哪里。 对于堆/ malloc,这通常是交换磁盘[aka分页磁盘]。
在Linux下,通常是安装系统时添加的“linux swap”类型的分区。 当一个页面被写入,必须刷新到磁盘释放一些物理内存,它写在那里。 请注意,第一部分中的页面共享算法仍然适用。
无论如何,当一个可执行文件首次映射到内存中时,它的后备存储就是文件系统中的可执行文件。
因此,内核设置应用程序的计数器指向ELF解释器的起始位置,并将控制权交给它。
ELF口译员开展业务。 每次它试图执行映射但未加载的本身的一部分(“代码”页面)时,就会发生页面错误,并从后台存储(例如ELF解释器的文件)中进行页面加载,并将映射更改为R / O但现在 。
这发生在ELF解释器,共享库和可执行文件本身。
ELF解释器现在将使用mmap
将libc
映射到应用程序空间[同样,受到需求加载的影响]。 如果ELF解释器必须修改代码页以重新定位符号[或者尝试写入任何具有文件作为后备存储的文件,如data
页面],则会发生保护错误,内核会更改页面的后备存储从磁盘文件到交换磁盘上的一个页面,调整保护并恢复应用程序。
内核还必须处理ELF解释器(例如)试图写入一个尚未加载的data
页的情况(即,它必须先加载它,然后将后备存储区更改为交换磁盘)
ELF解释器然后使用libc
来帮助它完成初始链接活动。 它重新安置了必要的最低限度,以使其能够完成工作。
但是,ELF解释器不会在大多数其他共享库的所有符号附近的任何地方重新定位。 它将查看可执行文件,并再次使用mmap
为可执行文件需要的共享库(即执行ldd executable
时看到的内容)创建一个映射 。
这些映射到共享库和可执行文件,可以被认为是“段”。
有一个符号跳转表,指向每个共享库中的解释器。 但是,ELF解释器做了最小的改变。
[ 注意:这是一个松散的解释]只有当应用程序试图调用给定函数的跳转条目[这是GOT et。 人。 你可能见过的东西]是否会发生重新定位? 跳转条目将控制权传递给解释器,解释器定位符号的实际地址,并调整GOT使其现在直接指向符号的最终地址,并重新调用现在称为实际函数的调用。 在随后的调用同一个给定的函数,现在直接。
这被称为“按需连接”。
所有这些mmap
活动的副产品是经典的sbrk
系统调用是没有用处的。 它很快就会和其中一个共享库内存映射发生冲突。
所以,现代的libc
不使用它。 当malloc
从操作系统需要更多的内存时,它会从匿名mmap
请求更多内存,并跟踪哪些分配属于哪个mmap
映射。 (即如果足够的内存被释放以构成整个映射,则free
可以做一个munmap
)。
所以,总结一下,我们有“抄写”,“按需加载”和“按需连接”都是同时进行的。 看起来很复杂,但是让fork
和exec
快速,顺利地进行。 这增加了一些复杂性,但是额外的开销仅在需要时(“按需”)完成。
因此,在程序开始启动时,并不是一开始就拖延,而是根据需要在程序的整个生命周期内分摊开销。
为了更好地理解,你应该从你的词汇中去除术语段 。 大多数系统在页面上工作; 不是细分。 在64位英特尔部分终于消失了。
你应该问:“在写入时复制哪些页面”。
当一个进程写入时,这将是多个进程可写和共享的页面。
这可以发生在叉后。 实现分叉的一种方法是创建父进程的地址空间的完整副本。 然而,这可能是一个很大的努力,尤其是因为大部分时间在叉子后面的子项目中执行一个exec。
父母和子女可以共享相同的内存。 对于只读内存来说工作正常,但如果多个进程可以写入相同的内存,则会出现明显的问题。
这可以通过让进程充电读/写内存直到进程写入内存来解决。 在这种情况下,该页面在写入过程中不再共享,操作系统分配一个新的页面框架,将其映射到地址空间,将原始数据复制到该页面,然后允许写入过程继续。