try ai
科普
编辑
分享
反馈
  • 伙伴分配算法

伙伴分配算法

SciencePedia玻尔百科
核心要点
  • 伙伴系统通过递归地分裂大小为 2 的幂的大内存块来满足请求,并在释放时积极地合并(coalescing)这些块来管理内存。
  • 它使用单一、高效的按位异或(XOR)运算来即时定位一个块的“伙伴”以进行合并,这反映了其底层的二叉树结构。
  • 虽然设计优雅,但该系统通过将请求大小向上取整到 2 的幂而引入了内部碎片,并且在特定工作负载下容易产生外部碎片。
  • 它是现代操作系统中的一个关键组件,用于为 DMA 分配物理上连续的内存,并与 slab 分配器协同工作。
  • 伙伴分配的原理被应用于应对现代挑战,包括管理 GPU 上的内存以及复杂的异构内存系统中的内存。

引言

在复杂的计算机科学世界里,内存管理是一个基础而又持久的挑战。操作系统和应用程序不断地请求和释放大小不一的内存块,从而构成一个动态的谜题。一种天真的方法会迅速导致内存碎片化,即空闲空间被分散成许多微小、无法使用的碎片,以至于连简单的请求都无法满足。本文将探讨解决此问题的一种最优雅且具影响力的方案:伙伴分配系统。首先,我们将深入探讨“原理与机制”,揭示其简单的 2 的幂次方逻辑、分裂与合并的递归之舞,以及定义其行为的内在权衡。随后,在“应用与跨学科关联”部分,我们将看到这个基本算法如何超越理论,在从底层硬件通信到现代编程语言复杂的内存管理的各个方面扮演着关键角色。

原理与机制

想象一下,你是一位巨大而空旷的图书馆的管理员。你的工作是把一排排书架分配给提出请求的读者。有些人只想要一小格架子,有些人则需要一整条走道。当他们用完后,会把空间还给你。你如何跟踪哪些空间是空闲的,而又不让你的目录变得一团糟,尽是些微小、无法使用的空隙?这本质上就是内存管理的挑战,而有史以来设计出的最优雅的解决方案之一便是​​伙伴系统​​。

初看起来,伙伴系统似乎强加了一条僵硬、近乎专制的规则:所有内存块的大小都必须是 2 的幂——1、2、4、8、16,依此类推。这似乎效率极低。如果有人请求 9KB 的内存,你却必须给他们一个 16KB 的块!但正如我们将看到的,正是这种严格的纪律,构成了该系统深刻的简洁性与速度之源。

一个构建于“二”之上的世界

伙伴系统之美始于其对内存的层级化视角。它将一个巨大的连续内存池不视为一条简单的字节线,而是一棵完整的二叉树。在最顶端,树的根是整个内存块,比如说大小为 2M2^M2M。这个块可以分裂成两个“子”块,每个大小为 2M−12^{M-1}2M−1。每个子块又可以进一步分裂,如此继续,直到你达到希望管理的最小可能块大小。

这种结构决定了系统的两个基本操作:分裂与合并之舞。

分配:分裂的艺术

当一个大小为 SSS 的内存请求到达时,分配器首先确定所需的块大小。它将 SSS 向上取整到下一个 2 的幂,我们称之为 B=2kB=2^kB=2k。例如,一个 180,000 字节的请求将被向上取整到 218=262,1442^{18} = 262,144218=262,144 字节。

然后,分配器会寻找一个大小恰好为此的空闲块。如果正好有,太好了!直接交出。但如果没有呢?这时分裂就开始了。分配器找到比所需大小 BBB 更大的最小可用空闲块。假设它找到了一个大小为 2j2^{j}2j 的块,其中 j>kj > kj>k。它会拿走这个块并将其一分为二,创建两个大小为 2j−12^{j-1}2j−1 的“伙伴”块。其中一个伙伴被放到对应大小的空闲链表中,而另一个则被用于下一步。这个过程——分裂,将一个伙伴放入空闲链表,取走另一个——会沿着树的层级级联而下,直到一个大小恰好为 2k2^k2k 的块被划分出来。这个过程是确定性的,并且效率极高;就像从树的根导航到一个特定的叶子。

释放:合并的魔力

伙伴系统的真正天才之处在内存被归还时显现出来。一个天真的系统可能只是将归还的块标记为“空闲”,导致一个由微小、无用区块组成的碎片化景观。伙伴系统则更聪明。当一个块被释放时,它会立即寻找它的伙伴——即当初与它从同一个父块分裂出来的另一半。

