在malloc中,为什么要使用brk?为什么不直接使用mmap?

37
典型的malloc实现使用brk/sbrk作为从操作系统申请内存的主要手段。然而,它们还使用mmap来获取大块内存分配。使用brk而不是mmap是否真的有好处,还是只是传统惯例?全部使用mmap是否同样有效呢?
(注意:我在这里交替使用sbrkbrk,因为它们是相同的Linux系统调用接口brk。)

作为参考,这里有几篇描述glibc malloc的文档:

GNU C Library参考手册: GNU分配器
https://www.gnu.org/software/libc/manual/html_node/The-GNU-Allocator.html

glibc维基: Malloc概述
https://sourceware.org/glibc/wiki/MallocInternals

这些文档描述了sbrk用于声明小型分配的主要竞技场,mmap用于声明次要竞技场,并且mmap也用于为大型对象(“远大于一页”)申请空间。

使用应用程序堆(使用sbrk声明)和mmap引入了一些额外的复杂性,这可能是不必要的:

已分配的竞技场 - 主竞技场使用应用程序的堆。其他竞技场使用mmap的堆。要将块映射到堆,需要知道哪种情况适用。如果该位为0,则该块来自主竞技场和主堆。如果该位为1,则该块来自mmap的内存,并且可以从块的地址计算出堆的位置。

