内存那些事儿(中)
文章翻译自 http://landley.net/writing/memory-faq.txt ,已有部分译文在内存那些事儿(上)。
什么是文件映射?
文件映射是文件内容在内存中的镜像。映射的管理数据有:
- 该映射对应哪个文件
- 从文件哪个位置开始映射的
- 映射页的读/写/执行权限
当文件映射通过 page fault 分配新的物理页时,页面内容是通过读取磁盘中对应位置的内容来初始化的。内核中会缓存着一些磁盘的文件页,这个缓存称为 page cache。新分配的物理页通常是和 page cache 共享的,在文件被读进内存的时候,其内容通常就会被内核缓存起来,这些缓存的页面可与进程共享,以降低系统内存的使用量。
对使用 MAP_SHARED 标志创建的文件映射进行写操作时会更新 page cache 内容,使更新后的文件内容立即对使用该文件的其他进程可见,并且最终 page cache 会被刷新到磁盘,更新磁盘上文件的内容。
对使用 MAP_PRIVATE 标志创建的文件映射进行写操作时会执行写时复制,即分配一个新的本地页面副本来存储更改。这些更改对其他进程不可见,并且不会更新到磁盘上。
请注意,这意味着对 MAP_SHARED 页面的写操作不会分配额外的物理页面(页面已经通过读取进入了 page cache,如果物理页面在其他地方需要(译注:如内存不足),数据可以刷新回磁盘),但对 MAP_PRIVATE 页面的写操作就需要分配额外的物理页面(page cache 中的副本与程序需要的本地副本会不一致,因此需要两个页面来存储它们,并且将 page cache 中的副本刷新回磁盘不会释放更改内容的本地副本)。
什么是 page cache?
page cache 是内核对文件内容的缓存。它是不属于特定进程的虚拟内存中的主要部分。更多关于 page cache 的信息见上面的 ”什么是文件映射?“和《内存那些事儿(下)》的”什么是空闲内存“部分。
什么是 CPU 缓存?
CPU 缓存是内置在 CPU 中的内存,它通常很小但速度很快,它缓存着部分主存的数据来提高数据访问速度。
L1 缓存是一种非常小的内存(通常在1k到64k之间),它直接连接到处理器中,可以在一个时钟周期内访问。L2 缓存是处理器附近的一种较大内存(可达数兆字节),可以在较少的时钟周期内访问。访问未被缓存的内存(通过内存总线)可能需要数十、数百甚至数千个时钟周期。
(请注意,CPU缓存解决的是延迟问题,而不是吞吐量问题:内存总线可以提供持续的内存流,但需要一段时间才能开始提供。)
有关详细信息,请参阅 Ulrich Drepper 的 What every programmer should know about memory, Part 2。
什么是转译后备缓冲器(TLB)?
TLB 是 MMU 的缓存。CPU L1 缓存中的所有内存都必须有关联的 TLB 条目,而使 TLB 条目失效会清除相关的缓存行。
TLB 是一个小型的固定大小的数组,其内容和最近访问的页面有关,CPU 在每次内存访问时都会检查它。它列出了当前分配给物理页面的一些虚拟地址范围。访问在 TLB 中的虚拟地址,访问会直接转发到关联的物理内存(或 CPU 缓存),而不会触发 page fault(假设页面权限允许访问);访问不在 TLB 中的虚拟地址(即”TLB未命中“),会触发页表查找操作,页表查找操作如何执行取决于处理器类型,可以由硬件或 page fault 处理程序执行。
有关详细信息,请参阅:
Translation lookaside buffer - Wikipedia
1995 年的一次采访中,Linus Torvalds 描述了 i386、PPC 和 Alpha 的 TLB:
什么是 page fault 处理程序?
page fault 处理程序是一个中断处理程序,由内存管理单元(MMU)调用来响应未能立即成功的虚拟内存访问。
当程序对一个页面尝试执行读取、写入或执行指令,但该页面对应的页表项未设置适当权限位的内存时,指令会生成一个中断。这会调用 page fault 处理程序来检查被中断进程的寄存器和页表,并确定如何处理这个 fault。
page fault 处理程序可能以三种方式响应页面错误:
-
page fault 处理程序可以通过立即将物理内存页附加到相应的页表条目上,调整页表条目,并恢复被中断的指令来解决 fault。这称为“软错误”。
-
当 page fault 处理程序无法立即解决错误时,它可能会挂起被中断的进程,并在系统解决问题时调度到另一个进程。这称为“硬错误”,当需要执行 I/O 操作来准备解决 fault 所需的物理页面时会发生。
-
如果 page fault 处理程序无法解决 fault,则向进程发送一个信号(SIGSEGV),通知其发生错误。虽然进程可以安装一个 SIGSEGV 处理程序(调试器和模拟器倾向于这样做),但未处理的 SIGSEGV 的默认行为是以 “bus error” 消息终止进程。
在中断处理程序中发生的 page fault 称为 “double fault”,通常会导致内核崩溃。double fault 处理程序调用内核的 panic() 函数打印错误消息,以帮助诊断问题。访问了不该访问的内存导致即将被杀的进程是内核本身,因此系统已经陷入了混乱,无法继续正常运行。更多信息请参阅 http://en.wikipedia.org/wiki/Double_fault
triple fault 无法在软件中处理。如果在 double fault 处理程序中发生 page fault,计算机会立即重新启动。更多信息请参阅 http://en.wikipedia.org/wiki/Triple_fault
page fault 处理程序如何分配物理内存?
Linux 内核使用延迟(按需)分配物理页面,推迟分配直到必要时,并避免分配永远不会实际使用的物理页面。
内存映射通常开始时不附加任何物理页面。它们定义了虚拟地址范围,但没有关联的物理内存。因此,malloc() 和类似函数分配了空间,但实际的内存是由之后的page fault 处理程序分配的。
没有关联物理页面的虚拟页面在其页表条目中将读取、写入和执行位禁用。这会导致对该地址的任何访问都会生成 page fault,从而会中断进程并调用 page fault 处理程序。
当 page fault 处理程序需要分配物理内存来处理页面错误时,它会将一个空闲的物理页面清零(或从一个预先清零的页面池中获取一个页面),将该内存页面附加到与错误相关联的页表条目,更新该 PTE 以允许合适的访问,并恢复导致 page fault 的指令。
注意,实现这一点需要两组页表标志,用于读取、写入、执行和共享。VM_READ、VM_WRITE、VM_EXEC 和 VM_SHARED 标志(在 linux/mm.h 中)由 MMU 用于生成 page fault,VM_MAYREAD、VM_MAYWRITE、VM_MAYEXEC 和 VM_MAYSHARE 标志由 page fault 处理程序用于确定尝试的访问是否合法,如果合法, page fault 处理程序应调整 PTE 以解决 fault 并允许进程继续执行。
fork 如何工作?
fork() 系统调用通过复制现有进程来创建一个新进程。调用 fork() 的进程会创建一个具有其页表副本的新进程。这些页表都是写时复制映射,以在父进程和子进程之间共享现有物理页面。
exec 如何工作?
exec() 系统调用在当前进程上下文中执行一个新文件。它会清空进程的当前页表、丢弃所有现有的映射,并用一个包含少量新映射的新页表替换它们。这些新映射包括 exec() 调用中传递新文件时执行的可执行权限的 mmap()、环境变量和命令行参数的少量管理空间,以及新的进程栈等。
在类 Unix 系统中启动新进程的常规方法是调用 fork(),然后立即在新进程中调用 exec()。因此,fork() 会将父进程的现有内存映射复制到新进程中,然后 exec() 立即再次丢弃它们(译注:所以有了 vfork() 系统调用)。由于这些是共享映射,fork() 会分配大量虚拟空间,但只消耗很少的新物理页面。
共享库如何工作?
只有静态链接的可执行文件才能直接执行。共享库由动态链接器(ld-linux.so.2或ld-uClibc.so.0)执行,尽管其名称如此,但它是一个静态链接的可执行文件(译注:可参考 https://stackoverflow.com/questions/37026193/how-is-ld-linux-so-itself-linked-and-loaded),类似于 shell 脚本中的 #!/bin/sh 或#!/usr/bin/perl。它是运行程序的二进制文件,并且程序的路径作为其第一个参数提供给它。
动态链接器使用 MAP_PRIVATE 标志对可执行文件及其所需的任何共享库进行 mmap() 映射。这使得它可以写入这些页面,执行动态链接修复,从而允许可执行文件的调用连接到共享库代码。(在将控制权交给链接的可执行文件之前,它调用 mprotect() 将页面设置为只读。)动态链接器遍历程序的 ELF 表中的各种调用列表,查找每个调用的适当函数指针,并将该指针写入内存映射中的调用位置。
动态链接器写入页面的行为实质上是通过破坏写时复制机制转换为匿名页面,这些新的“脏”页面分配了仅对该进程可见的物理内存。因此,将脏页的数量保持最小可以使其余的页面保持共享,从而节省内存。
共享库通常使用 -fpic 标志(位置无关代码)进行编译,这会创建一个对象表,其中包含对外部数据和函数的所有引用。代码不直接访问共享对象,而是通过这个表来间接访问。这会使代码略微变大,因为会插入额外的跳转或额外的加载,但优点是链接器修改的所有外部引用都被集中在少量页面中。
因此,虽然二进制文件稍微变大,但由于每次使用共享对象时动态链接器脏化的物理页面数量都较少,因此很多的页面都可以被共享。
通常只有共享库以这种方式编译,但一些程序(例如busybox)可能会更多地受益于增加的共享性,因为系统可能会运行多个实例,而不至于因为文件大小增加而受到影响。
请注意,静态链接的程序不需要任何修复程序,因此没有私有可执行页面。它们可执行映射的每个页面都保持共享状态。它们的启动速度也更快,因为没有动态链接器执行修复操作。因此,在某些情况下,静态链接实际上比动态链接更有效率。(虽然有些奇怪,但这是事实。)