try ai
科普
编辑
分享
反馈
  • Slab 分配:原理、性能与应用

Slab 分配:原理、性能与应用

SciencePedia玻尔百科
核心要点
  • Slab 分配通过将内存组织成专用于单一、固定大小对象的池(slab),来解决外部碎片问题。
  • 它通过最大化空间局部性,显著提高了系统性能,从而实现对 CPU 缓存的高效利用。
  • 现代分配器通过每 CPU 缓存来适应并发性,并通过 NUMA 感知设计来适应复杂的硬件。
  • Slab 分配的原理已广泛应用于操作系统内核之外的领域,影响了游戏引擎设计、网络服务器性能和安全漏洞缓解等方面。

引言

在复杂的计算机科学世界里,内存管理是一项基础性挑战。操作系统需要处理无数的内存请求,如果没有巧妙的策略,它将面临陷入大量无法使用的、碎片化的内存空隙的风险,从而导致性能停滞。这个问题被称为外部碎片,在处理内核每秒创建和销毁的成千上万个小型、短生命周期对象时尤为严重。一种简单的方法是不足够的;我们需要的是一个优雅的组织系统。

本文介绍 slab 分配器,一个针对此问题的精妙解决方案。它是一种内存管理技术,通过将内存不视为单一的池,而是看作一个为每种类型的组件都设有专用存储空间的、组织良好的工作室,从而为混乱带来秩序。通过阅读本文,您将全面了解这一强大的方法。第一章“原理与机制”将解构 slab 分配器的工作方式,探讨其内部结构、对 CPU 性能的深远影响,以及其必须平衡的关键设计权衡。随后的“应用与跨学科联系”一章将拓宽视野,揭示 slab 分配的核心思想如何远远超出操作系统的范畴,影响着从游戏开发、云计算到计算机安全前沿的方方面面。

原理与机制

在我们理解计算机如何管理其资源的过程中,内存是最基本的资源之一。它是所有计算发生的广阔工作空间。但这个工作空间是如何组织的呢?如果你将内存想象成一个巨大的空仓库,而程序不断请求各种不同形状和大小的存储空间,你很快就能想象到即将出现的混乱。一个对大空间的需求可能会失败,不是因为仓库满了,而是因为剩余的空闲空间被分割成了无数个存储物品之间无法使用的小间隙。这个问题有一个名字:​​外部碎片​​。对于一个每秒必须处理数千个微小、短生命周期数据结构(如网络数据包描述符或文件系统标识符)的操作系统来说,这种混乱将是灾难性的。系统会陷入停顿,淹没在自己产生的零碎废料的海洋中。

自然界和优秀的计算机科学都厌恶这种浪费。解决方案通常不是蛮力,而是优雅的组织。这就是 ​​slab 分配器​​的故事。

Slab 哲学:万物各得其所

Slab 分配器没有将内存视为一个巨大的、无差别的池,而是借鉴了能工巧匠的策略。想象一个工作室。一个杂乱无章的木工可能会从大木板上随意切割小块,留下一堆无用的、形状奇特的边角料。而一位大师则会维护一组抽屉:一个放 1 英寸的螺丝,另一个放 2 英寸的钉子,依此类推。当需要一个螺丝时,就从螺丝抽屉里取。当不再需要时,就把它放回螺丝抽屉,为下一个任务做好准备。

Slab 分配器对内存做的正是这件事。它将广阔的系统内存划分成页面大小的块。然后,它将整个块(称为 ​​slab​​)专门用于管理单一、固定大小的对象。一个为 64 字节对象指定的 slab 将永远只包含 64 字节的对象。当程序需要一个 64 字节的对象时,分配器会从一个 64 字节的 slab 中取出一个空闲对象。当程序使用完毕后,该对象会被返回到同一个 slab 中,使其槽位再次可用。

这个简单的设计举措意义深远。它消除了这些对象的外部碎片问题。一个被释放的 64 字节槽位为下一个 64 字节的分配提供了一个完美的、现成的家。对象之间没有浪费。系统不再被大量无法使用的间隙所困扰。

