3.2.4 Buddy System(伙伴系统)
3.2.4.1 算法思路
对于内存管理,一个最基础的需求就是对连续内存页面的分配与释放,而在对内存页面的分配与释放过程中,需要解决的一个重要问题就是如何避免内存出现大量的碎片。例如对于一个有4个页面的系统,系统发起了两次请求,每次申请一个页面,系统可以有如下两种分配策略:

在方案一中,剩下的两个页面被隔离开了,如果系统接下来想要申请两个连续页面,那么请求就会失败;而方案二中,被分配的两个页面是挨着的,系统还可以成功地申请两个连续的页面。不管实际上物理内存页面的数量如何,一个优秀的内存分配器都应该采用方案二这种方式来工作,尽可能地将可用页面保留成连续的区域。
这种碎片被称为外部碎片(External Fragmentation),与之相对应的是内部碎片(Internal Fragmentation),指在页内分配对象时造成的浪费,这在后续介绍Slab Allocator时会提及。
Linux内核使用Buddy System来负责连续页面的分配与释放,内核将连续的内存页面按照固定的数量进行分组,对于任何一个分组,合法的页面数量是 2^n
且 0<=n<=10
, 大小相等且相邻的两个分组是彼此的Buddy, 例如下图中,当 n=1
时,组的页面数量是2, 此时 Group 1 与 Group 2 互为Buddy, 但Group 1 与 Group 3 由于隔开了,就不是Buddy.

我们定义Buddy这个概念的目的是在内存分配与释放的过程中,能够动态地维护尽可能长的连续内存。例如上图中,如果 Group1 与 Group 2 都被释放回来后,系统就将会对两个Buddy进行合并,形成一个数量更大的组Group 4, 这也是两个Buddy必须相邻的原因。在分配内存时,Buddy System 会首先根据请求的页面数量确定目标分组,如果请求的连续内存数量是k, 那么Buddy System 将选择满足 2^n >= k
的最小的数字n 对应的那个分组,如果Buddy System 中没有对应的分组,那么系统将会将更大的分组劈开形成一对Buddy, 然后看劈开后的分组是否适合,如果不行的话就继续劈开,直到到达合适的大小为止。下图演示了一个Buddy System 从64页连续内存中分配8页的过程:

系统本来只有一个64 页的分组,完成此次分配之后,系统中可用的分组如下:一个8页分组、一个16页分组、以及一个32页的分组,即图中红色方框的部分。由于每个分组的页数都满足 2^n
, 为了避免分配页面时出现碎片,Buddy System要求分配时请求的页面数量也必须满足 2^n
的数字大小,如果实际请求的数字 k 不满足2的n次方的话,Buddy System会分配大于等于 k 的最小的2的某次方的那个数字进行分配,例如如果系统请求的页面数量是7, 那么系统实际上会分配8个页面。
当释放内存时,Buddy System 会查看释放分组的Buddy 是否空闲,如果空闲的话便会合并两个分组形成一个更大的分组,并递归该操作直到当前分组的Buddy 繁忙(指被分配出去还未释放)或已经形成最大数量的分组为止。例如上图中,如果系统释放刚刚分配回去的8 个页面的话,Buddy System 又会自底向上地合并所有的分组,最终又形成一个64页的分组。
由此可知,图2中的Group 2与 Group 3虽然数量相等且相邻,但他们并不是Buddy, 根据Buddy System 中对于分组数量的规定,两个分组如何可合并的话,那么第一个分组的首地址必然是合并后组内页面数量的整数倍。如果将Group 2与Group 3合并,将得到一个页数是4的分组,因此Group 2的起始地址应该是4的整数倍,但Group 2的起始地址是2, 因此Group 2与Group 3不会有合并的机会。
Buddy System是内存管理的基础,其分配的对象的连续的物理页面,这是一个比较大的粒度,内核会在Buddy System 之上构建更加精细的内存分配机制提供给程序使用,这就是后续将要介绍的Slab系统。
3.2.4.2 数据结构
Buddy System从 zone
中分配内存,zone 中使用一个数组来管理不同大小的Buddy, 该字段定义如下:
/* file: include/linux/mmzone.h */
struct zone {
/* free areas of different sizes */
struct free_area free_area[MAX_ORDER];
}
MAX_ORDER
的值是 11, 也就是说,Buddy System 一次能够分配的最大连续页面是 210=1024 个,即4M的连续内存。数组 free_area
每个索引 i 存放的就是大小为 2i 页面数量的分组,这样可以提升Buddy System分配内存时查找分组的效率。数据结构 free_area
的定义如下:
/* file: include/linux/mmzone.h */
struct free_area {
struct list_head free_list[MIGRATE_TYPES];
unsigned long nr_free;
};
free_area
中的空闲分组按照 MIGRATE_TYPES
进行了分组,每种类型都保存在一个双向链表中,这里我们将关注点放在Buddy System算法的实现上,不展开讨论 MIGRATE_TYPES
.
分配内存时还需要很多标记类参数,例如指定目标 zone
(是从 DMA 请求内存还是 NORMAL)、分配到的内存是否要全部置零、如果内存不足,是否可以触发内存回收机制等等,这些标记都定义在 include/linux/gfp.h
中,例如:
/* file: include/linux/gfp.h */
#define ___GFP_DMA 0x01u
#define ___GFP_HIGHMEM 0x02u
#define ___GFP_DMA32 0x04u
#define ___GFP_MOVABLE 0x08u
#define ___GFP_RECLAIMABLE 0x10u
#define ___GFP_HIGH 0x20u
#define ___GFP_IO 0x40u
#define ___GFP_FS 0x80u
#define ___GFP_ZERO 0x100u
#define ___GFP_ATOMIC 0x200u
/* 通过位运算将不同的标记组合起来 */
#define GFP_ATOMIC (__GFP_HIGH|__GFP_ATOMIC|__GFP_KSWAPD_RECLAIM)
#define GFP_KERNEL (__GFP_RECLAIM | __GFP_IO | __GFP_FS)
#define GFP_KERNEL_ACCOUNT (GFP_KERNEL | __GFP_ACCOUNT)
#define GFP_NOWAIT (__GFP_KSWAPD_RECLAIM)
源文件中对每个标记位的作用都有详细的说明,这里不再一一讨论。特别指出的是,GFP是Get Free Page的简称,这些标记叫着GFP Flags, 他们控制着Buddy System分配内存时的行为。
3.2.4.3 分配内存
入口函数
涉及物理内存分配的主要代码在文件
include/linux/gpf.h
与mm/page_alloc.c
中,本节的代码也都是摘自这两个文件。
内存分配的入口函数有很多,我们来看一个典型的入口函数申明:
/* file: include/linux/gfp.h */
static inline struct page *alloc_pages(gfp_t gfp_mask, unsigned int order)
{
return alloc_pages_node(numa_node_id(), gfp_mask, order);
}
函数接收两个参数,一个是 GFP Mask, 另一个用来指定页面数量:参数 order
表示需要申请 2^order
个连续页面。该函数是NUMA架构下的入口函数之一,其首先通过函数 numa_node_id
找到当前CPU所在的NUMA Node, 即定位目标 pglist_data
对象,然后调用 alloc_pages_node
函数进行内存分配。不管入口函数是哪个,最终都会调用到 __alloc_pages
:
/* file: include/linux/gfp.h */
static inline struct page *
__alloc_pages(gfp_t gfp_mask, unsigned int order, int preferred_nid)
{
return __alloc_pages_nodemask(gfp_mask, order, preferred_nid, NULL);
}
总而言之,内核在向Buddy System申请内存时需要提供如下信息:
如果在NUMA架构下,需要指定目标Node
页面数量
GFP Flags, 用来控制Buddy System的各种行为
函数 __alloc_pages_nodemask
是Buddy System 的核心,包含三部分:
准备内存分配的上下文,即初始化各种参数并将其封装到数据结构
alloc_context
中尝试通过快速路径(fastpath)分配内存,快速路径表示系统能够直接满足内存分配请求
尝试通过慢速路径(slowpath)分配内存,表示当前系统无法满足内存分配请求,系统会暂时挂起内存分配操作,并通过回收内存、规整(compact)内存、或者通过 OOM Killer 杀死某些进程回首内存之后,再尝试内存分配操作
函数的总体逻辑如下:
/* file: mm/page_alloc.c */
struct page *__alloc_pages_nodemask(gfp_t gfp_mask, unsigned int order,
int preferred_nid, nodemask_t *nodemask)
{
struct page *page;
unsigned int alloc_flags = ALLOC_WMARK_LOW;
gfp_t alloc_mask; /* The gfp_t that was actually used for allocation */
struct alloc_context ac = {};
/* 1. 第一步,准备内存分配的上下文 */
if (!prepare_alloc_pages(gfp_mask, order, preferred_nid, nodemask, &ac,
&alloc_mask, &alloc_flags))
return NULL;
/* 2. 快速路径分配内存 */
page = get_page_from_freelist(alloc_mask, order, alloc_flags, &ac);
if (likely(page))
/* 如果快速路径分配成功,则直接返回 */
goto out;
/* 3. 慢速路径分配内存 */
page = __alloc_pages_slowpath(alloc_mask, order, &ac);
out:
return page;
}
后续我们将主要关注快速路径的分配逻辑。
定位目标zone
想要完成内存分配操作,首先需要找到合适的 zone. 细心的读者可以已经发现,函数 __alloc_pages
中用于指定目标 node 的参数名叫 preferred_nid
, 之所以叫 preferred
的原因是当前所选择的目标 node 可能无法满足程序的内存申请,此时系统可能会尝试着从其它 node 进行分配。在讨论 2.4 的数据结构时,我们提到过每个 node 中都保存着所有 node 的所有 zone,相关字段定义如下:
/* file: include/linux/mmzone.h */
typedef struct pglist_data {
/*
* node_zonelists contains references to all zones in all nodes.
* Generally the first zones will be references to this node's
* node_zones.
*/
struct zonelist node_zonelists[MAX_ZONELISTS];
}
/*
* One allocation request operates on a zonelist. A zonelist
* is a list of zones, the first one is the 'goal' of the
* allocation, the other zones are fallback zones, in decreasing
* priority.
*
* To speed the reading of the zonelist, the zonerefs contain the zone index
* of the entry being read. Helper functions to access information given
* a struct zoneref are
*
* zonelist_zone() - Return the struct zone * for an entry in _zonerefs
* zonelist_zone_idx() - Return the index of the zone for an entry
* zonelist_node_idx() - Return the index of the node for an entry
*/
struct zonelist {
struct zoneref _zonerefs[MAX_ZONES_PER_ZONELIST + 1];
};
/*
* This struct contains information about a zone in a zonelist. It is stored
* here to avoid dereferences into large structures and lookups of tables
*/
struct zoneref {
struct zone *zone; /* Pointer to actual zone */
int zone_idx; /* zone_idx(zoneref->zone) */
};
数组 node_zonelists
长度通过 MAX_ZONELISTS
确定:
/* file: include/linux/mmzone.h */
enum {
ZONELIST_FALLBACK, /* zonelist with fallback */
#ifdef CONFIG_NUMA
/*
* The NUMA zonelists are doubled because we need zonelists that
* restrict the allocations to a single node for __GFP_THISNODE.
*/
ZONELIST_NOFALLBACK, /* zonelist without fallback (__GFP_THISNODE) */
#endif
MAX_ZONELISTS
};
在NUMA架构下,该数组包含着两个列表,一个 fallback
列表一个 nofallback
列表,前者包含着系统中所有的 zone, 列表的排列顺序就是Buddy System 分配内存时选择 zone 的优先级顺序,后者仅包含当前 node 的 zone。fallback 的意思是当前 node 的 zone 无法满足要求时,是否需要使用其它 node 中的 zone 来顶替。 zonelist
就是一个 zone 的数组,Buddy System分配内存时就会遍历整个数组,从第一个合适的 zone 中分配内存,zonelist 的长度定义如下:
/* file: include/linux/mmzone.h */
/* zonelist 的最大长度,node 数量乘以每个 node 的分区数量,用于存放整个 fallback 列表 */
#define MAX_ZONES_PER_ZONELIST (MAX_NUMNODES * MAX_NR_ZONES)
两个 zonelist 在系统的启动阶段进行初始化,NUMA 架构下 fallback 列表的构建逻辑是先将所有的 node 按照到当前 node 的“远近程度”排序,然后再将每个 node 的 zone 按照一定顺序排列起来。核心代码如下:
/* file: mm/page_alloc.c */
/*
* Build zonelists ordered by zone and nodes within zones.
* This results in conserving DMA zone[s] until all Normal memory is
* exhausted, but results in overflowing to remote node while memory
* may still exist in local DMA zone.
*/
static void build_zonelists(pg_data_t *pgdat)
{
static int node_order[MAX_NUMNODES];
int node, load, nr_nodes = 0;
nodemask_t used_mask = NODE_MASK_NONE;
int local_node, prev_node;
/* NUMA-aware ordering of nodes */
local_node = pgdat->node_id;
load = nr_online_nodes;
prev_node = local_node;
memset(node_order, 0, sizeof(node_order));
/* find_next_best_node 会寻找下一个距离 local_node 最近的 node */
while ((node = find_next_best_node(local_node, &used_mask)) >= 0) {
/*
* We don't want to pressure a particular node.
* So adding penalty to the first node in same
* distance group to make it round-robin.
*/
if (node_distance(local_node, node) !=
node_distance(local_node, prev_node))
node_load[node] = load;
node_order[nr_nodes++] = node;
prev_node = node;
load--;
}
build_zonelists_in_node_order(pgdat, node_order, nr_nodes);
build_thisnode_zonelists(pgdat);
}
函数 find_next_best_node
会寻找下一个距离 localnode 最近的 node, 这样通过 while 循环就可以将所有的 node 按照“距离(Distance)”排好序。“距离(Distance)”是NUMA架构下CPU访问其它节点的内存耗时的一个度量单位,两个CPU 节点距离越远,访问起来越耗时,因此构建 fallback 列表时,需要按照距离由近到远对所有的 node 进行排序。例如下图中,三个CPU 的 fallback 顺序依次是:
Node 1: 1 -> 2 -> 3
Node 2: 2 -> 1 -> 3
Node 3: 3 -> 1 -> 2

