欢迎来到Introzo百科
Introzo百科
当前位置:网站首页 > 技术 > 深入理解Linux物理内存分配全链路实现

深入理解Linux物理内存分配全链路实现

日期:2023-10-03 14:32

往期回顾

在之前的文章《深入理解 Linux 物理内存管理》中,作者详细介绍了Linux内核如何管理物理内存以及一些相关的内核数据结构。

在介绍物理内存管理之前,笔者首先从CPU的角度介绍了三种Linux物理内存模型:FLATMEM扁平内存模型、DISCONTIGMEM非连续内存模型、SPARSEMEM稀疏内存模型。

然后作者带着大家站在一个新的角度,从整体上看待物理内存,从CPU对物理内存的访问以及CPU与物理内存之间相对位置变化的角度介绍了两种物理内存架构:一致统一内存访问UMA架构,非统一内存访问NUMA架构。

NUMA架构下,只有DISCONTIGMEM非连续内存模型和SPARSEMEM稀疏内存模型。在UMA架构下,可以配置和使用前面介绍的三种内存模型。

NUMA架构和UMA架构都是在内核中使用相同的数据结构来组织和管理的。 UMA架构被视为一种伪NUMA架构,在内核的内存管理模块中只有一个NUMA节点。

5 {IMG_5:Ahr0CHM6LY9PBWCYMDIZLMNUYMXVZ3MUY2JSB2JSB2CVMJKWNZU2MC8YMDEVMJKWNZU2MC0YMDEWMDEXMDEXMDEXJAYNTQ0OTIXL nbuzw ==/}

这样,这两种架构模式就在内核中统一管理了。基于这个事实,我们深入分析了NUMA架构下内核用于物理内存管理的相关数据结构:struct pglist_data(NUMA节点)、struct zone(物理内存区域)、struct page(物理页)。

6 {IMG_6: Ahr0CHM6LY9PBWCYMDIZLMNUYMXVZ3MUY2JSB2CVMJKWNZU2MC8YMDIZMDEVMJKWNZU2MDIZMDEWMDEYNI0XMDEGXMDQYL nbuzw ==/}

上图展示了NUMA架构下NUMA节点、物理内存区域、物理内存页之间的层级关系。

物理内存分为内存节点(NUMA节点)。每个NUMA节点内部,其管理的物理内存根据不同的功能被划分为不同的内存区域。每个内存区域zone管理一个用于特定功能的物理内存页,内核会为每个内存区域分配一个伙伴系统来管理该内存区域下物理内存页的分配和释放。

内核中管理的物理内存的层次关系为:None -> Zone -> page

在上一篇文章的最后,作者花了很大的篇幅给大家介绍了struct page结构。我们通过struct page结构来了解内核是如何描述物理内存页的。该结构是内核中最复杂的一个。结构体由于是物理内存管理的最小单位,因此在内核的各种复杂机制中被频繁使用。

通过以上内容的介绍,笔者感觉大家对Linux物理内存管理在架构层面有了更深入的了解。现在我们已经建立了物理内存管理架构,那么基于这个架构级别内核是如何运行的呢?那么分配物理内存呢?

为了给大家讲清楚内核分配物理内存的过程以及涉及到的各个重要模块,本文的内容就到这里了~~

1。内核物理内存分配接口

在向大家介绍物理内存分配之前,笔者先介绍一下内核中用于物理内存分配的几个核心接口。这些物理内存分配接口都是基于伙伴系统的。伙伴系统的一个特点是,它分配的所有物理内存页都是物理上连续的,并且它只能分配2的整数次方的页。这里的整数次方在内核中称为分配顺序。

下面要介绍的物理内存分配接口都需要指定这个分配顺序,即向伙伴系统请求多少个物理内存页。假设我们指定分配顺序为order,那么我们会向伙伴系统Power of的物理内存页申请order 2。

内核提供了alloc_pages函数,用于分配阶数为2的物理内存页。参数中的unsigned int order表示为底层伙伴系统指定的分配顺序。参数 gfp_t gfp 是在内核中定义的参数,这里我们不会扩展标准化物理内存分配行为的修饰符。我将在下面的章节中向您详细介绍它们。

struct page *alloc_pages(gfp_t gfp, unsigned int order);

alloc_pages函数用于向底层伙伴系统申请由2阶power物理内存页组成的内存块。该函数的返回值是一个struct page类型的指针,用于指向所请求的内存块中的第一块物理内存。页。

alloc_pages函数用于分配多个连续的物理内存页。在内核的一些内存分配场景中,有时并不需要分配那么多连续的内存页,而只需分配一个。物理内存页已经足够了,所以内核为这种单内存页场景提供了alloc_page宏。可以看到底层依然依赖alloc_pages函数,只不过order指定为0。

#define alloc_page(gfp_mask) alloc_pages(gfp_mask, 0)

当系统中的空闲物理内存无法满足内存分配时,内存分配将会失败,alloc_pages和alloc_page将返回空指针NULL。

vmalloc底层分配机制是alloc_page

当物理内存分配成功时,alloc_pages和alloc_page函数返回struct page指针,指向其申请的物理内存块的第一个物理内存页。

可以直接理解为返回的是一块物理内存,但是CPU能直接访问的是虚拟内存,所以内核提供了一个函数__get_free_pages,直接返回物理内存页的虚拟内存地址。用户可以直接使用。

unsigned long __get_free_pages(gfp_t gfp_mask, unsigned int order);

__get_free_pages函数的使用方式与alloc_pages相同,函数参数的含义也相同,只不过一个返回物理内存页的虚拟内存地址,另一个直接返回物理内存页。

其实__get_free_pages函数底层也是基于alloc_pages实现的,只是多了一层虚拟地址转换的工作。

unsigned long __get_free_pages(gfp_t gfp_mask, unsigned int order)
{
结构页*页;
// 物理页不能分配在高端内存,因为虚拟内存地址不能直接映射
页面 = alloc_pages(gfp_mask & ~__GFP_HIGHMEM, order);
如果(!页)
返回0;
// 将直接映射区域中的物理内存页转换为虚拟内存地址
返回(无符号长)page_address(页);
}

page_address函数用于将给定的物理内存页page转换为其虚拟内存地址,但是,这只适用于内核虚拟内存空间中的直接映射区域,因为在直接映射中Area 虚拟内存地址直接映射到物理内存地址。虚拟内存地址减去固定偏移量可以直接得到物理内存地址。

如果物理内存页在高端内存中,则不能直接用这种方式进行转换。通过alloc_pages函数获得物理内存页page后,需要调用kmap映射将该页映射到内核虚拟地址空间。

忘记这部分内容的同学,可以回顾一下作者上一篇文章《深入理解虚拟内存管理》中的“7.1.4永久测绘区域”部分。

与alloc_page函数一样,内核也提供了__get_free_page用于只分配单个物理内存页的场景。底层依然依赖__get_free_pages函数,参数order指定为0。

#define __get_free_page(gfp_mask) \
__get_free_pages((gfp_mask), 0)

无论是alloc_pages还是__get_free_pages,它们申请的内存页中包含的数据一开始并不是空白的,而是内核随机生成的一些垃圾信息,但实际上这些信息可能并不都是空的。它是完全随机的,并且可能随机包含一些敏感信息。

这些敏感信息可能会被某些黑客利用,并对计算机系统造成一些有害行为。因此,从安全角度考虑,内核提供了一个函数get_zeroed_pa​​ge。顾名思义,该函数将从合作伙伴系统中检索。所有请求的内存页都被初始化并填充0,这在将物理内存页分配给用户空间时非常有用。

无符号长 get_zeroed_pa​​ge(gfp_t gfp_mask)
{
返回 __get_free_pages(gfp_mask | __GFP_ZERO, 0);
}

get_zeroed_pa​​ge函数底层也依赖于__get_free_pages。指定的分配顺序也是 0,这意味着只向伙伴系统请求一个物理内存页并用 0 进行初始化。

此外,内核还提供了一个__get_dma_pages函数,专门用于从DMA内存区域分配适合DMA的物理内存页。底层还依赖__get_free_pages函数。

unsigned long __get_dma_pages(gfp_t gfp_mask, unsigned int order);

这些依赖__get_free_pages的底层物理内存分配函数在内存分配失败时会返回0。

上面介绍的物理内存分配函数分配物理上连续的内存页。

当然,随着内存的分配,内存也会被释放,所以内核还提供了两个释放物理内存页的函数:

void __free_pages(struct page *page, unsigned int order);
void free_pages(unsigned long addr, unsigned int order);
  • __free_pages:对应alloc_pages函数,用于释放1个或2的阶次方内存页。释放的物理内存区域的起始地址由该区域中的第一个页实例指针表示,即参数。结构体页*页指针。

  • free_pages:对应__get_free_pages函数。与__free_pages函数的区别在于,释放物理内存时,使用的是虚拟内存地址,而不是页指针。

释放内存时需要非常小心。我们只能释放自己的内存页。传递错误的struct page指针或错误的虚拟内存地址,或者传递错误的顺序值可能会导致系统崩溃。坍塌。在内核空间中,内核完全信任自己,这与用户空间不同。

此外,内核还提供了__free_page和free_page两个宏,专门用于释放单个物理内存页。

#define __free_page(页) __free_pages((页), 0)
#定义 free_page(addr) free_pages((addr), 0)

至此,作者已经给大家讲解了内核中物理内存分配和释放的接口。但是,您可能有一个疑问。当我们引入alloc_pages和__get_free_pages函数时,它们的参数有gfp_t gfp_mask。之前我简单提过这个 gfp_mask 掩码:它是内核中定义的一个掩码,用于规范物理内存分配行为。

那么这个掩码调节什么物理内存分配行为呢?以及对物理内存的分配有什么影响呢?大家请跟随作​​者的脚步继续阅读吧~~~

2。调节物理内存分配行为的掩码 gfp_mask

作者在《深入理解 Linux 物理内存管理》文章的“4.3 NUMA节点物理内存区域的划分”一节中详细介绍了NUMA节点中物理内存区域的划分。

作者在文中提到,由于实际的计算机架构受到硬件的限制,间接限制了页框的使用。因此,内核会根据不同物理内存区域的不同功能,将NUMA节点中的物理内存划分为以下物理内存区域:ZONE_DMA、ZONE_DMA32、ZONE_NORMAL、ZONE_HIGHMEM。

ZONE_MOVABLE区域是内核的逻辑划分。该区域的物理内存页均来自上述内存区域。目的是避免内存碎片,支持内存热插拔

当我们调用上一节介绍的物理内存分配接口时,如:alloc_pages和__get_free_pages。我们会遇到一个问题,我们申请的物理内存来自哪个物理内存区域zone。如果我们要从指定的物理内存区域申请内存,我们如何告诉内核呢?

struct page *alloc_pages(gfp_t gfp, unsigned int order);
unsigned long __get_free_pages(gfp_t gfp_mask, unsigned int order);