Slab 的剖析:一场俄罗斯方块游戏

让我们窥探一下这些内存“抽屉”的内部。一个 slab 通常由一个或多个物理内存页构成。页是硬件处理内存的基本单位,大小通常为 4096 字节(4 KiB4\,\mathrm{KiB}4KiB)。slab 的一小部分被保留用于元数据——​​slab 头部​​——它记录着哪些槽位是空闲的等信息。剩余的空间是一个原始的网格,等待被对象填充。

但是我们能容纳多少个对象呢?这就是计算机体系结构中那些既优美又时而令人沮丧的现实发挥作用的地方。让我们在一个拥有 4096 字节页和 128 字节 slab 头部的系统上进行一个思想实验。这给我们留下了 4096−128=39684096 - 128 = 39684096−128=3968 字节用于存放我们的对象。

如果我们的对象大小是,比如说,64 字节,那么计算很简单:⌊3968/64⌋=62\lfloor 3968 / 64 \rfloor = 62⌊3968/64⌋=62 个对象可以完美地装入,浪费的空间为零字节。完美契合!

但如果我们的对象大小是 72 字节呢?并且,如果出于性能原因,硬件要求每个对象都必须起始于 16 字节的倍数的内存地址上呢?这被称为​​对齐​​约束。一个 72 字节的对象不能放在一个 72 字节的槽位中;它必须被放置在下一个不小于其大小且是 16 的倍数的槽位尺寸中,也就是 80 字节。现在每个 72 字节的对象都消耗了 80 字节的 slab 空间,其中有 8 字节作为填充被浪费掉了。

现在,我们的计算变了。我们能容纳的对象数量是 ⌊3968/80⌋=49\lfloor 3968 / 80 \rfloor = 49⌊3968/80⌋=49。实际数据使用的总空间仅为 49×72=352849 \times 72 = 352849×72=3528 字节。这一个 slab 内总共浪费的空间达到了惊人的 3968−3528=4403968 - 3528 = 4403968−3528=440 字节!对象大小和对齐方式上一个微小、看似无害的变化,就使 slab 的容量减少了 20% 以上,并引入了大量的浪费。 这种浪费,即已分配但在块内部未使用的空间,被称为​​内部碎片​​。它有两种形式:由于对齐造成的填充浪费,以及 slab 末尾因太小而无法容纳另一个槽位的尾部浪费。

这就引出了一个绝妙的理论问题:对于一个大小为 SSS 的对象,无论 slab 有多大,其末尾绝对最多能浪费多少空间?答案出奇地简单而优雅:S−1S-1S−1 字节。即使你的 slab 有一千兆字节大,末尾剩下的那一小片空间也永远不会超过一个对象的大小减去一个字节。这是由整数除法的本质所决定的一个基本限制。

性能奇迹:利用局部性

Slab 分配不仅仅是为了整洁的内存管理;其真正的天才之处在于其性能。而秘诀就在于一个叫做​​空间局部性​​的概念。其原理很简单:如果你访问了一块数据,你很可能很快就会访问它附近的数据。

现代 CPU 严重依赖这一原理。它们拥有小而极快的内存缓存。当 CPU 需要从主内存获取数据时,它不只是获取那一个字节;它会获取整个周围的内存块(一个​​缓存行​​,通常为 64 字节)并将其放入缓存中。

一个通用的分配器可能会将同一类型的对象散布在物理内存的各个角落。访问这样一个对象的链表对缓存来说将是一场噩梦。每次访问都可能需要一次缓慢的主内存之旅。这就像读一本页码被打乱并散落在整个图书馆里的书。

Slab 分配器通过将相同类型的对象连续地打包到一个 slab 中,有效地将书的页面按顺序排好。当一个程序访问 slab 中的第一个对象时,包含它的整个缓存行(以及它的几个邻居)被拉入高速缓存。下一次访问,即访问紧邻的下一个对象,现在就快如闪电——一次缓存命中!通过遍历 slab 内的对象,程序可以实现近乎完美的缓存性能,将时间花在计算上,而不是等待数据。 同样的原理也使​​转译后备缓冲器 (TLB)​​ 受益,这是一种用于虚拟到物理地址转换的特殊缓存,从而进一步减少了内存访问开销。