如果它的伙伴也是空闲的,它们会立即​​合并​​(coalesce),重新形成它们原来大小为两倍的父块。但这还没完。这个新形成的父块接着会寻找它自己的伙伴。如果那个伙伴也是空闲的,它们也会合并。这个连锁反应,即​​级联合并​​,会沿着层级结构尽可能地向上进行,积极地重新组装成越来越大的连续空闲内存块。系统不断努力修复它自己造成的碎片,旨在将内存恢复到其最初的、单一巨大空闲块的原始状态。

伙伴的秘密:一点位魔法

这一切听起来很美妙,但它取决于一个关键问题:一个块如何找到它的伙伴?它需要查阅复杂的目录或搜索长长的列表吗?答案是一段计算的诗篇,也是伙伴系统因其优雅而备受喜爱的原因。伙伴的地址可以通过一个单一、闪电般快速的操作找到:按位异或(XOR)。

对于一个大小为 2k2^k2k、地址为 AAA 的块,其伙伴的地址就是 A⊕2kA \oplus 2^kA⊕2k。

这为什么能行得通?回想一下树形结构。当某个地址的父块被分裂时,它会创建两个子块。一个子块的地址与父块相同,而另一个的地址则偏移了子块的大小。例如,一个位于地址 0 的 64KB 块分裂成一个位于地址 0 的 32KB 块和另一个位于地址 32768 的块。在二进制中,它们的地址只有一个比特位不同——即对应于值 32768 的那个比特位。XOR 操作只是简单地翻转这一个特定的比特位,就能瞬间从一个块的地址跳到它在树中的兄弟姐妹的地址。这不仅仅是一个聪明的技巧;它是系统二叉树性质的深刻体现,通过计算机最基本的操作来实现。

不可避免的权衡:两种碎片的故事

没有系统是完美的,伙伴系统优雅的纪律也伴随着代价,表现为两种形式的碎片。

内部碎片:向上取整的代价

第一个代价是显而易见的。当我们将一个大小为 SSS 的请求向上取整到下一个 2 的幂 B=2kB=2^kB=2k 时,已分配块内部的空间 (B−S)(B-S)(B−S) 就被浪费了。这被称为​​内部碎片​​。这似乎可能是一个大问题。如果有人请求 2k−1+12^{k-1} + 12k−1+1 字节怎么办?我们给他们一个大小为 2k2^k2k 的块,几乎一半的空间都被浪费了!

值得注意的是,情况绝不会比这更糟。根据 2 的幂次取整的性质,请求的大小 SSS 总是大于它所分配到的块大小的一半(S>B/2S > B/2S>B/2)。这带来了一个简单而有力的保证:浪费空间的比例 (B−S)/B(B-S)/B(B−S)/B 总是小于 0.5,即 50%。虽然一些模型显示平均碎片率更低,大约在 25% 左右,但这个最坏情况下小于 50% 的保证是系统可预测性的基石。

外部碎片:末日棋盘

第二个,更微妙的代价是​​外部碎片​​。当总的空闲内存足以满足一个请求,但这些内存分散在不连续的小块中时,就会发生这种情况。伙伴系统的积极合并策略就是为了对抗这个问题而设计的。但是它能被击败吗?

考虑这个思想实验。想象一下,我们用最小可能大小的块(比如 16 字节)填满整个内存。现在,我们遍历并释放每隔一个块,从而创造出一种已分配和空闲的 16 字节块交错的“棋盘”模式。会发生什么?每个空闲块都会寻找它的伙伴进行合并。但它的伙伴是相邻的 16 字节块,根据我们的设计,这个块仍然是已分配的!合并在所有地方都失败了。

结果是碎片化的一场灾难。总内存的一半是空闲的,但它完全以孤立的 16 字节块的形式存在。系统有大量的空闲空间,但如果一个仅需 32 字节的请求到来,它将会失败。这个病态案例说明了其根本局限性:分配器满足请求的能力不取决于总的空闲内存,而取决于最大的单一连续空闲块的大小。

从理想模型到混乱现实

这个将内存视为一个完美的 2 的幂大小的块的美丽、自洽的模型是一种理想化。真实世界的物理内存通常是混乱的。它可能有“空洞”——为硬件保留、操作系统无法使用的区域。这打破了单一连续块的假设。