这个时候这些物理内存分配接口中的gfp_t参数就派上用场了。前缀gfp是get free page的缩写,表示获取空闲物理内存页时需要指定分配掩码gfp_mask。

gfp_mask的低4位用于指示应该从哪个物理内存区域区域获取内存页page。

1 {IMG_11:Ahr0Chm6ly9pbwcymdizlmnuymxvz3Muy29tl2JSB2CVMJKWNZU2MC8yMDEVMJKWNZU2MDIZMDEWMDI1NI0XMTQZODC1MZIU CG5N/}

gfp_mask 掩码中的区域修饰符在内核 /include/linux/gfp.h 文件中定义:

#定义___GFP_DMA 0x01u
#定义___GFP_HIGHMEM 0x02u
#定义___GFP_DMA32 0x04u
#定义___GFP_MOVABLE 0x08u

这里你可能会好奇,为什么没有定义 ___GFP_NORMAL 掩码?

这是因为内核对物理内存的分配主要落在ZONE_NORMAL区域。如果我们不指定物理内存的分配区域,内核会默认从ZONE_NORMAL区域分配内存。如果ZONE_NORMAL区域中的空闲内存不够,内核将降级到ZONE_DMA区域进行分配。

关于物理内存分配的区域降级策略,作者在上一篇文章《深入理解 Linux 物理内存管理》的“5.1物理内存区域预留内存”一节中已经详细介绍过,但之前的介绍只停留在理论上,这个物理内存区域降级策略是在哪里实现的呢?接下来的内容作者就为大家揭晓啦~~~

内核在/include/linux/gfp.h文件中定义了一个名为 gfp_zone 的函数。该函数用于将我们在物理内存分配接口中指定的gfp_mask掩码转换为物理内存。 area,返回的物理内存区域是内存分配的最高级别的内存区域。如果这个最高级别的内存区域不足以满足内存分配需求,则会按照 ZONE_HIGHMEM -> ZONE_NORMAL -> ZONE_DMA 的顺序降级。

静态内联枚举zone_type gfp_zone(gfp_t flags)
{
枚举 zone_type z;
int 位 = (__force int) (flags & GFP_ZONEMASK);
z = (GFP_ZONE_TABLE >> (位 * GFP_ZONES_SHIFT)) &
((1 << GFP_ZONES_SHIFT) - 1);
VM_BUG_ON((GFP_ZONE_BAD >> 位) & 1);
返回z;
}

上面的gfp_zone函数是在5.19版本的内核中实现的。在高版本中,使用了大量的移位操作来替代低版本中的实现。目的是为了提高程序的性能,但是却带来了可读性的巨大下降。

写到这里,我觉得把每个移位操作的实现分析清楚,对你理解这个函数的主要逻辑没有任何实际帮助,而且离本文的主题太远了,所以我们回到较低的版本。 2.6.24中的实现直接反映了该版本中gfp_zone函数的原貌。

静态内联枚举zone_type gfp_zone(gfp_t flags)
{
int基数=0;
#ifdef CONFIG_NUMA
if (标志 & __GFP_THISNODE)
基数 = MAX_NR_ZONES;
#万一
#ifdef CONFIG_ZONE_DMA
如果(标志和 __GFP_DMA)
返回基址+ZONE_DMA;
#万一
#ifdef CONFIG_ZONE_DMA32
如果(标志和 __GFP_DMA32)
返回基址+ZONE_DMA32;
#万一
if ((标志 & (__GFP_HIGHMEM | __GFP_MOVABLE)) ==
(__GFP_HIGHMEM | __GFP_MOVABLE))
返回基址 + ZONE_MOVABLE;
#ifdef CONFIG_HIGHMEM
如果(标志和 __GFP_HIGHMEM)
返回基址 + ZONE_HIGHMEM;
#万一
//内存默认从普通区分配
返回基址+ZONE_NORMAL;
}

我们看到2.6.24内核版本中gfp_zone函数的实现逻辑非常清晰。核心逻辑主要如下:

  • 只要掩码标志中设置了__GFP_DMA,无论__GFP_HIGHMEM是否设置,内存分配都只会在ZONE_DMA区域中分配。

  • 如果掩码只设置了ZONE_HIGHMEM,那么分配物理内存时,会先分配在ZONE_HIGHMEM区域。如果容量不够,就会降级为ZONE_NORMAL。如果仍然不够,则会进一步降级为ZONE_DMA进行分配。

  • 如果掩码既没有设置 ZONE_HIGHMEM 也没有设置 __GFP_DMA,则转到最后一个分支,并默认优先从 ZONE_NORMAL 区域分配内存。如果容量不够,就会降级到ZONE_DMA区域进行分配。

  • 单独设置__GFP_MOVABLE实际上并不影响内核的分配策略。如果我们希望内核在ZONE_MOVABLE区域分配内存,我们需要同时指定__GFP_MOVABLE和__GFP_HIGHMEM。

ZONE_MOVABLE只是内核定义的一个虚拟内存区域,用于避免内存碎片并支持内存热插拔。上面介绍的ZONE_HIGHMEM、ZONE_NORMAL、ZONE_DMA才是真正的物理内存区域。 ZONE_MOVABLE虚拟内存区域中的物理内存来自于上述三个物理内存区域。

在 32 位系统上,ZONE_MOVABLE 虚拟内存区域中的物理内存页来自 ZONE_HIGHMEM。

在64位系统中,ZONE_MOVABLE虚拟内存区域中的物理内存页来自ZONE_NORMAL或ZONE_DMA区域。

以下是不同gfp_t掩码设置方法及其对应的内存区域降级策略的总结列表:

gfp_t 面膜 内存区降级策略
什么都没有设置 ZONE_NORMAL -> ZONE_DMA
__GFP_DMA ZONE_DMA
__GFP_DMA 和 __GFP_HIGHMEM ZONE_DMA
__GFP_HIGHMEM ZONE_HIGHMEM -> ZONE_NORMAL -> ZONE_DMA

除了上面介绍的gfp_t掩码中的四个物理内存区域修饰符之外,内核还定义了一些调节内存分配行为的修饰符。这些行为修饰符不限制内核从哪个物理内存区域提取。分配内存会限制物理内存分配的行为,那么具体会限制哪些内存分配行为呢?继续读下去吧~~~

这些内存分配行为修饰符也在 /include/linux/gfp.h 文件中定义:

#define ___GFP_RECLAIMABLE 0x10u
#定义___GFP_HIGH 0x20u
#定义___GFP_IO 0x40u
#定义___GFP_FS 0x80u
#定义___GFP_ZERO 0x100u
#定义___GFP_ATOMIC 0x200u
#define ___GFP_DIRECT_RECLAIM 0x400u
#define ___GFP_KSWAPD_RECLAIM 0x800u
#定义___GFP_NOWARN 0x2000u
#define ___GFP_RETRY_MAYFAIL 0x4000u
#定义___GFP_NOFAIL 0x8000u
#定义___GFP_NORETRY 0x10000u
#define ___GFP_HARDWALL 0x100000u
#定义___GFP_THISNODE 0x200000u
#定义___GFP_MEMALLOC 0x20000u
#定义___GFP_NOMEMALLOC 0x80000u
  • ___GFP_RECLAIMABLE 用于指定分配的页可以回收,___GFP_MOVABLE 用于指定分配的页可以移动。这两个标志将影响底层伙伴系统从中获取空闲内存页的区域。这个我们会在后面讲解伙伴系统的时候详细介绍区块内容。

  • ___GFP_HIGH 表示内存分配请求优先级高,内核急需内存。如果内存分配失败,将会给系统带来非常严重的后果。设置此标志通常不允许内存分配失败。如果空闲则内存不足,则从紧急保留内存中分配。

关于物理内存区域的紧急预留内存,笔者在上一篇文章《深入理解 Linux 物理内存管理》的“5.1物理内存区域的预留内存”一节中已经详细介绍过。

  • ___GFP_IO表示内核在分配物理内存时可以发起磁盘IO操作。这意味着什么?例如,当内核分配内存时,发现物理内存不足,就需要将不常用的内存页替换到SWAP分区或SWAP文件中。这就涉及到IO操作了。如果设置了该标志,则表示允许。内核替换不常用的内存页。

  • ___GFP_FS 允许内核执行低级文件系统操作。该标志必须在与 VFS 虚拟文件系统层关联的内核子系统中禁用,否则可能会导致文件系统操作的循环递归调用,因为 ___GFP_FS 标志是在设置 ___GFP_FS 标志后分配的。在内存的情况下,可能会引起更多的文件系统操作,而这些文件系统操作又可能进一步引起内存分配行为,这样的递归继续下去。

  • ___GFP_ZERO 内核成功分配内存后,内存页被初始化并填充字节0。

  • ___GFP_ATOMIC 该标志的设置表示分配物理内存时内存不允许休眠。内存分配必须以原子方式完成。例如,在中断处理程序中,您不能休眠,因为中断程序无法重新调度。同时,不能在持有自旋锁的进程上下文中休眠,因为这可能会导致死锁。 总之,该标志只能在无法安全重新调度的进程的上下文中使用

  • ___GFP_DIRECT_RECLAIM 表示内核在分配内存时可以直接进行内存回收。当剩余内存容量低于水位线[WMARK_MIN]时,说明此时的内存容量非常危险。如果此时进程请求分配内存,内核会直接进行内存回收,直到内存水位线回到_watermark[WMARK_HIGH]之上。

  • ___GFP_KSWAPD_RECLAIM表示内核分配内存时,如果剩余内存容量在_watermark[WMARK_MIN]和_watermark[WMARK_LOW]之间,内核会唤醒kswapd进程,开始异步内存回收,直到剩余内存高于_watermark直到[WMARK_HIGH]。

  • ___GFP_NOWARN 表示当内核分配内存失败时,抑制内核分配失败错误报告。

  • ___GFP_RETRY_MAYFAIL 当内核分配内存失败时,允许重试,但重试仍可能失败,重试几次后会停止。相应地,___GFP_NORETRY标志表示内存分配失败时不允许重试。

  • ___GFP_NOFAIL 如果内核分配失败,请重试直至成功。

  • ___GFP_HARDWALL 该标志限制内核将内存分配给仅与当前进程所分配到的 CPU 关联的 NUMA 节点的行为。仅当进程可以运行的 CPU 受到限制时,此标志才有意义。如果进程允许在所有CPU上运行,则该标志没有意义。

  • ___GFP_THISNODE 该标志限制内核的内存分配行为,仅在当前 NUMA 节点或指定的 NUMA 节点中分配内存。如果内存分配失败,则不允许从其他备份 NUMA 节点分配内存。

  • ___GFP_MEMALLOC 允许内核在分配内存时从所有内存区域获取内存,包括从紧急保留内存中获取内存。不过,使用该标志时,需要保证进程获取内存后能够快速释放内存,并且不会占用太长时间。尤其要警惕,避免过度消耗紧急保留内存区域的内存。

  • ___GFP_NOMEMALLOC 标志用于显式禁止内核从紧急保留内存获取内存。 ___GFP_NOMEMALLOC 标志的优先级高于 ___GFP_MEMALLOC