分配与释放之舞:LIFO vs. FIFO

对象并非永生;它们被创建(分配)然后消亡(释放)。分配器维护一个可用槽位的​​空闲列表​​来管理这个周期。但是,如何管理这个列表存在一个微妙而关键的选择。你是重用最近释放的槽位,还是最旧的那个?

  • ​​LIFO (后进先出):​​ 这种策略就像一叠盘子。你最后放上去的盘子是你第一个拿走的。对于内存而言,这意味着最近释放的对象会立即被用于下一次分配。这种行为对缓存性能极佳。它创建了一个小的、“热”的对象集,这些对象被不断回收利用,最大化了空间局部性。然而,这其中存在一个权衡。因为你总是在重用一个 slab 中的少数几个槽位,所以这个 slab 很少(甚至从不)会完全变空。这使得操作系统很难回收整个内存页,即使整体使用率很低。

  • ​​FIFO (先进先出):​​ 这就像邮局里的队列。排在队伍最前面的人最先得到服务。在这里,空闲列表上存在时间最长的对象被重用。这种方法倾向于在所有部分填充的 slab 之间循环使用所有空闲对象。这损害了缓存局部性,因为连续的分配可能会在不同的 slab 之间跳转。但它有一个很好的副作用:它系统性地“排空”slab。通过不立即重用部分满的 slab 中的槽位,它给了同一 slab 中其他对象被释放的机会。这增加了 slab 最终完全变空的可能性,从而允许操作系统回收其内存页,并在更大范围内对抗碎片。

这个选择揭示了一个经典的工程难题:你是优化原始速度(LIFO)还是优化更佳的长期内存健康状况(FIFO)?答案完全取决于系统的目标。

现代世界中的 Slab:并发性与复杂性

在现代多核和多处理器硬件面前,单一分配器带单一空闲列表的简单模型会失效。

想象一下,八个 CPU 核心同时尝试分配一个小对象。如果只有一个全局空闲列表,它们都必须排队,等待一个单一的锁被释放。这种​​锁竞争​​将彻底抵消我们所追求的性能优势。解决方案是 slab 原理的一个优美扩展:如果专门的池是好的,那么更专门的池就更好!现代分配器使用​​每 CPU 缓存​​。每个 CPU 核心都有自己的私有空闲列表。大多数时候,一个核心可以从其本地列表中分配和释放对象,无需加锁,也无需等待。只有当其私有列表为空时,它才会去全局池中抓取一批空闲对象。或者如果其私有列表变得太满,它会向全局池返回一批对象。这摊销了加锁的成本,使其成为一个罕见事件,而不是一个持续的瓶颈。

对于具有​​非统一内存访问 (NUMA)​​ 的大型服务器系统,情况变得更加复杂。在 NUMA 机器中,一个 CPU 访问其自己的“本地”内存要比访问连接到另一个 CPU 插槽的“远程”内存快得多。一个不感知 NUMA 的分配器可能会给一个在 CPU 0 上运行的线程分配一个其内存物理上位于 CPU 8 旁边的对象。现在对该对象的每一次访问都要承受远程访问的延迟惩罚。解决方案是让分配器感知 NUMA,维护​​插槽本地的空闲列表​​。Slab 从给定 CPU 插槽的本地内存中分配,该插槽上的线程主要从该本地池中分配。这确保了内存访问保持快速和本地化,尊重机器的物理拓扑。

宏观视角:一场永无止境的战斗

Slab 分配器是解决小对象外部碎片问题的绝佳工具。但它存在于一个更大的生态系统中。Slab 本身由页组成,而这些页是从更底层的页分配器(如伙伴分配器)分配的。一个使用多种不同大小的小对象的工作负载,可能导致 slab 分配器请求大量页,这些页可能会散布在整个物理内存中。讽刺的是,这可能会导致页级内存的碎片化,使得系统难以找到其他任务(如高性能 DMA 缓冲区)所需的大块连续页。解决一个层面的碎片问题可能会无意中在另一个层面加剧碎片问题。

