表面上,malloc(64) 已经把请求交代完整。实际上,这个调用只说明程序需要多少字节,没有说明将由哪个线程释放它、对象会存活多久、下一次请求是 64 字节还是 64 兆字节、多少个逻辑 CPU 正在争用资源,也没有说明空闲页面需要多快归还操作系统。
缺失的上下文解释了一个常见现象:jemalloc、TCMalloc 和 mimalloc 都实现了熟悉的 C 内存分配接口,用在同一个应用中却会给出不同结果。三者都会避免为每个小对象向内核申请内存,把请求向上取整到某个大小类别,让可复用内存靠近当前执行位置,并维护元数据以加快常用路径。它们的分歧在于缓存应放在哪里,内存如何在线程或 CPU 之间转移,空页面何时能被进程之外再次利用,以及运维人员应当拥有多少调节权。[1][2][3]
这些替代策略组成了一套生态,领奖台式排名无法说明它们的适用范围。有效的比较从进程的流量形态出发,最终落到整个应用的延迟和内存表现上。因此,更换内存分配器属于一次工作负载实验,远比软件包升级多一层验证责任。
题图把最底层的界线变得可见:技术员正在检查实体 RAM 模块,用户空间内存分配器则工作在其上方数层。[5] 它切分进程的虚拟地址空间,并向内核申请映射。它无法创造容量、修复内存泄漏,也无法保证为了快速复用而保留的页面一定容得进容器的内存限额。
共同的快速路径掩盖了分歧
若每次 malloc() 和 free() 都进入内核,代价会很高。通用内存分配器通常先以较大的单元取得内存,再将其划入不同大小类别,并从本地空闲数据结构中满足大量请求。常见情形下,一次系统调用由此变成了簿记操作,同时也带来核心取舍:留在线程或 CPU 附近的内存复用很快,却未必能供进程的其他部分使用,还会继续计入驻留内存。
大小类别还会带来另一项取舍。33 字节的请求通常由大于 33 字节的类别承接,因此对象会得到一些余量。类别划分越细,内部碎片越少,元数据和管理工作也越多;划分越粗,管理方式越简洁,浪费的空间则越多。生命周期长短不同的对象还会彼此牵制,把空间留在部分占用的页面里,即使空闲内存总量看起来很充裕,也很难将这些页面完整回收。
“哪一个库能让 malloc() 少花几纳秒”只覆盖了调用本身。真正需要比较的是“哪一个库的放置、缓存、转移和页面归还策略适合这个进程”。三个项目分别给出了自己的答案。
jemalloc 让内存分配器成为可观测的子系统
jemalloc 把分配工作分散到多个 arena(分配区),以减少锁争用。小对象装入 extent 内的 slab,线程专用缓存则能省去同步操作,直接处理许多请求。项目手册也直陈其中的成本:额外 arena 各有固定开销,彼此独立管理内存;线程缓存还会囤留一批闲置对象,数量虽有上限,仍会加重碎片化。[1]
jemalloc 的鲜明优势,在于它向运维人员开放了大量策略设置。MALLOC_CONF 用于设定启动选项;mallctl() 命名空间可以读取统计数据、调整部分控制项并触发操作;malloc_stats_print() 可以输出 JSON。构建时还可启用堆分析。未使用的 dirty 与 “muzzy” 页面遵循可配置的衰减周期,可选的后台线程则能异步清退页面。[1]
长期运行的服务需要热路径之外的观测能力时,这些统计与调节手段很有吸引力。运维人员可以查看字节处于 active(活跃)、allocated(已分配)、mapped(已映射)、resident(驻留)或 retained(保留)状态,或正停在线程缓存中,再验证减少 arena 数量、缩短衰减时间一类调整。每项调节都有代价。缩短衰减时间有助于较早归还内存;需求再次上升时,缺页次数和内核工作也有增加的风险。增加 arena 可以减轻争用,但各自管理的空闲空间会更难相互复用。
因此,试用 jemalloc 时,观测必须同步跟上。将其自身统计数据与进程 RSS、请求延迟、分配速率和缺页计数一同采集。服务若有突发流量阶段,也要覆盖突发结束后的安静时段;流量停下后的内存分配器行为,有时比峰值分配速率更能左右结果。
TCMalloc 把最频繁访问的缓存放在 CPU 上
本文所称 TCMalloc 指 Google 的 google/tcmalloc 项目,与 gperftools 中同名且单独维护的内存分配器有别。[6] Google TCMalloc 把设计分成三层:前端负责快速分配和释放;中间层包含转移缓存(TransferCache)与中央空闲链表(CentralFreeList);后端负责取得和归还页面。其前端采用 per-CPU 设计,取代旧式 per-thread 方案。每个逻辑 CPU 都按大小类别持有可用对象数组;对象不足时,从中间层批量取回,数量溢出时再成批交还。[2]
这一放置方式针对的是特定的扩展问题。一项服务创建的线程数可以远远超过 CPU 数量。per-thread 缓存会让闲置内存随线程数成倍增加,per-CPU 缓存则把快速存储绑定到真正能够运行代码的执行资源上。MallocExtension::SetMaxPerCpuCacheSize 控制缓存容量上限,ReleaseCpuMemory 可以把指定 CPU 中缓存的对象释放回中间层。一个 CPU 分配对象、另一个 CPU 负责释放时,中间层的转移缓存尤其重要。[2]
后端把这种策略继续推进到页面层。TCMalloc 可以使用感知大页的 page heap;在 x86 上,这套设计专门考虑了 2 MiB 大页,以减轻地址转换后备缓冲器(TLB)的压力。文档也提醒使用者留意相应的计账形态:内存分配器通常会预留很大的虚拟区域,所以虚拟内存大小(VSS)会显著超过驻留集大小(RSS);即便物理内存仍未耗尽,进程也存在提前触及虚拟地址空间上限的风险。[2]
这些选择让 TCMalloc 成为大型机器上高度并行 C++ 服务中值得验证的一项假设,尤其适合在性能分析中已经看到 per-thread 缓存增长或地址转译成本的情况。它们仍不足以推导出通用的云环境默认选项。per-CPU 元数据本身也有内存占用;按设计说明,典型配置会为每个逻辑 CPU 设置一个 256 KiB slab。缓存上限应当按实际核心数量测试。项目还明确警告,不要把 TCMalloc 加载进已经用另一种内存分配器创建过对象的进程:一个分配域生成的指针,此后存在被交给另一分配域释放的风险。[2]
mimalloc 让释放路径停留在页面局部
mimalloc 把小对象组织到自身的 page(内部分配页)中,再把每个 page 的空闲状态分片,使本地分配、本地释放和另一线程发起的释放不会共同争抢一条链表。其设计强调紧凑的快速路径、page 变空时的尽早清退,以及在线程退出后回收失去所有者的 segment。项目还提供 first-class heap(一等堆),供应用建立生命周期明确的分配区域。[3]
进程若存在大量跨线程释放、许多短命 heap,或强烈希望在一个阶段结束后释放空页面,mimalloc 就值得测试。即便采用“尽早”策略,RSS 回落仍有自己的时间尺度。操作系统决定解除提交或重置页面后如何反映在计账中;页面只要仍被部分占用,也无法仅凭其中存在空闲对象就归还。
mimalloc 还将性能选择与加固选择分开。secure 构建会在内存分配器元数据周围启用保护页,并加入编码的空闲链表指针、随机化复用和 double-free 检测。项目文档把这些措施界定为缓解手段,没有作出安全保证;按其基准测试,平均性能损失约为 10%。[3] 对安全敏感的服务应把这种构建与常规配置比较,同时保留内存安全测试和编译器加固。内存分配器可以提高漏洞利用难度,却无法把未定义行为变成安全代码。
mimalloc 最吸引人的特性,应当转写成一项可验证的主张。若看中跨线程释放行为,就测量生产者与消费者之间的所有权变化;若看中页面归还,就记录突发流量和长时间空闲阶段的驻留内存;若看中 first-class heap,就确认应用的对象图确实允许整片区域销毁,并且不会留下悬空引用。
测量交叉路径,宣传册留在一旁
一项独立的 HotOS 研究对微基准测试发出了重要警示。在 SPEC CPU2017 的一个 XML 转换工作负载上,PTMalloc2、jemalloc、TCMalloc 和 mimalloc 之间的选择使总运行时间相差最高 72%,尽管直接花在 malloc() 和 free() 中的时间仅约 2%。作者追踪后发现,更广泛的差异来自缓存和 TLB 行为,并指出分配速度与内存占用无法由同一套方案包办。[4]
这项结果说明,内存分配策略的影响可以扩散到整个程序。至于数据库、代理、游戏引擎、编译器或桌面应用上的胜负,原工作负载的赢家没有这种外推效力。硬件、工具链、线程拓扑、请求大小分布和对象生命周期都是结果的一部分。
严谨的试验需要让这些条件保持可见:
- 每种内存分配器都使用与生产形态一致的请求、相同的编译器选项、CPU 绑核方式、NUMA 策略和容器限额。
- 覆盖预热、稳态、突发流量和空闲恢复阶段。5 秒的吞吐量测试看不到缓慢累积的碎片化或页面归还行为。
- 除吞吐量和 p50/p99 延迟外,同时记录峰值 RSS、空闲后的 RSS、虚拟内存大小、缺页次数、CPU 时间,以及条件允许时的 LLC 和 TLB 未命中。
- 分开测量同线程释放与跨线程释放。转移对象所有权的队列,走的是与线程本地解析器不同的路径。
- 在目标核心数量上重复测试。8 个逻辑 CPU 上的结果,到了 128 CPU 主机上有时会因缓存与 arena 数量变化而发生反转。
- 将内存分配器版本、构建选项、环境配置和链接方式与基准测试产物一同保存。
只有应用及其依赖项都支持时,才从进程级符号介入(interposition)开始。金丝雀验证成功的 preload 方案可以迅速撤回;混合分配域缺少同样直接的回滚手段。插件、外部函数接口、自定义 new/delete、静态链接组件,以及要求使用自身释放函数的库,都需要明确的边界测试。每个候选方案都要跑单元测试、sanitizer、负载测试和进程退出路径,测试范围还要覆盖基准测试正常路径之外的情况。
选择团队有能力处置的故障形态
对于缺少内存分配剖析数据和内存遥测的小团队,系统内存分配器通常是符合现状的基线。更换内存分配器会增加一个原生依赖,也会带来新的版本跟踪、新的诊断术语,以及崩溃和内存耗尽事件中的又一个变量。基准测试所声称的收益,只有经受住生产形态的测量,才足以抵偿这些运维成本。
对于拥有稳定金丝雀机制和堆可观测能力的高吞吐服务,这套生态给出了三项值得验证的假设。arena、缓存、衰减和分析数据能够指导调优时,jemalloc 很有吸引力;per-CPU 缓存和大页行为契合大型并行集群时,TCMalloc 很有吸引力;页面局部释放路径、尽早清退、first-class heap 或可选加固模式契合应用时,mimalloc 很有吸引力。[1][2][3] 这些只是验证的起点,尚不足以下定论。
在发布前写清回滚方案。保留基线构建,在诊断信息中暴露内存分配器身份,同时对延迟和驻留内存设置告警,并让金丝雀运行跨过应用真实的流量阶段。候选方案只有在改善实验所针对的指标,同时守住内存、稳定性和可运维性预算时,才值得保留。
共同的 malloc() 函数签名让这些库看起来可以互换,背后的策略却各不相同:一个把工作分入可观测的 arena,一个把快速路径放在逻辑 CPU 上,一个在页面内部把空闲状态分片。完整进程接受测量之后,取舍依然有利的那一个,才是适合当前工作负载的选择。
来源
- jemalloc 项目,
jemalloc(3)手册——arena、线程缓存、大小类别、MALLOC_CONF、mallctl、统计数据、堆分析,以及 dirty/muzzy 页面衰减。 - Google TCMalloc,《TCMalloc Design》——per-CPU 与旧式 per-thread 前端、转移缓存与中央缓存、page heap、大页处理、控制项,以及文档列出的注意事项。
- Microsoft,
mimalloc项目文档——页面局部空闲链表分片、无主 segment 回收、尽早清退、first-class heap、guarded 模式和 secure 构建缓解措施。 - Ruihao Li 等,《NextGen-Malloc: Giving Memory Allocator Its Own Room in the House》,HotOS ’23——对内存分配器造成的整体运行时间、缓存、TLB 和碎片化影响所作的独立比较。
- Wikimedia Commons,《Electronics Technician 3rd Class Elmarco McNair inspects a RAM chip aboard USS Nimitz》——2005 年 8 月 23 日美国海军照片及署名记录。
- Google TCMalloc,《Gperftools TCMalloc》——Google 当前的
google/tcmalloc实现与单独维护的 gperftools 内存分配器之间的区别。