好了到现在为止,我们已经知道了 gfp_t 掩码中包含的内存区域修饰符以及内存分配行为修饰符,是不是感觉头有点大了,事实上确实很让人头大,因为内核在不同场景下会使用不同的组合,这么多的修饰符总是以组合的形式出现,如果我们每次使用的时候都需要单独指定,那就会非常繁杂也很容易出错。

于是内核将各种标准情形下用到的 gfp_t 掩码组合,提前为大家定义了一些标准的分组,方便大家直接使用。

#define GFP_ATOMIC	(__GFP_HIGH|__GFP_ATOMIC|__GFP_KSWAPD_RECLAIM)
#define GFP_KERNEL	(__GFP_RECLAIM | __GFP_IO | __GFP_FS)
#define GFP_NOWAIT	(__GFP_KSWAPD_RECLAIM)
#define GFP_NOIO	(__GFP_RECLAIM)
#define GFP_NOFS	(__GFP_RECLAIM | __GFP_IO)
#define GFP_USER	(__GFP_RECLAIM | __GFP_IO | __GFP_FS | __GFP_HARDWALL)
#define GFP_DMA		__GFP_DMA
#define GFP_DMA32	__GFP_DMA32
#define GFP_HIGHUSER	(GFP_USER | __GFP_HIGHMEM)
  • GFP_ATOMIC 是掩码 __GFP_HIGH,__GFP_ATOMIC,__GFP_KSWAPD_RECLAIM 的组合,表示内存分配行为必须是原子的,是高优先级的。在任何情况下都不允许睡眠,如果空闲内存不够,则会从紧急预留内存中分配。该标志适用于中断程序,以及持有自旋锁的进程上下文中。

  • GFP_KERNEL 是内核中最常用的标志,该标志设置之后内核的分配内存行为可能会阻塞睡眠,可以允许内核置换出一些不活跃的内存页到磁盘中。适用于可以重新安全调度的进程上下文中。

  • GFP_NOIO 和 GFP_NOFS 分别禁止内核在分配内存时进行磁盘 IO 和 文件系统 IO 操作。

  • GFP_USER 用于映射到用户空间的内存分配,通常这些内存可以被内核或者硬件直接访问,比如硬件设备会将 Buffer 直接映射到用户空间中

  • GFP_DMA 和 GFP_DMA32 表示需要从 ZONE_DMA 和 ZONE_DMA32 内存区域中获取适用于 DMA 的内存页。

  • GFP_HIGHUSER 用于给用户空间分配高端内存,因为在用户虚拟内存空间中,都是通过页表来访问非直接映射的高端内存区域,所以用户空间一般使用的是高端内存区域 ZONE_HIGHMEM。

现在我们算是真正理解了,在本小节开始时,介绍的那几个内存分配接口函数中关于内存分配掩码 gfp_mask 的所有内容,其中包括用于限制内核从哪个内存区域中分配内存,内核在分配内存过程中的行为,以及内核在各种标准分配场景下预先定义的掩码组合。

这时我们在回过头来看内核中关于物理内存分配的这些接口函数是不是感觉了如指掌了:

struct page *alloc_pages(gfp_t gfp, unsigned int order)
unsigned long __get_free_pages(gfp_t gfp_mask, unsigned int order)
unsigned long get_zeroed_page(gfp_t gfp_mask)
unsigned long __get_dma_pages(gfp_t gfp_mask, unsigned int order)

好了,现在我们已经清楚了这些内存分配接口的使用,那么这些接口又是如何实现的呢 ?让我们再一次深入到内核源码中去探索内核到底是如何分配物理内存的~~

3. 物理内存分配内核源码实现

本文基于内核 5.19 版本讨论

在介绍 Linux 内核关于内存分配的源码实现之前,我们需要先找到内存分配的入口函数在哪里,在上小节中为大家介绍的众多内存分配接口的依赖层级关系如下图所示:

我们看到内存分配的任务最终会落在 alloc_pages 这个接口函数中,在 alloc_pages 中会调用 alloc_pages_node 进而调用 __alloc_pages_node 函数,最终通过 __alloc_pages 函数正式进入内核内存分配的世界~~

__alloc_pages 函数为 Linux 内核内存分配的核心入口函数

static inline struct page *alloc_pages(gfp_t gfp_mask, unsigned int order)
{
return alloc_pages_node(numa_node_id(), gfp_mask, order);
}
static inline struct page *
__alloc_pages_node(int nid, gfp_t gfp_mask, unsigned int order)
{
// 校验指定的 NUMA 节点 ID 是否合法,不要越界
VM_BUG_ON(nid < 0 || nid >= MAX_NUMNODES);
// 指定节点必须是有效在线的
VM_WARN_ON((gfp_mask & __GFP_THISNODE) && !node_online(nid));
return __alloc_pages(gfp_mask, order, nid, NULL);
}

__alloc_pages_node 函数参数中的 nid 就是我们在上篇文章 《深入理解 Linux 物理内存管理》的 “ 4.1 内核如何统一组织 NUMA 节点 ” 小节介绍的 NUMA 节点 id。

内核使用了一个大小为 MAX_NUMNODES 的全局数组 node_data[] 来管理所有的 NUMA 节点,数组的下标即为 NUMA 节点 Id 。

#ifdef CONFIG_NUMA
extern struct pglist_data *node_data[];
#define NODE_DATA(nid)		(node_data[(nid)])

这里指定 nid 是为了告诉内核应该在哪个 NUMA 节点上分配内存,我们看到在
alloc_pages 函数中通过 numa_node_id() 获取运行当前进程的 CPU 所在的 NUMA 节点。并通过 !node_online(nid) 确保指定的 NUMA 节点是有效在线的。

关于 NUMA 节点的状态信息,大家可回看上篇文章的 《4.5 NUMA 节点的状态 node_states》小节。

3.1 内存分配行为标识掩码 ALLOC_*

在我们进入 __alloc_pages 函数之前,笔者先来为大家介绍几个影响内核分配内存行为的标识,这些重要标识定义在内核文件 /mm/internal.h 中:

#define ALLOC_WMARK_MIN     WMARK_MIN
#define ALLOC_WMARK_LOW     WMARK_LOW
#define ALLOC_WMARK_HIGH    WMARK_HIGH
#define ALLOC_NO_WATERMARKS 0x04 /* don't check watermarks at all */
#define ALLOC_HARDER         0x10 /* try to alloc harder */
#define ALLOC_HIGH       0x20 /* __GFP_HIGH set */
#define ALLOC_CPUSET         0x40 /* check for correct cpuset */
#define ALLOC_KSWAPD        0x800 /* allow waking of kswapd, __GFP_KSWAPD_RECLAIM set */

我们先来看前四个标识内存水位线的常量含义,这四个内存水位线标识表示内核在分配内存时必须考虑内存的水位线,在不同的水位线下内存的分配行为也会有所不同。

笔者在上篇文章 《深入理解 Linux 物理内存管理》的 “ 5.2 物理内存区域中的水位线 ” 小节中曾详细地介绍了各个水位线的含义以及在不同水位线下内存分配的不同表现。

上篇文章中我们提到,内核会为 NUMA 节点中的每个物理内存区域 zone 定制三条用于指示内存容量的水位线,它们分别是:WMARK_MIN(页最小阈值), WMARK_LOW (页低阈值),WMARK_HIGH(页高阈值)。

这三个水位线定义在 /include/linux/mmzone.h 文件中:

enum zone_watermarks {
WMARK_MIN,
WMARK_LOW,
WMARK_HIGH,
NR_WMARK
};

三条水位线对应的 watermark 具体数值存储在每个物理内存区域 struct zone 结构中的 _watermark[NR_WMARK] 数组中。

struct zone {
// 物理内存区域中的水位线
unsigned long _watermark[NR_WMARK];
}

物理内存区域中不同水位线的含义以及内存分配在不同水位线下的行为如下图所示:

  • 当该物理内存区域的剩余内存容量高于 _watermark[WMARK_HIGH] 时,说明此时该物理内存区域中的内存容量非常充足,内存分配完全没有压力。

  • 当剩余内存容量在 _watermark[WMARK_LOW] 与_watermark[WMARK_HIGH] 之间时,说明此时内存有一定的消耗但是还可以接受,能够继续满足进程的内存分配需求。

  • 当剩余内存容量在 _watermark[WMARK_MIN] 与 _watermark[WMARK_LOW] 之间时,说明此时内存容量已经有点危险了,内存分配面临一定的压力,但是还可以满足进程此时的内存分配要求,当给进程分配完内存之后,就会唤醒 kswapd 进程开始内存回收,直到剩余内存高于 _watermark[WMARK_HIGH] 为止。

在这种情况下,进程的内存分配会触发内存回收,但请求进程本身不会被阻塞,由内核的 kswapd 进程异步回收内存。

  • 当剩余内存容量低于 _watermark[WMARK_MIN] 时,说明此时的内存容量已经非常危险了,如果进程在这时请求内存分配,内核就会进行直接内存回收,这时内存回收的任务将会由请求进程同步完成。

注意:上面提到的物理内存区域 zone 的剩余内存是需要刨去 lowmem_reserve 预留内存大小(用于紧急内存分配)。也就是说 zone 里被伙伴系统所管理的内存并不包含 lowmem_reserve 预留内存。

好了,在我们重新回顾了内存分配行为在这三条水位线:_watermark[WMARK_HIGH],_watermark[WMARK_LOW],watermark[WMARK_MIN] 下的不同表现之后,我们在回过来看本小节开始处提到的那几个 ALLOC* 内存分配标识。

ALLOC_NO_WATERMARKS 表示在内存分配过程中完全不会考虑上述三个水位线的影响。

ALLOC_WMARK_HIGH 表示在内存分配的时候,当前物理内存区域 zone 中剩余内存页的数量至少要达到 _watermark[WMARK_HIGH] 水位线,才能进行内存的分配。

ALLOC_WMARK_LOW 和 ALLOC_WMARK_MIN 要表达的内存分配语义也是一样,当前物理内存区域 zone 中剩余内存页的数量至少要达到水位线 _watermark[WMARK_LOW] 或者 _watermark[WMARK_MIN],才能进行内存的分配。

ALLOC_HARDER 表示在内存分配的时候,会放宽内存分配规则的限制,所谓的放宽规则就是降低 _watermark[WMARK_MIN] 水位线,努力使内存分配最大可能成功。