当系统处于​​内存压力​​下,迫切需要释放空间时会发生什么?它会求助于 slab 分配器,要求其“收缩”,即返回所有空的 slab。但是分配器如何选择收缩哪些缓存呢?一个智能的分配器可以求助于数学。利用排队论中一个被称为​​利特尔定律​​的结果,它可以通过将近期分配率 (λ\lambdaλ) 乘以平均对象生命周期 (μ\muμ) 来估计缓存中活动对象的数量 (LLL),即 L=λμL = \lambda \muL=λμ。一个估计活动对象数量非常少的缓存是收缩的首要候选者,因为它在统计上更有可能包含可回收的空 slab。

即使有这些复杂的策略,一些碎片仍然是不可避免的。一个常见的减少浪费的策略是每个缓存最多只允许一个部分填充的 slab。然而,即使在这种理想化的情况下,所有这些单个部分填充 slab 中未使用的槽位加起来也可能相当可观。总浪费空间就是每个缓存的部分填充 slab 中空槽位的总和,这提醒我们,对抗碎片的战斗是一场管理和缓解的战斗,而不是彻底根除的战斗。

总而言之,slab 分配器是一个关于优美妥协的故事。它用少量的内部碎片换取了速度的大幅提升和外部碎片的消除。它展示了一个简单而强大的思想如何被提炼和调整,以应对从缓存行到多插槽处理器等现代计算机系统的巨大复杂性。它证明了那些为看似混乱的计算带来秩序的优雅原则的力量。

应用与跨学科联系

在了解了 slab 分配器优雅的机制之后,人们可能会倾向于将其视为一个制作精美但高度专业化的工具——一个为操作系统内核这一特定环境而精心调校的引擎。虽然那是它的原生环境,但如果仅止于此,就如同只欣赏物理学宏伟定理其证明的抽象之美,而看不到它在宇宙中泛起的深远涟漪。

Slab 分配器真正的美,就像一条基本的自然法则,在于其普适性。它的核心原则——物以类聚、为未来需求做准备、以及最小化浪费和精力——并不仅限于内核内存管理。它们是一种思维模式,在任何追求性能、效率和可预测性的地方都会出现。现在,让我们探索这个更广阔的世界,看看这个看似不起眼的 slab 分配器如何与一系列令人惊讶的学科联系并照亮它们。

机器之心:操作系统内核

我们从一切开始的地方说起:在繁忙的操作系统内核之城内部。内核始终处于忙碌状态,不断创建和销毁数十亿个微小、短生命周期的对象——文件句柄、网络数据包描述符、进程调度器等等。一个通用的分配器,就像你在自己程序中可能使用的 malloc 一样,好比在一个需要生产一百万把相同椅子的工厂里,请来一位定制家具的工匠。它太慢,太通用,而且造成太多浪费。

相比之下,slab 分配器就是完美的工厂。通过准备好整“slab”的、相同的、预初始化的对象,它将昂贵的内存分配过程转变为一个快如闪电的操作:只需从空闲列表中取出一个对象。一个有原则的性能模型揭示了这种差异是多么巨大。Slab 分配器的元数据紧凑且被频繁访问,这意味着它在 CPU 缓存中保持“热”状态。而通用分配器的元数据更复杂且分散,导致昂贵的“缓存未命中”,此时 CPU 必须等待从慢速主内存中获取数据。当你考虑到指令数的减少和获取新内存页的摊销成本时,slab 分配器在其目标工作负载上的性能可以显著超越通用分配器。

但它在内核中的作用远不止于原始速度。它还是一个关键的诊断工具。想象一下,一位系统管理员正在调查由内存不足(OOM)错误引起的服务器崩溃。Slab 分配器的状态提供了一份名副其实的“崩溃转储取证”报告。通过检查不同的缓存,人们可以为系统“把脉”。一个健康的缓存会显示出满、部分和空 slab 之间的平衡,并拥有储备充足的每 CPU 空闲列表。相比之下,一个涉及内存泄漏的缓存则呈现出截然不同的景象:大量的满 slab,几乎没有部分或空的 slab,以及完全耗尽的空闲列表。这种模式是一个软件错误的明确症状,该错误只分配对象从不释放,导致缓存不受控制地增长,直到耗尽所有可用内存。在一个假设的崩溃转储场景中,通过分析 dentry_cache(存储目录条目)的状态与一个健康的 inode_cache 的对比,可以清晰地阐明这一原则,从而使 slab 分配器成为整个系统的生命体征监视器。