这是否意味着我们必须放弃我们优雅的伙伴系统?完全不是。这个原则是稳健的。操作系统可以为每个物理内存的连续区域(zone)运行一个独立的伙伴系统,而不是管理一个巨大的内存池。一个 64 MiB 的区域有它自己的伙伴分配器,另一个地方的 32 MiB 区域也是如此。合并被限制在每个区域的边界之内。伙伴查找逻辑可能需要稍作调整,以便在每个区域内使用相对地址工作,但核心的 XOR 原则依然不变。这种基于树的空间划分被证明是一个灵活而强大的概念,能够应用于真实计算机内存中不相连的区域,将一个复杂问题转化为一系列更简单、可解决的问题。

应用与跨学科关联

在领略了伙伴系统优雅的机制——其递归分裂与积极合并——之后,人们可能会倾向于将其视为一件整洁、自成一体的算法艺术品。但它真正的美,如同科学中任何基本原理一样,不在于其孤立性,而在于其与周围世界深刻且常常令人惊讶的联系。伙伴系统不仅仅是一个算法;它是一个反复出现的答案,回答了一个根本问题:我们如何在一个不断被拆分和重组的资源上强加一种简单、可预测的秩序?这个简单的思想回响在现代计算的各个层面,从硬件接口的裸金属到编程语言的抽象领域,甚至更远。

最后一公里:连接虚拟梦想与物理现实

在我们的现代计算世界里,我们有幸拥有虚拟内存这一美妙的幻象。每个程序都相信自己拥有一个巨大、原始且完全连续的内存空间。内存管理单元(MMU)在幕后不知疲倦地工作,像一位翻译大师,将分散的物理内存碎片拼凑起来,以维持这个宏伟的谎言。你的程序看到的连续虚拟地址块,可能对应着散布各处的物理页框的混乱杂烩。

但这个幻象有其局限。一些硬件组件,特别是像网卡或图像信号处理器这样的高性能设备,并不了解 CPU 复杂的把戏。为了速度和简单,它们通常执行直接内存访问(DMA),直接写入物理内存。如果这类设备没有自己的翻译器(IOMMU),它就需要一个在冰冷、坚硬的物理现实中,而非在虚拟梦境中连续的缓冲区。

突然之间,操作系统面临一项艰巨的任务。对于 CPU 来说看似平坦大道的内存,对于 DMA 设备来说,实际上是一堆不相连的岛屿。当自由内存只是由单个页面的零散缺口构成时,操作系统如何为一个高清视频帧找到一条完整的、比如十六个页面(64 KiB64\,\mathrm{KiB}64KiB)的链条?这正是伙伴系统最经典、最至关重要的角色。它被请来专门对抗*外部碎片*——即总空闲内存充足,但没有一块是连续的。通过不断尝试合并相邻的空闲块,伙伴系统努力维持更大的连续区域,从而增加满足大型、物理连续缓冲区请求的可能性。当然,它并非万能药;在一个已经运行了很长时间的系统上,找到一个大的连续块变得越来越不可能,但伙伴系统给了操作系统一个奋斗的机会。对于真正关键的任务,现代系统甚至采用专门的技术,如 Linux 的连续内存分配器(Contiguous Memory Allocator, CMA),它在启动时保留一个大的内存区域,并用类似伙伴系统的原则来管理它,以保证像 4K 摄像机这样的设备总能获得巨大的连续块。

伙伴的艺术:构建现实世界的分配器

伙伴系统是管理大型、页对齐块的大师。但内核需要的无数微小数据结构呢——网络包头、文件描述符、进程控制块?用伙伴分配器为一个 60 字节的对象分配一个 4 KiB4\,\mathrm{KiB}4KiB 的页面,会造成极大的浪费。在这里,我们看到伙伴系统不是一个孤胆英雄,而是一个两级策略中的关键伙伴。

想象一个工作负载,它在需要一个大的连续 DMA 缓冲区和突然需要数千个小对象之间交替。伙伴系统处理大的请求。对于小对象,内核则采用 ​​slab 分配器​​。slab 分配器的天才之处在于,它从伙伴系统请求一个相当大的内存块——一个或多个页面的“slab”。然后,它像一个精密机械师一样,将这个 slab 分割成许多大小完美的小槽,用于管理它所负责的微小对象。这种合作关系非常美妙:伙伴系统管理粗粒度的物理页面景观,而 slab 分配器则处理这些页面内的细粒度世界,从而消除了小对象的内部碎片。

