外碎片问题
每当页面被分配和回收时,系统都要遇到名为外部碎片的内存碎片问题。这是由于可用页面散布于整个内存空间中,即使系统可用页面总数足够多,但也无法分配大块连续页面。
内核应该为分配一组连续的页框而建立一种健壮、高效的分配策略。频繁的请求和释放不同大小的一组连续页框,必然导致在已分配页框的块内分散了许多小块的空闲页框。 这样,即使这些页框是空闲的,其他需要分配连续页框的应用也很难得到满足。
从本质上来说,避免外碎片的方法有两种:
- 利用分页单元把一组非连续的空闲页框映射到连续的线性地址区间。
- 开发一种适当的技术来记录现存的空闲连续页框块的情况,以尽量避免为满足对小块的请求而把大的空闲块进行分割。
基于以下三种原因,内核首选第二种方法:
- 在某些情况下,连续的页框确实是必要的,因为连续的线性地址不足以满足请求。比如给DMA处理器分配缓冲区的时候,DMA会忽略分页单元而直接访问地址总线,因此,所请求的缓冲区就必须位于连续的页框中。
- 频繁的修改页表势必导致平均访问内存次数的增加,因为会频繁的刷新TLB的内容。
- 内核通过4MB的页可以访问大块连续的物理内存。
伙伴系统概述
Linux使用著名的 伙伴系统(buddy system) 这样的内存管理算法来解决外部碎片问题。
伙伴系统把内存中的空闲块组成链表。每个链表都指向不同大小的内存块,虽然大小不同,但都是 。至于系统中有多少个链表,则取决于具体实现。从最小的空闲块链表中分配页面,这样保证了较大的内存块可留给更大的内存请求。
当分配的块被释放时,伙伴系统搜索与所释放块大小相等的可用空闲内存块,如果找到相邻的空闲块,那么就将它们合并成两倍于自身大小的一个新内存块。这些可合并的内存块(所释放块和与其相邻的空闲块)就称为伙伴,所以也就有了伙伴系统一说。之所以要这么做,是因为内核需要确保只要页面被释放,就能获得更大的可用内存块。
Linux内核把所有的空闲页框分组为11个块链表,每个块链表分别包含大小为1,2,4,8,16,32,64,128,256,512和1024个连续页框的页框块。最大可以申请1024个连续页框,对应4MB大小的连续内存。
每个页框块的第一个页框的物理地址是该块大小的整数倍。 例如:大小为16个页框的块,其起始地址是 的倍数。其中, ,是一个常规页的大小。
我们接下来通过一个简单的例子来说明该算法的工作原理。
假设要请求一个 128 个页框的块(0.5M)。
- 算法首先会在128个页框的链表中检查是否有一个空闲块。如果没有找到,那么算法会查找下一个更大的页块。
- 继续在256个页框的链表中找一个空闲块。如果存在这样的块,那么内核就把256的页框分成两等分,一半用做满足请求,另一半插入到 128 个页框的链表中。
- 如果在256个页框中没有找到空闲块,那么就继续找更大的块——512个页框的块。如果在其中找到空闲块,那么内核就把512个页框的块的128个页框用做请求,然后从剩余的384个页框中拿出256个插入到256个页框的链表中,再把最后的128个插入到128个页框的链表中。
- 如果512个页框的链表中仍没有空闲块,继续向1024个页框的链表查找,如果仍然没有,则返回错误。
以上过程的逆过程就是页框块的释放过程,也是该算法名字的由来。内核试图把大小为 的一对空闲块伙伴合并为一个大小为 的单独块。满足一下条件的两个块称为伙伴。
- 两个块具有相同的大小, 记作 。
- 它们的物理地址是连续的。
- 第一块的第一个页框的物理地址是 的倍数。
该算法是迭代的,如果它成功合并所释放的块,那么它会试图继续合并 的块来形成更大的块。
主要数据结构
Linux2.6为每个管理区使用不同的伙伴系统,内核空间分为三种区:DMA、NORMAL、HIGHMEM,对于每一种区,都有对应的伙伴算法。
每个伙伴系统使用的主要数据结构如下:
- 每个管理区都关系到mem_map元素的子集,子集中的第一个元素和元素的个数分别由管理区描述符 zone_mem_map 和 size 字段指定。
- 包含有11个元素、元素类型为 free_area 的一个数组,每个元素对于一种块大小,该数组存放在管理区描述符的 free_area 字段中。
#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块链表,内存分配的最大长度便是 页面。
上面两个结构体向我们揭示了伙伴算法管理结构。
- zone结构中的free_area数组,大小为11,分别存放着这11个组。
- free_area结构体里面又标注了该组别空闲内存块的情况。
我们考虑管理区描述符中 free_area 数组的第 个元素,它标识所有大小为 的空闲块。这个元素的 free_list 字段是双向循环链表的头,这个双向循环链表集中了大小为 页的空闲块对应的页描述符。
更精确的说,该链表包含每个空闲页块(大小为 )的起始页框的页描述符;指向链表中相邻元素的指针存放在页描述符的lru字段中。
除了链表头外,free_area 数组的第k个元素同样包含字段nr_free,它指定了大小为 页的空闲块的个数。当然,如果没有大小为 的空闲页框块,则nr_free等于 0 且 free_list 为空( free_list 的两个指针都指向它自己的 free_list 字段)。
最后,一个 的空闲页块的第一个页的描述符的private字段存放了块的 order,也就是数字 。正是由于这个字段,当页块被释放时,内核可以确定这个块的伙伴是否也空闲,如果是的话,它可以把两个块结合成大小为 页的单一块。
整个分配图示,大概如下:
分配块
使用 __rmqueue() 函数用来在管理区中找到一个空闲块。该函数需要两个参数:管理区描述符的地址zone和order,order表示请求的空闲页块大小的对数值(0 表示一个单页块,1 表示一个两页块,2表示四个页块,以此类推)。
如果页框被成功分配,__rmqueue()函数就返回第一个被分配页框的页描述符。否则,函数返回NULL。
在__rmqueue()函数中,从所请求order的链表开始,它扫描每个可用块链表进行循环搜索,如果需要搜索更大的order,就继续搜索:
struct free_area *area;
unsigned int current_order;
for (current_order=order; current_order<11; ++current_order) {
area = zone->free_area + current_order;
if (!list_empty(&area->free_list))
goto block_found;
}
return NULL;
如果直到循环结束还没有找到合适的空闲块,那么__rmqueue()就返回NULL。
否则,找到了一个合适的空闲块,在这种情况下,从链表中删除它的第一个页框描述符,并减少管理区描述符中的 free_pages 的值:
block_found:
page = list_entry(area->free_list.next, struct page, lru);
list_del(&page->lru);
ClearPagePrivate(page);
page->private = 0;
area->nr_free--;
zone->free_pages -= 1UL << order;
如果从 curr_order 链表中找到的块大于请求的order,就执行一个while循环。这几行代码蕴含的原理如下:当为了满足 个页框的请求而有必要使用 个页框的块时 ,程序就分配前面的 个页框,而把后面 个页框循环再分配给 free_area 链表中下标在 到 之间的元素:
size = 1 << curr_order;
while (curr_order > order) {
area--;
curr_order--;
size >>= 1;
buddy = page + size;
/* insert buddy as first element in the list */
list_add(&buddy->lru, &area->free_list);
area->nr_free++;
buddy->private = curr_order;
SetPagePrivate(buddy);
}
return page;
因为__rmqueue()函数已经找到了合适的空闲块,所以它返回所分配的第一个页框对应的页描述符的地址page。
释放块
__free_pages_bulk()函数按照伙伴系统的策略释放页框。它使用3个基本输入参数:
- page:被释放块中所包含的第一个页框描述符的地址。
- zone:管理区描述符的地址。
- order:块大小的对数。
__free_pages_bulk()首先声明和初始化一些局部变量:
struct page * base = zone->zone_mem_map;
unsigned long buddy_idx, page_idx = page - base;
struct page * buddy, * coalesced;
int order_size = 1 << order;
page_idx 局部变量包含块中第一个页框的下标,这是相对于管理区中的第一个页框而言的。order_size 局部变量用于增加管理区中空闲页框的计数器:
zone->free_pages += order_size;
现在函数开始执行循环,最多循环 (10 - order) 次,每次都尽量把一个块和它的伙伴进行合并。函数以最小的块开始,然后向上移动到顶部:
while (order < 10) {
buddy_idx = page_idx ^ (1 << order);
buddy = base + buddy_idx;
if (!page_is_buddy(buddy, order))
break;
list_del(&buddy->lru);
zone->free_area[order].nr_free--;
ClearPagePrivate(buddy);
buddy->private = 0;
page_idx &= buddy_idx; /* 合并 */
order++;
}
比如,我们这里order是 4,那么 order_size 的值为 ,也就是16,表明要释放16个连续的page。page_idx为这个连续16个page的老大的mem_map数组的下标。进入循环后,函数首先寻找该块的伙伴,即mem_map数组中page_idx-16或page_idx+16的下标buddy_idx,进一步说明一下,就是为了在下标为16的free_area中找到一个空闲的块,并且这个块与page所带的那个拥有16个page的块相邻。
在循环体内,函数寻找块的下标 buddy_idx,它是拥有 page_idx 页描述符下标块的伙伴,结果这个下标可以被简单地如下计算:
buddy_idx = page_idx ^ (1 << order)
这行代码很巧妙,短小精干。因为order一来就等于4,所以循环从4开始的,即第一个循环为buddy_idx = page_idx ^ (1<<4),即buddy_idx = page_idx ^ 10000。如果page_idx第5位为1,比如是20号页框(10100),那么在异或以后,buddy_idx为4号页框(00100)。如果page_idx第5位为0,比如是第40号页框(101000),那么在异或以后,buddy_idx为56号页框(111000)。
抽象一下上述计算:使用( )掩码的异或(XOR)转换page_idx第order位的值。因此,如果这个位原先是0,buddy_idx 就等于 page_idx + order_size。相反,如果这个位原先是1,buddy_idx 就等于 page_idx - order_size。
一旦知道了伙伴块下标,那么就可以通过下式很容易的获得伙伴块的页描述符:
buddy = base + buddy_idx;
现在函数调用 page_is_buddy() 来检查 buddy 是否是真正的值得信赖的伙伴,也就是 buddy 是否描述了大小为order_size的空闲页框块的第一个页。
int page_is_buddy(struct page *page, int order)
{
if (PagePrivate(buddy) && page->private == order &&
!PageReserved(buddy) && page_count(page) ==0)
return 1;
return 0;
}
正如所见,要想成为伙伴,必须满足以下四个条件:
- buddy 的第一个页必须为空闲( _count 字段等于-1);
- 它必须属于动态内存( PG_reserved 位清零);
- 它的 private 字段必须有意义( PG_private 位置位);
- 它的 private 字段必须存放将要被释放的块的 order。
如果所有这些条件都符合,伙伴块就被释放,并且函数将它从以order排序的空闲链表上删除,并再执行一次循环以寻找两倍大小的伙伴块。
如果 page_is_buddy() 中至少有一个条件没有被满足,则该函数跳出循环,因为获得的空闲块不能再和其他空闲块合并。函数将它插入适当的链表并以块大小的 order 更新第一个页框的 private 字段。
coalesced = base + page_idx;
coalesced->private = order;
SetPagePrivate(coalesced);
list_add(&coalesced->lru, &zone->free_area[order].free_list);
zone->free_area[order].nr_free++;