Slitter 是 Backtrace 特意中间的线程缓存slab 分配器,具有显式分配类标签(而不是从对象的大小类派生)。它主要用 Rust 编写,我们在我们的 C 后端服务器中使用它。 Slitter 的设计几乎是标准的:我们希望将项目的复杂性预算用于始终在线的“可观察性”和安全功能。我们不希望检测所有甚至大部分内存管理错误,但我们应该在统计上捕获一小部分(足以帮助查明生产问题)此类错误,并且始终将其范围限制在管理不当的分配类中。 1 去年 4 月我们决定对 Slitter 进行编码,当时我们注意到我们将立即受益于临时文件映射的后备分配:2我们的大部分数据是从持久数据文件映射的,但我们也在启动期间重新生成一些冷元数据,并且对该元数据的访问具有惊人的局部性,时间和空间(假设凹凸分配)。我们不希望 OS 交换出所有的堆——这样就存在灰色故障——所以我们选择特定的分配类到其中。就其本身而言,这不是编写slab分配器的理由:例如,我们可以很容易地在jemalloc中配置专门的arenas。然而,我们也着眼于可观察性和调试或缓解生产中内存管理错误的长期改进,这些只能通过迁移到每个分配类(类型)具有显式标记的接口来解锁。像 jemalloc 和 tcmalloc 这样的经典 malloc 从根本上无法匹配这种集成级别:我们无法告诉 malloc(3) 我们正在尝试分配什么(例如,HTTP 模块中的结构请求),只能知道它的大小。仍然可以将 malloc 包装在更丰富的接口中,例如,通过标记跟踪堆消耗。不幸的是,结果比本地解决方案慢,而且如果没有底层分配器的帮助,很容易在 malloc 和 free 调用之间错误地匹配标签。根据我的经验,这通常会导致无用的分配统计信息,通常围绕着人们试图调试的非常错误的代码路径。即使我们在正则 malloc 之上建立了详细的统计信息,也很难说服底层分配器只在对象类中回收分配: malloc 不仅会急切地回收类似大小的分配而不管它们的类型,而且它们还会释放未使用的地址运行空间,或将它们重新用于完全不同的尺寸等级。这就是 malloc 应该做的事情&mldr,当不可避免地出错时,它也会使调试变得更加困难。 3 Slab 分配器使用语义更丰富的分配标签:分配标签描述其对象的大小,但也可以指定如何初始化、回收或取消初始化它们。问题在于,slab 分配器往往只关注速度。
由于 Solaris 的普遍挂钩文化,libumem 的分支可能是个例外。然而,umem 的设计反映了 00 年代的敏感性,当它被编写时:线程共享一些缓存,分配器尝试重用地址空间。相比之下,Slitter 假设内存足够用于线程本地缓存和类型稳定分配。 4 我们已经在生产环境中运行 Slitter 两个多月了,并依靠它来:检测何时使用错误的分配类标签释放了分配(即检测免费的类型混淆)。避免任何带内元数据:在分配和分配器元数据之间有保护页面,并且没有侵入性的空闲列表供释放后使用。保证类型稳定分配:一旦一个地址被用于满足对某个分配类的请求,它将只用于该类。 Slitter 不会在释放的分配之上覆盖侵入性列表,因此数据始终反映应用程序最后存储在那里的内容。这意味着双重释放和释放后使用仅影响错误分配类。应用程序甚至可以依靠释放后读取是良性的来简化非阻塞算法。 5 让每个分配类指定它的后备内存应该如何映射(例如,普通的 4 KB 页面或文件支持的可交换页面)。由于广泛的合同以及硬编码和随机测试的混合,我们在最初推出时只遇到了两个问题,都在难以测试的少量无锁 C 代码中。 6
字体稳定性对 Slitter 的设计有很大的影响,并且有明显的缺点。例如,一个短暂的应用程序通过阶段管道进行,其中每个阶段分配不同的类型,如果用类型稳定的分配器(如 Slitter)替换常规 malloc,肯定会浪费内存。我们相信隔离的好处是值得的,至少对于快速进入稳定状态的长寿命服务器而言。除了这些安全特性,我们计划依靠分配器来提高调用程序的可观察性,并希望:这是它目前的工作原理,以及我们为什么用 Rust 和 dashof C 编写它。许多通用内存分配器实现策略同样受到 Bonwick 的slab 分配器的启发,经过时间考验的mallocs 可能比Slitter 提供更好的性能和更低的碎片。 8 设计 Slitter 的主要动机是在 API 中具有显式分配类使分配器更容易提高调用程序的可调试性和弹性。 9例如,大多数分配器可以告诉您程序堆的大小,但是当按结构类型或程序模块分解时,这些数据会更有用。大多数分配器试图最小化对与分配相关联的元数据的访问。事实上,这通常被视为slab接口的一个优势:分配器可以只依赖调用者传递正确的分配类标签,而不是点击元数据来确定释放的地址应该去那里。我们和 Slitter 朝着相反的方向前进。我们仍然依靠分配类标签来提高速度,但也在从释放调用返回之前积极寻找不匹配。不依赖于不匹配检测逻辑计算的值,并且结果分支是微不足道的可预测的(标签总是匹配),所以我们可以希望宽无序的 CPU 会隐藏大部分检查代码,如果它足够简单的话。这种关注(在几条指令中访问元数据)结合我们避免带内元数据的目标导致每个块的数据和元数据的简单布局。
.-------.------.-------|--------------.-------.|守卫|元 |守卫|数据...数据|守卫|'-------'------'-------|--------------'-------' 2 MB 2 MB 2 MB | 1 GB 2 MB v 对齐到 1 GB 块的数据始终是 1 GB 地址范围,对齐到 1 GB:底层映射器不必立即用内存支持它,但它肯定可以,例如,为了使用巨大的页。该块的前后是 2 MB 保护页。块数据的元数据位于 2 MB 范围内,就在前一个保护页之前(即,对齐的 1 GB 范围开始之前的 4 MB 到 2 MB)。最后,2 MB 元数据范围本身前面有一个 2MBguard 页面。每个块被静态划分为 65536 个跨度,每个跨度为 16 KB。我们可以使用移位、掩码和一些地址算法将跨度映射到元数据块中的插槽。 Mills 不必一次分发单独的 16 KB 跨度,他们只需要以 16 KB 的倍数工作,并且永远不会将跨度一分为二。我们从 C 调用 Slitter,但用 Rust 编写它,尽管构建 10 过程更加痛苦:这种痛苦不会随处可见,因为我们希望我们的后端在很长一段时间内混合使用 C、C++ 和 Rust。我们还加入了一些 C 语言,而另一种选择是拉入一个箱子只是为了进行一对系统调用,或者启用不稳定的 Rust 功能:我们不是“rewrite-it-in-Rust”绝对主义者,而只是希望使用 Rust因为它的优点(对数据布局的控制、对特定领域不变量的支持、对性能不太敏感的逻辑的大型生态系统、必要时向编译器撒谎的能力,&mldr),同时避免了它的缺点(与 C 头文件定义的 Linux 接口交互,或微调代码生成)。大多数分配只与线程本地杂志交互。这就是为什么我们在 C:stable Rust 中编写该代码的原因(还)不允许我们访问可能/不可能的注释,也不允许我们访问快速的“initial-exec”线程本地存储。当然,分配和释放是内存的主要入口点分配库,因此这会与 Rust 的链接过程产生一些摩擦。 11 我们还必须在 C 中实现我们的无锁 multipopper Treiber 堆栈:x86-64 没有像 LL/SC 这样的东西,所以我们改为将栈顶指针与代计数器和mldr 配对,并且 Rust 还没有稳定128 位原子还没有。我们选择在 C 中使用原子而不是在 Rust 中使用简单的锁,因为无锁堆栈(以及 Rust 处理的原子碰撞指针)对于我们的用例很重要:当我们在启动时重新水化冷元数据时,我们从多个 I /O-bound 线程,我们已经观察到由于 malloc 中的锁争用导致的打嗝。在某些时候,锁获取非常罕见,以至于争用不是问题;这就是为什么我们在重新填充凹凸分配区域时对锁感到满意的原因。
Slitteris 设计中反复出现的一个主题是,我们找到了使核心(去)分配逻辑稍微快一点的方法,并立即将这种效率用于安全性、可调试性或最终可观察性。对于很多代码,性能是满足的约束,而不是最大化的目标;一旦我们接近足够好,就可以交易表现了。 12 我还认为,与从分配路径中减少几纳秒相比,在内存放置方面存在着更低的悬而未决的成果。 Slitter 还专注于始终处于活动状态(甚至在生产中)的检测和调试功能,而不是将其留给开发工具或必须显式启用的逻辑。在 SaaS 世界中,永远不会进行开发和调试。选择加入工具绝对有用,但始终启用的功能更有可能帮助开发人员捕获很少发生的错误,他们往往会花费大量的调查工作(如果可以在生产中大规模安全地启用调试功能,为什么不让它永远启用?)。如果这听起来对slab 分配器来说是一个有趣的哲学,那就来破解 Slitter!诚然,对于纯 Rust 黑客来说,Slitter 的价值并不像我们这些混合 C 和 Rust 的人那么清楚,但是每类分配统计和放置决策应该是有用的,即使在 safeRust 中,尤其是对于运行时间较长的大型程序。我们的 MIT 许可代码在 github 上,有很多小的改进需要处理,虽然我们仍然需要重新审查文档,但它的覆盖率最好,我们尝试编写简单的代码。根据我的经验,它们无限的爆炸半径使内存管理错误难以追踪。通用内存分配器的设计目标(例如,快速回收内存)和一些实现策略(例如,带内元数据)使得一个模块中的错误很容易在一个完全不相关的模块中显示为损坏的不变量,而这些不变量恰好共享分配地址与前者。对抗性思想家甚至会利用隔离的缺失将小的编程错误放大为任意代码执行。当然,人们不应该编写错误,但是当它们确实发生时,很高兴知道损坏的代码最有可能在调用图中击中自身及其邻居,而不是使用相同内存分配器的不相关代码(Windows 得到的东西)正确的私人堆)。 ↩︎ Linux 没有像 BSD 的 MAP_NOSYNC mmap 标志那样的东西。这在历史上给像 LMDB 这样的重度 mmap 用户带来了问题。从经验上看,如今 Linux 的刷新行为更加合理,尤其是当脏页只占物理 RAM 的一小部分时,就像对我们一样:在配置良好的后端服务器安装中,大部分 RAM 用于清理文件映射,所以只有dirty_expire_centisec 计时器会触发写出,而且我们还没有以足够快的速度增长文件支持的堆,以至于基于时间的刷新程序无法进行过多的处理。 ↩︎ umem 还需要降低性能,以便让对象类定义对象初始化、回收和销毁的回调。让分配器做一些预分配工作是有意义的:如果第一次写入分配会导致缓存未命中,最好在立即想要新分配的对象之前这样做(是的,配置文件将在分配器中显示更多循环,但您只是在转移工作,希望远离关键路径)。 Slitter 只支持最低限度:对象要么总是零初始化,要么最初是零填充,后来保持不变。这涵盖了最常见的情况,而不会导致太多的分支预测错误。 ↩︎
人们可能会想要真正依赖它,不仅是为了隔离和恢复能力,而且是在正常操作期间。这听起来是个坏主意(我们当然还没有采取那种飞跃),至少在 Slitter 与 Valgrind/ASan/LSan 一起工作之前:当人们可以插入对常规 malloc/calloc/free 的调用时,调试容易重现的问题会更容易使用专用的堆调试器。 ↩︎ 很容易把责任归咎于无锁代码的复杂性,但最初的版本,带有 C11 原子,是正确的。不幸的是,gcc 使用锁支持 C11 atomic uint128_ts,所以我们不得不切换到遗留接口,这就是错误出现的时候。基础对象。值得庆幸的是,缓冲区溢出倾向于从尺寸过小的对象的实际末端开始线性进行。 ↩︎ 事实上,Slitter 主动恶化外部碎片以保证类型稳定的分配。我们认为为了控制释放后使用和双重释放的爆炸半径而牺牲堆占用空间是合理的。 ↩︎ 这就是为什么我们对分配类标签感兴趣,但它们也可以帮助应用程序和 malloc 性能。一些 malloc 开发人员正在研究放置标签(分配是否应该由 NUMA 节点的本地内存支持,带有大页面,&mldr?)或生命周期(分配是不朽的、短暂的还是绑定到请求?)提示. ↩︎ 我们从 uber-crate 重新导出我们的依赖项,并让我们的外部介子构建调用 cargo 为该外观 uber-crate 生成静态库。 ↩︎ Rust 在链接 cdylib 时会自动隐藏外来符号。我们通过静态链接解决了这个问题,但是静态链接的 rust 库是相互不兼容的,因此是 uber-crate。 ↩︎
不仅仅是为了安全或生产力功能!我发现放弃小的性能胜利(例如,积极的自动向量化或链接时间优化)通常是有意义的,因为它们会使未来的性能调查变得更加困难。后者风险更高,只有潜在的好处,但它们的优势(数量级改进)小矮人保证了及时冻结代码的小胜利。 ↩︎