对 node 排好序之后,通过函数 build_zonelists_in_node_order
来构建当前 node 的 fallback 列表:
/* file: mm/page_alloc.c */
static void build_zonelists_in_node_order(pg_data_t *pgdat, int *node_order,
unsigned nr_nodes)
{
struct zoneref *zonerefs;
int i;
/* 拿到当前 node 的 fallback 列表 */
zonerefs = pgdat->node_zonelists[ZONELIST_FALLBACK]._zonerefs;
for (i = 0; i < nr_nodes; i++) {
int nr_zones;
/* 获取到对应的 node */
pg_data_t *node = NODE_DATA(node_order[i]);
/* 将 node 中的各个分区按照由高到低的顺序放入 zonerefs 中,放入顺序为:Movable -> Normal -> DMA */
nr_zones = build_zonerefs_node(node, zonerefs);
zonerefs += nr_zones;
}
zonerefs->zone = NULL;
zonerefs->zone_idx = 0;
}
分配内存时的遍历逻辑在函数 get_page_from_freelist
中(该函数也是快速路径分配的入口函数),通过宏 for_next_zone_zonelist_nodemask
来完成,找到合适的 zone
了之后,系统通过函数 rmqueue
来完成内存分配。
内存分配
快速路径(fastpath)下分配内存的核心逻辑在函数 rmqueue
中,函数的主体代码如下:
/* file: mm/page_alloc.c */
static inline struct page *rmqueue(struct zone *preferred_zone,
struct zone *zone, unsigned int order,
gfp_t gfp_flags, unsigned int alloc_flags,
int migratetype)
{
unsigned long flags;
struct page *page;
if (likely(order == 0)) {
/* 对于 order=0 的情况,尝试从 per_cpu_pageset 中分配内存 */
if (!IS_ENABLED(CONFIG_CMA) || alloc_flags & ALLOC_CMA ||
migratetype != MIGRATE_MOVABLE) {
page = rmqueue_pcplist(preferred_zone, zone, gfp_flags,
migratetype, alloc_flags);
goto out;
}
}
/* 访问 zone 时需要加锁 */
spin_lock_irqsave(&zone->lock, flags);
do {
page = NULL;
if (order > 0 && alloc_flags & ALLOC_HARDER) {
/* 从 zone 的free area中分配内存,free area 就是Buddy System 中用来存放各种 order 的内存页面的地方 */
page = __rmqueue_smallest(zone, order,
MIGRATE_HIGHATOMIC);
}
/* 如果还没有分配到内存,则尝试通过 fallback 列表分配内存 */
if (!page)
page = __rmqueue(zone, order, migratetype, alloc_flags);
} while (page && check_new_pages(page, order));
/* 释放锁 */
spin_unlock(&zone->lock);
if (!page)
goto failed;
local_irq_restore(flags);
out:
return page;
failed:
local_irq_restore(flags);
return NULL;
}
总体来说,函数的逻辑根据 order 的大小分为两部分:
当order = 0, 即申请的内存页数等于1时,系统会从每个CPU的缓存页面中分配内存
当order > 0, 即申请的内存页数大于1时,系统会从 zone 中分配内存
之所以这样区别对待,是因为 zone
是共享的数据结构,在多核系统中会面临巨大的竞争,通过代码也可以发现,当通过 zone 分配内存时需要对 zone->lock 加锁。而根据实际经验,人们发现系统对于 order=0 的内存分配请求是出现频次最高的,为了提升性能,系统就为每个CPU 建立了一个“缓存池”,也就是前文讨论 zone
的数据结构时提到的字段 struct per_cpu_pageset __percpu *pageset;
, 内核将其称为 pcplist, 即 per cpu pages list, 从 pcplist 分配内存的函数是 rmqueue_pcplist
:
/* file: mm/page_alloc.c */
static struct page *rmqueue_pcplist(struct zone *preferred_zone,
struct zone *zone, gfp_t gfp_flags,
int migratetype, unsigned int alloc_flags)
{
struct per_cpu_pages *pcp;
struct list_head *list;
struct page *page;
unsigned long flags;
local_irq_save(flags);
/* 获取到当前CPU 的 pageset */
pcp = &this_cpu_ptr(zone->pageset)->pcp;
/* 拿到 pageset 中对应类型的内存列表 */
list = &pcp->lists[migratetype];
page = __rmqueue_pcplist(zone, migratetype, alloc_flags, pcp, list);
if (page) {
__count_zid_vm_events(PGALLOC, page_zonenum(page), 1);
zone_statistics(preferred_zone, zone);
}
local_irq_restore(flags);
return page;
}
static struct page *__rmqueue_pcplist(struct zone *zone, int migratetype,
unsigned int alloc_flags,
struct per_cpu_pages *pcp,
struct list_head *list)
{
struct page *page;
do {
/* 如果目标列表为空,则通过 Buddy System 为该列表补充内存,然后再进行分配 */
if (list_empty(list)) {
pcp->count +=
rmqueue_bulk(zone, 0, READ_ONCE(pcp->batch),
list, migratetype, alloc_flags);
if (unlikely(list_empty(list)))
return NULL;
}
/* 将对应内存列表的第一个内存页作为结果返回 */
page = list_first_entry(list, struct page, lru);
list_del(&page->lru);
pcp->count--;
} while (check_new_pcp(page));
return page;
}
整个逻辑非常直观,就是找到当前CPU 的内存“缓存池”,然后从取出一页 migratetype 对应的内存即可。值得一提的是如果 migratetype 对应的内存列表为空,那么系统会通过 Buddy System 为该列表补充内存,该逻辑通过函数 rmqueue_bulk
完成,这里不再深入。
如果 order>0, 则通过函数 __rmqueue_smallest
从 zone 中分配内存,Buddy System 的算法实现也包含在该函数中:
/* file: mm/page_alloc.c */
static __always_inline struct page *
__rmqueue_smallest(struct zone *zone, unsigned int order, int migratetype)
{
unsigned int current_order;
struct free_area *area;
struct page *page;
/* Find a page of the appropriate size in the preferred list */
for (current_order = order; current_order < MAX_ORDER;
++current_order) {
area = &(zone->free_area[current_order]);
/* include/linux/mmzone.h: 作用是从 area->free_list[migratetype] 列表中拿到第一个元素,也就是一个连续 2^order 个物理页面 */
page = get_page_from_free_area(area, migratetype);
/* 如果没有对应大小的free list, 则尝试下一个 order 的列表 */
if (!page)
continue;
del_page_from_free_list(page, zone, current_order);
/* 如果 order != current_order, 则需要按照Buddy Algorithm将更大的连续内存劈开成更小的Buddy并插入到对应的 free area 列表中,该逻辑在 expand 函数中完成 */
expand(zone, page, order, current_order, migratetype);
set_pcppage_migratetype(page, migratetype);
return page;
}
return NULL;
}
函数通过 for 循环遍历 zone->free_area
, 找到能够满足请求的最小的Buddy并从中分配内存,如果实际的Buddy 列表大小大于请求的 order, 则通过 expand
对其进行拆分并将未分配的内存存入对应 order 的Buddy列表中,整个算法思路与前文讨论的一样。
Last updated
Was this helpful?