在 Linux 和 Windows 上分配内存
如何在 Linux 和 Windows 上分配内存
本文讨论的是一些非常底层的概念,这些概念有时会“泄露”(或者说“冒泡”?)到令人惊讶的高层抽象。它并非关于 malloc()
和 free()
,或者只是沾边而已。
首先,快速回顾一下两个系统上内存的工作方式。
Linux 和 Windows 上的用户空间进程通过虚拟地址访问物理内存。这些地址在硬件(MMU)的帮助下映射到实际的物理内存地址,硬件在每次访问时执行翻译。映射不是连续设置的,而是以 4096 字节的块(称为页面)为基本单位。 (存在具有不同页面大小的硬件,x86 上还有更大的所谓大页面,但这超出了范围)。
因此,内核会设置一个映射,这个映射在概念上看起来像下面的表格(地址仅用于说明):
虚 | 物理 | |
... | ||
0x400000-0x400fff | ⟶ | 0x09000000-0x09000fff |
0x401000-0x401fff | ⟶ | 0x09001000-0x09001fff |
0x401000-0x401fff | ⟶ | 0x0A500000-0x0A500fff |
... |
左边是程序中存储在指针中的常规地址(例如从 malloc()
或 new
获得的地址),而右边是只有内核才知道的真实物理地址。右侧是易失的,可以在程序生命周期内更改,左侧由你控制。
当你访问 0x4001180
这样的地址时,硬件会将该地址的最低 12 位设置为 0(得到地址 0x4001000
),然后查找此表,并从物理内存中的实际地址 + 0x180
(即 0x09001180
)读取值。
这个(每个进程不同!)映射存储在内核内存中,由于它可能占用大量内存(要映射 1GB 需要设置 256k 页的映射),因此映射实际上被分成多个表,这些表通过地址的不同位进行索引(有关详细信息,请参阅 此处)。如前所述,页表可由执行实际翻译的硬件访问,还有一些我将省略的实现细节(硬件不会在每次内存访问时读取它,它有一个小的缓存 - 称为 TLB - 存储最近 64-512 次查找,如果频繁访问相同的虚拟地址范围,这将大大加快速度。然而,过快地跳到不同的地址会导致昂贵的实际表读取)。
右侧的表不必是连续的。左侧的表也不是,它可以有空隙,如果你尝试访问表中未找到的地址,你将得到一个“段错误”。内核也可能为虚拟地址设置一个映射,但尚未为其分配物理页面(或者决定将其移至分页文件),在这种情况下,它会适当标记表中的条目,并在发生页面错误(即实际访问)时执行此操作。
每个页面可以具有不同的属性(由硬件强制执行),其中基本属性是控制该页面内存的可读、可写或可执行能力的标志。理论上,内核可以将相同的物理地址两次映射到不同的标志,但这在 Linux/Windows 中并未向用户空间程序公开。
到目前为止,这两个系统是相似的。但是,每个操作系统向用户程序公开不同的 API 来管理映射。
Windows
Windows 拥有 VirtualAlloc/Free 系列函数(至少在 Win32 API 中),它们在概念上类似于 malloc()
/ free()
。值得注意的是,Windows 上的每个页面可以处于 三种状态之一:空闲 - 不属于任何映射,保留 - 准备映射,以及 已提交 - 映射到物理 RAM 中的页面或磁盘上的分页文件中的位置。因此,在 Windows 上分配有用内存的过程可以分为两个步骤:
- 保留一个不包含任何内容的虚拟范围
如果你调用
VirtualAlloc(...,16384, MEM_RESERVE, ...)
来保留 16KB 的范围,你将得到一个如下表:虚 物理 0x400000-0x400fff ⟶ ??? 0x401000-0x401fff ⟶ ??? 0x402000-0x402fff ⟶ ??? 0x403000-0x403fff ⟶ ??? 它有什么用?嗯,你可能想确保你在真正需要之前有一个连续的(从你的程序的角度来看)地址范围。或者反之,你曾经使用过内存,但你决定“取消提交”(decommit)它,同时保持地址空间保留,以确保任何残留的指针如果被访问将导致段错误。
- “提交”一个已保留范围中的页面(或页面)
假设你调用
VirtualAlloc(0x401000, 4096, MEM_COMMIT,...)
来提交上述范围中的一个页面 - 结果将如下所示:虚 物理 0x400000-0x400fff ⟶ ??? 0x401000-0x401fff ⟶ 0x8800000-0x8800fff 0x402000-0x402fff ⟶ ??? 0x403000-0x403fff ⟶ ??? 现在你可以访问地址
0x401000-0x401fff
处的内存(但不能访问0x402000
或0x400fff
)。实际上,上面的表格有点骗人——我们无法知道虚拟地址在
VirtualAlloc()
返回后是否会被物理 RAM 支持,以及物理地址会是什么。提交的页面可能会 - 而且很可能 - 在我们第一次读写它时被“驻留”(即分配实际的物理 RAM)。关键在于,访问时我们不再会遇到段错误。
完成这两个步骤后,Windows 就为你提供了可以自由使用的内存。事实上,你可以将这两个步骤合并为一个,并在第一次调用时请求 MEM_COMMIT
,很多软件都会这样做,但请记住 Windows 允许你只完成一半。
每个映射的最小大小当然是单个页面(4KB),因为硬件就是这样工作的。但是,Windows 只能设置一个从 64KB 可整除的地址开始的映射(理论上,这个数字不是固定的,有一个系统调用可以用来获取这个粒度,但实际上,它永远不会改变,因为它会破坏太多软件)。是的,这意味着如果你调用 VirtualAlloc()
分配 4KB,然后再次调用分配下一个 4KB,返回的地址将相差至少 64 KB。这将导致一个非常“空洞”的虚拟地址空间,如下所示:
0x400000-0x400fff | ⟶ | ??? |
0x410000-0x410fff | ⟶ | ??? |
要“取消提交”或完全删除映射,你可以调用 VirtualFree()
,传入从 VirtualAlloc()
获得的相同指针。Windows 在底层存储自己的簿记信息,允许函数查找映射大小的详细信息。
由于保留比实际“已提交”的范围存储更有效,因此你可以保留的虚拟地址空间限制是 *巨大的* - 目前是 128TB。 “已提交”内存的限制取决于实际物理 RAM 和配置的交换空间量,因此低得多。
总而言之,这些是 Windows API 的关键方面:
- 有三种页面状态和两个独立的映射建立步骤 - 仅保留地址范围,以及实际用 RAM 或交换空间支持它。
- 每个映射都是一个单独的“分配”,其大小在内部跟踪,
VirtualFree()
的调用次数必须与VirtualAlloc()
的调用次数匹配。就像malloc()
/free()
一样,你必须传入分配时获得的相同指针。 - 在 Windows 上请求少于 64KB 的虚拟地址空间是不理想的。可能是由于历史原因或簿记数据引入了开销,Windows 以 64KB 的增量(或减量,由
VirtualAlloc()
的一个标志控制)分配虚拟地址空间。这会导致地址空间浪费(碎片化)。 - Windows 容忍“空洞”的地址空间(但
VirtualAlloc()
的性能会随着分配数量的增加而下降)。地址空间保留的限制是巨大的(128TB)。
请记住这些结论,因为它们在 Linux 上都将无效,甚至恰恰相反。
Linux
Linux 使用来自经典的 BSD Unix 的 mmap()
/munmap()
API,该 API 已在 POSIX 中编码(也存在于 Mac、*BSD 和一些其他平台上)。这是一个强大的 API,表面上允许进行相当任意的映射操作。然而,Linux 添加了自己的调整,使得该 API 的用途不如它可能的那样。
当你调用 mmap()
请求相同的 16KB 虚拟地址空间时,你将获得一个已经可用的映射(同样,物理地址的随机性仅用于说明):
虚 | 物理 | |
0x7ff01000-0x7ff01fff | ⟶ | 0x090000-0x090fff |
0x7ff02000-0x7ff02fff | ⟶ | 0x078000-0x078fff |
0x7ff03000-0x7ff03fff | ⟶ | 0x091000-0x091fff |
0x7ff04000-0x7ff04fff | ⟶ | 0x056000-0x056fff |
在 Linux 上,你没有单独的“提交”内存的步骤,返回的 16KB 已经提交,并且可以合法地读写(排除保护标志),就像你在 VirtualAlloc()
中传入 MEM_COMMIT
标志一样。与 Windows 一样,上表有点骗人 - 你不控制驻留,所以我们无法知道页面是否实际存在于物理 RAM 中,或者只是标记为在首次使用时从分页文件中检索或初始化(为零)- 但是你可以通过 madvise(MADV_WILLNEED)
向内核提示这一点。
另一个惊喜是 munmap()
的工作方式。就像在 Windows 上一样,你可以一次性 munmap()
掉所有 16 KB,但你也可以不这样做。请注意,munmap 接受 len 参数,实际上可以传入任意范围,而不仅仅是你用 mmap()
建立的范围。你可以通过逐页 munmap()
来释放内存,或者你可以将其中的一部分变成一个“洞”,例如,通过使用 munmap(0x7ff02000, 8192)
删除中间的 8KB,从而得到一个如下的映射:
0x7ff01000-0x7ff01fff | ⟶ | 0x090000-0x090fff |
0x7ff04000-0x7ff04fff | ⟶ | 0x056000-0x056fff |
现在地址 0x7ff01000
- 0x7ff02fff
不再合法访问,任何试图读写该范围的行为都会导致 SIGSEGV
。
另外,请注意 mmap()
返回的地址不是 64KB 对齐的 - 它可以是任意的(但当然总是按页面,即 4KB 对齐)。事实上,由于 Linux 添加的“调整”,mmap()
返回的地址很可能紧邻某些现有虚拟地址(例如,下次调用 mmap()
时,你可能会得到 0x7ff05000
)。
这一切都可以通过 Linux 如何表示映射来解释。内核称它们为 VMA,即“虚拟内存区域”。每个 VMA/映射将具有相同属性的页面范围连续映射,通常是作为单个 mmap()
调用产生的结果。与 Windows 分配不同,VMA 可以被内核拆分,当范围内的页面被取消映射或其属性因 mprotect()
调用(以及其他原因)而改变时。
所有 VMA 合起来描述了你程序的地址空间的样子,可以被认为是硬件无关的“影子”页表。理论上,由于内核已经维护了硬件的页表,它不需要另一个。然而,它们被引入 Linux 是为了解决两个主要问题:容纳不断增长的页面属性集(其中一些只对内核有意义,而不是对 MMU)以及支持完全不使用页表将虚拟地址映射到实际地址的体系结构。正如 Linus 在 17 年前的一封电子邮件 中所述:
We used to have these "this is a COW page" and "this is shared writable"
bits in the page table etc - there are two sw bits on x86, and I think we
used them both.
These days, the vma's just have too much information, and the page tables
can't be counted on to have enough bits.
[...]
This is most clearly seen on CPU's that don't have traditional page table
trees, but use software fill TLB's, hashes, or other things in hardware.
你可以在 /proc/$PID/maps 中查找进程的 VMA(你可以在 /proc/$PID/smaps 中查找每个映射的更多详细信息)。
所以,每次你进行 mmap()
操作时,你都在创建一个 VMA 或扩大一个现有的 VMA,因为内核足够智能,可以将新的映射放置在具有相同属性的其他映射旁边,从而将它们合并。然而,由于 VMA 只表示一个连续的虚拟内存区域,这意味着不仅 mmap()
可以创建它们,munmap()
也可以。在上面的例子中(我们取消了 16KB 分配中间部分的映射),我们实际上创建了两个新的 4KB VMA,因为它们不再相邻(一个从 0x7ff01000 开始到 0x7ff01fff 结束,另一个从 0x7ff04000 开始到 0x7ff04fff 结束)。
到目前为止一切都很好。然而,请记住 VMA 结构包含“过多的信息”,它比硬件页表中的页面条目更大。程序员的直觉立刻表明,VMA 的存在本身就限制了映射的精细程度——事实确实如此。一个退化的情况——将每个虚拟页面放入自己的单独 VMA——将消耗太多资源,因此 Linux 限制了映射的数量。限制是 vm.max_map_count
,默认情况下相当低——只允许 65530 个映射。
这还引入了另一个限制,即你可以分配的最大虚拟内存量(通常,类似于 Windows 上的提交限制,取决于物理 RAM 和交换空间,由 vm.overcommit_memory 设置控制)。如果你因为某种原因,确实达到了每个 VMA 一个页面的情况,你将收到一个 ENOMEM
错误,分配了 65530 * 4096 = ~255MB,无论你的物理 RAM 和交换空间有多大。
这种情况——每个 VMA 一个页面——在实践中并不罕见,因为 Electric Fence 类型的内存调试技术可以将“读/写”页面与“只读”页面交错,迫使内核拆分映射。
vm.max_map_count
限制也是你可能获得 ENOMEM
的原因,即使在释放内存时(如果你试图释放现有映射中的一个“洞”,并且内核无法将其拆分成两个),这并不那么直观。
为什么 VMA 的数量有限制?如果你有 root 权限,你可以通过 sudo sysctl -w vm.max_map_count=1000000
来提高 vm.max_map_count
,有什么大不了的?
然而,VMA 的数量受到限制,不仅是因为它们的尺寸,还因为它们需要在每次页面错误期间进行遍历,而页面错误发生的频率非常高。最重要的是,即使 *你* 可以提高限制,你也绝对不能指望你的用户能够或愿意提高系统范围的限制仅仅为了运行你的程序。
这意味着在实践中,你需要将映射数量保持在合理范围内,避免使用 munmap()
取消映射内存,使用 mprotect()
更改页面保护,或者使用 madvise()
更改映射部分页面的属性(这可能会将其拆分成两个甚至三个 VMA)。
不幸的是,没有一个传统的 Linux 监控工具会关注进程的映射数量,因为上述调用会导致地址空间碎片化,映射数量会不可预测地变化。内核甚至不会提供这个信息,你需要自己计算,通过汇总 /proc/$PID/maps。我建议使用 watch -n1 "wc -l /proc/\`pidof ProgName\`/maps"
来持续监控它,在你程序运行时。
结论
现在是时候进行一次最重要的特征的并排比较了:
Windows | Linux |
虚拟内存范围可以仅仅被保留,或者实际被映射(“已提交”,即由 RAM 或交换空间支持)。映射可以被“取消提交”,将其转换回仅仅是保留。 | 创建映射既保留了地址空间,又允许访问其内容,即映射始终是“已提交”的。 |
每个映射都是由操作系统进行簿记的单独分配。VirtualFree() 的调用次数必须与 VirtualAlloc() 的调用次数匹配,并且必须传递相同的指针 - 没有“部分”释放。 | 映射可以根据需要通过 mmap() 和 munmap() 进行合并和拆分。munmap() 的调用次数可以大于或小于 mmap() 的调用次数,只要所有映射都被移除。程序负责记录映射的大小。 |
映射总是从 64KB 对齐的边界开始。在 Windows 上请求少于 64KB 的虚拟地址空间是不理想的,并且会导致地址空间的浪费(碎片化)。 | 映射可以从任意页面对齐的地址开始。你可以逐个页面映射,而不会对地址空间产生不良影响(内核会尝试将新映射“附加”到现有映射)。 |
对映射数量没有限制(VirtualAlloc() 的性能会下降,但它不会拒绝分配)。 | 对映射数量有限制(非常低,65530)。你很难知道映射的数量,因为它们可以在你部分取消映射现有映射时创建,或者在更改 mprotect() 或 madvise() 的属性时创建。 |
你可以保留高达 128TB 的虚拟地址空间,但是提交限制取决于你的物理 RAM 和交换大小。 | 由于所有映射始终是已提交的,因此虚拟地址空间的限制要低得多,并且(默认情况下)取决于 RAM 和交换大小。 然而,对映射的限制 *也限制* 了地址空间,使其可能小得多——最坏的情况(每个映射只包含一个页面)允许分配 255MB(65530 * 4k)。 |
其中,最重要的区别可能是最后两个。映射数量的低限制以及它们对地址空间不可预测的影响肯定会削弱本已强大的 mmap()
API。更糟糕的是,这个因素似乎是 Linux 内核特有的(FreeBSD 默认拥有更大的映射限制,Mac 似乎没有暴露这一点),而对于来自其他平台背景的开发者来说,不太会考虑 64 位操作系统上的虚拟地址空间碎片化,因此为 VMAS 不足而进行的修复可能 很难向现有软件添加。
其他不同之处,例如 Linux 上缺少单独的“提交”步骤,在实践中可以通过 madvise()
调用(Linux 特定,因此超出 POSIX 范围)的标志和 mprotect()
权限来近似。然而,无法分配非常大的(例如 1TB)映射,在默认设置下是无法克服的,这阻止了 Linux 应用程序应用某些调试和优化技术。
从 Linux 转到 Windows,mmap()
/munmap()
的灵活性很难复制(但也很少需要)。然而,值得注意的是,这种限制可能不存在于 Win32 API 以下的级别,Windows 内核本身可能具备 mmap()
/munmap()
的功能(毕竟,它在其生命早期就获得了 POSIX 认证)。我还没有深入研究过,但像 NtMapViewOfSection
这样的原生 API 似乎非常强大。