通常情况下,一个高级操作系统必须要给进程提供基本的、能够在任意时刻申请和释放任意大小内存的功能,就像 malloc 函数那样,然而,实现 malloc 函数并不简单,由于进程申请内存的大小是任意的,如果操作系统对 malloc 函数的实现方法不对,将直接导致一个不可避免的问题,那就是内存碎片。
内存碎片就是内存被分割成很小很小的一些块,这些块虽然是空闲的,但是却小到无法使用。随着申请和释放次数的增加,内存将变得越来越不连续。最后,整个内存将只剩下碎片,即使有足够的空闲页框可以满足请求,但要分配一个大块的连续页框就可能无法满足,所以减少内存浪费的核心就是尽量避免产生内存碎片。
针对这样的问题,有很多行之有效的解决方法,其中伙伴算法被证明是非常行之有效的一套内存管理方法,因此也被相当多的操作系统所采用。
伙伴算法,简而言之,就是将内存分成若干块,然后尽可能以最适合的方式满足程序内存需求的一种内存管理算法,伙伴算法的一大优势是它能够完全避免外部碎片的产生。什么是外部碎片以及内部碎片,前面博文 slab 分配器后面已有介绍。申请时,伙伴算法会给程序分配一个较大的内存空间,即保证所有大块内存都能得到满足。很明显分配比需求还大的内存空间,会产生内部碎片。所以伙伴算法虽然能够完全避免外部碎片的产生,但这恰恰是以产生内部碎片为代价的。
Linux 便是采用这著名的伙伴系统算法来解决外部碎片的问题。把所有的空闲页框分组为 11 块链表,每一块链表分别包含大小为 1,2,4,8,16,32,64,128,256,512 和 1024 个连续的页框。对1024 个页框的最大请求对应着 4MB 大小的连续 RAM 块。每一块的第一个页框的物理地址是该块大小的整数倍。例如,大小为 16 个页框的块,其起始地址是 16 * 2 ^ 12 (2 ^ 12 = 4096,这是一个常规页的大小)的倍数。
下面通过一个简单的例子来说明该算法的工作原理:
假设要请求一个 256(129 ~ 256)个页框的块。算法先在 256 个页框的链表中检查是否有一个空闲块。如果没有这样的块,算法会查找下一个更大的页块,也就是,在 512 个页框的链表中找一个空闲块。如果存在这样的块,内核就把 512 的页框分成两等分,一般用作满足需求,另一半则插入到 256 个页框的链表中。如果在 512 个页框的块链表中也没找到空闲块,就继续找更大的块 —— 1024 个页框的块。如果这样的块存在,内核就把 1024 个页框块的 256 个页框用作请求,然后剩余的 768 个页框中拿 512 个插入到 512 个页框的链表中,再把最后的 256 个插入到 256 个页框的链表中。如果 1024 个页框的链表还是空的,算法就放弃并发出错误信号。
简而言之,就是在分配内存时,首先从空闲的内存中搜索比申请的内存大的最小的内存块。如果这样的内存块存在,则将这块内存标记为“已用”,同时将该内存分配给应用程序。如果这样的内存不存在,则操作系统将寻找更大块的空闲内存,然后将这块内存平分成两部分,一部分返回给程序使用,另一部分作为空闲的内存块等待下一次被分配。
以上过程的逆过程就是页框块的释放过程,也是该算法名字的由来。内核试图把大小为 b 的一对空闲伙伴块合并为一个大小为 2b 的单独块。满足以下条件的两个块称为伙伴:
- 两个块具有相同的大小,记作 b 。
- 它们的物理地址是连续的。
- 第一块的第一个页框的物理地址是 2 * b * 2^12 的倍数。
该算法是迭代的,如果它成功合并所释放的块,它会试图合并 2b 的块,以再次试图形成更大的块。
假设要释放一个 256 个页框的块,算法就把其插入到 256 个页框的链表中,然后检查与该内存相邻的内存,如果存在同样大小为 256 个页框的并且空闲的内存,就将这两块内存合并成 512 个页框,然后插入到 512 个页框的链表中,如果不存在,就没有后面的合并操作。然后再进一步检查,如果合并后的 512 个页框的内存存在大小为 512 个页框的相邻且空闲的内存,则将两者合并,然后插入到 1024 个页框的链表中。
简而言之,就是当程序释放内存时,操作系统首先将该内存回收,然后检查与该内存相邻的内存是否是同样大小并且同样处于空闲的状态,如果是,则将这两块内存合并,然后程序递归进行同样的检查。
下面通过一个例子,来深入地理解一下伙伴算法的真正内涵(下面这个例子并不严格表示Linux 内核中的实现,是阐述伙伴算法的实现思想):
假设系统中有 1MB 大小的内存需要动态管理,按照伙伴算法的要求:需要将这 1 M 大小的内存进行划分。这里,我们将这 1 M 的内存分为 64K、64K、128K、256K、和 512K 共五个部分,如下图 a 所示
1、此时,如果有一个程序 A 想要申请一块 45 K 大小的内存,则系统会将第一块 64 K 的内存块分配给该程序(产生内部碎片为代价),如图 b 所示;
2、然后程序 B 向系统申请一块 68 K 大小的内存,系统会将 128 K 内存分配给该程序,如图 c 所示;
3、接下来,程序 C 要申请一块大小为 35 K 的内存。系统将空闲的 64 K 内存分配给该程序,如图 d 所示;
4、之后程序 D 需要一块大小为 90 K 的内存。当程序提出申请时,系统本该分配给程序 D 一块 128 K 大小的内存,但此时内存中已经没有空闲的 128 K 内存块了,于是根据伙伴算法的原理,系统会将 256 K 大小的内存块平分,将其中一块分配给程序 D,另一块作为空闲内存块保留,等待以后使用,如图 e 所示;
5、紧接着,程序 C 释放了它申请的 64 K 内存。在内存释放的同时,系统还负责检查与之相邻并且同样大小的内存是否也空闲,由于此时程序A并没有释放它的内存,所以系统只会将程序 C 的 64 K 内存回收,如图 f 所示;
6、然后程序 A 也释放掉由它申请的 64 K 内存,系统随机发现与之相邻且大小相同的一段内存块恰好也处于空闲状态。于是,将两者合并成 128 K 内存,如图 g 所示;
7、之后程序 B 释放掉它的 128 k,系统也将这块内存与相邻的 128 K 内存合并成 256 K 的空闲内存,如图 h 所示;
8、最后程序 D 也释放掉它的内存,经过三次合并后,系统得到了一块 1024 K 的完整内存,如图 i 所示。
有了前面的了解,我们通过Linux 内核源码(mmzone.h)来看看伙伴算法是如何实现的:
伙伴算法管理结构
#define MAX_ORDER 11
struct zone {
……
struct free_area free_area[MAX_ORDER];
……
}
struct free_area {
struct list_head free_list[MIGRATE_TYPES];
unsigned long nr_free; //该组类别块空闲的个数
};
前面说到伙伴算法把所有的空闲页框分组为 11 块链表,内存分配的最大长度便是 2^10 页面。
上面两个结构体向我们揭示了伙伴算法管理结构。zone 结构中的 free_area 数组,大小为 11,分别存放着这 11 个组,free_area 结构体里面又标注了该组别空闲内存块的情况。
将所有空闲页框分为 11 个组,然后同等大小的串成一个链表对应到 free_area 数组中。这样能很好的管理这些不同大小页面的块。
(啊哦,有时间再补充吧...)
(SAW:Game Over!)