与硬件和并发的对话

Slab 分配器并非生活在一个抽象的软件世界中;它是操作系统逻辑与物理硬件之间的关键接口。这种对话要求它必须能说硬件的语言。例如,高性能设备,特别是使用直接内存访问(DMA)的设备,通常对内存缓冲区有严格的规定:它们必须从特定的地址边界开始(例如,128 字节对齐),并且不能跨越这些边界。

一个标准的分配器很难满足这样的约束。然而,slab 分配器可以被优雅地定制。通过设计 slab 布局,将每个 128 字节对齐的块视为一个潜在的槽位,它可以保证其分发的每个对象都完美地适配 DMA 引擎。这可能会以一些空间浪费为代价——如果一个对象只有 96 字节,其对齐槽位中剩余的 32 字节就会被闲置。但这种经过计算的权衡,即以内存利用率的损失换取硬件正确性和性能的提升,是复杂系统工程的标志。

在并发的世界里,这场舞蹈变得更加错综复杂。在现代内核中,多个 CPU 核心读取一个数据结构而另一个核心正在修改它是很常见的。为了避免高昂的锁成本,人们使用了像读-复制-更新(RCU)这样的巧妙机制。RCU 的基本承诺是读者永远不必等待;他们可以继续进行,但保证他们能看到的任何内存,在所有读者完成之前都不会被物理回收。

这对我们的 slab 分配器意味着什么?当一个写线程“释放”一个对象时,分配器不能立即将其返回到空闲列表。这样做就像在有人还在读书时从图书馆书架上抽走那本书。相反,分配器必须与 RCU 系统协作。该对象进入一个“僵尸”状态,逻辑上是空闲的但物理上被占用,直到一个“宽限期”过去。只有到那时,将对象放回空闲列表才是安全的。这种交互意味着在任何给定时间,一定数量的 slab 将保持在部分填充状态,不是因为它们正在被积极使用,而是因为它们持有这些等待 RCU 宽限期结束的僵尸对象。人们甚至可以用排队论的原理来估计这种开销,展示了内存管理和并发控制的美妙交集。

这种对系统行为的敏感性在实时系统中也至关重要,例如控制工业机器人或飞机的系统。在这里,可预测性至上。一个意想不到的延迟,或称“抖动”,可能是灾难性的。虽然 slab 分配器平均速度很快,但其维护活动,如扫描部分填充的 slab 列表以回收内存,如果设计不当可能会引入停顿。最坏情况分析可能会显示,在这样的收缩操作期间持有的全局锁可能会超过实时调度器允许的最大暂停时间。在这类系统中,必须修改分配器的设计,例如将这些操作推迟到非关键时间执行,以确保硬实时保证始终得到满足。

一种兼顾性能与安全的模式

Slab 分配的原则如此强大,以至于它们在远超操作系统内核的领域被采纳和改编。它已经成为一种通用的设计模式,用于管理任何相同的、创建成本高昂的资源池。

考虑一个高性能的 Web 服务器。处理一个新客户端涉及到创建一个连接对象,这个过程可能相对较慢。既然所有连接在结构上都是相同的,为什么不用一个类似 slab 的分配器来管理它们呢?当客户端断开连接时,服务器不是释放连接对象的内存,而是简单地将该对象返回到一个“连接池”中,以便立即为下一个客户端重用。这种直接模仿 slab 分配的方法,对于在网络应用中实现高吞吐量至关重要。