当我们在 gfp_t 掩码中设置了 ___GFP_HIGH 时,ALLOC_HIGH 标识才起作用,该标识表示当前内存分配请求是高优先级的,内核急切的需要内存,如果内存分配失败则会给系统带来非常严重的后果,设置该标志通常内存是不允许分配失败的,如果空闲内存不足,则会从紧急预留内存中分配。

ALLOC_CPUSET 表示内存只能在当前进程所允许运行的 CPU 所关联的 NUMA 节点中进行分配。比如使用 cgroup 限制进程只能在某些特定的 CPU 上运行,那么进程所发起的内存分配请求,只能在这些特定 CPU 所在的 NUMA 节点中进行。

ALLOC_KSWAPD 表示允许唤醒 NUMA 节点中的 KSWAPD 进程,异步进行内存回收。

内核会为每个 NUMA 节点分配一个 kswapd 进程用于回收不经常使用的页面。

typedef struct pglist_data {
.........
// 页面回收进程
struct task_struct *kswapd;
..........
} pg_data_t;

3.2 内存分配的心脏 __alloc_pages

好了,在为大家介绍完这些影响内存分配行为的相关标识掩码:GFP_*ALLOC_* 之后,下面就该来介绍本文的主题——物理内存分配的核心函数 __alloc_pages ,从下面内核源码的注释中我们可以看出,这个函数正是伙伴系统的核心心脏,它是内核内存分配的核心入口函数,整个内存分配的完整过程全部封装在这里。

该函数的逻辑比较复杂,因为在内存分配过程中需要涉及处理各种 GFP_*ALLOC_* 标识,然后根据上述各种标识的含义来决定内存分配该如何进行。所以大家需要多点耐心,一步一步跟着笔者的思路往下走~~~

/*
* This is the 'heart' of the zoned buddy allocator.
*/
struct page *__alloc_pages(gfp_t gfp, unsigned int order, int preferred_nid,
nodemask_t *nodemask)
{
// 用于指向分配成功的内存
struct page *page;
// 内存区域中的剩余内存需要在 WMARK_LOW 水位线之上才能进行内存分配,否则失败(初次尝试快速内存分配)
unsigned int alloc_flags = ALLOC_WMARK_LOW;
// 之前小节中介绍的内存分配掩码集合
gfp_t alloc_gfp;
// 用于在不同内存分配辅助函数中传递参数
struct alloc_context ac = { };
// 检查用于向伙伴系统申请内存容量的分配阶 order 的合法性
// 内核定义最大分配阶 MAX_ORDER -1 = 10,也就是说一次最多只能从伙伴系统中申请 1024 个内存页。
if (WARN_ON_ONCE_GFP(order >= MAX_ORDER, gfp))
return NULL;
// 表示在内存分配期间进程可以休眠阻塞
gfp &= gfp_allowed_mask;
alloc_gfp = gfp;
// 初始化 alloc_context,并为接下来的快速内存分配设置相关 gfp
if (!prepare_alloc_pages(gfp, order, preferred_nid, nodemask, &ac,
&alloc_gfp, &alloc_flags))
// 提前判断本次内存分配是否能够成功,如果不能则尽早失败
return NULL;
// 避免内存碎片化的相关分配标识设置,可暂时忽略
alloc_flags |= alloc_flags_nofragment(ac.preferred_zoneref->zone, gfp);
// 内存分配快速路径:第一次尝试从底层伙伴系统分配内存,注意此时是在 WMARK_LOW 水位线之上分配内存
page = get_page_from_freelist(alloc_gfp, order, alloc_flags, &ac);
if (likely(page))
// 如果内存分配成功则直接返回
goto out;
// 流程走到这里表示内存分配在快速路径下失败
// 这里需要恢复最初的内存分配标识设置,后续会尝试更加激进的内存分配策略
alloc_gfp = gfp;
// 恢复最初的 node mask 因为它可能在第一次内存分配的过程中被改变
// 本函数中 nodemask 起初被设置为 null
ac.nodemask = nodemask;
// 在第一次快速内存分配失败之后,说明内存已经不足了,内核需要做更多的工作
// 比如通过 kswap 回收内存,或者直接内存回收等方式获取更多的空闲内存以满足内存分配的需求
// 所以下面的过程称之为慢速分配路径
page = __alloc_pages_slowpath(alloc_gfp, order, &ac);
out:
// 内存分配成功,直接返回 page。否则返回 NULL
return page;
}

__alloc_pages 函数中的内存分配整体逻辑如下:

  • 首先内核会尝试在内存水位线 WMARK_LOW 之上快速的进行一次内存分配。这一点我们从开始的 unsigned int alloc_flags = ALLOC_WMARK_LOW 语句中可以看得出来。

  • 校验本次内存分配指定伙伴系统的分配阶 order 的有效性,伙伴系统在内核中的最大分配阶定义在 /include/linux/mmzone.h 文件中,最大分配阶 MAX_ORDER -1 = 10,也就是说一次最多只能从伙伴系统中申请 1024 个内存页,对应 4M 大小的连续物理内存。
/* Free memory management - zoned buddy allocator.  */
#ifndef CONFIG_FORCE_MAX_ZONEORDER
#define MAX_ORDER 11
  • 调用 prepare_alloc_pages 初始化 alloc_context ,用于在不同内存分配辅助函数中传递内存分配参数。为接下来即将进行的快速内存分配做准备。
struct alloc_context {
// 运行进程 CPU 所在 NUMA  节点以及其所有备用 NUMA 节点中允许内存分配的内存区域
struct zonelist *zonelist;
// NUMA  节点状态掩码
nodemask_t *nodemask;
// 内存分配优先级最高的内存区域 zone
struct zoneref *preferred_zoneref;
// 物理内存页的迁移类型分为:不可迁移,可回收,可迁移类型,防止内存碎片
int migratetype;
// 内存分配最高优先级的内存区域 zone
enum zone_type highest_zoneidx;
// 是否允许当前 NUMA 节点中的脏页均衡扩散迁移至其他 NUMA 节点
bool spread_dirty_pages;
};
  • 调用 get_page_from_freelist 方法首次尝试在伙伴系统中进行内存分配,这次内存分配比较快速,只是快速的扫描一下各个内存区域中是否有足够的空闲内存能够满足本次内存分配,如果有则立马从伙伴系统中申请,如果没有立即返回, page 设置为 null,进行后续慢速内存分配处理。

这里需要注意的是:首次尝试的快速内存分配是在 WMARK_LOW 水位线之上进行的。

  • 当快速内存分配失败之后,情况就会变得非常复杂,内核将不得不做更多的工作,比如开启 kswapd 进程异步内存回收,更极端的情况则需要进行直接内存回收,或者直接内存整理以获取更多的空闲连续内存。这一切的复杂逻辑全部封装在 __alloc_pages_slowpath 函数中。

alloc_pages_slowpath 函数复杂在于需要结合前边小节中介绍的 GFP*,ALLOC* 这些内存分配标识,根据不同的标识进入不同的内存分配逻辑分支,涉及到的情况比较繁杂。这里大家只需要简单了解,后面笔者会详细介绍~~~

以上介绍的 __alloc_pages 函数内存分配逻辑以及与对应的内存水位线之间的关系如下图所示:

总体流程介绍完之后,我们接着来看一下以上内存分配过程涉及到的三个重要内存分配辅助函数:prepare_alloc_pages,__alloc_pages_slowpath,get_page_from_freelist 。

3.3 prepare_alloc_pages

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_gfp,
unsigned int *alloc_flags)
{
// 根据 gfp_mask 掩码中的内存区域修饰符获取内存分配最高优先级的内存区域 zone
ac->highest_zoneidx = gfp_zone(gfp_mask);
// 从 NUMA 节点的备用节点链表中一次性获取允许进行内存分配的所有内存区域
ac->zonelist = node_zonelist(preferred_nid, gfp_mask);
ac->nodemask = nodemask;
// 从 gfp_mask 掩码中获取页面迁移属性,迁移属性分为:不可迁移,可回收,可迁移。这里只需要简单知道,后面在相关章节会细讲
ac->migratetype = gfp_migratetype(gfp_mask);
// 如果使用 cgroup 将进程绑定限制在了某些 CPU 上,那么内存分配只能在
// 这些绑定的 CPU 相关联的 NUMA 节点中进行
if (cpusets_enabled()) {
*alloc_gfp |= __GFP_HARDWALL;
if (in_task() && !ac->nodemask)
ac->nodemask = &cpuset_current_mems_allowed;
else
*alloc_flags |= ALLOC_CPUSET;
}
// 如果设置了允许直接内存回收,那么内存分配进程则可能会导致休眠被重新调度
might_sleep_if(gfp_mask & __GFP_DIRECT_RECLAIM);
// 提前判断本次内存分配是否能够成功,如果不能则尽早失败
if (should_fail_alloc_page(gfp_mask, order))
return false;
// 获取最高优先级的内存区域 zone
// 后续内存分配则首先会在该内存区域中进行分配
ac->preferred_zoneref = first_zones_zonelist(ac->zonelist,
ac->highest_zoneidx, ac->nodemask);
return true;
}

prepare_alloc_pages 主要的任务就是在快速内存分配开始之前,做一些准备初始化的工作,其中最核心的就是从指定 NUMA 节点中,根据 gfp_mask 掩码中的内存区域修饰符获取可以进行内存分配的所有内存区域 zone (包括其他备用 NUMA 节点中包含的内存区域)。

之前笔者已经在 《深入理解 Linux 物理内存管理》一文中的 “ 4.3 NUMA 节点物理内存区域的划分 ” 小节为大家已经详细介绍了 NUMA 节点的数据结构 struct pglist_data。

struct pglist_data 结构中不仅包含了本 NUMA 节点中的所有内存区域,还包括了其他备用 NUMA 节点中的物理内存区域,当本节点中内存不足的情况下,内核会从备用 NUMA 节点中的内存区域进行跨节点内存分配。

typedef struct pglist_data {
// NUMA 节点中的物理内存区域个数
int nr_zones;
// NUMA 节点中的物理内存区域
struct zone node_zones[MAX_NR_ZONES];
// NUMA 节点的备用列表,其中包含了所有 NUMA 节点中的所有物理内存区域 zone,按照访问距离由近到远顺序依次排列
struct zonelist node_zonelists[MAX_ZONELISTS];
} pg_data_t;

我们可以根据 nid 和 gfp_mask 掩码中的物理内存区域描述符利用 node_zonelist 函数一次性获取允许进行内存分配的所有内存区域(所有 NUMA 节点)。

static inline struct zonelist *node_zonelist(int nid, gfp_t flags)
{
return NODE_DATA(nid)->node_zonelists + gfp_zonelist(flags);
}

4. 内存慢速分配入口 alloc_pages_slowpath

正如前边小节我们提到的那样,alloc_pages_slowpath 函数非常的复杂,其中包含了内存分配的各种异常情况的处理,并且会根据前边介绍的 GFP_,ALLOC_ 等各种内存分配策略掩码进行不同分支的处理,这样就变得非常的庞大而繁杂。

