哪些段受写时复制影响?

Which segments are affected by a copy-on-write?

我对写时复制的理解是"Everyone has a single, shared copy of the same data until it's written, and then a copy is made"。

  1. 相同数据的共享副本是由堆和bss段组成还是只有堆?
  2. 哪些内存段将被共享,这是否取决于 OS?

为了更好地理解,您应该从词汇表中删除术语 segment。大多数系统都在页面上工作;不是段。在 64 位 Intel 细分市场终于消失了。

你应该问,"What pages are affected in copy on write."

当一个进程写入时,这将是多个进程可写和共享的页面。

这可能发生在分叉之后。实现分叉的一种方法是创建 parent 进程地址 space 的完整副本。然而,这可能需要付出很多努力,尤其是因为大多数时候人们会在 fork 之后立即在 child 中执行 exec。

另一种方法是让 parent 和 children 共享相同的内存。这适用于 read-only 内存,但如果多个进程可以写入同一内​​存,就会出现明显的问题。

这可以通过让进程充电 read/write 内存直到进程写入它来克服。在这种情况下,该页面不再被写入进程共享,OS 分配一个新的页面框架,将其映射到地址 space,将原始数据复制到该页面,然后允许写入进程继续。

OS 可以设置它希望的任何 "copy on write" 策略,但一般来说,它们都做同样的事情(即最有意义的)。

松散地,对于类似 POSIX 的系统(linux、BSD、OSX),有四个感兴趣的区域(您称之为段): dataint x = 1; 所在)、bssint y 所在)、sbrk(这是 heap/malloc)和 stack

fork 完成后,OS 会为共享父页面的子页面设置一个新的页面映射。然后,在父和子的页面映射中,所有页面都标记为只读。

每个页面映射还有一个引用计数,指示有多少进程正在共享该页面。分叉前,refcount 为 1,分叉后为 2。

现在,当 任一 进程尝试写入 R/O 页面时,它将出现页面错误。 OS 将看到这是针对 "copy on write" 的,将为该进程创建一个私有页面,从共享中复制数据,将页面标记为该进程的 writable 并恢复它。

它也会降低引用计数。如果引用计数现在[再次]为 1,OS 会将 other 进程中的页面标记为 writable 和非共享 [这消除了第二个另一个进程中的页面错误——加速只是因为此时 OS 知道另一个进程应该可以自由地再次写入不受干扰]。此加速可能 OS 相关。

实际上,bss 部分甚至得到 更多 特殊待遇。在它的初始页面映射中,所有页面都映射到包含全零的 单个 页面(也称为 "zero page")。映射标记为 R/O。因此,bss 区域的大小可能为千兆字节,并且只会占用一个物理页面。 all bss 进程的 all 部分共享这个单一的、特殊的零页,无论它们是否有 任何彼此之间的关系。

因此,一个进程可以从该区域中的任何页面读取并获得它所期望的:零。只有当进程尝试写入这样的页面时,相同的写时复制机制才会启动,进程获得私有页面,调整映射,然后恢复进程。现在可以随意写入页面。

再一次,OS 可以选择其策略。例如,在 fork 之后,共享 most 堆栈页面可能更有效,但从 "current" 页面的私有副本开始,由值决定堆栈指针寄存器。

exec 系统调用 [对子项] 完成时,内核必须撤消 fork [降低引用计数] 期间完成的大部分映射,释放子项的映射等并恢复父级的原始页面保护(即它将不再共享其页面,除非它再做一次 fork


虽然不是您的原始问题的一部分,但您可能会感兴趣的相关活动,例如 按需加载 [页数] 和 按需加载在 exec 系统调用后链接 [符号]。

当进程执行 exec 时,内核会进行上述清理,并读取 executable 文件的一小部分以确定其对象格式。主要格式是 ELF,但可以使用内核理解的任何格式(例如 OSX 可以使用 ELF [IIRC],但它也有其他格式。

对于 ELF,executable 有一个特殊的部分,它提供了一个完整的 FS 路径到所谓的 "ELF interpreter",它是一个共享库,通常是 /lib64/ld.linux.so .

内核使用 mmap 的内部形式,将其映射到应用程序 space,并为 executable 文件本身设置映射。大多数东西被标记为 R/O 页 "not present".

在我们继续之前,我们需要讨论页面的 "backing store"。也就是说,如果发生页面错误,我们需要从磁盘加载页面,它来自哪里。对于 heap/malloc,这通常是交换磁盘 [aka 分页磁盘]。

在linux下,一般是安装系统时添加的"linux swap"类型的分区。当写入必须刷新到磁盘以释放一些物理内存的页面时,它会被写入那里。请注意,第一节中的页面共享算法仍然适用。

无论如何,当 executable 首先 映射 到内存中时,它的后备存储是文件系统中的 executable 文件。

因此,内核将应用程序的程序计数器设置为指向 ELF 解释器的起始位置,并将控制权转移给它。

ELF 解释器开始工作。每次它试图执行自己的一部分 [a "code" page] 时 mappednot loaded,就会发生页面错误并从后备存储(例如 ELF 解释器的文件)加载该页面并将映射更改为 R/O 但 存在 .

这发生在 ELF 解释器、共享库和 executable 本身。

ELF 解释器现在将使用 mmaplibc 映射到应用程序 space [同样,取决于需求加载]。如果 ELF 解释器必须修改代码页以重新定位符号[或尝试写入任何以该文件作为后备存储的文件,如 data 页],则会发生保护错误,内核会更改后备存储将磁盘 文件 中的页面存储到交换磁盘上的页面,调整保护,然后恢复应用程序。

内核还必须处理 ELF 解释器(例如)试图写入 [say] 一个从未加载过的 data 页面的情况(即它必须先加载然后再加载)将后备存储更改为交换磁盘)

ELF 解释器然后使用 libc 的部分来帮助它完成初始链接活动。它重新定位了允许其完成工作所需的最低限度。

但是,ELF 解释器不会 重新定位大多数其他共享库的所有符号附近的任何位置。它查看executable并再次使用mmap,为execu的共享库创建一个映射 table 需要(即当你做 ldd executable 时所看到的)。

这些到共享库和 executable 的映射,可以被认为是 "segments".

有一个符号跳转table指向每个共享库中的解释器。但是,ELF 解释器只做了很小的改动。

[注意:这是一个松散的解释]只有当应用程序试图调用给定函数的跳转入口时[这就是GOT等。阿尔。 stuff you may have seen] 是否发生搬迁。跳转入口将控制转移到解释器,解释器找到符号的 真实 地址并调整 GOT,使其现在直接指向符号的最终地址并重新调用,这现在将调用真正的功能。在随后调用相同的给定函数时,它现在直接进行。

这叫做"on demand linking"。

所有这些 mmap activity 的副产品是经典的 sbrk 系统调用几乎没有用处。它很快就会与共享库内存映射之一发生冲突。

因此,现代 libc 不使用它。当 malloc 需要来自 OS 的更多内存时,它会从匿名 mmap 请求更多内存并跟踪哪些分配属于哪个 mmap 映射。 (即,如果释放了足够的内存来构成整个映射,free 可以执行 munmap)。

所以,总而言之,我们同时进行了 "copy on write"、"on demand loading" 和 "on demand linking"。看起来很复杂,但是 forkexec 进行得又快又顺利。这增加了一些复杂性,但仅在需要时才进行额外开销 ("on demand")。

因此,开销 activity 会根据需要分散到程序的整个生命周期中,而不是在程序开始启动时出现大量 lurch/delay。