一个更令人惊讶的应用出现在游戏开发领域。现代游戏引擎通常使用一种称为实体组件系统(ECS)的架构。引擎不是创建庞大的“玩家”或“敌人”对象,而是管理作为简单 ID 的实体,并为其附加组件(如 Position、Velocity、Health)。为了获得最高性能,所有单一类型的组件(例如,所有的 Position 组件)都一起存储在一个连续的内存块中。这其实是 slab 分配器的另一种叫法!这种“面向数据的设计”允许游戏引擎以惊人的速度遍历所有的位置或所有的速度,其利用 CPU 缓存的方式与内核完全相同。这是软件领域趋同演化的一个绝佳例子,即为了解决完全不同领域中的相似问题,人们独立地发现了相同的最优解决方案。

这种模式甚至可以扩展到系统架构的最高层,即云计算。在一个多租户系统中,多个客户(或容器)在同一个内核上运行,你需要强制执行公平性,并防止一个行为不当的租户消耗掉所有资源。在这里,slab 分配器可以被扩展为“命名空间感知”的。每个租户都有自己的一套 slab 缓存,并有内存配额。通过将分配器与准入控制策略(例如,基于最大最小公平的策略)相结合,系统可以智能地限制分配请求,以确保每个租户获得公平的内存份额,并且永远不会超过全局内存上限。这使得一个底层的内存分配器成为安全、多租户云基础设施的关键推动者。

安全的堡垒

Slab 分配器最引人注目的现代应用之一可能是在计算机安全领域。最危险和最常见的软件漏洞类型之一是“释放后使用”(UAF)错误。当一个程序释放了一块内存但错误地保留了指向它的指针,随后使用那个“悬挂”指针来访问可能已被重新分配用于完全不同目的的内存时,就会发生这种错误。

内存分配器如何提供帮助?通过设置一个陷阱。我们可以修改 slab 分配器来实现一个“对象隔离区”。当一个对象被释放时,分配器不是立即将其放回空闲列表,而是在一个特殊的隔离队列中将其保留一小段时间,即“停留时间”τ\tauτ。在此期间,内存被“投毒”,或标记为无效。如果带有错误的代码在此隔离期间尝试使用其悬挂指针,系统可以检测到非法访问并安全地终止程序。

此方法的美妙之处在于其有效性可以被量化。如果我们有一个关于释放操作和随后的错误使用之间时间延迟 Δ\DeltaΔ 的统计模型,我们就可以计算出捕获该错误的概率:它就是 Δ≤τ\Delta \leq \tauΔ≤τ 的概率。对于一个该延迟遵循对数正态分布的工作负载,我们可以推导出一个精确的公式来计算捕获 UAF 的概率,从而将内存分配器变成一个可验证的安全机制。

前沿:大规模并行世界

旅程并未在此结束。Slab 分配的原理现在正被重新构想,以适应计算领域最极端的环境之一:图形处理器(GPU)。GPU 是一种大规模并行机器,有数千个线程同步执行。在这里,旧的规则改变了。

一个简单的 slab 分配器会彻底失败。如果每个线程都试图访问一个单一的空闲列表,竞争将使整个 GPU 陷入停顿。设计必须适应 GPU 的“单指令,多线程”(SIMT)执行模型。一个成功的 GPU slab 分配器使用 warp 同步分配,即一个“warp”(一组 32 或 64 个线程)中的一个线程通过一次原子操作为整个 warp 分配一个对象块。Slab 布局本身必须被设计为促进“合并”内存访问,确保 warp 中的线程在访问它们的对象时,是从连续的内存位置进行的,从而最大化内存带宽。

值得注意的是哪些原则可以移植,哪些必须被重新发明。Slab 中固定大小、预初始化对象的核心思想依然至关重要。然而,像“每 CPU”缓存这样的优化必须被重新思考为“每流式多处理器”或“每线程块”缓存。这种持续的适应性表明,slab 分配不是一个陈旧的遗物,而是一个活生生的、不断演变的概念,在新的计算领域中不断找到新的价值。

从内核到云,从游戏引擎到 GPU,slab 分配器证明了自己远不止是一种简单的内存管理算法。它是一种为混乱带来秩序的基本模式,证明了在计算领域,正如在物理学中一样,设计的优雅往往会带来惊人的力量和普适性。