alloc_pages_slowpath 函数包含了整个内存分配的核心流程,本身非常的繁杂庞大,为了能够给大家清晰的梳理清楚这些复杂的内存分配流程,所以笔者决定还是以 总 - 分 - 总 的结构来给大家呈现。

下面这段伪代码是笔者提取出来的 alloc_pages_slowpath 函数的主干框架,其中包含的一些核心分支以及核心步骤笔者都通过注释的形式为大家标注出来了,这里我先从总体上大概浏览下 alloc_pages_slowpath 主要分为哪几个逻辑处理模块,它们分别处理了哪些事情。

还是那句话,这里大家只需要总体把握,不需要掌握每个细节,关于细节的部分,笔者后面会带大家逐个击破!!!

static inline struct page *
__alloc_pages_slowpath(gfp_t gfp_mask, unsigned int order,
struct alloc_context *ac)
{
......... 初始化慢速内存分配路径下的相关参数 .......
retry_cpuset:
......... 调整内存分配策略 alloc_flags 采用更加激进方式获取内存 ......
......... 此时内存分配主要是在进程所允许运行的 CPU 相关联的 NUMA 节点上 ......
......... 内存水位线下调至 WMARK_MIN ...........
......... 唤醒所有 kswapd 进程进行异步内存回收  ...........
......... 触发直接内存整理 direct_compact 来获取更多的连续空闲内存 ......
retry:
......... 进一步调整内存分配策略 alloc_flags 使用更加激进的非常手段进行内存分配 ...........
......... 在内存分配时忽略内存水位线 ...........
......... 触发直接内存回收 direct_reclaim ...........
......... 再次触发直接内存整理 direct_compact ...........
......... 最后的杀手锏触发 OOM 机制  ...........
nopage:
......... 经过以上激进的内存分配手段仍然无法满足内存分配就会来到这里 ......
......... 如果设置了 __GFP_NOFAIL 不允许内存分配失败,则不停重试上述内存分配过程 ......
fail:
......... 内存分配失败,输出告警信息 ........
warn_alloc(gfp_mask, ac->nodemask,
"page allocation failure: order:%u", order);
got_pg:
......... 内存分配成功,返回新申请的内存块 ........
return page;
}

4.1 初始化内存分配慢速路径下的相关参数

static inline struct page *
__alloc_pages_slowpath(gfp_t gfp_mask, unsigned int order,
struct alloc_context *ac)
{
// 在慢速内存分配路径中可能会导致内核进行直接内存回收
// 这里设置 __GFP_DIRECT_RECLAIM 表示允许内核进行直接内存回收
bool can_direct_reclaim = gfp_mask & __GFP_DIRECT_RECLAIM;
// 本次内存分配是否是针对大量内存页的分配,内核定义 PAGE_ALLOC_COSTLY_ORDER = 3
// 也就是说内存请求内存页的数量大于 2 ^ 3 = 8 个内存页时,costly_order = true,后续会影响是否进行 OOM
const bool costly_order = order > PAGE_ALLOC_COSTLY_ORDER;
// 用于指向成功申请的内存
struct page *page = NULL;
// 内存分配标识,后续会根据不同标识进入到不同的内存分配逻辑处理分支
unsigned int alloc_flags;
// 后续用于记录直接内存回收了多少内存页
unsigned long did_some_progress;
// 关于内存整理相关参数
enum compact_priority compact_priority;
enum compact_result compact_result;
int compaction_retries;
// 记录重试的次数,超过一定的次数(16次)则内存分配失败
int no_progress_loops;
// 临时保存调整后的内存分配策略
int reserve_flags;
// 流程现在来到了慢速内存分配这里,说明快速分配路径已经失败了
// 内核需要对 gfp_mask 分配行为掩码做一些修改,修改为一些更可能导致内存分配成功的标识
// 因为接下来的直接内存回收非常耗时可能会导致进程阻塞睡眠,不适用原子 __GFP_ATOMIC 内存分配的上下文。
if (WARN_ON_ONCE((gfp_mask & (__GFP_ATOMIC|__GFP_DIRECT_RECLAIM)) ==
(__GFP_ATOMIC|__GFP_DIRECT_RECLAIM)))
gfp_mask &= ~__GFP_ATOMIC;
retry_cpuset:
retry:
nopage:
fail:
got_pg:
}

在内核进入慢速内存分配路径之前,首先会在这里初始化后续内存分配需要的参数,由于笔者已经在各个字段上标注了丰富的注释,所以这里笔者只对那些难以理解的核心参数为大家进行相关细节的铺垫,这里大家对这些参数有个大概印象即可,后续在使用到的时候,笔者还会再次提起~~~

首先我们看 costly_order 参数,order 表示底层伙伴系统的分配阶,内核只能向伙伴系统申请 2 的 order 次幂个内存页,costly 从字面意思上来说表示有一定代价和消耗的,costly_order 连起来就表示在内核中 order 分配阶达到多少,在内核看来就是代价比较大的内存分配行为。

这个临界值就是 PAGE_ALLOC_COSTLY_ORDER 定义在 /include/linux/mmzone.h 文件中:

#define PAGE_ALLOC_COSTLY_ORDER 3

也就是说在内核看来,当请求内存页的数量大于 2 ^ 3 = 8 个内存页时,costly_order = true,内核就认为本次内存分配是一次成本比较大的行为。后续会根据这个参数 costly_order 来决定是否触发 OOM 。

    const bool costly_order = order > PAGE_ALLOC_COSTLY_ORDER;

当内存严重不足的时候,内核会开启直接内存回收 direct_reclaim ,参数 did_some_progress 表示经过一次直接内存回收之后,内核回收了多少个内存页。这个参数后续会影响是否需要进行内存分配重试。

no_progress_loops 用于记录内存分配重试的次数,如果内存分配重试的次数超过最大限制 MAX_RECLAIM_RETRIES,则停止重试,开启 OOM。

MAX_RECLAIM_RETRIES 定义在 /mm/internal.h 文件中:

#define MAX_RECLAIM_RETRIES 16

compact_* 相关的参数用于直接内存整理 direct_compact,内核通常会在直接内存回收 direct_reclaim 之前进行一次 direct_compact,如果经过 direct_compact 整理之后有了足够多的空间内存就不需要进行 direct_reclaim 了。

那么这个 direct_compact 到底是干什么的呢?它在慢速内存分配过程起了什么作用?

随着系统的长时间运行通常会伴随着不同大小的物理内存页的分配和释放,这种不规则的分配释放,随着系统的长时间运行就会导致内存碎片,内存碎片会使得系统在明明有足够内存的情况下,依然无法为进程分配合适的内存。

如上图所示,假如现在系统一共有 16 个物理内存页,当前系统只是分配了 3 个物理页,那么在当前系统中还剩余 13 个物理内存页的情况下,如果内核想要分配 8 个连续的物理页由于内存碎片的存在则会分配失败。(只能分配最多 4 个连续的物理页)

内核中请求分配的物理页面数只能是 2 的次幂!!

为了解决内存碎片化的问题,内核将内存页面分为了:可移动的,可回收的,不可移动的三种类型。

可移动的页面聚集在一起,可回收的的页面聚集在一起,不可移动的的页面聚集也在一起。从而作为去碎片化的基础, 然后进行成块回收。

在回收时把可回收的一起回收,把可移动的一起移动,从而能空出大量连续物理页面。direct_compact 会扫描内存区域 zone 里的页面,把已分配的页记录下来,然后把所有已分配的页移动到 zone 的一端,这样就会把一个已经充满碎片的 zone 整理成一段完全未分配的区间和一段已经分配的区间,从而腾出大块连续的物理页面供内核分配。

4.2 retry_cpuset

在介绍完了内存分配在慢速路径下所需要的相关参数之后,下面就正式来到了 alloc_pages_slowpath 的内存分配逻辑:

static inline struct page *
__alloc_pages_slowpath(gfp_t gfp_mask, unsigned int order,
struct alloc_context *ac)
{
......... 初始化慢速内存分配路径下的相关参数 .......
retry_cpuset:
// 在之前的快速内存分配路径下设置的相关分配策略比较保守,不是很激进,用于在 WMARK_LOW 水位线之上进行快速内存分配
// 走到这里表示快速内存分配失败,此时空闲内存严重不足了
// 所以在慢速内存分配路径下需要重新设置更加激进的内存分配策略,采用更大的代价来分配内存
alloc_flags = gfp_to_alloc_flags(gfp_mask);
// 重新按照新的设置按照内存区域优先级计算 zonelist 的迭代起点(最高优先级的 zone)
// fast path 和 slow path 的设置不同所以这里需要重新计算
ac->preferred_zoneref = first_zones_zonelist(ac->zonelist,
ac->highest_zoneidx, ac->nodemask);
// 如果没有合适的内存分配区域,则跳转到 nopage , 内存分配失败
if (!ac->preferred_zoneref->zone)
goto nopage;
// 唤醒所有的 kswapd 进程异步回收内存
if (alloc_flags & ALLOC_KSWAPD)
wake_all_kswapds(order, gfp_mask, ac);
// 此时所有的 kswapd 进程已经被唤醒,正在异步进行内存回收
// 之前我们已经在 gfp_to_alloc_flags 方法中重新调整了 alloc_flags
// 换成了一套更加激进的内存分配策略,注意此时是在 WMARK_MIN 水位线之上进行内存分配
// 调整后的 alloc_flags 很可能会立即成功,因此这里先尝试一下
page = get_page_from_freelist(gfp_mask, order, alloc_flags, ac);
if (page)
// 内存分配成功,跳转到 got_pg 直接返回 page
goto got_pg;
// 对于分配大内存来说 costly_order = true (超过 8 个内存页),需要首先进行内存整理,这样内核可以避免直接内存回收从而获取更多的连续空闲内存页
// 对于需要分配不可移动的高阶内存的情况,也需要先进行内存整理,防止永久内存碎片
if (can_direct_reclaim &&
(costly_order ||
(order > 0 && ac->migratetype != MIGRATE_MOVABLE))
&& !gfp_pfmemalloc_allowed(gfp_mask)) {
// 进行直接内存整理,获取更多的连续空闲内存防止内存碎片
page = __alloc_pages_direct_compact(gfp_mask, order,
alloc_flags, ac,
INIT_COMPACT_PRIORITY,
&compact_result);
if (page)
goto got_pg;
if (costly_order && (gfp_mask & __GFP_NORETRY)) {
// 流程走到这里表示经过内存整理之后依然没有足够的内存供分配
// 但是设置了 NORETRY 标识不允许重试,那么就直接失败,跳转到 nopage
if (compact_result == COMPACT_SKIPPED ||
compact_result == COMPACT_DEFERRED)
goto nopage;
// 同步内存整理开销太大,后续开启异步内存整理
compact_priority = INIT_COMPACT_PRIORITY;
}
}
retry:
nopage:
fail:
got_pg:
return page;
}