这种混合方法的原则是如此强大,以至于它已成为日常程序使用的通用堆分配器中的标准模式。像 jemalloc 这样的分配器可能会对小请求(例如,小于 512512512 字节)使用一种策略,如分离空闲链表,而对大请求则切换到类似伙伴的算法。这是一项精湛的工程设计,它认识到没有一个“最佳”的分配器,只有适合特定工作的正确工具,并将它们和谐地组合在一起。

在更广阔世界的回响:数据结构与语言

伙伴系统 2 的幂次性质的影响远远超出了内核,与我们在应用程序中构建的数据结构产生了微妙但重要的交互。以普通的动态数组为例,它是 C++ 的 std::vector 或 Python 的 list 的基础。这些结构的一个常见策略是,在空间用尽时将其容量加倍。

当这种加倍策略遇到伙伴分配器时会发生什么?假设一个动态数组需要增长并请求一个新的内存块。数组的大小加倍,这是一个 2 的幂次增长。伙伴分配器提供大小为 2 的幂的块。这可能导致一种美妙的协同作用,数组的需求与分配器的供给完美契合,从而最大限度地减少浪费。然而,轻微的不匹配——比如说,一个包含 8 字节元素的数组增长到需要略多于 1024 字节的容量——就可能迫使伙伴系统分配一个 2048 字节的块,造成显著的内部碎片。因此,一个高级数据结构的性能,包括其添加元素的摊销成本,都无形中与其运行的底层分配器的行为联系在一起。

在像 Java、C# 或 Go 这样的托管语言世界里,这种联系变得更加深刻。它们的运行时具有复杂的垃圾收集器(GC),可以自动管理内存。一种常见的策略是按大小分离对象。小对象可能由一个“复制”收集器处理,该收集器通过移动对象来压缩内存。但复制非常大的对象(例如,一个数兆字节的图像或一个机器学习模型)的成本高得令人望而却步。相反,这些对象通常被放置在一个特殊的“大对象空间”(Large Object Space, LOS)中,在那里它们被分配并且永不移动。而管理一个用于存放大小可变、不可移动的大对象的内存区域,最完美的工具是什么?伙伴系统。它为 LOS 提供分配和释放服务,与 GC 机器的其余部分在一个复杂、精细调校的舞蹈中并存,以保持应用程序平稳运行。

现代前沿:驾驭新型异构硬件

伙伴系统的原理构思于几十年前,但已证明具有非凡的韧性,现在正被应用于解决最先进硬件的挑战。

以图形处理单元(GPU)为例。一个 GPU 包含数十个处理核心,每个核心都有自己微小、超快的“暂存”内存(scratchpad memory)。为了实现最高性能,这个宝贵的资源必须在并行运行的众多线程(warps)之间仔细划分。伙伴系统是这种划分的自然选择。在这里,分配器行为的后果是即时且显著的。如果一个 warp 请求 0.9 KiB0.9\,\mathrm{KiB}0.9KiB 的内存,伙伴分配器可能会将其向上取整为一个 1 KiB1\,\mathrm{KiB}1KiB 的块。这个看似微小的浪费——内部碎片——当乘以数百个 warp 时,可能意味着更少的线程块可以容纳到 GPU 的核心上。这直接降低了 GPU 的“占用率”(occupancy),这是一个用于隐藏延迟和实现高吞吐量的关键指标。碎片的抽象概念变成了高性能并行计算中的具体瓶颈。

或者考虑​​异构内存系统​​的兴起。一个现代服务器可能有一小部分超快的 DRAM,配上大量稍慢但更便宜的非易失性内存(Non-Volatile Memory, NVM)。操作系统的任务是充当一个聪明的交通警察,确保“热”的、频繁访问的数据驻留在 DRAM 中,而“冷”的、不活跃的数据则被移到 NVM。为此,操作系统需要管理两个池中的空闲空间。于是我们发现有独立的伙伴分配器,一个用于 DRAM,一个用于 NVM,协同工作。当一个新的热对象到来且 DRAM 已满时,操作系统可以查询其伙伴分配器,对将一个冷对象从 DRAM 迁移到一个可用的 NVM 块进行成本效益分析,然后使用新释放的 DRAM 块来存放热对象。伙伴系统在一个复杂的、系统级的优化引擎中成为了关键的促成者。

在这段从 DMA 控制器的物理需求到 GPU 性能调优的旅程中,我们看到了伙伴系统的本质:一个简单、强大且具有适应灵活性的思想。它证明了计算机科学持久的美丽,即一个问题的优雅解决方案可以向外扩散,为无数其他情境带来清晰和秩序,揭示了该领域深刻而令人满意的统一性。