文章说明:
Linux内核版本:5.0
架构:ARM64
参考资料:《奔跑吧Linux内核》
Linux 5.0内核源码注释及学习笔记仓库地址:
内存碎片化是内存管理中一个比较难以解决的问题。Linux内核在采用伙伴系统算法时考虑了如何减少内存碎片化。在伙伴系统算法中,两个什么样的内存块可以成为伙伴呢?其实伙伴系统算法有如下3个基本条件需要满足:
内存块的划分如下图所示:
在内存块合并的过程中,可能会出现如下情况:
此时,P0和P3就变成了空洞,这就产生了外碎片化(external fragmentation),随着时间的推移,外碎片化会变得越来越严重,内存利用率也随之下降。
学术上常用的解决外碎片化问题的技术叫做内存规整(memory compaction),也就是利用移动页面的位置让空闲页面连成一片。如果从页面的迁移属性来看,用户进程分配使用的内存是可以迁移的,但是内核本身使用的内存页面是不能随便迁移的。
为什么内核本身使用的页面不能被迁移?
因为要迁移这种页面,首先需要把物理页面的映射断开,然后重新去建立映射关系。在断开映射关系的过程中,如果内核继续访问这个页面,就会访问不正确的指针和内存,导致内核出现 Oops 错误,甚至导致系统崩溃(crash),内核作为敏感区,必须保证使用的内存是安全的。
这和用户进程不太一样,用户进程使用的页面在断开映射关系之后,如果用户进程继续访问这个页面,就会产生缺页异常。在缺页异常处理中,可以重新分配物理页面,然后和虚拟内存建立映射关系。这个过程对于用户进程来说是安全的。
在Linux2.6.24的开发阶段,社区专家就引入了防止碎片的功能,叫做反碎片化(anti-fragmentation)。其实就是利用迁移类型来实现的。迁移类型是按照页块来划分的,一个页块的大小正好是页面分配器所能分配的最大内存块大小,即2的MAX_ORDER-1次幂字节,通常是4MB。
#define MAX_ORDER 11
页面的类型如下:
因此,伙伴系统中的free_area数据结构包含了MIGRATE_TYPES个链表,这里相当于内存管理区(zone)中根据order的大小有0~MAX_ORDER-1个free_area。每个free_area根据MIGRATE_TYPES类型又有几个相应的链表,如下图所示:
// 内存管理区,zone 经常会被访问到,因此这个数据结构要求以 L1 高速缓存对齐
struct zone {
...
// 管理空闲区域的数组,包含管理链表等
struct free_area free_area[MAX_ORDER];
...
}
struct free_area {
struct list_head free_list[MIGRATE_TYPES];
unsigned long nr_free;
};
enum migratetype {
MIGRATE_UNMOVABLE,
MIGRATE_MOVABLE,
MIGRATE_RECLAIMABLE,
MIGRATE_PCPTYPES, /* the number of types on the pcp lists */
MIGRATE_HIGHATOMIC = MIGRATE_PCPTYPES,
MIGRATE_TYPES
};
在运用了这种技术的Linux内核中,所有页块里面的页面都是同一种迁移类型,中间不会再掺杂其他类型的页面。
最后给出伙伴系统的定义:伙伴系统(buddy system)是操作系统中常用的一种动态存储管理方法,在用户提出申请时,分配一个大小合适的内存块给用户,并在用户释放内存块时回收。在伙伴系统中,内存块的大小是2的order次幂个页面。Linux内核中order的最大值用MAX_ORDER来表示,通常是11,也就是把所有的空闲页面分组成11个内存块链表,每个内存块链表分别包括1,2,4,8,16,32,…,1024个连续的页面。1024个页面对应着4MB大小的连续物理内存。
在内核启动时,内核知道DDR物理内存的大小并且计算出高端内存的起始地址和内核空间的内存布局后,物理内存页面就要添加到伙伴系统中,那么物理内存页面如何添加到伙伴系统中呢?
内核初始化时所有的页面最初都被标记为MIGRATE_MOVABLE类型,见fee_area_init_core()→memmap_init_zone()函数。
void __meminit memmap_init_zone(unsigned long size, int nid, unsigned long zone,
unsigned long start_pfn, enum memmap_context context,
struct vmem_altmap *altmap)
{
...
for (pfn = start_pfn; pfn < end_pfn; pfn++) {
...
// 设置指定页块的MIGRATE_TYPES类型,最后调用set_pfnblock_flags_mask()来设置页块的迁移类型
set_pageblock_migratetype(page, MIGRATE_MOVABLE);
...
}
}
问题:
在Linux中,内核初始化时所有的页面最初都被标记为MIGRATE_MOVABLE类型,那么在什么时候将页面设置为相对应的类型?
chatGPT答:
在Linux中,内核初始化时将所有的页面都标记为MIGRATE_MOVABLE
类型,这是因为在初始化阶段,内核还不清楚各个页面的具体用途和属性。页面的具体类型在运行时动态确定,并且可以根据系统的运行状况进行调整。
在Linux内核中,页面的类型可能会发生变化,具体的时机和条件取决于不同的情况。以下是一些常见的页面类型转变的情况:
分配内存时: 当内核通过kmalloc
、vmalloc
等函数动态分配内存时,内核会根据分配的上下文和需求来设置页面的类型。
页迁移: 在内存紧张的情况下,Linux内核可能会进行页面迁移,将页面从一种类型移动到另一种类型,以满足特定的内存需求。
内存回收: 当系统需要释放内存时,内核可能会将页面的类型更改为适当的类型,以便于回收或重新分配。
内存热插拔: 在支持内存热插拔的系统中,当添加或移除内存时,内核可能会重新评估和更新页面的类型。
总体而言,页面类型的设置是一个动态的过程,取决于系统的运行状况和内核的管理策略。这种动态性允许内核根据需要灵活地管理内存。
下面我们来思考物理页面是如何添加到伙伴系统中的。是逐个页面添加,还是一次添加2的n次方个页面呢?
在free_low_memory_core_early()函数中,通过for_each_free_mem_range()函数来遍历所有 的内存块,找出内存块的起始地址和结束地址。
<start_kernel()->mm_init()->mem_init()->free_all_bootmem()->free_low_memory_core_early()>
static unsigned long __init free_low_memory_core_early(void)
{
...
for_each_free_mem_range(i, NUMA_NO_NODE, MEMBLOCK_NONE, &start, &end,
NULL)
count += __free_memory_core(start, end);
...
}
把内存块传递到__free_pages_memory()函数,该函数的定义如下:
static void __init __free_pages_memory(unsigned long start, unsigned long end)
{
int order;
while (start < end) {
// 取MAX_ORDER-1和__ffS(start)中的较小值
// __ffS(start)函数计算start中第一个位为1的位置。注意,__ffS()=__ffS()-1
// 假设start起始地址为0x63300,说明该地址以0xl00对齐。通过__ffS()函数来计算出的合适的order值为8,因为2的8次方等于0x100。
order = min(MAX_ORDER - 1UL, __ffs(start));
while (start + (1UL << order) > end)
order--;
// ->__free_pages_boot_core()
memblock_free_pages(pfn_to_page(start), start, order);
start += (1UL << order);
}
}
// 得到 order 值后,就可以把内存块通过 __free_pages_boot_core 函数添加到伙伴系统中
static void __init __free_pages_boot_core(struct page *page, unsigned int order)
{
...
// 伙伴系统的核心函数,这里按照order的方式把内存块添加内存到伙伴系统中
__free_pages(page, order);
}
由于内核使用zone来管理一个内存节点,因此一个内存节点可能被分成了多个不同的zone。内核使用zonelist数据结构来管理—个内存节点的zone。
// zonelist 是所有可用 zone 的链表,其中排在第一个的 zone 是页面分配器“最喜欢的”,也是首选,其他的 zone 是备选
struct zonelist {
// 每一个 zoneref 数据结构描述一个 zone
struct zoneref _zonerefs[MAX_ZONES_PER_ZONELIST + 1];
};
相关数据结构:
// 每一个 zoneref 数据结构描述一个 zone
struct zoneref {
// zone 成员指向实际的 zone
struct zone *zone; /* Pointer to actual zone */
// zone_idx 是一个编号,使用 zone_idx() 函数获取的编号
// 通常:
// 0表示最低的 zone,如 ZONE_DMA32,用于最低4GB的内存访问的设备,如只支持32位的DMA设备。
// 1表示 ZONE_NORMAL,4GB以后的物理内存,用于线性映射物理内存。若系统内存小于4GB,则没有这个内存管理区
// 2表示 ZONE_HGHMEM,用于管理高端内存,这些高端内存是不能线性映射到内核地址空间的。注意,在64位Linux操作系统中没有这个内存管理区
int zone_idx; /* zone_idx(zoneref->zone) */
};
// 内存节点的数据结构
typedef struct pglist_data {
...
// 有两个 zonelist:
// 其中一个是 ZONELIST_FALLBACK,指向本地的zone,即包含备选的 zone
// 另一个是 ZONELIST_NOFALLBACK,用于 NUMA 系统,指向远端的内存结点的 zone
struct zonelist node_zonelists[MAX_ZONELISTS];
...
}
我们假设系统中只有—个内存节点,有两个zone,分别是ZONE_DMA32和ZONE_NORMAL,那么zonelist中zone类型、_zonerefs[]数组和zone_idx之间的关系如下图所示:
在此情况下,分配物理内存时,优先考虑类型为ZONE_NORMAL的zone,即zone_idx值最大的zone优先分配。
伙伴系统是Linux内核中基本的内存分配系统。伙伴系统的概念不难理解,但是一直以来,分配物理页面是内存管理中最复杂的部分,它涉及页面回收、内存规整、直接回收内存等相当错综复杂的机制。页面分配器是Linux内核内存管理中最基本的分配器,基于伙伴系统算法和基于zone的设计理念。本文关注在内存充足的情况下如何分配连续物理内存。
分配内存的快速流程如下图所示:
get_page_from_freelist()函数的流程如下图所示:
为了使读者有更真切的理解,下文将根据流程图围绕源代码进行讲解这个过程:
alloc_pages->...->__alloc_pages_nodemask
struct page *
__alloc_pages_nodemask(gfp_t gfp_mask, unsigned int order, int preferred_nid,
nodemask_t *nodemask)
{
struct page *page;
// ALLOC_WMARK_LOW 允许分配内存的判断条件为低水位
unsigned int alloc_flags = ALLOC_WMARK_LOW;
gfp_t alloc_mask; /* The gfp_t that was actually used for allocation */
// alloc_context 数据结构是伙伴系统分配函数中用于保存相关参数的数据结构
struct alloc_context ac = { };
// 伙伴系统能分配的最大内存块大小是 2 的 MAX_ORDER-1 次方
if (unlikely(order >= MAX_ORDER)) {
WARN_ON_ONCE(!(gfp_mask & __GFP_NOWARN));
return NULL;
}
gfp_mask &= gfp_allowed_mask;
alloc_mask = gfp_mask;
// prepare_alloc_pages 函数会计算相关的信息并保存到 alloc_context 数据结构中
if (!prepare_alloc_pages(gfp_mask, order, preferred_nid, nodemask, &ac, &alloc_mask, &alloc_flags))
return NULL;
// 用于确定首选的 zone
finalise_ac(gfp_mask, &ac);
// 内存碎片化方面的一个优化
alloc_flags |= alloc_flags_nofragment(ac.preferred_zoneref->zone, gfp_mask);
// 尝试从伙伴系统的空闲链表中分配内存
// 若分配成功,则返回内存块的第一个页面的 page 数据结构
// 若分配不成功,则会进入分配的慢速路径,即 __alloc_pages_slowpath 函数
page = get_page_from_freelist(alloc_mask, order, alloc_flags, &ac);
if (likely(page))
goto out;
...
page = __alloc_pages_slowpath(alloc_mask, order, &ac);
out:
...
return page;
}
alloc_pages->...->__alloc_pages_nodemask->prepare_alloc_pages
// 主要用于初始化页面分配器中用到的参数,这些参数会存放在 alloc_context 数据结构中
static inline bool prepare_alloc_pages(gfp_t gfp_mask, unsigned int order,
int preferred_nid, nodemask_t *nodemask,
struct alloc_context *ac, gfp_t *alloc_mask,
unsigned int *alloc_flags)
{
// gfp_zone() 函数根据分配掩码计算出 zone 的 zoneidx
ac->high_zoneidx = gfp_zone(gfp_mask);
// node_zonelist() 函数返回首选内存节点 preferred_nid 对应的 zonelist
// 通常一个内存节点包含两个 zonelist :一个是 ZONELIST_FALLBACK,表示本地;另一个是 ZONELIST_NOFALLBACK,表示远端
ac->zonelist = node_zonelist(preferred_nid, gfp_mask);
ac->nodemask = nodemask;
// 根据分配掩码来获取内存的迁移类型
ac->migratetype = gfpflags_to_migratetype(gfp_mask);
...
// 使用新引入的故障注入技术
if (should_fail_alloc_page(gfp_mask, order))
return false;
if (IS_ENABLED(CONFIG_CMA) && ac->migratetype == MIGRATE_MOVABLE)
*alloc_flags |= ALLOC_CMA;
return true;
}
alloc_pages->...->__alloc_pages_nodemask->get_page_from_freelist
// 从伙伴系统的空闲页面链表中尝试分配物理页面
static struct page *
get_page_from_freelist(gfp_t gfp_mask, unsigned int order, int alloc_flags,
const struct alloc_context *ac)
{
struct zoneref *z;
struct zone *zone;
struct pglist_data *last_pgdat_dirty_limit = NULL;
bool no_fallback;
retry:
// 新增标志 ALLOC_NOFRAGMENT,表示需要避免内存碎片化
no_fallback = alloc_flags & ALLOC_NOFRAGMENT;
// preferred_zoneref 表示 zonelist 中首选和推荐的 zone
z = ac->preferred_zoneref;
// 从推荐的 zone 开始遍历所有的 zone
for_next_zone_zonelist_nodemask(zone, z, ac->zonelist, ac->high_zoneidx,
ac->nodemask) {
...
// 这是 NUMA 系统的一个特殊情况。当需要分配内存的 zone 不在本地内存结点(即在远端节点)时,
// 要考虑的不是内存碎片化,而是内存的本地性,因为访问本地内存节点要比访问远端内存节点快很多
if (no_fallback && nr_online_nodes > 1 &&
zone != ac->preferred_zoneref->zone) {
...
}
// wmark_pages() 宏用来计算 zone 中某个水位的页面大小
mark = wmark_pages(zone, alloc_flags & ALLOC_WMARK_MASK);
// zone_watermark_fast() 函数用于判断当前 zone 的空闲页面是否满足 WMARK_LOW
// 用于测试当前 zone 的水位情况,以及检查是否满足多个页面(order大于0)的分配请求
if (!zone_watermark_fast(zone, order, mark,
ac_classzone_idx(ac), alloc_flags)) {
// 处理当前的 zone 不满足内存分配需求的情况
int ret;
...
// 若 node_reclaim_mode 为 0,则表示可以从下一个 zone 或者内存节点中分配内存;
// 否则,表示可以在这个 zone 中进行一些内存回收的动作
if (node_reclaim_mode == 0 ||
!zone_allows_reclaim(ac->preferred_zoneref->zone, zone))
continue;
// node_reclaim() 函数尝试回收一部分内存
ret = node_reclaim(zone->zone_pgdat, gfp_mask, order);
...
}
// 表示马上要从这个 zone 中分配内存了
try_this_zone:
// rmqueue() 函数会从伙伴系统中分配内存,rmqueue() 函数是伙伴系统的核心分配函数
// 若需要的内存块不能满足,那么可以从大内存块中“切”内存,就像切蛋糕一样
// 如应用程序想分配order为5的内存块,但是order为5的空闲链表中没有空闲内存,而order为6的空闲链表中有,
// 那么会把order为6的内存块取出来,然后把其中一块分配出去,把剩余的一块天骄到order为5的空闲链表中
page = rmqueue(ac->preferred_zoneref->zone, zone, order,
gfp_mask, alloc_flags, ac->migratetype);
if (page) {
// 当从伙伴系统分配页面成功之后需要设置页面的一些属性以及做必要的检查
// 最后返回成功分配页面的 page 数据结构
prep_new_page(page, order, gfp_mask, alloc_flags);
...
return page;
} else {
#ifdef CONFIG_DEFERRED_STRUCT_PAGE_INIT
/* Try again if zone has deferred pages */
if (static_branch_unlikely(&deferred_pages)) {
if (_deferred_grow_zone(zone, order))
goto try_this_zone;
}
#endif
}
}
// 当遍历完所有的 zone 后,还没有成功分配出所需要的内存,最后可能的情况是系统中产生了外碎片化。这时可以重新尝试一次
if (no_fallback) {
alloc_flags &= ~ALLOC_NOFRAGMENT;
goto retry;
}
return NULL;
}
alloc_context数据结构注释:
struct alloc_context {
// 指向每一个内存节点对应的 zonelist
struct zonelist *zonelist;
// 表示内存节点的掩码
nodemask_t *nodemask;
// 表示首选 zone 的 zoneref
struct zoneref *preferred_zoneref;
// 表示迁移类型
int migratetype;
// 分配掩码计算 zone 的 zoneidx,表示这个分配掩码允许内存分配的最高 zone
enum zone_type high_zoneidx;
// 用于指定是否传播脏页
bool spread_dirty_pages;
};