流程走到这里,说明内核在 《3.2 内存分配的心脏 __alloc_pages》小节中介绍的快速路径下尝试的内存分配已经失败了,所以才会走到慢速分配路径这里来。

之前我们介绍到快速分配路径是在 WMARK_LOW 水位线之上进行内存分配,与其相配套的内存分配策略比较保守,目的是快速的在各个内存区域 zone 之间搜索可供分配的空闲内存。

快速分配路径下的失败意味着此时系统中的空闲内存已经不足了,所以在慢速分配路径下内核需要改变内存分配策略,采用更加激进的方式来进行内存分配,首先会把内存分配水位线降低到 WMARK_MIN 之上,然后将内存分配策略调整为更加容易促使内存分配成功的策略。

而内存分配策略相关的调整逻辑,内核定义在 gfp_to_alloc_flags 函数中:

static inline unsigned int gfp_to_alloc_flags(gfp_t gfp_mask)
{
// 在慢速内存分配路径中,会进一步放宽对内存分配的限制,将内存分配水位线调低到 WMARK_MIN
// 也就是说内存区域中的剩余内存需要在 WMARK_MIN 水位线之上才可以进行内存分配
unsigned int alloc_flags = ALLOC_WMARK_MIN | ALLOC_CPUSET;
// 如果内存分配请求无法运行直接内存回收,或者分配请求设置了 __GFP_HIGH
// 那么意味着内存分配会更多的使用紧急预留内存
alloc_flags |= (__force int)
(gfp_mask & (__GFP_HIGH | __GFP_KSWAPD_RECLAIM));
if (gfp_mask & __GFP_ATOMIC) {
//  ___GFP_NOMEMALLOC 标志用于明确禁止内核从紧急预留内存中获取内存。
// ___GFP_NOMEMALLOC 标识的优先级要高于 ___GFP_MEMALLOC
if (!(gfp_mask & __GFP_NOMEMALLOC))
// 如果允许从紧急预留内存中分配,则需要进一步放宽内存分配限制
// 后续根据 ALLOC_HARDER 标识会降低 WMARK_LOW 水位线
alloc_flags |= ALLOC_HARDER;
// 在这个分支中表示内存分配请求已经设置了  __GFP_ATOMIC (非常重要,不允许失败)
// 这种情况下为了内存分配的成功,会去除掉 CPUSET 的限制,可以在所有 NUMA 节点上分配内存
alloc_flags &= ~ALLOC_CPUSET;
} else if (unlikely(rt_task(current)) && in_task())
// 如果当前进程不是 real time task 或者不在 task 上下文中
// 设置 HARDER 标识
alloc_flags |= ALLOC_HARDER;
return alloc_flags;
}

在调整好的新的内存分配策略 alloc_flags 之后,就需要根据新的策略来重新获取可供分配的内存区域 zone。

  ac->preferred_zoneref = first_zones_zonelist(ac->zonelist,
ac->highest_zoneidx, ac->nodemask);

从上图中我们可以看出,当剩余内存处于 WMARK_MIN 与 WMARK_LOW 之间时,内核会唤醒所有 kswapd 进程来异步回收内存,直到剩余内存重新回到水位线 WMARK_HIGH 之上。

    if (alloc_flags & ALLOC_KSWAPD)
wake_all_kswapds(order, gfp_mask, ac);

到目前为止,内核已经在慢速分配路径下通过 gfp_to_alloc_flags 调整为更加激进的内存分配策略,并将水位线降低到 WMARK_MIN,同时也唤醒了 kswapd 进程来异步回收内存。

此时在新的内存分配策略下进行内存分配很可能会一次性成功,所以内核会首先尝试进行一次内存分配。

page = get_page_from_freelist(gfp_mask, order, alloc_flags, ac);

如果首次尝试分配内存失败之后,内核就需要进行直接内存整理 direct_compact 来获取更多的可供分配的连续内存页。

如果经过 direct_compact 之后依然没有足够的内存可供分配,那么就会进入 retry 分支采用更加激进的方式来分配内存。如果内存分配策略设置了 __GFP_NORETRY 表示不允许重试,那么就会直接失败,流程跳转到 nopage 分支进行处理。

4.3 retry

内存分配流程来到 retry 分支这里说明情况已经变得非常危急了,在经过 retry_cpuset 分支的处理,内核将内存水位线下调至 WMARK_MIN,并开启了 kswapd 进程进行异步内存回收,触发直接内存整理 direct_compact,在采取了这些措施之后,依然无法满足内存分配的需求。

所以在接下来的分配逻辑中,内核会近一步采取更加激进的非常手段来获取连续的空闲内存,下面我们来一起看下这部分激进的内容:

static inline struct page *
__alloc_pages_slowpath(gfp_t gfp_mask, unsigned int order,
struct alloc_context *ac)
{
......... 初始化慢速内存分配路径下的相关参数 .......
retry_cpuset:
......... 调整内存分配策略 alloc_flags 采用更加激进方式获取内存 ......
......... 此时内存分配主要是在进程所允许运行的 CPU 相关联的 NUMA 节点上 ......
......... 内存水位线下调至 WMARK_MIN ...........
......... 唤醒所有 kswapd 进程进行异步内存回收  ...........
......... 触发直接内存整理 direct_compact 来获取更多的连续空闲内存 ......
retry:
// 确保所有 kswapd 进程不要意外进入睡眠状态
if (alloc_flags & ALLOC_KSWAPD)
wake_all_kswapds(order, gfp_mask, ac);
// 流程走到这里,说明在 WMARK_MIN 水位线之上也分配内存失败了
// 并且经过内存整理之后,内存分配仍然失败,说明当前内存容量已经严重不足
// 接下来就需要使用更加激进的非常手段来尝试内存分配(忽略掉内存水位线),继续修改 alloc_flags 保存在 reserve_flags 中
reserve_flags = __gfp_pfmemalloc_flags(gfp_mask);
if (reserve_flags)
alloc_flags = gfp_to_alloc_flags_cma(gfp_mask, reserve_flags);
// 如果内存分配可以任意跨节点分配(忽略内存分配策略),这里需要重置 nodemask 以及 zonelist。
if (!(alloc_flags & ALLOC_CPUSET) || reserve_flags) {
// 这里的内存分配是高优先级系统级别的内存分配,不是面向用户的
ac->nodemask = NULL;
ac->preferred_zoneref = first_zones_zonelist(ac->zonelist,
ac->highest_zoneidx, ac->nodemask);
}
// 这里使用重新调整的 zonelist 和 alloc_flags 在尝试进行一次内存分配
// 注意此次的内存分配是忽略内存水位线的 ALLOC_NO_WATERMARKS
page = get_page_from_freelist(gfp_mask, order, alloc_flags, ac);
if (page)
goto got_pg;
// 在忽略内存水位线的情况下仍然分配失败,现在内核就需要进行直接内存回收了
if (!can_direct_reclaim)
// 如果进程不允许进行直接内存回收,则只能分配失败
goto nopage;
// 开始直接内存回收
page = __alloc_pages_direct_reclaim(gfp_mask, order, alloc_flags, ac,
&did_some_progress);
if (page)
goto got_pg;
// 直接内存回收之后仍然无法满足分配需求,则再次进行直接内存整理
page = __alloc_pages_direct_compact(gfp_mask, order, alloc_flags, ac,
compact_priority, &compact_result);
if (page)
goto got_pg;
// 在内存直接回收和整理全部失败之后,如果不允许重试,则只能失败
if (gfp_mask & __GFP_NORETRY)
goto nopage;
// 后续会触发 OOM 来释放更多的内存,这里需要判断本次内存分配是否需要分配大量的内存页(大于 8 ) costly_order = true
// 如果是的话则内核认为即使执行 OOM 也未必会满足这么多的内存页分配需求.
// 所以还是直接失败比较好,不再执行 OOM,除非设置 __GFP_RETRY_MAYFAIL
if (costly_order && !(gfp_mask & __GFP_RETRY_MAYFAIL))
goto nopage;
// 流程走到这里说明我们已经尝试了所有措施内存依然分配失败了,此时内存已经非常危急了。
// 走到这里说明进程允许内核进行重试流程,但在开始重试之前,内核需要判断是否应该进行重试,重试标准:
// 1 如果内核已经重试了 MAX_RECLAIM_RETRIES (16) 次仍然失败,则放弃重试执行后续 OOM。
// 2 如果内核将所有可选内存区域中的所有可回收页面全部回收之后,仍然无法满足内存的分配,那么放弃重试执行后续 OOM
if (should_reclaim_retry(gfp_mask, order, ac, alloc_flags,
did_some_progress > 0, &no_progress_loops))
goto retry;
// 如果内核判断不应进行直接内存回收的重试,这里还需要判断下是否应该进行内存整理的重试。
// did_some_progress 表示上次直接内存回收,具体回收了多少内存页
// 如果 did_some_progress = 0 则没有必要在进行内存整理重试了,因为内存整理的实现依赖于足够的空闲内存量
if (did_some_progress > 0 &&
should_compact_retry(ac, order, alloc_flags,
compact_result, &compact_priority,
&compaction_retries))
goto retry;
// 根据 nodemask 中的内存分配策略判断是否应该在进程所允许运行的所有 CPU 关联的 NUMA 节点上重试
if (check_retry_cpuset(cpuset_mems_cookie, ac))
goto retry_cpuset;
// 最后的杀手锏,进行 OOM,选择一个得分最高的进程,释放其占用的内存
page = __alloc_pages_may_oom(gfp_mask, order, ac, &did_some_progress);
if (page)
goto got_pg;
// 只要 oom 产生了作用并释放了内存 did_some_progress > 0 就不断的进行重试
if (did_some_progress) {
no_progress_loops = 0;
goto retry;
}
nopage:
fail:
warn_alloc(gfp_mask, ac->nodemask,
"page allocation failure: order:%u", order);
got_pg:
return page;
}

retry 分支包含的是更加激进的内存分配逻辑,所以在一开始需要调用 __gfp_pfmemalloc_flags 函数来重新调整内存分配策略,调整后的策略为:后续内存分配会忽略水位线的影响,并且允许内核从紧急预留内存中获取内存。

static inline int __gfp_pfmemalloc_flags(gfp_t gfp_mask)
{
// 如果不允许从紧急预留内存中分配,则不改变 alloc_flags
if (unlikely(gfp_mask & __GFP_NOMEMALLOC))
return 0;
// 如果允许从紧急预留内存中分配,则后面的内存分配会忽略内存水位线的限制
if (gfp_mask & __GFP_MEMALLOC)
return ALLOC_NO_WATERMARKS;
// 当前进程处于软中断上下文并且进程设置了 PF_MEMALLOC 标识
// 则忽略内存水位线
if (in_serving_softirq() && (current->flags & PF_MEMALLOC))
return ALLOC_NO_WATERMARKS;
// 当前进程不在任何中断上下文中
if (!in_interrupt()) {
if (current->flags & PF_MEMALLOC)
// 忽略内存水位线
return ALLOC_NO_WATERMARKS;
else if (oom_reserves_allowed(current))
// 当前进程允许进行 OOM
return ALLOC_OOM;
}
// alloc_flags 不做任何修改
return 0;
}