[Glibc malloc是从ptmalloc派生而来,而ptmalloc则是从1987年开始的dlmalloc派生而来的。]
jemalloc手册(http://jemalloc.net/jemalloc.3.html)中提到:
传统上,分配器使用sbrk(2)获取内存,这种方式存在多种缺点,包括竞争条件、增加的碎片和对最大可用内存的人为限制。如果操作系统支持sbrk(2),则该分配器将按照mmap(2)和sbrk(2)的顺序使用两者;否则,只使用mmap(2)
所以,即使他们已经费尽心思编写代码使其可以不使用sbrk,但他们仍然使用它,甚至在此处明确表示sbrk是次优的。
[编写jemalloc始于2005年。]
更新:进一步思考,“按照优先顺序”这句话让我有了一个探究的方向。为什么要按照这个顺序?他们只是将sbrk作为备选项,以防mmap不被支持(或缺少必要的功能),还是可能出现某种状态使得进程可以使用sbrk但无法使用mmap?我将查看他们的代码,看看能否弄清楚它在做什么。
我之所以询问是因为我正在C语言中实现垃圾回收系统,到目前为止我没有发现使用mmap之外的任何理由。不过我想知道是否有我忽略的东西。
(在我的情况下,我有另一个避免使用brk的原因,那就是我可能需要在某个时候使用malloc。)

你的意思是使用单个 mmap 来为成千上万个较小的分配分配一个池,对吧?而不是像为大型分配那样每个分配都使用一个 mmap - that other guy
有使用 mmap()malloc() 版本。 - Barmar
@thatotherguy:我在问题中添加了一些关于我阅读的分配器实际执行的信息。 - Nate C-K
1
请注意,glibc malloc确实对大型分配使用mmapjemalloc关于brk的负面评论最适用于将其用于所有内容,例如古老的Unix历史malloc实现。 (特别是碎片化:如果短期大量分配后存在长期小额分配,则无法将内存返还给内核。) - Peter Cordes
5个回答

13
调用mmap(2)一次分配一块内存并不适用于通用内存分配器,因为mmap(2)的分配粒度(可以一次分配的最小单元)是PAGESIZE(通常为4096字节),而且需要一个缓慢而复杂的系统调用。对于小型分配和低碎片率的分配器快速路径应该不需要系统调用。
因此,无论使用哪种策略,您仍然需要支持多个glibc所谓的内存区域,并且GNU手册提到:“存在多个区域允许多个线程同时在单独的区域中分配内存,从而提高性能。”
jemalloc手册(http://jemalloc.net/jemalloc.3.html)中提到:
传统上,分配器使用sbrk(2)获取内存,这种方法存在多种问题,包括竞争条件、增加的内存碎片和对最大可用内存的人为限制。如果操作系统支持sbrk(2),则此分配器将按照首选项的顺序同时使用mmap(2)和sbrk(2);否则仅使用mmap(2)。
就我了解的现代使用方式来看,我不认为这些问题适用于 sbrk(2)。竞争条件通过线程原语处理。与由mmap(2)分配的内存池一样,内存碎片可以得以解决。最大可用内存大小是无关紧要的,因为任何大内存分配都应该使用mmap(2)来减少碎片并在 free(3)后立即释放回操作系统。
使用应用程序堆(通过sbrk声明)和mmap引入了一些额外的复杂性,这可能是不必要的:
分配的Arena-主要的Arena使用应用程序的堆。其他Arena使用mmap'd堆。要将一个块映射到堆,您需要知道适用哪种情况。如果此位为0,则该块来自主要Arena和主堆。如果此位为1,则该块来自mmap'd内存,堆的位置可以从块的地址计算出来。
那么现在的问题是,如果我们已经使用mmap(2),为什么不在进程启动时使用mmap(2)分配一个arena,而不是使用sbrk(2)?特别是如果,正如引用的那样,需要跟踪使用了哪种分配类型。有几个原因:
  1. mmap(2) 可能不被支持。
  2. sbrk(2) 已经为进程初始化,而 mmap(2) 会引入额外的要求。
  3. glibc wiki 所说,"如果请求足够大,mmap() 将直接从操作系统请求内存[...],并且同时存在的这些映射可能有限制。"
  4. 使用 mmap(2) 分配的内存映射不易扩展。Linux 有 mremap(2),但其使用限制了分配器的内核支持。使用 PROT_NONE 访问预映射许多页面会使用太多虚拟内存。使用 MMAP_FIXED 会在没有警告的情况下取消任何可能存在的映射。 sbrk(2) 没有这些问题,并且明确设计用于安全地扩展其内存。

1
如果您在大型分配中使用brk,碎片化将是一个问题:您只能按LIFO顺序将内存返回给内核,因此短暂的大型分配之后的长期小型分配可能会阻止我们归还那个大块内存。当然,我们可以将其放在空闲列表中,并将其分成更小的块以供将来分配,但如果它是一千兆字节的脏私有内存,那就不是您想要的。或者如果它很大,但不够大以满足下一个大型分配,也不好。这些基本上是碎片化问题。(glibc通过mmap避免这些问题) - Peter Cordes
1
在分配了大块内存后,如果有单独的malloc分配,则无法扩展断点区域中的内存分配。我们可以很好地扩展断点,但这并不能满足realloc库调用。因此,为每个大型分配使用专用的mmap使得realloc变得更大而不会影响其他分配的可能性更大。(避免复制对于较大的分配来说更加重要)。因此,这是glibc具有启发式从arena切换到直接mmap的另一个很好的理由。 - Peter Cordes

11
系统调用brk()的优点在于只需要跟踪单个数据项来跟踪内存使用情况,而这个数据项也直接与堆的总大小相关联。
自1975年的Unix V6以来,这种形式一直保持不变。需要注意的是,V6支持65535字节的用户地址空间,因此并没有考虑管理超过64K的内存,更不用说TB级别的内存了。
使用mmap似乎是合理的选择,但我开始想知道如何使用改动或添加垃圾回收功能的mmap,而不需要重新编写分配算法。
这样做能很好地与realloc()fork()等函数协同工作吗?

问题在于,自那时起,现代分配器已经彻底重写了它们的分配算法。其中一个jemalloc甚至直到2005年才被编写出来。现代分配器确实广泛使用mmap,因此它们似乎已经找到了如何使其正常工作的方法。然而,我一直在研究的这些分配器将其与对sbrk的调用混合使用,正如我在问题的一些更新中所描述的那样。 - Nate C-K

10

mmap()在Unix早期版本中不存在。当时唯一增加进程数据段大小的方法是使用brk()。第一个具有mmap()的Unix版本是80年代中期的SunOS,第一个开源版本是1990年的BSD-Reno。

而为了使malloc()可用,您不希望需要真正的文件来备份内存。 1988年,SunOS为此实现了/dev/zero,而在1990年代,HP-UX实现了MAP_ANONYMOUS标志。

现在有许多版本的mmap()提供了各种分配堆的方法。


4
这解释了为什么过去没有使用mmap,但现代版本确实使用它,所以我不确定历史是否解释了为什么它们不专门使用它。也许最初是只编写了使用brk,然后稍后添加了mmap调用作为改进?但是jemalloc只追溯到2005年,并且同时使用sbrkmmap - Nate C-K

8
明显的优点是你可以就地增加最后一块内存分配,这是使用mmap(2)(mremap(2)是Linux扩展,不可移植)所不能做到的。对于使用realloc(3)的简单(和不那么简单)程序(例如追加字符串),这相当于提高1到2个数量级的速度提升;-)

我能不能使用mmap声明大量的RAM,比如1GB,然后从底部开始填充呢?我认为在页面被访问之前,这并不会实际消耗物理内存,对吧?我还可以使用PROT_NONE标记未使用的页面,以确保它们在我打算使用它们之前不会被意外访问。 - Nate C-K
2
不要过于深思熟虑:在32位机器上声明一个大块连续的虚拟内存(例如1G)会导致严重问题。这通常只在Lisp VM、模拟器等情况下使用;让类似malloc()这样的库函数实现占用大部分可用地址空间是不可接受的。 - user10678532
是的,这很有道理。也许这就是我缺失的部分:malloc必须避免干扰其他程序,从底部扩展堆意味着其他程序知道如何避免干扰它。但是,你可以使用mmap从底部扩展堆而无需调用brk吗?虽然这样做的话可能会打扰到brk,但你可以考虑这么做。 - Nate C-K
2
谷歌建议Go语言的内存分配器完全基于mmap - that other guy
1
如果我没记错的话,FreeBSD的malloc也是这样。 - user10678532
据我所知,FreeBSD在其libc中使用jemalloc。我在问题中添加了一些关于jemalloc的信息:如果sbrk不可用,它将完全依赖mmap,但它也可以使用sbrk。 - Nate C-K

5

我不清楚 Linux 的详细情况,但在 FreeBSD 上,多年来 mmap 已经成为首选,在 FreeBSD 的 libc 中,jemalloc 已经完全禁用了 sbrk()。在较新的 aarch64 和 risc-v 端口中,内核没有实现 brk()/sbrk()。

如果我正确理解了 jemalloc 的历史,它最初是 FreeBSD libc 中的新分配器,然后被拆分并变得可移植。现在,FreeBSD 是 jemalloc 的下游用户。它很可能对 mmap() 的偏好起源于围绕实现 mmap 接口的 FreeBSD VM 系统的特性。

值得注意的是,在 SUS 和 POSIX 中,brk/sbrk 已被弃用,目前应被视为不可移植。如果您正在开发新的分配器,您可能不想依赖它们。


符号常量'MAN_ANONYMOUS'在POSIX中没有指定用于mmap()...祝你好运,如果不使用匿名映射,尝试使用mmap()实现malloc() :/ - MrIo

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接