在调整好更加激进的内存分配策略 alloc_flags 之后,内核会首先尝试从伙伴系统中进行一次内存分配,这时会有很大概率促使内存分配成功。

注意:此次尝试进行的内存分配会忽略内存水位线:ALLOC_NO_WATERMARKS

   page = get_page_from_freelist(gfp_mask, order, alloc_flags, ac);

如果在忽略内存水位线的情况下,内存依然分配失败,则进行直接内存回收 direct_reclaim 。

   page = __alloc_pages_direct_reclaim(gfp_mask, order, alloc_flags, ac,
&did_some_progress);

经过 direct_reclaim 之后,仍然没有足够的内存可供分配的话,那么内核会再次进行直接内存整理 direct_compact 。

    page = __alloc_pages_direct_compact(gfp_mask, order, alloc_flags, ac,
compact_priority, &compact_result);

如果 direct_compact 之后还是没有足够的内存,那么现在内核已经处于绝境了,是时候使用杀手锏:触发 OOM 机制杀死得分最高的进程以获取更多的空闲内存。

但是在进行 OOM 之前,内核还是需要经过一系列的判断,这时就用到了我们在 《4.1 初始化内存分配慢速路径下的相关参数》小节中介绍的 costly_order 参数了,它会影响内核是否触发 OOM 。

如果 costly_order = true,表示此次内存分配的内存页大于 8 个页,内核会认为这是一次代价比较大的分配行为,况且此时内存已经非常危急,严重不足。在这种情况下内核认为即使触发了 OOM,也无法获取这么多的内存,依然无法满足内存分配。

所以当 costly_order = true 时,内核不会触发 OOM,直接跳转到 nopage 分支,除非设置了 __GFP_RETRY_MAYFAIL 内存分配策略:

    if (costly_order && !(gfp_mask & __GFP_RETRY_MAYFAIL))
goto nopage;

下面内核也不会直接开始 OOM,而是进入到重试流程,在重试流程开始之前内核需要调用 should_reclaim_retry 判断是否应该进行重试,重试标准:

  1. 如果内核已经重试了 MAX_RECLAIM_RETRIES (16) 次仍然失败,则放弃重试执行后续 OOM。

  2. 如果内核将所有可选内存区域中的所有可回收页面全部回收之后,仍然无法满足内存的分配,那么放弃重试执行后续 OOM。

如果 should_reclaim_retry = false,后面会进一步判断是否应该进行 direct_compact 的重试。

    if (did_some_progress > 0 &&
should_compact_retry(ac, order, alloc_flags,
compact_result, &compact_priority,
&compaction_retries))
goto retry;

did_some_progress 表示上次直接内存回收具体回收了多少内存页,如果 did_some_progress = 0 则没有必要在进行内存整理重试了,因为内存整理的实现依赖于足够的空闲内存量。

当这些所有的重试请求都被拒绝时,杀手锏 OOM 就开始登场了:

   page = __alloc_pages_may_oom(gfp_mask, order, ac, &did_some_progress);
if (page)
goto got_pg;

如果 OOM 之后并没有释放内存,那么就来到 nopage 分支处理。

但是如果 did_some_progress > 0 表示 OOM 产生了作用,至少释放了一些内存那么就再次进行重试。

4.4 nopage

到现在为止,内核已经尝试了包括 OOM 在内的所有回收内存的措施,但是仍然没有足够的内存来满足分配要求,看上去此次内存分配就要宣告失败了。

但是这里还有一定的回旋余地,如果内存分配策略中配置了 __GFP_NOFAIL,则表示此次内存分配非常的重要,不允许失败。内核会在这里不停的重试直到分配成功为止。

我们在 《深入理解 Linux 物理内存管理》一文中的 “ 3.2 非一致性内存访问 NUMA 架构 ” 小节,介绍 NUMA 内存架构的时候曾经提到:当 CPU 自己所在的本地 NUMA 节点内存不足时,CPU 就需要跨 NUMA 节点去访问其他内存节点,这种跨 NUMA 节点分配内存的行为就发生在这里,这种情况下 CPU 访问内存就会慢很多

static inline struct page *
__alloc_pages_slowpath(gfp_t gfp_mask, unsigned int order,
struct alloc_context *ac)
{
......... 初始化慢速内存分配路径下的相关参数 .......
retry_cpuset:
......... 调整内存分配策略 alloc_flags 采用更加激进方式获取内存 ......
......... 此时内存分配主要是在进程所允许运行的 CPU 相关联的 NUMA 节点上 ......
......... 内存水位线下调至 WMARK_MIN ...........
......... 唤醒所有 kswapd 进程进行异步内存回收  ...........
......... 触发直接内存整理 direct_compact 来获取更多的连续空闲内存 ......
retry:
......... 进一步调整内存分配策略 alloc_flags 使用更加激进的非常手段尽心内存分配 ...........
......... 在内存分配时忽略内存水位线 ...........
......... 触发直接内存回收 direct_reclaim ...........
......... 再次触发直接内存整理 direct_compact ...........
......... 最后的杀手锏触发 OOM 机制  ...........
nopage:
// 流程走到这里表明内核已经尝试了包括 OOM 在内的所有回收内存的动作。
// 但是这些措施依然无法满足内存分配的需求,看上去内存分配到这里就应该失败了。
// 但是如果设置了 __GFP_NOFAIL 表示不允许内存分配失败,那么接下来就会进入 if 分支进行处理
if (gfp_mask & __GFP_NOFAIL) {
// 如果不允许进行直接内存回收,则跳转至 fail 分支宣告失败
if (WARN_ON_ONCE_GFP(!can_direct_reclaim, gfp_mask))
goto fail;
// 此时内核已经无法通过回收内存来获取可供分配的空闲内存了
// 对于 PF_MEMALLOC 类型的内存分配请求,内核现在无能为力,只能不停的进行 retry 重试。
WARN_ON_ONCE_GFP(current->flags & PF_MEMALLOC, gfp_mask);
// 对于需要分配 8 个内存页以上的大内存分配,并且设置了不可失败标识 __GFP_NOFAIL
// 内核现在也无能为力,毕竟现实是已经没有空闲内存了,只是给出一些告警信息
WARN_ON_ONCE_GFP(order > PAGE_ALLOC_COSTLY_ORDER, gfp_mask);
// 在 __GFP_NOFAIL 情况下,尝试进行跨 NUMA 节点内存分配
page = __alloc_pages_cpuset_fallback(gfp_mask, order, ALLOC_HARDER, ac);
if (page)
goto got_pg;
// 在进行内存分配重试流程之前,需要让 CPU 重新调度到其他进程上
// 运行一会其他进程,因为毕竟此时内存已经严重不足
// 立马重试的话只能浪费过多时间在搜索空闲内存上,导致其他进程处于饥饿状态。
cond_resched();
// 跳转到 retry 分支,重试内存分配流程
goto retry;
}
fail:
warn_alloc(gfp_mask, ac->nodemask,
"page allocation failure: order:%u", order);
got_pg:
return page;
}

这里笔者需要着重强调的一点就是,在 nopage 分支中决定开始重试之前,内核不能立即进行重试流程,因为之前已经经历过那么多严格激进的内存回收策略仍然没有足够的内存,内存现状非常紧急。

所以我们有理由相信,如果内核立即开始重试的话,依然没有什么效果,反而会浪费过多时间在搜索空闲内存上,导致其他进程处于饥饿状态。

所以在开始重试之前,内核会调用 cond_resched() 让 CPU 重新调度到其他进程上,让其他进程也运行一会,与此同时 kswapd 进程一直在后台异步回收着内存。

当 CPU 重新调度回当前进程时,说不定 kswapd 进程已经回收了足够多的内存,重试成功的概率会大大增加同时又避免了资源的无谓消耗。

5. __alloc_pages 内存分配流程总览

到这里为止,笔者就为大家完整地介绍完内核分配内存的整个流程,现在笔者再把内存分配的完整流程图放出来,我们在结合完整的内存分配相关源码,整体在体会一下:

static inline struct page *
__alloc_pages_slowpath(gfp_t gfp_mask, unsigned int order,
struct alloc_context *ac)
{
// 在慢速内存分配路径中可能会导致内核进行直接内存回收
// 这里设置 __GFP_DIRECT_RECLAIM 表示允许内核进行直接内存回收
bool can_direct_reclaim = gfp_mask & __GFP_DIRECT_RECLAIM;
// 本次内存分配是否是针对大量内存页的分配,内核定义 PAGE_ALLOC_COSTLY_ORDER = 3
// 也就是说内存请求内存页的数量大于 2 ^ 3 = 8 个内存页时,costly_order = true,后续会影响是否进行 OOM
const bool costly_order = order > PAGE_ALLOC_COSTLY_ORDER;
// 用于指向成功申请的内存
struct page *page = NULL;
// 内存分配标识,后续会根据不同标识进入到不同的内存分配逻辑处理分支
unsigned int alloc_flags;
// 后续用于记录直接内存回收了多少内存页
unsigned long did_some_progress;
// 关于内存整理相关参数
enum compact_priority compact_priority;
enum compact_result compact_result;
int compaction_retries;
int no_progress_loops;
unsigned int cpuset_mems_cookie;
int reserve_flags;
// 流程现在来到了慢速内存分配这里,说明快速分配路径已经失败了
// 内核需要对 gfp_mask 分配行为掩码做一些修改,修改为一些更可能导致内存分配成功的标识
// 因为接下来的直接内存回收非常耗时可能会导致进程阻塞睡眠,不适用原子 __GFP_ATOMIC 内存分配的上下文。
if (WARN_ON_ONCE((gfp_mask & (__GFP_ATOMIC|__GFP_DIRECT_RECLAIM)) ==
(__GFP_ATOMIC|__GFP_DIRECT_RECLAIM)))
gfp_mask &= ~__GFP_ATOMIC;
retry_cpuset:
// 在之前的快速内存分配路径下设置的相关分配策略比较保守,不是很激进,用于在 WMARK_LOW 水位线之上进行快速内存分配
// 走到这里表示快速内存分配失败,此时空闲内存严重不足了
// 所以在慢速内存分配路径下需要重新设置更加激进的内存分配策略,采用更大的代价来分配内存
alloc_flags = gfp_to_alloc_flags(gfp_mask);
// 重新按照新的设置按照内存区域优先级计算 zonelist 的迭代起点(最高优先级的 zone)
// fast path 和 slow path 的设置不同所以这里需要重新计算
ac->preferred_zoneref = first_zones_zonelist(ac->zonelist,
ac->highest_zoneidx, ac->nodemask);
// 如果没有合适的内存分配区域,则跳转到 nopage , 内存分配失败
if (!ac->preferred_zoneref->zone)
goto nopage;
// 唤醒所有的 kswapd 进程异步回收内存
if (alloc_flags & ALLOC_KSWAPD)
wake_all_kswapds(order, gfp_mask, ac);
// 此时所有的 kswapd 进程已经被唤醒,正在异步进行内存回收
// 之前我们已经在 gfp_to_alloc_flags 方法中重新调整了 alloc_flags
// 换成了一套更加激进的内存分配策略,注意此时是在 WMARK_MIN 水位线之上进行内存分配
// 调整后的 alloc_flags 很可能会立即成功,因此这里先尝试一下
page = get_page_from_freelist(gfp_mask, order, alloc_flags, ac);
if (page)
// 内存分配成功,跳转到 got_pg 直接返回 page
goto got_pg;
// 对于分配大内存来说 costly_order = true (超过 8 个内存页),需要首先进行内存整理,这样内核可以避免直接内存回收从而获取更多的连续空闲内存页
// 对于需要分配不可移动的高阶内存的情况,也需要先进行内存整理,防止永久内存碎片
if (can_direct_reclaim &&
(costly_order ||
(order > 0 && ac->migratetype != MIGRATE_MOVABLE))
&& !gfp_pfmemalloc_allowed(gfp_mask)) {
// 进行直接内存整理,获取更多的连续空闲内存防止内存碎片
page = __alloc_pages_direct_compact(gfp_mask, order,
alloc_flags, ac,
INIT_COMPACT_PRIORITY,
&compact_result);
if (page)
goto got_pg;
if (costly_order && (gfp_mask & __GFP_NORETRY)) {
// 流程走到这里表示经过内存整理之后依然没有足够的内存供分配
// 但是设置了 NORETRY 标识不允许重试,那么就直接失败,跳转到 nopage
if (compact_result == COMPACT_SKIPPED ||
compact_result == COMPACT_DEFERRED)
goto nopage;
// 同步内存整理开销太大,后续开启异步内存整理
compact_priority = INIT_COMPACT_PRIORITY;
}
}
retry:
// 确保所有 kswapd 进程不要意外进入睡眠状态
if (alloc_flags & ALLOC_KSWAPD)
wake_all_kswapds(order, gfp_mask, ac);
// 流程走到这里,说明在 WMARK_MIN 水位线之上也分配内存失败了
// 并且经过内存整理之后,内存分配仍然失败,说明当前内存容量已经严重不足
// 接下来就需要使用更加激进的非常手段来尝试内存分配(忽略掉内存水位线),继续修改 alloc_flags 保存在 reserve_flags 中
reserve_flags = __gfp_pfmemalloc_flags(gfp_mask);
if (reserve_flags)
alloc_flags = gfp_to_alloc_flags_cma(gfp_mask, reserve_flags);
// 如果内存分配可以任意跨节点分配(忽略内存分配策略),这里需要重置 nodemask 以及 zonelist。
if (!(alloc_flags & ALLOC_CPUSET) || reserve_flags) {
// 这里的内存分配是高优先级系统级别的内存分配,不是面向用户的
ac->nodemask = NULL;
ac->preferred_zoneref = first_zones_zonelist(ac->zonelist,
ac->highest_zoneidx, ac->nodemask);
}
// 这里使用重新调整的 zonelist 和 alloc_flags 在尝试进行一次内存分配
// 注意此次的内存分配是忽略内存水位线的 ALLOC_NO_WATERMARKS
page = get_page_from_freelist(gfp_mask, order, alloc_flags, ac);
if (page)
goto got_pg;
// 在忽略内存水位线的情况下仍然分配失败,现在内核就需要进行直接内存回收了
if (!can_direct_reclaim)
// 如果进程不允许进行直接内存回收,则只能分配失败
goto nopage;
// 开始直接内存回收
page = __alloc_pages_direct_reclaim(gfp_mask, order, alloc_flags, ac,
&did_some_progress);
if (page)
goto got_pg;
// 直接内存回收之后仍然无法满足分配需求,则再次进行直接内存整理
page = __alloc_pages_direct_compact(gfp_mask, order, alloc_flags, ac,
compact_priority, &compact_result);
if (page)
goto got_pg;
// 在内存直接回收和整理全部失败之后,如果不允许重试,则只能失败
if (gfp_mask & __GFP_NORETRY)
goto nopage;
// 后续会触发 OOM 来释放更多的内存,这里需要判断本次内存分配是否需要分配大量的内存页(大于 8 ) costly_order = true
// 如果是的话则内核认为即使执行 OOM 也未必会满足这么多的内存页分配需求.
// 所以还是直接失败比较好,不再执行 OOM,除非设置 __GFP_RETRY_MAYFAIL
if (costly_order && !(gfp_mask & __GFP_RETRY_MAYFAIL))
goto nopage;
// 流程走到这里说明我们已经尝试了所有措施内存依然分配失败了,此时内存已经非常危急了。
// 走到这里说明进程允许内核进行重试流程,但在开始重试之前,内核需要判断是否应该进行重试,重试标准:
// 1 如果内核已经重试了 MAX_RECLAIM_RETRIES (16) 次仍然失败,则放弃重试执行后续 OOM。
// 2 如果内核将所有可选内存区域中的所有可回收页面全部回收之后,仍然无法满足内存的分配,那么放弃重试执行后续 OOM
if (should_reclaim_retry(gfp_mask, order, ac, alloc_flags,
did_some_progress > 0, &no_progress_loops))
goto retry;
// 如果内核判断不应进行直接内存回收的重试,这里还需要判断下是否应该进行内存整理的重试。
// did_some_progress 表示上次直接内存回收具体回收了多少内存页
// 如果 did_some_progress = 0 则没有必要在进行内存整理重试了,因为内存整理的实现依赖于足够的空闲内存量
if (did_some_progress > 0 &&
should_compact_retry(ac, order, alloc_flags,
compact_result, &compact_priority,
&compaction_retries))
goto retry;
// 根据 nodemask 中的内存分配策略判断是否应该在进程所允许运行的所有 CPU 关联的 NUMA 节点上重试
if (check_retry_cpuset(cpuset_mems_cookie, ac))
goto retry_cpuset;
// 最后的杀手锏,进行 OOM,选择一个得分最高的进程,释放其占用的内存
page = __alloc_pages_may_oom(gfp_mask, order, ac, &did_some_progress);
if (page)
goto got_pg;
// 只要 oom 产生了作用并释放了内存 did_some_progress > 0 就不断的进行重试
if (did_some_progress) {
no_progress_loops = 0;
goto retry;
}
nopage:
// 流程走到这里表明内核已经尝试了包括 OOM 在内的所有回收内存的动作。
// 但是这些措施依然无法满足内存分配的需求,看上去内存分配到这里就应该失败了。
// 但是如果设置了 __GFP_NOFAIL 表示不允许内存分配失败,那么接下来就会进入 if 分支进行处理
if (gfp_mask & __GFP_NOFAIL) {
// 如果不允许进行直接内存回收,则跳转至 fail 分支宣告失败
if (WARN_ON_ONCE_GFP(!can_direct_reclaim, gfp_mask))
goto fail;
// 此时内核已经无法通过回收内存来获取可供分配的空闲内存了
// 对于 PF_MEMALLOC 类型的内存分配请求,内核现在无能为力,只能不停的进行 retry 重试。
WARN_ON_ONCE_GFP(current->flags & PF_MEMALLOC, gfp_mask);
// 对于需要分配 8 个内存页以上的大内存分配,并且设置了不可失败标识 __GFP_NOFAIL
// 内核现在也无能为力,毕竟现实是已经没有空闲内存了,只是给出一些告警信息
WARN_ON_ONCE_GFP(order > PAGE_ALLOC_COSTLY_ORDER, gfp_mask);
// 在 __GFP_NOFAIL 情况下,尝试进行跨 NUMA 节点内存分配
page = __alloc_pages_cpuset_fallback(gfp_mask, order, ALLOC_HARDER, ac);
if (page)
goto got_pg;
// 在进行内存分配重试流程之前,需要让 CPU 重新调度到其他进程上
// 运行一会其他进程,因为毕竟此时内存已经严重不足
// 立马重试的话只能浪费过多时间在搜索空闲内存上,导致其他进程处于饥饿状态。
cond_resched();
// 跳转到 retry 分支,重试内存分配流程
goto retry;
}
fail:
warn_alloc(gfp_mask, ac->nodemask,
"page allocation failure: order:%u", order);
got_pg:
return page;
}

现在内存分配流程中涉及到的三个重要辅助函数:prepare_alloc_pages,__alloc_pages_slowpath,get_page_from_freelist 。笔者已经为大家介绍了两个了。prepare_alloc_pages,__alloc_pages_slowpath 函数主要是根据不同的空闲内存剩余容量调整内存的分配策略,尽量使内存分配行为尽最大可能成功。

理解了以上两个辅助函数的逻辑,我们就相当于梳理清楚了整个内存分配的链路流程。但目前我们还没有涉及到具体内存分配的真正逻辑,而内核中执行具体内存分配动作是在 get_page_from_freelist 函数中,这也是掌握内存分配的最后一道关卡。

由于 get_page_from_freelist 函数执行的是具体的内存分配动作,所以它和内核中的伙伴系统有着千丝万缕的联系,而本文的主题更加侧重描述整个物理内存分配的链路流程,考虑到文章篇幅的关系,笔者把伙伴系统这部分的内容放在下篇文章为大家讲解。

总结

本文首先从 Linux 内核中常见的几个物理内存分配接口开始,介绍了这些内存分配接口的各自的使用场景,以及接口函数中参数的含义。

并以此为起点,结合 Linux 内核 5.19 版本源码详细讨论了物理内存分配在内核中的整个链路实现。在整个链路中,内存的分配整体分为了两个路径:

  1. 快速路径 fast path:该路径的下,内存分配的逻辑比较简单,主要是在 WMARK_LOW 水位线之上快速的扫描一下各个内存区域中是否有足够的空闲内存能够满足本次内存分配,如果有则立马从伙伴系统中申请,如果没有立即返回。

  2. 慢速路径 slow path:慢速路径下的内存分配逻辑就变的非常复杂了,其中包含了内存分配的各种异常情况的处理,并且会根据文中介绍的 GFP_,ALLOC_ 等各种内存分配策略掩码进行不同分支的处理,整个链路非常庞大且繁杂。

本文铺垫了大量的内存分配细节,但是整个内存分配链路流程的精髓,笔者绘制在了下面这副流程图中,方便大家忘记的时候回顾。

关灯