Linux - 物理内存

前言

虚拟内存文章中,我们知道进程的虚拟内存布局以及相关知识。

为了能够承上启下,我们下面从计算机组成原理的角度介绍物理内存的相关概念,以便后续能够将虚拟内存与物理内存知识进行关联串联,使自己更深入的了解内存管理相关知识点,最后对Go的内存管理进行解析。

物理内存模型

内核是以页(Page Frame)为基本单位对物理内存进行管理的,内核将整个物理内存按照页对齐方式划分成千上万个页(Page Frame)进行管理,每页大小为 4K

系统中每个Page Frame都是struct page的一个实例,那么针对一个4GB内存,那么将会存在上百万个struct page结构。

struct page 中封装了每页内存块的状态信息,比如:组织结构,使用信息,统计信息,以及与其他结构的关联映射信息等。该结构体定义在:include/linux/mm_types.h 文件中:

//path: /include/linux/mm_types.h
struct page {
    unsigned int flags;			// 标志位,用于存储页面的状态和属性信息
  	......
}

为了快速索引到具体的物理内存页,内核为每个物理页 struct page 结构体定义了一个索引编号:PFNPage Frame Number)。

内核提供了两个接口来完成 PFN 与 物理页结构体 struct page 之间的相互转换。它们分别是 page_to_pfnpfn_to_page:

//linux 6.6  path: /include/asm-generic/memory_model.h
#define ARCH_PFN_OFFSET		(0UL)
#define __pfn_to_page(pfn)	(mem_map + ((pfn) - ARCH_PFN_OFFSET))
#define __page_to_pfn(page)	((unsigned long)((page) - mem_map) + \
				 ARCH_PFN_OFFSET)

/* memmap is virtually contiguous.  */
#define __pfn_to_page(pfn)	(vmemmap + (pfn))
#define __page_to_pfn(page)	(unsigned long)((page) - vmemmap)

/*
 * Note: section's mem_map is encoded to reflect its start_pfn.
 * section[i].section_mem_map == mem_map's address - start_pfn;
 */
#elif defined(CONFIG_SPARSEMEM)
#define __page_to_pfn(pg)					\
({	const struct page *__pg = (pg);				\
	int __sec = page_to_section(__pg);			\
	(unsigned long)(__pg - __section_mem_map_addr(__nr_to_section(__sec)));	\
})

#define __pfn_to_page(pfn)				\
({	unsigned long __pfn = (pfn);			\
	struct mem_section *__sec = __pfn_to_section(__pfn);	\
	__section_mem_map_addr(__sec) + __pfn;		\
})

从上述代码可以看出不同的物理内存模型,应对的场景以及 page_to_pfnpfn_to_page 的计算逻辑都是不一样的。

从处理器(CPU)角度看到的物理内存分布,内核管理不同内存模型的方式存在差异。内存管理子系统当中有3种内存模型:

  • 平坦内存(Flat Memory):内存的物理地址空间是连续的,没有空洞;
  • 不连续内存(Discontiguous Memory):内存的物理地址空间存在空洞,这种模型可以高效地处理空洞;
  • 稀疏内存(Sparse Memory):内存的物理地址空间存在空洞,如果需要支持内存热插拔,只能选择稀疏内存模型。

平坦内存(Flat Memory)

内存的物理地址空间是连续的,没有空洞,那么这种计算机系统的内存模型就是Flat memory

image-20231016172109804

内核中使用了一个 mem_map 的全局数组用来组织所有划分出来的物理内存页。mem_map 全局数组的下标就是相应物理页对应的 PFN

在平坦内存模型下 ,PFNmem_map数组index的关系是线性的, 因此从PFN到对应的page数据结构是非常容易的。page_to_pfnpfn_to_page 的计算逻辑就非常简单,本质就是基于 mem_map 数组进行偏移操作,即:

//linux 6.6  path: /include/asm-generic/memory_model.h
#ifndef ARCH_PFN_OFFSET    // ARCH_PFN_OFFSET 是指 PFN 的起始偏移量
#define ARCH_PFN_OFFSET		(0UL)
#endif  
#define __pfn_to_page(pfn)	(mem_map + ((pfn) - ARCH_PFN_OFFSET))
#define __page_to_pfn(page)	((unsigned long)((page) - mem_map) + \
				 ARCH_PFN_OFFSET)

PFNstruct page 的地址,只需在 struct page 数组的基地址 mem_map 的基础上,加上 PFN(再减去体系结构定义的偏移量 ARCH_PFN_OFFSET,以适配不从 0x0 地址开始的物理空间)即可;而从 struct page 的地址到 PFN 也仅仅是把上述公式进行一下移项变换而已。

image-20231018110912790

FLATMEM 模型的优点是结构简单,而且 pfn_to_page()page_to_pfn() 只需进行两次加减法运算,十分高效。但另一方面,现代的 SoC 中拥有不连续的物理地址空间的现象很普遍(即物理地址空间有「空洞」),而 FLATMEM 认为物理地址是连续的,这使得即使某些页帧所对应的物理地址并没有实际的内存,Linux 也要为其分配 struct page 结构体,十分浪费内存资源。

所以一种名为不连续内存( DISCONTIGMEM ) 的内存模型诞生了。

不连续内存(Discontiguous Memory)

不连续内存(Discontiguous Memory)是一种内存模型,与传统的连续内存模型不同。在传统的内存模型中,内存是一块连续的地址空间,数据和程序都存储在这个连续的地址范围内。然而,不连续内存模型允许数据和程序在内存中分散存储在不同的地方,而不必依赖于连续的地址分配。

这种内存模型通常用于特定的计算环境和需求,比如某些嵌入式系统、虚拟内存管理以及非传统的存储体系结构中。

假设如果我们还是采取平坦内存(Flat Memory)这种模型去解决不连续的地址空间,会发生什么问题呢?

由于用于组织物理页的底层数据结构是 mem_map 数组,数组的特性又要求这些物理页是连续的,所以只能为这些内存地址空洞也分配 struct page 结构用来填充数组使其连续。如下图:

image-20231016181813952

而每个 struct page 结构大部分情况下需要占用空间,如果物理内存中存在的大块的地址空洞,那么为这些空洞而分配的 struct page 将会占用大量的内存空间,导致巨大的浪费。

为了解决这个浪费问题,引入了不连续内存(Discontiguous Memory)模型,该模型将内存划分为一个个node,每个 node 节点管理一块连续的物理内存,连续的物理内存页均被划归到了对应的 node 节点中管理,就避免了内存空洞造成的空间浪费。

image-20231017094952030

这些node 节点在内核里面用struct pglist_data进行管理,因为每个node节点里面的空间是连续的,所以依然可以采取连续内存平坦内存(Flat Memory)模型来管理node里面的地址空间。

struct pglist_data定义:

//linux 5.7  path: /include/linux/mmzone.h
typedef struct pglist_data {
   #ifdef CONFIG_FLATMEM
   struct page *node_mem_map;
   #endif
}

每个 node 节点中包含一个 struct page *node_mem_map 数组,用来组织管理 node 中的连续物理内存页。

我们可以看出 DISCONTIGMEM 非连续内存模型其实就是 FLATMEM 平坦内存模型的一种扩展,在面对大块不连续的物理内存管理时,通过将每段连续的物理内存区间划归到 node 节点中进行管理,避免了为内存地址空洞分配 struct page 结构,从而节省了内存资源的开销。

由于引入了 node 节点这个概念,所以在 DISCONTIGMEM 非连续内存模型下 page_to_pfnpfn_to_page 的计算逻辑就比 FLATMEM 内存模型下的计算逻辑多了一步定位 page 所在 node 的操作。

//linux 5.7  path: include/asm-generic/memory_model.h
#elif defined(CONFIG_DISCONTIGMEM)

#define __pfn_to_page(pfn)			\
({	unsigned long __pfn = (pfn);		\
	unsigned long __nid = arch_pfn_to_nid(__pfn);  \
	NODE_DATA(__nid)->node_mem_map + arch_local_page_offset(__pfn, __nid);\
})

#define __page_to_pfn(pg)						\
({	const struct page *__pg = (pg);					\
	struct pglist_data *__pgdat = NODE_DATA(page_to_nid(__pg));	\
	(unsigned long)(__pg - __pgdat->node_mem_map) +			\
	 __pgdat->node_start_pfn;					\
})

  • 通过 arch_pfn_to_nid 可以根据物理页的 PFN 定位到物理页所在 node
  • 通过 page_to_nid 可以根据物理页结构 struct page 定义到 page 所在 node

当定位到物理页 struct page 所在 node 之后,剩下的逻辑就和 FLATMEM 内存模型一模一样了。

需要注意的,由于但由于DISCONTIGMEM模型管理的粒度较粗,无法支持内存热插拔功能,后续的SPARSEMEM内存模型功能已经完全覆盖 DISCONTIGMEM,DISCONTIGMEM已于 2021 年被移除。在此就不详细展开说明了。

稀疏内存(Sparse Memory)

内存模型也是一个演进过程,刚开始的时候,使用平坦内存(flat memory)模型去抽象一个连续的内存地址空间(mem_maps[]),而出现非一致性内存访问NUMA之后,整个不连续的内存空间被分成若干个node,每个node上是连续的内存地址空间,也就是说,原来的单一的一个mem_maps[]变成了若干个mem_maps[]了,即不连续内存(Discontiguous Memory)模型。一切看起来已经完美了,但是内存热拔插技术的出现让原来完美的设计变得不完美了,因为即便是一个node中的mem_maps[]也有可能是不连续了。

每个 node 中都有一套完整的内存管理系统,如果 node 数目多的话,那这个开销就大了,于是就有了对连续物理内存更细粒度的管理需求,为了能够更灵活地管理粒度更小的连续物理内存,SPARSEMEM 稀疏内存模型就此登场了。

SPARSEMEM 稀疏内存模型中,提出了Section的概念,一个比 page 更大的内存管理粒度。

整个连续的物理地址空间是按照一个Section一个Section来切分,每一个Section内部,其内存空间是连续的,而这个Section就是用于管理连续内存块的最小单元。因此,mem_mappage数组依附于Section结构(struct mem_section)而不是node结构了(struct pglist_data)。

SPARSEMEM 模型中的 Section定义为mem_section

//linux 6.6  path: /include/linux/mmzone.h
struct mem_section {
  unsigned long section_mem_map;
  struct mem_section_usage *usage;
  ......
}

struct mem_section 只有两个成员。其中 section_mem_map 主要是该 mem_section 管理的 struct page 的数组指针,指向 section 中管理连续内存的 page 数组,但为了充分利用空间,在这其中还编码了其他信息。

再来看看一个 mem_section 所对应的内存大小,这个内存大小是由宏 SECTION_SIZE_BITS 定义:

//linux 6.6 path: /arch/x86/include/asm/sparsemem.h
#ifdef CONFIG_X86_32
# ifdef CONFIG_X86_PAE
#  define SECTION_SIZE_BITS	29
#  define MAX_PHYSMEM_BITS	36
# else
#  define SECTION_SIZE_BITS	26
#  define MAX_PHYSMEM_BITS	32
# endif
#else
# define SECTION_SIZE_BITS	27 /* matt - 128 is convenient right now */
# define MAX_PHYSMEM_BITS	(pgtable_l5_enabled() ? 52 : 46)
#endif

//linux 6.6 path: /arch/riscv/include/asm/sparsemem.h
#ifdef CONFIG_SPARSEMEM
#ifdef CONFIG_64BIT
#define MAX_PHYSMEM_BITS	56
#else
#define MAX_PHYSMEM_BITS	34
#endif /* CONFIG_64BIT */
#define SECTION_SIZE_BITS	27
#endif /* CONFIG_SPARSEMEM */

//linux 6.6 path: /arch/arm64/include/asm/sparsemem.h
#define MAX_PHYSMEM_BITS	CONFIG_ARM64_PA_BITS
#ifdef CONFIG_ARM64_64K_PAGES
#define SECTION_SIZE_BITS 29
#else
#define SECTION_SIZE_BITS 27
#endif /* CONFIG_ARM64_64K_PAGES */

单个Section的空间大小是通过2^SECTION_SIZE_BITS^得出的,可以看出在X86_64RISC-V以及page大小默认4K大小的ARM64架构下SECTION_SIZE_BITS都是27,即单个Section的空间大小为2^27^128MB)。

SPARSEMEM 模型中总共的mem_section数量则由宏 NR_MEM_SECTIONS 来定义:

// include/linux/page-flags-layout.h
#define SECTIONS_SHIFT	(MAX_PHYSMEM_BITS - SECTION_SIZE_BITS)
// include/linux/mmzone.h
#define NR_MEM_SECTIONS		(1UL << SECTIONS_SHIFT)

MAX_PHYSMEM_BITS则取决于架构,表示最大支持的物理内存位数。而NR_MEM_SECTIONS则表明整个物理地址空间支持最大Section的数量,分析源码,总结通过下面公式可以计算:

NR_MEM_SECTIONS = 2 ^ (MAX_PHYSMEM_BITS - SECTION_SIZE_BITS)

32 位条件下,struct section_mem 的最大支持数量为 2^7^ = 128 个;而在 64 位系统中,其最大支持数量可以达到 2^29^ =536870912 个!

536870912这是多么巨大的一个数字,这么大的数组实在是会造成大量浪费空间以及管理上的麻烦! 因此后续又增加了 SPARSEMEM 模型的两个扩展版本:SPARSEMEM_EXTREMESPARSEMEM_VMEMMAP

经典SPARSEMEM

如果CONFIG_SPARSEMEM_EXTREME编译选项不开启,则默认使用经典SPARSEMEM 模型。

在经典 SPARSEMEM 模型中,struct mem_section 在程序中的组织方式也很简单,通过一个二维数组将所有的 struct mem_section 保存在一个连续、固定的内存空间中:

//linux 6.6  path: /include/linux/mmzone.h
#define PA_SECTION_SHIFT	(SECTION_SIZE_BITS)

#define SECTIONS_PER_ROOT	1
#endif

#define NR_SECTION_ROOTS	DIV_ROUND_UP(NR_MEM_SECTIONS, SECTIONS_PER_ROOT)
extern struct mem_section mem_section[NR_SECTION_ROOTS][SECTIONS_PER_ROOT];
#endif

在经典 SPARSEMEM 模型中, SECTIONS_PER_ROOT 被定义为 1mem_section 二维数组实际上就是长度为 NR_MEM_SECTIONS 的一维数组。经典 SPARSEMEM 模型中 struct mem_section 的组织结构如下:

image-20231018162240208

每一个 struct mem_section 都有一个编号,叫做 section_nr,定义方式为物理地址右移 PA_SECTION_SHIFT 位,PA_SECTION_SHIFT 的值就等于 SECTION_SIZE_BITS

因此从 PFNsection_nr 转换过程也就是简单的移位过程:

//linux 6.6  path: /include/linux/mmzone.h
static inline unsigned long pfn_to_section_nr(unsigned long pfn)
{
	return pfn >> PFN_SECTION_SHIFT;
}

static inline unsigned long section_nr_to_pfn(unsigned long sec)
{
	return sec << PFN_SECTION_SHIFT;
}

我们回头再去看看mem_section 的初始化函数 sparse_init_one_sectionsection_mem_map 的赋值逻辑:

//linux 6.6  path: /include/linux/mmzone.h
struct mem_section {
  unsigned long section_mem_map;
  struct mem_section_usage *usage;
  ......
}

//linux 6.6  path: /mm/sparse.c
static void __meminit sparse_init_one_section(struct mem_section *ms,
		unsigned long pnum, struct page *mem_map,
		struct mem_section_usage *usage, unsigned long flags)
{
  //清除了SECTION_MAP_MASK位, SECTION_MAP_MASK可能是一个预定义的位掩码,用于标记内存区域的映射状态
	ms->section_mem_map &= ~SECTION_MAP_MASK;  
  /**
  首先调用sparse_encode_mem_map(mem_map, pnum)可能将页号和内存映射信息编码到一个特定的位模式中
  然后,它设置SECTION_HAS_MEM_MAP标志位,表示该内存区域有内存映射
  最后,它设置前面通过flags传入的任何其他标志位,在系统初始化时加载的 mem_section,该 flags 传的值为 SECTION_IS_EARLY;而对于热插入的 mem_section,该值为 0
  */
	ms->section_mem_map |= sparse_encode_mem_map(mem_map, pnum)
		| SECTION_HAS_MEM_MAP | flags;   
	ms->usage = usage;		// 设置内存区段的使用情况
}

这段代码的目的是初始化一个内存区段数据结构,包括内存映射和使用情况信息。代码逻辑就不细讲了,注释已经能够把大概步骤标出,再来看看代码中的sparse_encode_mem_map函数:

//linux 6.6  path: /mm/sparse.c
static unsigned long sparse_encode_mem_map(struct page *mem_map, unsigned long pnum)
{
	unsigned long coded_mem_map =
		(unsigned long)(mem_map - (section_nr_to_pfn(pnum)));
	BUILD_BUG_ON(SECTION_MAP_LAST_BIT > PFN_SECTION_SHIFT);
	BUG_ON(coded_mem_map & ~SECTION_MAP_MASK);
	return coded_mem_map;
}

该函数则相对复杂而巧妙一些,它传入了两个参数:

  • mem_map 是这个 mem_sectionstruct page 数组地址;
  • pnum 是该 mem_sectionsection_nr,即它的编号。

sparse_encode_mem_map() 内部,将 mem_mapsection_nr 转换得到的 PFN 做差值,结果则为函数的返回值,最终写入 section_mem_map 结构体成员中。这样就将该 mem_section 的初始 PFN 也编码进其中,其主要是,以后进行转换时可通过 PFN 作为 section_mem_map 的索引,快速得到 struct page 的地址;或者通过 struct page 的地址,快速得到 PFN

讲完上面的内容,我们就可以容易的理解经典SPARSEMEM模型的page_to_pfnpfn_to_page 的计算逻辑了,定义代码如下:

//linux 6.6  path: /include/asm-generic/memory_model.h
#elif defined(CONFIG_SPARSEMEM)
#define __page_to_pfn(pg)					\
({	const struct page *__pg = (pg);				\
	int __sec = page_to_section(__pg);			\
	(unsigned long)(__pg - __section_mem_map_addr(__nr_to_section(__sec)));	\
})

#define __pfn_to_page(pfn)				\
({	unsigned long __pfn = (pfn);			\
	struct mem_section *__sec = __pfn_to_section(__pfn);	\
	__section_mem_map_addr(__sec) + __pfn;		\
})

//linux 6.6  path:  /include/linux/mmzone.h

// 接受一个指向 mem_section 结构体的指针作为参数,并返回一个指向 page 结构体的指针
static inline struct page *__section_mem_map_addr(struct mem_section *section)
{
  // 从传入的 section 结构体中获取 section_mem_map 成员变量的值,并存储在一个无符号长整型变量 map 中
	unsigned long map = section->section_mem_map;
	map &= SECTION_MAP_MASK;
  // 返回 map,它现在是一个指向 page 结构体的地址
	return (struct page *)map;
}

// 接受一个无符号长整型参数 nr,并返回一个指向 mem_section 结构体的指针
static inline struct mem_section *__nr_to_section(unsigned long nr)
{
  // 如果 CONFIG_SPARSEMEM_EXTREME 开启,检查 mem_section 是否为 NULL,如果是,则返回 NULL
#ifdef CONFIG_SPARSEMEM_EXTREME
	if (!mem_section)
		return NULL;
#endif
  // 使用 SECTION_NR_TO_ROOT 宏获取 nr 对应的根 mem_section 的索引
	if (!mem_section[SECTION_NR_TO_ROOT(nr)])
		return NULL;
  // 使用 SECTION_ROOT_MASK 宏和位运算,从根 mem_section 中获取具体的 mem_section 结构体的地址
	return &mem_section[SECTION_NR_TO_ROOT(nr)][nr & SECTION_ROOT_MASK];
}

PFNstruct page 的步骤:

  1. 首先需要通过 __pfn_to_section 根据 PFN 定位到得到该 PFN 对应的 section_nr(即mem_section 数组中的索引);
  2. mem_section 数组中,获得下标为 section_nrstruct mem_section;
  3. 把得到的 struct mem_section 中的 section_mem_map 成员中编码的 flags 去掉,再利用 PFN 作为下标进行索引(即地址 + PFN),即可得到 struct page 的地址。

struct pagePFN的步骤:

  1. 首先需要通过 page_to_section 根据 struct page 结构定位到 mem_section 数组中具体的 Section 结构;
  2. 计算 struct page 地址与 section_mem_map 成员的差值,即为 PFN

经典 SPARSEMEM 模型虽然解决了DISCONTIGMEM的问题,但仍有两大问题:

  1. 经典 SPARSEMEM 模型的 mem_section 数组是固定分配的,在 32位 架构下,共 128 个,这样的开销还可以接受;但在 64位 架构下,其数量达到 536,870,912个,实在是浪费空间十分严重;

  2. 尽管已经做了非常「巧妙」的编码,经典 SPARSEMEM 模型的 pfn_to_page()page_to_pfn()FLATMEM相比,仍然较为复杂。就 pfn_to_page() 来说,前者需要 2 次加法操作、1 次移位操作、1 次按位与操作和 1 次内存读取操作;而后者只需 1 次加法操作和 1 次减法操作即可。

因此SPARSEMEM 模型的两个扩展版本:SPARSEMEM_EXTREMESPARSEMEM_VMEMMAP解决了上述两个问题。

SPARSEMEM_EXTREME

SPARSEMEM_EXTREME 扩展是为了解决上文中提到的 SPARSEMEM 的第 1 个问题而诞生的。

我们来上面说过,SPARSEMEM_EXTREME扩展是否启用是根据CONFIG_SPARSEMEM_EXTREME编译选项来决定的,当CONFIG_SPARSEMEM_EXTREMEtrue or 1时候,SPARSEMEM_EXTREME扩展功能开启。开启后SPARSEMEM模型就会发生一些改变:

//linux 6.6  path: /include/linux/mmzone.h
#ifdef CONFIG_SPARSEMEM_EXTREME
#define SECTIONS_PER_ROOT       (PAGE_SIZE / sizeof (struct mem_section))
#define NR_SECTION_ROOTS	DIV_ROUND_UP(NR_MEM_SECTIONS, SECTIONS_PER_ROOT)
#ifdef CONFIG_SPARSEMEM_EXTREME
extern struct mem_section **mem_section;
  • 首先,SECTIONS_PER_ROOT值的变更,由经典SPARSEMEM模型下的值1 变成了 (PAGE_SIZE / sizeof (struct mem_section)),这意味着原先一个 SECTION_ROOT下只有一个struct mem_section将变成了SECTIONS_PER_ROOTstruct mem_section(即一页大小的 struct mem_section);

    PAGE_SIZE默认为4K大小的情况下,(PAGE_SIZE / sizeof (struct mem_section)) = 4096/16 = 256 ,也就是刚好用一个物理page来存放一组Section

  • mem_section 也不再是一个固定分配的二维数组,而是变成了一个二级指针,动态分配所需要的 struct section_mem 的内存空间。

    在初始化时会分配 struct mem_section* 指针数组:

    //linux 6.6  path: /mm/sparse.c
    
    // 如果 CONFIG_SPARSEMEM_EXTREME开启
    #ifdef CONFIG_SPARSEMEM_EXTREME
    		// 使用 unlikely 宏来提示编译器不常见的情况,这是一种优化技巧
        if (unlikely(!mem_section)) {
          	// 声明两个无符号长整型变量 size 和 align
            unsigned long size, align;
          	// 计算要分配的内存大小,其中 NR_SECTION_ROOTS 是一个常量,表示 mem_section 数组的根节点数目
            size = sizeof(struct mem_section *) * NR_SECTION_ROOTS;
          	// 计算内存分配的对齐要求,INTERNODE_CACHE_SHIFT 是一个常量,用于确定对齐的大小
            align = 1 << (INTERNODE_CACHE_SHIFT);
            // 使用 memblock_alloc 函数来分配内存,将分配的内存地址赋给 mem_section
            mem_section = memblock_alloc(size, align);
          	// 如果内存分配失败,报告错误并中断程序执行
            if (!mem_section)
                panic("%s: Failed to allocate %lu bytes align=0x%lx\n",
                      __func__, size, align);
        }
    #endif
    

    这段代码的主要目的是检查是否需要为mem_section分配内存。如果CONFIG_SPARSEMEM_EXTREME已定义,它首先检查mem_section是否为NULL。如果mem_sectionNULL,则它计算出要分配的内存大小和对齐要求,并使用memblock_alloc函数来分配内存。如果内存分配失败,它会调用panic函数报告错误并中断程序执行。这段代码用于确保mem_section在需要时具有有效的内存分配。

    初始化时分配该 mem_section 所在的空间后,原则是如果分配一个 mem_section,则必须将该 mem_section 所属的 SECTION_ROOT 中所有的 mem_section 的空间全部分配完毕,写入 mem_section 二级指针中:

    //linux 6.6  path: /mm/sparse.c
    
    #ifdef CONFIG_SPARSEMEM_EXTREME  // 如果 CONFIG_SPARSEMEM_EXTREME 定义
    
    // 分配一个新的稀疏内存索引结构,其中包括一个 mem_section 结构数组
    static noinline struct mem_section __ref *sparse_index_alloc(int nid)
    {
        struct mem_section *section = NULL;
        unsigned long array_size = SECTIONS_PER_ROOT * sizeof(struct mem_section);
        // 如果可以使用 slab 分配器,使用 kzalloc_node 分配内存
        if (slab_is_available()) {
            section = kzalloc_node(array_size, GFP_KERNEL, nid);
        } else {
            // 否则,使用 memblock_alloc_node 分配内存
            section = memblock_alloc_node(array_size, SMP_CACHE_BYTES, nid);
            if (!section)
                panic("%s: Failed to allocate %lu bytes nid=%d\n", __func__, array_size, nid);
        }
        return section;
    }
    
    // 初始化稀疏内存索引结构的特定部分
    static int __meminit sparse_index_init(unsigned long section_nr, int nid)
    {
        unsigned long root = SECTION_NR_TO_ROOT(section_nr);
        struct mem_section *section;
        // 如果 mem_section 数组的指定根部分已经存在,直接返回
        if (mem_section[root])
            return 0;
        // 否则,分配一个新的 mem_section 结构
        section = sparse_index_alloc(nid);
        if (!section)
            return -ENOMEM;
        // 将新的 mem_section 结构分配给 mem_section 数组的指定根部分
        mem_section[root] = section;
        return 0;
    }
    #endif
    

下图是 SPARSEMEM_EXTREME 扩展的 struct mem_section 组织结构:

image-20231019111318191

SPARSEMEM_VMEMMAP

SPARSEMEM_VMEMMAP 扩展是为了解决经典 SPARSEMEM 模型的第二个缺点,即 pfn_to_page()page_to_pfn() 过程较复杂而出现的。

它的主要思想并不复杂:在 SPARSEMEM 中,struct page 为应对内存空洞,实际上不会连续存在,但可以设法安排每个 struct page(不管其存在与否)的虚拟地址是固定且连续的,其实就是虚拟映射,说白了就是走页表,因为分配虚拟地址并不会有实际的开销,反而可以方便进行索引。

那么这样的话再去计算fpn就非常简单了:

//linux 6.6  path: /arch/x86/include/asm/pgtable_64.h
#define vmemmap ((struct page *)VMEMMAP_START)

//linux 6.6  path: /include/asm-generic/memory_model.h
#elif defined(CONFIG_SPARSEMEM_VMEMMAP)
#define __pfn_to_page(pfn)	(vmemmap + (pfn))
#define __page_to_pfn(page)	(unsigned long)((page) - vmemmap)

计算和数组vmemmap首地址的差值即可。

SPARSEMEM_VMEMMAP依然是按节将物理内存分成一块一块的,只不过用虚拟映射而不是直接映射来组织page struct数组. 这就意味着,内核的虚拟地址空间,必须要预留一个位置给vmemmap,回顾下在虚拟内存内容部分的X64位内核图,黄色区域为vmemmap映射区:

image-20231019150325404

内核预留了2^30^的位置,而一个page struct的大小大概是64B,即vmemmap最多可以存放:

1TB / 64B * 4KB = 16TB

也就是说,在X86_64架构上,最大支持的内存是16TB。关于映射部分将单独出来讲解,后续再涉及。

物理内存架构

这章节内容介绍两种多处理器系统内存架构:均匀内存访问架构(UMA)和非均匀内存访问架构(NUMA)。它们都是 SMP(对称多处理器,Symmetric multiprocessing)架构的具体实现。

UMA

UMAUniform Memory Access)均匀内存访问架构。所有 CPU 都是经过总线到内存控制器再到物理内存,访问相同的物理内存,并且访问距离和时间也相同。

下图是一个典型的 x86 UMA 内存架构,四路 CPU 通过前端系统总线(FSB, Front Side Bus)和主板上北桥(North Bridge)芯片中内存控制器 (MCH, Memory Controller Hub) 相连,再与物理内存相连:

image-20231020111608569

但是随着多核技术的发展,服务器上的 CPU 个数会越来越多,而 UMA 架构下所有 CPU 都是需要通过总线来访问内存的,这样总线很快就会成为性能瓶颈,主要体现在以下两个方面:

  1. 总线的带宽压力会越来越大,随着 CPU 个数的增多导致每个 CPU 可用带宽会减少;
  2. 总线的长度也会因此而增加,进而增加访问延迟。

为了解决以上问题,提高 CPU 访问内存的性能和扩展性,于是引入了一种新的架构:非一致性内存访问 NUMANon-uniform memory access)。

NUMA

NUMANon-Uniform Memory Access)非均匀内存访问架构。内存划分为多个块(NUMA 节点),每个 CPU 到不同内存块距离有远近之分,距离一个 CPU 近的内存块称为该 CPU 的本地内存;距离相对远的内存块称为该 CPU 的非本地内存(也叫远端内存)。在 NUMA 架构下,任意一个 CPU 都可以访问全部的内存节点,访问自己的本地内存节点是最快的,但访问其他内存节点就会慢很多,这就导致了 CPU 访问内存的速度不一致,所以叫做非一致性内存访问架构。

如下图所示,NUMA 内存架构把 CPU 和本地内存封装在一个 Node 节点里,并且将内存控制器芯片被集成到 CPU 内部,CPU 间通过 QPIQuickPath Interconnect)链路相连。每个 CPU 访问本地内存非常快,没有了总线,相当于直接访问。但是有时例如本地内存空间不足等情况,一个 CPU 可以通过 QPI 访问另一个 CPU 所在 Node 节点内的本地内存,也就是一个 CPU 可以访问非本地内存。有的架构将PCI-E总线资源(IOH)也集成到了 CPU 内部:

image-20231020115152180

一个 Node 节点由一个物理 CPU、本地内存和本地 IO 资源组成。一个物理 CPU 由多个 CPU Core(核心)和一个 UnCore部分组成。每个 CPU Core 一般有 2CPU Thread,也称逻辑 CPU Core(核心)。

  • 每个逻辑核心独立运行,共享 Core 内部的逻辑运算单元(ALU)、浮点运算单元(FPU)、L1L2 缓存;
  • Uncore 集成了内存控制器 iMC(Integrated Memory Controller)、PCIe Root ComplexQPI 控制器、L3 缓存和 CBox(负责缓存一致性),及其它外设控制器

内存分配策略

NUMA 的内存分配策略决定内存分配时的行为,例如优先请求本地内存节点分配内存呢 ?还是优先请求指定的 NUMA 节点分配内存 ?是只能在本地内存节点分配呢 ?还是允许当本地内存不足的情况下可以请求远程 NUMA 节点分配内存 ?

下面列出了几种分配策略:

内存分配策策略描述
MPOL_DEFAULT先从本节点分配内存,如果失败去系统认为比较近的其他节点分配内存。
MPOL_BIND必须在指定的一个或多个节点分配内存,如果分配失败,即使其他节点有内存也会进行 Swap 或 OOM。
MPOL_INTERLEAVE从指定的一个或多个节点内交错分配内存。
MPOL_PREFERRED优先在指定一个或多个节点内分配内存,当分配失败时去其他节点分配内存。
MPOL_LOCAL(默认)与 MPOL_DEFAULT 相似,也是优先在本地节点分配,当分配失败时去其他节点分配内存。

我们可以调用 libnuma 库中的 set_mempolicy 接口设置进程的内存分配策略:

#include <numaif.h>
long set_mempolicy(int mode, const unsigned long *nodemask,
                   unsigned long maxnode);
  • mode :指定 NUMA 内存分配策略;
  • nodemask:指定 NUMA 节点 Id
  • maxnode:指定最大 NUMA 节点 id,当指定节点内存不足时,遍历远端节点分配内存。

相关libnuma共享库 API 文档set_mempolicy 接口文档 点击地址链接参考。

通过下面命令查看 NUMA 的内存分配策略:

$ numactl -s
policy: default //默认策略
preferred node: current
physcpubind: 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31
cpubind: 0 1
nodebind: 0 1
membind: 0 1

具体的的相关命令使用,参考numactl 文档:https://man7.org/linux/man-pages/man8/numactl.8.html。

节点绑定

numactl 工具可以让我们应用程序指定运行在哪些 CPU 核心上,同时也可以指定我们的应用程序可以在哪些 NUMA 节点上分配内存。通过将应用程序与具体的 CPU 核心和 NUMA 节点绑定,从而可以提升程序的性能。

指定NUMA运行节点以及分配内存使用命令:

numactl --membind=nodes  --cpunodebind=nodes  command

使用示例:

$ numactl --membind=0 --cpunodebind=0 ./a.out
$ numactl --membind=1 --cpunodebind=0 ./b.out
  • membind:以指定我们的应用程序只能在哪些具体的 NUMA 节点上分配内存,如果这些节点内存不足,则分配失败;

  • cpunodebind:指定程序只能运行在哪些 NUMA 节点内的 CPU(s) 上。

另外我们还可以通过 --physcpubind 将我们的应用程序绑定到具体的物理 CPU 上:

$ numactl --physcpubind=cpus command

使用示例:

$ numactl --physcpubind=0 ./a.out   #绑定到 0 号 CPU
$ numactl --physcpubind=0-5 ./a.out #绑定到 0~5 号 CPU

另外CPU id 可以通过下面命令获得:

$ cat /proc/cpuinfo | grep processor
processor       : 0
processor       : 1

物理内存节点(node)

节点结构

无论是 NUMA 架构还是 UMA 架构在内核中都是使用相同的数据结构来组织管理的,在内核的内存管理模块中会把 UMA 架构当做只有一个 NUMA 节点的伪 NUMA 架构。这样一来这两种架构模式就在内核中被统一管理起来。

内核中定义了一个全局的 Node 节点数组来存储这些节点,定义如下:

// linux6.6  path: /arch/x86/include/asm/mmzone_64.h
extern struct pglist_data *node_data[];
#define NODE_DATA(nid)		(node_data[nid])

node_data[] 数组大小由 MAX_NUMNODES 定义:

// linux6.6  path: /include/linux/numa.h
#ifdef CONFIG_NODES_SHIFT
#define NODES_SHIFT     CONFIG_NODES_SHIFT
#else
#define NODES_SHIFT     0     //UMA架构
#endif
#define MAX_NUMNODES    (1 << NODES_SHIFT)

UMA 架构下 NODES_SHIFT0 ,所以内核中只用一个 NUMA 节点来管理所有物理内存。而不同架构下的NODES_SHIFT是不同的,可以参考源码中arch/(arm64|mips|x86|riscv|sparc|ia64)/Kconfig这个文件查看配置。

再来看看NUMA 节点的描述符 pglist_data,该描述符在连续内存(Discontiguous Memory)模型中出现过,现在更详细的介绍下:

// linux6.6  path: /include/linux/mmzone.h
typedef struct pglist_data {
    // 描述节点内不同的内存区域
    struct zone node_zones[MAX_NR_ZONES];
    // 描述节点的分区列表
    struct zonelist node_zonelists[MAX_ZONELISTS];
    // 节点包含的内存区域数目
    int nr_zones;

#ifdef CONFIG_FLATMEM  // 以下成员在 CONFIG_FLATMEM 定义时有效
    // 指向节点的内存映射表
    struct page *node_mem_map;
#ifdef CONFIG_PAGE_EXTENSION  // 指向节点的页扩展结构
    struct page_ext *node_page_ext;
#endif

#endif

#if defined(CONFIG_MEMORY_HOTPLUG) || defined(CONFIG_DEFERRED_STRUCT_PAGE_INIT)// 以下成员在 CONFIG_MEMORY_HOTPLUG 或 CONFIG_DEFERRED_STRUCT_PAGE_INIT 定义时有效
    // 自旋锁,用于保护节点的大小信息
    spinlock_t node_size_lock;
#endif

    // 物理内存区间的起始页框号
    unsigned long node_start_pfn;
    // 当前节点存在的页数(不包含内存空洞)
    unsigned long node_present_pages;
    // 当前节点跨越的页数(含内存空洞)
    unsigned long node_spanned_pages;
    // 节点的唯一标识符
    int node_id;
    // 等待队列头,用于等待 kswapd 守护进程
    wait_queue_head_t kswapd_wait;
    // 等待队列头,用于等待页面迁移内存分配
    wait_queue_head_t pfmemalloc_wait;
    // 数组,用于等待 VM 扫描线程的等待队列
    wait_queue_head_t reclaim_wait[NR_VMSCAN_THROTTLE];
    // 原子变量,用于限制写回页面的数量
    atomic_t nr_writeback_throttled;
    // 内存回收的起始页框号
    unsigned long nr_reclaim_start;

#ifdef CONFIG_MEMORY_HOTPLUG   // 以下成员在 CONFIG_MEMORY_HOTPLUG 定义时有效

    // 互斥锁,用于保护 kswapd 守护进程
    struct mutex kswapd_lock;
#endif
    // 指向 kswapd 守护进程的指针
    struct task_struct *kswapd;
    // kswapd 守护进程的优先级
    int kswapd_order;
    // kswapd 守护进程的最高区域索引
    enum zone_type kswapd_highest_zoneidx;
    // kswapd 守护进程的失败次数
    int kswapd_failures;

#ifdef CONFIG_COMPACTION  // 以下成员在 CONFIG_COMPACTION 定义时有效

    // 最大页面迁移阶段的页框号
    int kcompactd_max_order;
    // 页面迁移的最高区域索引
    enum zone_type kcompactd_highest_zoneidx;
    // 等待队列头,用于等待 kcompactd 守护进程
    wait_queue_head_t kcompactd_wait;
    // 指向 kcompactd 守护进程的指针
    struct task_struct *kcompactd;
    // 用于主动触发页面迁移的标志
    bool proactive_compact_trigger;
#endif

    // 总的保留页面数
    unsigned long totalreserve_pages;

#ifdef CONFIG_NUMA  // 以下成员在 CONFIG_NUMA 定义时有效

    // 最小未映射页面数
    unsigned long min_unmapped_pages;
    // 最小 slab 页面数
    unsigned long min_slab_pages;
#endif

    // 用于填充缓存行,提高性能
    CACHELINE_PADDING(_pad1_);

#ifdef CONFIG_DEFERRED_STRUCT_PAGE_INIT  // 以下成员在 CONFIG_DEFERRED_STRUCT_PAGE_INIT 定义时有效

    // 第一个延迟初始化的页框号
    unsigned long first_deferred_pfn;
#endif

#ifdef CONFIG_TRANSPARENT_HUGEPAGE    // 以下成员在 CONFIG_TRANSPARENT_HUGEPAGE 定义时有效

    // 延迟分割页队列
    struct deferred_split deferred_split_queue;
#endif

#ifdef CONFIG_NUMA_BALANCING   // 以下成员在 CONFIG_NUMA_BALANCING 定义时有效

    // NUMA 平衡的开始阈值
    unsigned int nbp_rl_start;
    // NUMA 平衡的候选页数
    unsigned long nbp_rl_nr_cand;
    // NUMA 平衡的阈值
    unsigned int nbp_threshold;
    // NUMA 平衡的开始页数
    unsigned int nbp_th_start;
    // NUMA 平衡的候选页数
    unsigned long nbp_th_nr_cand;
#endif

    // 用于 LRU 操作的 lruvec 结构
    struct lruvec __lruvec;
    // 位掩码,用于存储各种标志
    unsigned long flags;

#ifdef CONFIG_LRU_GEN  // 以下成员在 CONFIG_LRU_GEN 定义时有效

    // 用于管理 mm_walk 的结构
    struct lru_gen_mm_walk mm_walk;
    // 用于管理 memcg 内存回收的结构
    struct lru_gen_memcg memcg_lru;
#endif

    // 用于填充缓存行,提高性能
    CACHELINE_PADDING(_pad2_);
    // 指向每个 CPU 的节点统计数据
    struct per_cpu_nodestat __percpu *per_cpu_nodestats;
    // 虚拟内存统计数据
    atomic_long_t vm_stat[NR_VM_NODE_STAT_ITEMS];

#ifdef CONFIG_NUMA  // 以下成员在 CONFIG_NUMA 定义时有效

    // 内存层次结构的信息
    struct memory_tier __rcu *memtier;
#endif

#ifdef CONFIG_MEMORY_FAILURE  // 以下成员在 CONFIG_MEMORY_FAILURE 定义时有效

    // 内存故障统计信息
    struct memory_failure_stats mf_stats;
#endif
} pg_data_t

  • node_id: 表示 NUMA 节点的 id,我们可以通过 numactl -H 命令的输出结果查看节点 id。从 0 开始依次对 NUMA 节点进行编号;
  • node_start_pfn: 指向 NUMA 节点内第一个物理页的 PFN,系统中所有 NUMA 节点中的物理页都是依次编号的,每个物理页的 PFN 都是全局唯一的(不只是其所在 NUMA 节点内唯一);
  • node_present_pages: 用于统计 NUMA 节点内所有真正可用的物理页面数量(不包含内存空洞);
  • node_spanned_pages: 则是用于统计 NUMA 节点内所有的内存页,包含不连续的物理内存地址(内存空洞)的页面数;
  • node_zones:只包含此节点的内存区域(zone),并不是所有区域都会被填充,但它是完整的区域列表。它是一个区域数组,大小为 MAX_NR_ZONES,数组索引就是区域的类型,不是每个节点包含所有类型的区域,所以说数组中存在没有被填充的元素,下文会介绍区域类型定含义。
  • nr_zones:就是 node_zones 数组被填充元素的数目;
  • node_zonelists:引用本节点及其他节点的 node_zones。目的是当本地节点内存不足时,需要分配其他节点的本地内存;
  • kswapd:指向内核为本节点分配的 kswapd 进程,用于回收不经常使用的页;
  • kswapd_wait:用于 kswapd 进程周期性回收页面时使用到的等待队列;
  • kcompactd:指向内核为本节点分配的 kcompactd 进程,用于规整避免内存碎片;
  • kcompactd_wait:用于 kcompactd 进程周期性规整内存时使用到的等待队列。

下面用图来展示节点的相关关联图:

image-20231023113744672

区域类型

在一个理想的计算机系统中, 一个页框(Page)就是一个内存的分配单元, 可用于任何事情:存放内核数据, 用户数据和缓冲磁盘数据等等。任何种类的数据页都可以存放在任页框中, 没有任何限制。

但是Linux内核又把各个物理内存节点分成n个不同的管理区域zone, 这是为什么呢?

因为实际的计算机体系结构有硬件的诸多限制, 这限制了页框可以使用的方式。尤其是, Linux内核必须处理两种硬件约束:

  • X86 体系结构下,ISA 总线的 DMA (直接内存存取)控制器,只能对内存的前16M 进行寻址,这就导致了 ISA 设备不能在整个 32 位地址空间中执行 DMA,只能使用物理内存的前 16M 进行 DMA 操作;
  • 具有大容量RAM的现代32位计算机中, CPU不能直接访问所有的物理地址, 因为线性地址空间太小, 内核不可能直接映射所有物理内存到线性地址空间。

所以内核会根据各个物理内存区域的功能不同,将 NUMA 节点内的物理内存主要划分为几个物理内存区域(zone):

// linux6.6  path: /include/linux/mmzone.h
enum zone_type {
#ifdef CONFIG_ZONE_DMA
	ZONE_DMA,
#endif
#ifdef CONFIG_ZONE_DMA32
	ZONE_DMA32,
#endif
	ZONE_NORMAL,
#ifdef CONFIG_HIGHMEM
	ZONE_HIGHMEM,
#endif
	ZONE_MOVABLE,
#ifdef CONFIG_ZONE_DEVICE
	ZONE_DEVICE,
#endif
	__MAX_NR_ZONES
};

下面用表来表示各个区域的作用:

管理内存域(zone)描述
ZONE_DMA用于那些无法对全部物理内存进行寻址的硬件设备,进行 DMA 时的内存分配。例如前边介绍的 ISA 设备只能对物理内存的前 16M 进行寻址。该区域的长度依赖于具体的处理器类型
ZONE_DMA32ZONE_DMA 也是在外设不能 DMA 到所有可寻址物理内存空间(ZONE_NORMAL)时使用。不同之处是 ZONE_DMA32 供 32 位外设使用,寻址范围比使用 ZONE_DMA 的外设更大。并且 ZONE_DMA32 只有在 64 位系统中生效,32 位系统没有这个区域。64 位系统为了兼容 32 位外设才有了这个区域
ZONE_NORMAL表示内核能够直接线性映射的普通内存区域。比如内核程序中代码段、全局变量以及kmalloc获取的堆内存等。从此处获取内存一般是连续的,但是不能太大。
ZONE_HIGHMEM高端内存区,内核不可以直接访问,需要通过页表动态映射,将虚拟地址转换成物理地址再进行访问。因为 32 位系统寻找空间才有 4GB,所以该区域在 32 位系统中超过 896MB 的虚拟内存空间中;64 位系统不需要该区域,因为 64 位寻找空间非常大(128TB),完全可以放在 ZONE_NORMAL 区域里直接映射
ZONE_DEVICE通常与设备相关的内存缓冲区有关,这些缓冲区用于设备之间的数据传输。例如,网络适配器、图形卡、存储控制器等设备可能需要使用ZONE_DEVICE 内存来进行数据传输,而无需将数据映射到通常的系统内存区域;
为支持热插拔设备而分配的Non Volatile Memory非易失性内存
ZONE_MOVABLE内核定义的一个虚拟内存区域,该区域中的物理页均来自其他真实的物理区域,该区域中的物理页都是可以迁移的,目的是防止内存碎片和支持内存热插拔,处于 ZONE_MOVABLE 区域,内核可以通过迁移页面来来规整内存,避免内存碎片的问题

下面我们继续回到 struct pglist_data 结构中看下内核如何在 NUMA 节点中组织这些划分出来的内存区域:

// linux6.6  path: /include/linux/mmzone.h
typedef struct pglist_data {
    // 描述节点内不同的内存区域
    struct zone node_zones[MAX_NR_ZONES];

    // 描述节点的分区列表
    struct zonelist node_zonelists[MAX_ZONELISTS];

    // 节点包含的内存区域数目
    int nr_zones;
  ......
}pg_data_t;

nr_zones 用于统计 NUMA 节点内包含的物理内存区域个数,不是每个 NUMA 节点都会包含以上介绍的所有物理内存区域,NUMA 节点之间所包含的物理内存区域个数是不一样的

实际上只有第一个 NUMA 节点可以包含所有区域类型,其它节点只能包含部分区域类型,因为 ZONE_DMAZONE_DMA32 必须安排在物理内存的低地址,所以只能放在第一个节点。

下面是一个示例及对应的图解:

$ cat /proc/zoneinfo | grep Node
Node 0, zone      DMA
Node 0, zone    DMA32
Node 0, zone   Normal
Node 1, zone   Normal
Node 2, zone   Normal
Node 2, zone   Movable
Node 3, zone   Normal
Node 3, zone   Device

如图:

image-20231023140917457

node_zones[MAX_NR_ZONES] 数组包含了 NUMA 节点中的所有物理内存区域,物理内存区域在内核中的数据结构是 struct zone

node_zonelists[MAX_ZONELISTS]struct zonelist 类型的数组,它包含了备用 NUMA 节点和这些备用节点中的物理内存区域。备用节点是按照访问距离的远近,依次排列在 node_zonelists 数组中,数组第一个备用节点是访问距离最近的,这样当本节点内存不足时,可以从备用 NUMA 节点中分配内存。

节点状态和信息

系统中的 NUMA 节点多于一个,内核会维护一个位图 node_states,用于维护各个 NUMA 节点的状态信息,节点位图以及节点的状态掩码值定义在如下:

// linux6.6  path: /include/linux/nodemask.h
typedef struct { DECLARE_BITMAP(bits, MAX_NUMNODES); } nodemask_t;
extern nodemask_t _unused_nodemask_arg_;

节点的状态如下定义:

// linux6.6  path: /include/linux/nodemask.h
enum node_states {
	N_POSSIBLE,		
	N_ONLINE,		
	N_NORMAL_MEMORY,	
#ifdef CONFIG_HIGHMEM
	N_HIGH_MEMORY,	
#else
	N_HIGH_MEMORY = N_NORMAL_MEMORY,
#endif
	N_MEMORY,	
	N_CPU,	
	N_GENERIC_INITIATOR,
	NR_NODE_STATES
};

字段解释如下:

  • N_POSSIBLE: 表示节点在某个时刻可变为 online 状态;
  • N_ONLINE: 表示节点当前的状态为 online 状态;
  • N_NORMAL_MEMORY:表示节点没有高端内存,只有 ZONE_NORMAL 内存区域;
  • N_HIGH_MEMORY:表示节点有 ZONE_HIGHMEM 内存区域;
  • N_HIGH_MEMORY = N_NORMAL_MEMORY:表示节点有 ZONE_NORMALZONE_HIGHMEM 内存区域;
  • N_MEMORY:表示节点有 ZONE_NORMALZONE_HIGHMEMZONE_MOVABLE 内存区域;
  • N_CPU:表示节点有一个或多个 CPU
  • N_GENERIC_INITIATOR:标识节点有一个或多个通用启动器;
  • NR_NODE_STATES:表示节点状态的数量。

除了上面命令外,通过下面命令可以查看NUMA 节点信息:

[root@VM-16-10-centos ~]# cat /proc/zoneinfo

我们可以通过 cat /proc/zoneinfo | grep Node 命令来查看 NUMA 节点中内存区域的分布情况:

[root@VM-16-10-centos ~]# cat /proc/zoneinfo | grep Node
Node 0, zone      DMA
Node 0, zone    DMA32
Node 0, zone   Normal
[root@VM-16-10-centos ~]# 

物理内存区域(zone)

区域结构

在节点内容部分已经介绍了系统为什么把节点分为不同的管理区域zone,也介绍了每个zone的不同的作用,下面就来详细展开说说zone结构。

由于内核中 struct zone 数量比较少,多个 CPU 同时读写器中的字段就会比较频繁,就会带来缓存失效,然后去内存读写数据,造成延时增加,也称伪共享。为了降低缓存失效的概率,使用 3ZONE_PADDINGstruct zone 的数据成员分割成 4 个部分,通过 ZONE_PADDING 来填充字节,将这四个部分,分别填充到不同的 CPU 高速缓存行(cache line)中,使得它们各自独占 cache line,避免造成缓存失效。布局如下:

struct zone {
  .......省略 .......
  	CACHELINE_PADDING(_pad1_);
  .......省略 .......
  	CACHELINE_PADDING(_pad2_);
  .......省略 .......
    CACHELINE_PADDING(_pad3_);
  .......省略 .......
} ____cacheline_internodealigned_in_smp;

struct zone结构体使用了____cacheline_internodealigned_in_smp编译器关键字修饰,告知编译器这些结构体需要按照缓存行(cache line)对齐。

继续看看struct zone的具体定义,字段如下:

// linux6.6  path: /include/linux/mmzone.h
struct zone {
  	/*内存区域水位标记,通过 *_wmark_pages(zone) 宏访问*/
    unsigned long _watermark[NR_WMARK];
    unsigned long watermark_boost;
    unsigned long nr_reserved_highatomic;
  
    /**
    不知道要分配的内存是否可释放,或者最终是否会被释放,所以为了避免浪费几个GB的大量内存,我们必须保留一些较低内存区域的内存
    此数组在运行时根据 sysctl_lowmem_reserve_ratio 系统控制参数的变化进行重新计算
    */
    long lowmem_reserve[MAX_NR_ZONES];

#ifdef CONFIG_NUMA
    // 如果配置中启用了 NUMA,表示节点编号
    int node;
#endif

    // 指向所在的 NUMA 节点 pglist_data
    struct pglist_data *zone_pgdat;

    // 为每个CPU核心维护独立的页面集合,以提高内存分配的性能和效率,并减少多CPU核心之间的竞争和锁冲突
    struct per_cpu_pages __percpu *per_cpu_pageset;
    // 每个CPU的区域统计信息
    struct per_cpu_zonestat __percpu *per_cpu_zonestats;
    // 用于定义页面集合中的高水位标记,当页面集合中的页面数量达到高水位标记时,可能触发内存回收或其他管理操作
    int pageset_high;
    // 用于控制每个CPU核心在一次性内存分配操作中分配的页面数量,当一个CPU核心需要分配内存时,它会从 per_cpu_pageset 中获取一个批次大小的页面块。这个批次大小是由 pageset_batch 控制的,它决定了一次性内存分配的规模
    int pageset_batch;

#ifndef CONFIG_SPARSEMEM
    // 如果未启用 SPARSEMEM,表示页块标志数组
    unsigned long *pageblock_flags;
#endif
    // 区域的起始页框号(起始PFN)
    unsigned long zone_start_pfn;
    // 被伙伴系统所管理的物理页数
    atomic_long_t managed_pages;
    // 该内存区域中所有的物理页个数(包含内存空洞)
    unsigned long spanned_pages;
    // 该内存区域所有可用的物理页个数(不包含内存空洞)
    unsigned long present_pages;
  
#if defined(CONFIG_MEMORY_HOTPLUG)
    // 提前添加的页数(仅在配置中启用了内存热插拔时存在)
    unsigned long present_early_pages;
#endif

#ifdef CONFIG_CMA
    // 连续内存分配的页数(如果配置中启用了 CMA)
    unsigned long cma_pages;
#endif
    // 区域的名称
    const char *name;
#ifdef CONFIG_MEMORY_ISOLATION
    // 隔离的页块数
    unsigned long nr_isolate_pageblock;
#endif

#ifdef CONFIG_MEMORY_HOTPLUG
    // 区域的跨度序列锁
    seqlock_t span_seqlock;
#endif

    // 初始化标志
    int initialized;
  
    // 用于填充缓存行,提高性能
    CACHELINE_PADDING(_pad1_);

    // 自由区域数组
    struct free_area free_area[MAX_ORDER + 1];
#ifdef CONFIG_UNACCEPTED_MEMORY
    // 未接受的页链表头(如果配置中启用了未接受的内存)
    struct list_head unaccepted_pages;
#endif

    // 标志位
    unsigned long flags;
    // 自旋锁
    spinlock_t lock;
    // 用于填充缓存行,提高性能
    CACHELINE_PADDING(_pad2_);

    /*
     * 当空闲页低于此点时,在读取空闲页数时会采取额外步骤,
     * 以避免 per-cpu 计数器漂移,从而导致水位标记被突破
     */
    unsigned long percpu_drift_mark;

#if defined CONFIG_COMPACTION || defined CONFIG_CMA
    // 页面迁移相关的缓存值
    unsigned long compact_cached_free_pfn;
    unsigned long compact_cached_migrate_pfn[ASYNC_AND_SYNC];
    unsigned long compact_init_migrate_pfn;
    unsigned long compact_init_free_pfn;
#endif

#ifdef CONFIG_COMPACTION
    // 页面迁移的考虑值
    unsigned int compact_considered;
    unsigned int compact_defer_shift;
    int compact_order_failed;
#endif

#if defined CONFIG_COMPACTION || defined CONFIG_CMA
    // 页面迁移相关的标志
    bool compact_blockskip_flush;
#endif

    // 区域是否连续标志
    bool contiguous;

    // 用于填充缓存行,提高性能
    CACHELINE_PADDING(_pad3_);
    // 虚拟内存区域统计信息
    atomic_long_t vm_stat[NR_VM_ZONE_STAT_ITEMS];
    // NUMA事件统计信息
    atomic_long_t vm_numa_event[NR_VM_NUMA_EVENT_ITEMS];
} ____cacheline_internodealigned_in_smp;

每个属性字段都加了备注,这边挑几个简单描述下,后续个别字段在相关内容中会重点分析。

首先看看struct pglist_data *zone_pgdat,这个字段的类型是否很熟悉,其实它在NUMA 节点的描述符 struct pglist_data 的时候提到,pglist_data 通过 struct zone 类型的数组 node_zonesNUMA 节点中划分的物理内存区域连接起来:

// linux6.6  path: /include/linux/mmzone.h
typedef struct pglist_data {
    // 描述节点内不同的内存区域
    struct zone node_zones[MAX_NR_ZONES];
    // 描述节点的分区列表
    struct zonelist node_zonelists[MAX_ZONELISTS];
    // 节点包含的内存区域数目
    int nr_zones;
  ......
}pg_data_t

这些物理内存区域也会通过 struct zone 中的 zone_pgdat 指向自己所属的 NUMA 节点:

image-20231024101102635

zone_start_pfn 指向的是该内存区域内所管理的第一个物理页面 PFN

spanned_pages 表示该内存区域内所有的物理页总数(包含内存空洞),通过 spanned_pages = zone_end_pfn - zone_start_pfn 计算得到。

present_pages 则表示该内存区域内所有实际可用的物理页面总数(不包含内存空洞),通过 present_pages = spanned_pages - absent_pages(pages in holes) 计算得到。

managed_pages 用于表示该内存区域内被伙伴系统所管理的物理页数量。

再来说下,物理内存在内核中管理的层级关系:None -> Zone -> page

NUMA 架构下,物理内存被划分成了一个一个的内存节点(NUMA 节点),在每个 NUMA 节点内部又将其所管理的物理内存按照功能不同划分成了不同的内存区域,每个内存区域管理一片用于具体功能的物理内存,而内核会为每一个内存区域分配一个伙伴系统用于管理该内存区域下物理内存的分配和释放:

image-20231024104456262

预留内存

进程申请内存时,如果内存充裕,则立刻获得内存;如果内存紧张时,有以下两种情况:

  • 进程允许阻塞:内核将一部分不经常使用的物理内存回收,回收过程是需要时间的,期间进程被阻塞,回收完成后分配内存给进程;
  • 进程不能阻塞:内存分配的请求必须马上得到满足,因为内核核心操作申请内存是不允许失败的,所以每个区域(zone)都预留了一小部分内存供其申请。例如,中断处理函数无法被重新调度或者持有自旋锁时申请内存,进程就不允许睡眠。

nr_reserved_highatomic 是本区域的预留内存大小(128KB~65536KB),lowmem_reserve 数组是用于规定本区域为防止数组索引值对应类型的区域对本区域的侵占挤压,必须为本区域保留的物理页数量。

这两字段定义如下:

// linux6.6  path: /include/linux/mmzone.h
struct zone {
  unsigned long nr_reserved_highatomic;
  long lowmem_reserve[MAX_NR_ZONES];
  ......
}____cacheline_internodealigned_in_smp;

那么什么是高位内存区域 ?什么是低位内存区域 ? 高位内存区域为什么会对低位内存区域进行侵占挤压呢 ?

根据物理内存地址的高低,低位内存区域到高位内存区域的顺序依次是:ZONE_DMAZONE_DMA32ZONE_NORMALZONE_HIGHMEMZONE_MOVABLEZONE_DEVICE,其实与 zone_type 枚举值定义的顺序是一致的。

因为一些特定的操作,例如 DMA 等,必须在 ZONE_DMAZONE_DMA32 区域等低位区域分配内存,但是通常可以在高位区域分配内存,那么也可以在低位区域分配,如果高位区域内存不足时可以向低位区域寻找空闲内存,进而侵占挤压低位区域。

但是内核又不会允许高位内存区域对低位内存区域的无限制挤压占用,因为毕竟低位内存区域有它特定的用途,所以每个内存区域会给自己预留一定的内存,防止被高位内存区域挤压占用。而每个内存区域为自己预留的这部分内存就存储在 lowmem_reserve 数组中。

lowmem_reserve 数组中的值是根据每个区域大小和 lowmem_reserve_ratio 预留比例计算而来,可以通过下面两种命令查看每个区域预留比例:

[root@VM-16-10-centos ~]# cat /proc/sys/vm/lowmem_reserve_ratio
256     256     32

[root@VM-16-10-centos ~]# cat /proc/zoneinfo | grep Node
Node 0, zone      DMA
Node 0, zone    DMA32
Node 0, zone   Normal

从左到右分别代表了 ZONE_DMAZONE_DMA32ZONE_NORMAL,由于服务器是 64 位,所以没有 ZONE_HIGHMEM 区域。

下面的命令可以查看每个区域保留物理内存页数,输出的protection就是保存在lowmem_reserve 数组的值:

[root@VM-16-10-centos ~]# cat /proc/zoneinfo|grep protection
        protection: (0, 2720, 3678, 3678)
        protection: (0, 0, 958, 958)
        protection: (0, 0, 0, 0)

参与计算的是每个区域的managed_pages页数,就是被伙伴系统管理的物理页数,下面的命令输出了每个区域的managed_pages页数:

[root@VM-16-10-centos ~]# cat /proc/zoneinfo|grep managed
        managed  3977
        managed  696364
        managed  245376

方便理解, 将上面服务器上的数据做成一个图,方便展示lowmem_reserve计算方式以及结果:

image-20231024165457206

此外我们还可以通过 sysctl 对内核参数 lowmem_reserve_ratio 进行动态调整,这样内核会根据新的 lowmem_reserve_ratio 动态重新计算各个内存区域的预留内存大小:

$ sysctl -w vm.lowmem_reserve_ratio=256 256 32 0 0

区域中的水位线

内存资源是系统中最宝贵的系统资源,是有限的。当系统内存短缺的情况下仍去申请内存,可能会触发系统对内存的回收,那什么时候应该进行回收,回收到什么标准又可以停止回收,参考依据是什么?那就是该章节内容介绍的watermark(内存水位线)。

系统中每个NUMA node的每个struct zone中都定义着一个_watermark[NRWMARK]数组,其中存放着该zoneminlowhigh三种内存水位线。

简单来说,它们是衡量当前系统剩余内存是否充足的一个标尺。当zone中的剩余内存高于high时说明剩余内存充足,低于low但高于min时说明内存短缺但是仍可分配内存,若低于min则说明剩余内存极度短缺将停止分配(GFP_ATOMIC类型的分配例外)并全力回收。

这三条水位线定义:

//linux 6.6  path: /include/linux/mmzone.h
enum zone_watermarks {
	WMARK_MIN,
	WMARK_LOW,
	WMARK_HIGH,
	WMARK_PROMO,
	NR_WMARK
};

struct zone 结构体中 _watermark[NR_WMARK] 存储了水位线的值,下标就是 zone_watermarks 枚举值,即水位线类型。

看下struct zone 结构体中水位线的相关字段定义:

// linux6.6  path: /include/linux/mmzone.h
struct zone {
  	/*内存区域水位标记,通过 *_wmark_pages(zone) 宏访问*/
    unsigned long _watermark[NR_WMARK];
  	unsigned long watermark_boost;
  	......
}____cacheline_internodealigned_in_smp;

watermark_boost 字段表示基准水位线,通过动态改变该值来减少内存碎片对内存分配的影响。下面代码是获取水位线类型对应的水位线值的方法:

//linux 6.6  path: /include/linux/mmzone.h
#define min_wmark_pages(z) (z->_watermark[WMARK_MIN] + z->watermark_boost)
#define low_wmark_pages(z) (z->_watermark[WMARK_LOW] + z->watermark_boost)
#define high_wmark_pages(z) (z->_watermark[WMARK_HIGH] + z->watermark_boost)
#define wmark_pages(z, i) (z->_watermark[i] + z->watermark_boost)

当前水位 = 空闲内存(free) - 预留内存(lowmem_reserve),水位处在不同的水位线时处理逻辑如下:

image-20231025094924548

  • 当前水位处在WMARK_HIGH之上时,表示该内存区域的内存非常充足,分配内存毫无压力;
  • 当前水位处在WMARK_HIGHWMARK_LOW之间时,表示内存内存正常,可以满足内存分配;
  • 当前水位处在WMARK_LOWWMARK_MIN之间时,表示内存开始有点紧张了,没那么够用了,但是还可以进行内存分配,当分配完后,唤醒kswapd进程异步回收内存,直到内存回到正常水位之上,期间申请内存的进程不会被阻塞;
  • 当前水位处在WMARK_MIN之下时,表示内存已经紧缺了,不能再分配了,申请内存的进程被阻塞,直到内核直接回收内存完成后并为其分配完内存。

我们可以通过 cat /proc/zoneinfo 命令来查看不同 NUMA 节点中不同内存区域中的水位线:

[root@VM-16-10-centos ~]# cat /proc/zoneinfo
Node 0, zone      DMA
  pages free     3766   //空闲内存页数
        min      71     //_watermark[WMARK_MIN]
        low      88     //_watermark[WMARK_LOW]
        high     106    //_watermark[WMARK_HIGH]
        scanned  0
        spanned  4095
        present  3998
        managed  3977
    nr_free_pages 3766
    
  ......

实际上WMARK_MINWMARK_LOWWMARK_HIGH 水位线都是通过内核参min_free_kbytes(单位为 KB )分别计算得到,使用sysctl可以动态设置这个参数,达到动态控制水位线的目的。

[root@VM-16-10-centos ~]# cat /proc/sys/vm/min_free_kbytes
67584

通常情况下 WMARK_LOW 的值是 WMARK_MIN1.25 倍,WMARK_HIGH 的值是 WMARK_LOW1.5 倍。而 WMARK_MIN 的数值就是由这个内核参数 min_free_kbytes 来决定的。

下面我们就来看下内核中关于 min_free_kbytes 的计算方式:

//  linux 6.6   path: /mm/page_alloc.c
int __meminit init_per_zone_wmark_min(void)
{
  // 计算最小空闲内存值
	calculate_min_free_kbytes();
  // 设置每个内存区域的水位最小值
	setup_per_zone_wmarks();
  // 刷新内存区域的统计阈值
	refresh_zone_stat_thresholds();
  // 设置每个内存区域的低内存保留值
	setup_per_zone_lowmem_reserve();
#ifdef CONFIG_NUMA
  // 如果启用 NUMA,在NUMA系统上设置未映射页面和Slab页面的最小比例
	setup_min_unmapped_ratio();
	setup_min_slab_ratio();
#endif
  // 更新大页管理中的最小自由内存值
	khugepaged_min_free_kbytes_update();
  // 返回 0 表示初始化成功
	return 0;
}
postcore_initcall(init_per_zone_wmark_min)

通过上面源码分析,核心流程为2部分:

  1. 通过复杂的计算获取min_free_kbytes值;

min_free_kbytes 的计算主要逻辑还是在函数calculate_min_free_kbytes中,让我们进入该函数:

//  linux 6.6   path: /mm/page_alloc.c
void calculate_min_free_kbytes(void)
{
 unsigned long lowmem_kbytes;     // 低内存的页数,以 KB 为单位
 int new_min_free_kbytes;        // 新的最小空闲内存值,以 KB 为单位

 // 计算低内存的页数,以 KB 为单位
 lowmem_kbytes = nr_free_buffer_pages() * (PAGE_SIZE >> 10);

 // 计算新的最小空闲内存值,使用简单的数学运算
 new_min_free_kbytes = int_sqrt(lowmem_kbytes * 16);

 if (new_min_free_kbytes > user_min_free_kbytes) {
     // 如果新的最小值大于用户定义的最小空闲内存值,使用新值并进行一些限制
     min_free_kbytes = clamp(new_min_free_kbytes, 128, 262144);
 } else {
     // 如果新的最小值小于等于用户定义的值,发出警告
     pr_warn("min_free_kbytes is not updated to %d because user defined value %d is preferred\n",
             new_min_free_kbytes, user_min_free_kbytes);
 }
}

我们总结分析下这个函数代码的基本流程:

  1.  *首先通过 `nr_free_buffer_pages()`函数先计算出该节点被伙伴系统管理的内存页总数, 我们暂且叫做`nr_free_buffer_pages`;*

  `nr_free_buffer_pages`函数主要思路:
  $$
  nr\_free\_buffer\_pages = managed(DMA) + managed(DMA32) + managed(NORMAL)
  $$
  其中 `managed(DMA) + managed(DMA32) + managed(NORMAL)` 表示低位内存区域的`managed`之和。

  1. 重新计算新的 new_min_free_kbytes,根据代码可以得出new_min_free_kbytes计算公式:
    n e w _ m i n _ f r e e _ k b y t e s = n e w _ m i n _ f r e e _ k b y t e s ∗ ( P A G E _ S I Z E / 1024 ) ∗ 16 new\_min\_free\_kbytes = \sqrt{new\_min\_free\_kbytes * (PAGE\_SIZE / 1024) * 16} new_min_free_kbytes=new_min_free_kbytes(PAGE_SIZE/1024)16

  2. 如果new_min_free_kbytes大于user_min_free_kbytes,那么更新min_free_kbytesnew_min_free_kbytes,并且调整其值处在 [128,262144] 区间,即小于 128 则等于 128,大于 262144 则等于 262144,不大不小则不变。这里的user_min_free_kbytes就是用户通过sysctl设置的内存参数/proc/sys/vm/min_free_kbytes的值。

  3. 根据这个 min_free_kbytessetup_per_zone_wmarks() 方法中计算出该物理内存区域的三条水位线WMARK_MINWMARK_LOWWMARK_HIGH

    setup_per_zone_wmarks方法源码如下:

    //  linux 6.6   path: /mm/page_alloc.c
    static void __setup_per_zone_wmarks(void)
    {
        unsigned long pages_min = min_free_kbytes >> (PAGE_SHIFT - 10);
        unsigned long lowmem_pages = 0;
        struct zone *zone;
        unsigned long flags;
    
        // 计算低内存页面总数
        for_each_zone(zone) {
            if (!is_highmem(zone) && zone_idx(zone) != ZONE_MOVABLE)
                lowmem_pages += zone_managed_pages(zone);
        }
    
        // 遍历每个内存区域
        for_each_zone(zone) {
            u64 tmp;
    
            // 获取锁以保护内存区域的设置
            spin_lock_irqsave(&zone->lock, flags);
    
            // 计算 WMARK_MIN 水印
            tmp = (u64)pages_min * zone_managed_pages(zone);
            do_div(tmp, lowmem_pages);
            if (is_highmem(zone) || zone_idx(zone) == ZONE_MOVABLE) {
                // 如果是高内存区域或ZONE_MOVABLE,设置最小水印为固定值
                unsigned long min_pages;
                min_pages = zone_managed_pages(zone) / 1024;
                min_pages = clamp(min_pages, SWAP_CLUSTER_MAX, 128UL);
                zone->_watermark[WMARK_MIN] = min_pages;
            } else {
                // 否则,设置 WMARK_MIN 水印为计算的值
                zone->_watermark[WMARK_MIN] = tmp;
            }
    
            // 计算其他水印值
            tmp = max_t(u64, tmp >> 2, mult_frac(zone_managed_pages(zone), watermark_scale_factor, 10000));
    
            // 设置水印值
            zone->watermark_boost = 0;
            zone->_watermark[WMARK_LOW] = min_wmark_pages(zone) + tmp;
            zone->_watermark[WMARK_HIGH] = low_wmark_pages(zone) + tmp;
            zone->_watermark[WMARK_PROMO] = high_wmark_pages(zone) + tmp;
    
            // 释放锁
            spin_unlock_irqrestore(&zone->lock, flags);
        }
    
        // 计算总保留页面数
        calculate_totalreserve_pages();
    }
    

    该代码的主要作用是为每个内存区域设置不同水位级别的值,这些水位值用于内存管理和内存分配策略。核心的思路如下:

    首先根据每个区域容量大小比例,从min_free_kbytes划分每个区域的 WMARK_MIN 水位线,例如计算 ZONE_NORMAL 区域的 WMARK_MIN如下:
    W M A R K _ M I N = m i n _ f r e e _ k b y t e s ∗ [ m a n a g e d ( N O R M A L ) ] ∣ n r _ f r e e _ b u f f e r _ p a g e s WMARK\_MIN = min\_free\_kbytes * [managed(NORMAL)] | nr\_free\_buffer\_pages WMARK_MIN=min_free_kbytes[managed(NORMAL)]nr_free_buffer_pages

    有一个内核参数 watermark_scale_factor 用来调节水位线间的距离,避免 WMARK_MINWMARK_LOW 之间的距离过小,导致极端情况(例如短时间大量网络数据到来)直接同时打穿这两条水位线,给进程带来性能抖动。因为水位低于 WMARK_LOW 启用 kswapd 进程异步回收内存,不阻塞申请进程,低于 WMARK_MIN 内核直接回收(direct reclaim)内存,阻塞申请进程。

    所以要尽量扩大 WMARK_MINWMARK_LOW 之间的距离,当极端情况发生时有一个缓冲的余地。可以通过 sysctl 来动态调整 watermark_scale_factor内核参数,重新计算水位线之间的间距。间距计算公式如下:
    间距 = m a x [ ( W M A R K _ M I N / 4 ) , m a n a g e d [ N O R M A L ] ∗ w a t e r m a r k _ s c a l e _ f a c t o r / 10000 ] 间距 = max[ (WMARK\_MIN/4),managed[NORMAL] * watermark\_scale\_factor/10000] 间距=max[(WMARK_MIN/4),managed[NORMAL]watermark_scale_factor/10000]
    通常 WMARK_MIN / 4 是比较大的那个,所以一般情况下 WMARK_HIGHWMARK_LOW 分别是 WMARK_MIN1.5 倍和 1.25 倍:
    W M A R K _ L O W = W M A R K _ M I N + 间距 WMARK\_LOW = WMARK\_MIN + 间距 WMARK_LOW=WMARK_MIN+间距

    W M A R K _ H I G H = W M A R K _ L O W + 间距 WMARK\_HIGH = WMARK\_LOW + 间距 WMARK_HIGH=WMARK_LOW+间距

    这样 WMARK_MINWMARK_LOWWMARK_HIGH都计算出来了。

冷热页

根据摩尔定律:芯片中的晶体管数量每隔 18 个月就会翻一番,导致 CPU 的性能和处理速度变得越来越快,而内存的性能在缓慢的改进。随着时间的发展,内存和cpu性能的差距会越来越大,就像剪刀的口子一样,越张越大。即使今天也是如此,多核时代,CPU频率不再提高,但是芯片内处理器核的数目提高了,对内存带宽的需求也越来越高。

CPU和内存速度的“剪刀差”,我们加入cache来提供稳定的数据流,减小延迟。多层存储器结构,利用了局部性原理,并在存储器技术性能和成本做了折中,结合不同处理器的应用场景,形成了不同处理器的存储层次。

image-20231026092930370

那么在 NUMA 内存架构下,这些 NUMA 节点中的物理内存区域 zone 管理的这些物理内存页,哪些是在 CPU 的高速缓存中?哪些又不在 CPU 的高速缓存中呢?内核如何来管理这些加载进 CPU 高速缓存中的物理内存页呢?

加载到 CPU 缓存里的物理页叫热页(Hot Page),没有加载的物理页叫冷页(Cold Page)。因为每个 CPU 都有自己的缓存,所以内核为每个 CPU 分配一个本区域(zone)的struct per_cpu_pages结构体链表,热页放在列表的头部,冷页放在列表的尾部:

// linux6.6  path: /include/linux/mmzone.h
struct zone {
  ......
    struct per_cpu_pages __percpu *per_cpu_pageset;
  ......
}____cacheline_internodealigned_in_smp;

struct per_cpu_pages是用于管理热页或冷页集合的结构体,定义如下:

// linux6.6  path: /include/linux/mmzone.h
struct per_cpu_pages {
    spinlock_t lock;                // 自旋锁,用于保护对结构体的并发访问
    int count;                      // 当前页面集合中的页面数量
    int high;                       // 页面集合的高水位标记
    int batch;                      // 一次性分配的批次大小
    short free_factor;              // 空闲页面因子
#ifdef CONFIG_NUMA
    short expire;                   // 页面集合的过期标记(在 NUMA 系统上使用)
#endif
    struct list_head lists[NR_PCP_LISTS]; // 用于存储不同页面列表的数组
} ____cacheline_aligned_in_smp;

内核为了最大程度的防止内存碎片,将物理内存页面按照是否可迁移的特性分为了多种迁移类型:可迁移,可回收,不可迁移。在 struct per_cpu_pages 结构中,每一种迁移类型都会对应一个冷热页链表。关于页的内容下面会进行详细分析。

物理内存页(page)

页是内存管理当中最小单位,页面中的内存其物理地址是连续的。内核对物理内存的换入,换出,回收,内存映射等操作的单位就是页。内核为每一个物理内存区域分配了一个伙伴系统,用于管理该物理内存区域下所有物理内存页面的分配和释放。

Linux 默认支持的物理内存页大小为 4KB,在 64 位体系结构中还可以支持 8KB, MIPS64架构体系支持16kb,有的处理器还可以支持 4MB,支持物理地址扩展 PAE 机制的处理器上还可以支持 2MB

页结构

每一个物理页的对应一个数据结构struct page,称为页描述符。每 4K 物理内存对应一个 struct page 结构体,每个 struct page 大约 64 字节。

这个struct page结构体里面有很多联合体(union),目的是使用更小的结构体大小来应对各种不同的使用场景,使struct page体积维持在一个较小的水平,因为这个结构体被很多地方使用,每增加一个字段可能会影响其他模块。

struct page 结构可谓是内核中最为繁杂的一个结构体,应用在内核中的各种功能场景下,定义如下;

// linux6.6  path:  /include/linux/mm_types.h
struct page {
    unsigned long flags;  // 页面标志,用于标识页面的状态和属性

    union {
        struct {  // 通常页面的字段
            union {
                struct list_head lru;  // 用于双向链表的 LRU(Least Recently Used)页面列表
                struct {
                    void *__filler;  // 填充字段,通常为空
                    unsigned int mlock_count;  // 页面上的内存锁计数
                };
                struct list_head buddy_list;  // 用于伙伴系统的页面链表
                struct list_head pcp_list;  // 用于 per-CPU 页面缓存的页面链表
            };

            struct address_space *mapping;  // 映射信息,通常指向页所属的文件地址空间
            union {
                pgoff_t index;  // 页在文件中的偏移量
                unsigned long share;  // 页共享计数
            };
            unsigned long private;  // 页的私有数据字段
        };

        struct {
            unsigned long pp_magic;  // 页池(Page Pool)的魔数,用于页回收
            struct page_pool *pp;  // 指向页池的指针
            unsigned long _pp_mapping_pad;  // 页池映射填充字段
            unsigned long dma_addr;  // DMA 地址
            union {
                unsigned long dma_addr_upper;  // DMA 地址的高位部分
                atomic_long_t pp_frag_count;  // 页碎片计数
            };
        };

        struct {
            unsigned long compound_head;  // 复合页面头,用于跟踪复合页面的首部
        };

        struct {
            struct dev_pagemap *pgmap;  // 与设备页映射相关的信息
            void *zone_device_data;  // 与设备页映射相关的数据
        };

        struct rcu_head rcu_head;  // RCU(Read-Copy Update)头,用于释放页面

    };

    union {
        atomic_t _mapcount;  // 映射计数,用于跟踪页面的映射情况
        unsigned int page_type;  // 页面类型,标识页面的类型
    };

    atomic_t _refcount;  // 引用计数,用于跟踪页面的引用情况

#ifdef CONFIG_MEMCG
    unsigned long memcg_data;  // 与内存控制组(cgroup)相关的数据
#endif

#if defined(WANT_PAGE_VIRTUAL)
    void *virtual;  // 虚拟地址,通常用于用于页虚拟映射
#endif

#ifdef CONFIG_KMSAN
    struct page *kmsan_shadow;  // KMSAN(Kernel Memory Sanitizer)阴影页
    struct page *kmsan_origin;  // KMSAN(Kernel Memory Sanitizer)原始页
#endif

#ifdef LAST_CPUPID_NOT_IN_PAGE_FLAGS
    int _last_cpupid;  // 上一个 CPU 的 PID(Process ID)
#endif
} _struct_page_alignment;

可以看到在一个64位系统中,struct page主要包含两个union结构,大小分别位40个字节和4个字节,这样设计的目的主要是减少占用空间 。page结构划分如下几块:

image-20231026171620224

struct page结构体在不同场景下使用不同的字段,字段的不同组合可以表示页缓存(Page cache)、匿名页(anonymous pages)、复合页(compound page)、页表页和ZONE_DEVICE 页等。

页类型

匿名页

匿名页(Anonymous Page)用于存储进程运行过程中产生的临时数据,直接和进程虚拟地址空间建立映射存储在页表内,没有背靠一个硬盘文件作为数据来源。

匿名页主要用于存储进程的动态分配内存,当进程需要分配新的内存时,通常会向操作系统请求匿名页。例如堆栈和堆内存。它们还用于进程的未映射数据,如零初始化的全局变量或未初始化的局部变量。每个进程都有自己的匿名页,这样不同进程之间的内存是隔离的。这有助于确保一个进程的操作不会影响其他进程的内存数据。

匿名页通常会在分配时进行零填充,以确保新分配的内存不包含旧数据。这有助于防止内存泄漏和数据泄露。

当多个进程共享同一匿名页时,内核将允许它们共享页面的内容,只有在某个进程尝试修改页面内容时,内核才会为该进程复制一份私有的页副本。这有助于减少内存占用和提高性能。

当进程不再需要匿名页上的数据时,它可以将这些页标记为"未使用",并且内核可以随后回收这些页,使它们可用于其他用途。这有助于确保内存有效地被重复使用。

再来看看关于page结构体中关于匿名页的相关字段:

// linux6.6  path:  /include/linux/mm_types.h
struct page {
  ......
    	struct address_space *mapping;
  		pgoff_t index;
  ......
} _struct_page_alignment;

如果当前物理内存页 struct page 是一个匿名页的话,那么 mapping 指针的最低位会被设置为 1 , 指向该匿名页在进程虚拟内存空间中的匿名映射区域 struct anon_vma 结构(每个匿名页对应唯一的 anon_vma 结构),用于物理内存到虚拟内存的反向映射。

说到映射,虚拟内存到物理内存的映射称为正向映射,页表中的映射关系就是正向映射。那么反过来,物理内存到虚拟内存的映射就是反向映射,一个物理页可能映射到多个进程的虚拟地址空间中,是一对多的关系。

当进程通过mallocnew等函数申请内存时,其实内核根本没有为其分配物理内存,而是为进程申请的这块内存创建初始化一段虚拟内存区域struct vm_area_struct结构体。当后面进程真正使用这块内存时会产生缺页中断,缺页中断函数才会分配真正的物理内存,并完成正向和反向映射,正向映射存在页表里,反向映射存在struct pagemapping中。struct page_mapcount字段表示该物理页映射到了多少个进程的虚拟内存空间中。

关于正向和反向映射过程在后续章节中会单独详细介绍。

文件页

文件页(Page Cache)中的数据均来自硬盘文件,目的是降低读写硬盘的延时,文件页需要先关联一个硬盘文件,然后再和进程虚拟地址空间建立映射存储在页表内,进程通过操作虚拟内存实现对文件的操作,也称为内存映射文件(Memory-mapped File)。

struct pagemapping字段最低位为 0 表示文件页。

mapping指向该文件页关联文件的struct address_space(被文件的 inode 所持有),pgoff_t index字段表示该文件页在struct address_space中的索引。内核会通过 index 字段从 struct address_space 中查找该文件页。

涉及文件系统,这里就不过多介绍了。

复合页

Linux内核中,我们用page来描述一页,这一页通常是4KB。如果内核都是4KB的单页,那就简单归一了。但是有些特殊情况需要将两个或更多物理上连续的页面组合成一个单元,在许多方面可以将其视为单个更大的页面,这种页面我们称为复合页(Compound Pages)。

云计算时代来了,大页内存在服务器上的应用越来越多了。

下面是复合页和普通页的优势对比:

复合页的优势

  1. 减少内存碎片: 复合页将多个小页(通常是普通物理页)组合成一个大页,减少了内存碎片的发生。这可以提高内存的空间利用率,减少操作系统内存管理的复杂性。
  2. 减少页表开销: 使用复合页可以减少页表的大小和管理开销。因为一个复合页只需要一个页表项,而多个小页需要更多的页表项,这可以减少内存访问时的额外开销。例如父进程通过 fork 函数创建子进程是拷贝页表的开销小。
  3. 加速内存访问: 复合页可以提高内存访问速度,因为一个较大的页可以容纳更多的数据,减少了内存访问的次数。因为复合页占用的页表项较少,所以节约了 TLB 的空间,并且提升了 TLB 缓存命中率,从而加快访问速度。
  4. 更好的内存局部性: 复合页有助于提高内存局部性,因为它们通常包含相关的数据,减少了缓存失效的可能性,从而提高了程序的性能。

普通页的优势

  1. 灵活性: 普通物理页更加灵活,因为它们的大小通常比复合页小。这使得操作系统能够更好地适应不同大小的内存分配请求。
  2. 更好的内存分配粒度: 普通物理页可以更好地满足某些内存分配需求,特别是当应用程序需要较小的内存块时,使用普通物理页更为合适。
  3. 更好的隔离性: 普通物理页可以更好地实现内存的隔离,因为每个页都是独立的。这有助于防止一个应用程序的错误影响其他应用程序的内存。

前面提到复合页本质上是由多个连续的普通页拼接而成,第一个物理页称为首页(Head Page),其余的物理页均称为尾页(Tail Page)。

来看看复合页面在struct page中的相关字段定义:

// linux6.6  path:  /include/linux/mm_types.h
struct page {
  ......
    	unsigned long flags;  // 页面标志,用于标识页面的状态和属性
      struct {
          unsigned long compound_head;  // 复合页面头,用于跟踪复合页面的首部
      };
  ......
} _struct_page_alignment;

内核并没有为compound page而单独定义结构体,而是将其存放进了page结构体中,那怎么样分配复合页呢?看下面代码:

// linux6.6  path: /include/linux/page-flags.h
void prep_compound_page(struct page *page, unsigned int order)
{
	int i;
	int nr_pages = 1 << order;  // 计算复合页面中包含的物理页面数,即页面的阶(order)

	__SetPageHead(page);  //将给定的页面标记为复合页面的头部。这是一个宏,用于设置页面的标志,以指示它是一个复合页面的头部。

	for (i = 1; i < nr_pages; i++) {
		prep_compound_tail(page, i);  //这个函数将给定页面标记为复合页面的尾部
	}
	prep_compound_head(page, order);  //准备复合页面的头部,将页面的阶(order)设置为指定的值,表示它包含多少个物理页面
}

从代码可以看出,Head Pagepage 结构体中 flags 字段中 PG_head 位会被置成 1,表示该页是复合页的首页。所有的Tail Pagepage结构体中的 compound_head都指向Head Page地址。

image-20231030095628583

在此,就不详细介绍下去了,内容比较多,一时半会讲不完,这边知道这个页类型即可。

页标志

struct page 结构中的 flags定义如下:

struct page {
    unsigned long flags;
  	......
} _struct_page_alignment;

flags字段是个长度为 64 位的字段,但是其包含了很多逻辑,每个 bit 在不同场景下含义可能发生变化,里面不仅包含了很多标志位,还根据不同内存模型和内核参数包含了 sectionnode idzone不同的组合形式,主要 有5 种形式。

每种形式中都有ZONE,其长度是变长的,根据系统中区域类型的数量而定,取值由 03。代码如下:

// linux6.6  path:  /include/linux/page-flags-layout.h
#if MAX_NR_ZONES < 2
#define ZONES_SHIFT 0
#elif MAX_NR_ZONES <= 2
#define ZONES_SHIFT 1
#elif MAX_NR_ZONES <= 4
#define ZONES_SHIFT 2
#elif MAX_NR_ZONES <= 8
#define ZONES_SHIFT 3
#else
#error ZONES_SHIFT "Too many zones configured"
#endif

每种形式中也都有KASAN,用于内存监测。什么是KASAN呢?

KASANKernel Address Sanitizer)是一种用于检测操作系统内核中的内存错误的工具。具体来说,KASAN旨在帮助发现和修复内核代码中的内存访问问题,如缓冲区溢出、使用未初始化的内存、释放后再次访问内存等。KASANLinux 内核中的一个重要工具,它有助于提高内核代码的稳定性和安全性。

KASAN的工作原理是在内存分配和释放操作中,为每个分配的内存块添加特殊的标签或影子内存。这些标签与实际数据存储在一起,并用于跟踪内存访问。当内核代码尝试访问分配的内存时,KASAN会检查相应的标签,以查看是否存在任何错误或违规访问。如果发现问题,KASAN将生成相应的错误报告,帮助开发人员找到和修复问题。

当开启了 CONFIG_KASAN_SW_TAGSCONFIG_KASAN_HW_TAGS 选项,那么KASAN为 8 位,否则 0 位:

// linux6.6  path:  /include/linux/page-flags-layout.h
#if defined(CONFIG_KASAN_SW_TAGS) || defined(CONFIG_KASAN_HW_TAGS)
#define KASAN_TAG_WIDTH 8
#else
#define KASAN_TAG_WIDTH 0
#endif

下面来介绍下五种形式的flags

  1. sparse稀疏内存模型或sparse vmemmap的稀疏内存模型

    image-20231030103912238

    NODENUMA 架构中表示该 page 所属的 Node 节点的 id,如果是非 NUMA 系统则为 0ZONE 表示该 page 所属的内存区域(zone)。KASAN用于内存监测,低位为众多 FLAGS 标志位,中间剩余部分为保留位。

  2. 在 1 基础上开启 LAST_CPUPID

    image-20231030104418498

    // linux6.6  path:  /include/linux/page-flags-layout.h
    #ifdef CONFIG_NUMA_BALANCING
    #define LAST__PID_SHIFT 8
    #define LAST__PID_MASK  ((1 << LAST__PID_SHIFT)-1)
    
    #define LAST__CPU_SHIFT NR_CPUS_BITS
    #define LAST__CPU_MASK  ((1 << LAST__CPU_SHIFT)-1)
    
    #define LAST_CPUPID_SHIFT (LAST__PID_SHIFT+LAST__CPU_SHIFT)
    #else
    #define LAST_CPUPID_SHIFT 0
    #endif
    

    1 基础上增加了 LAST_CPUPID 字段,表示上一次访问的 CPUPID。如果其他字段太长,就关闭 LAST_CPUPID 字段。

  3. sparse vmemmap的稀疏内存模型

    image-20231030105316307

    // linux6.6  path:  /include/linux/page-flags-layout.h
    #ifdef CONFIG_SPARSEMEM
    #include <asm/sparsemem.h>
    #define SECTIONS_SHIFT	(MAX_PHYSMEM_BITS - SECTION_SIZE_BITS)
    #else
    #define SECTIONS_SHIFT	0
    #endif
    
    #if defined(CONFIG_SPARSEMEM) && !defined(CONFIG_SPARSEMEM_VMEMMAP)
    #define SECTIONS_WIDTH		SECTIONS_SHIFT
    #else
    #define SECTIONS_WIDTH		0
    #endif
    

    增加 SECTION 字段表示该 page 所在的 mem_section 段。前面介绍过的page_to_section函数就是通过 page 中的 flags 获取段号的:

    static inline unsigned long page_to_section(const struct page *page)
    {
        return (page->flags >> SECTIONS_PGSHIFT) & SECTIONS_MASK;
    }
    
  4. 在 3 基础上开启 LAST_CPUPID

    image-20231030105542269

    3 基础上增加了 LAST_CPUPID 字段,表示上一次访问的 CPUPID

  5. 稀疏内存模型不支持 NUMA

    image-20231030105718252

    4 的基础上去掉了 NODE

除了第 5 种极端形式没有 NODE,其他 4 种都有 NODE,其长度可配置,x86_64 系统默认为 6 位,取值范围 1~10 位,如果 ZONES_WIDTH + LRU_GEN_WIDTH + SECTIONS_WIDTH + NODES_SHIFT <= BITS_PER_LONG - NR_PAGEFLAGS,那么 NODE0 位,代码如下:

// linux6.6  path:  /arch/x86/Kconfig
config NODES_SHIFT
	int "Maximum NUMA Nodes (as a power of 2)" if !MAXSMP
	range 1 10
	default "10" if MAXSMP
	default "6" if X86_64
	default "3"
	depends on NUMA
	help
	  Specify the maximum number of NUMA Nodes available on the target
	  system.  Increases memory reserved to accommodate various tables.

// linux6.6  path:  /include/linux/numa.h
#if defined(CONFIG_SPARSEMEM) && !defined(CONFIG_SPARSEMEM_VMEMMAP)
#define SECTIONS_WIDTH		SECTIONS_SHIFT
#else
#define SECTIONS_WIDTH		0
#endif
    
// linux6.6  path:  /include/linux/page-flags-layout.h
#if ZONES_WIDTH + LRU_GEN_WIDTH + SECTIONS_WIDTH + NODES_SHIFT \
	<= BITS_PER_LONG - NR_PAGEFLAGS
#define NODES_WIDTH		NODES_SHIFT
#elif defined(CONFIG_SPARSEMEM_VMEMMAP)
#error "Vmemmap: No space for nodes field in page flags"
#else
#define NODES_WIDTH		0
#endif

接下来就该来介绍下在低位比特中表示的物理内存页的那些标志位,即21-28位的FLAGS,其值定义如下:

// linux6.6  /include/linux/page-flags.h
/*
 * Don't use the pageflags directly.  Use the PageFoo macros.
 *
 * The page flags field is split into two parts, the main flags area
 * which extends from the low bits upwards, and the fields area which
 * extends from the high bits downwards.
 *
 *  | FIELD | ... | FLAGS |
 *  N-1           ^       0
 *               (NR_PAGEFLAGS)
 *
 * The fields area is reserved for fields mapping zone, node (for NUMA) and
 * SPARSEMEM section (for variants of SPARSEMEM that require section ids like
 * SPARSEMEM_EXTREME with !SPARSEMEM_VMEMMAP).
 */
enum pageflags {
	PG_locked,		/* Page is locked. Don't touch. */
	PG_writeback,		/* Page is under writeback */
	PG_referenced,
	PG_uptodate,
	PG_dirty,
	PG_lru,
	PG_head,		/* Must be in bit 6 */
	PG_waiters,		/* Page has waiters, check its waitqueue. Must be bit #7 and in the same byte as "PG_locked" */
	PG_active,
	PG_workingset,
	PG_error,
	PG_slab,
	PG_owner_priv_1,	/* Owner use. If pagecache, fs may use*/
	PG_arch_1,
	PG_reserved,
	PG_private,		/* If pagecache, has fs-private data */
	PG_private_2,		/* If pagecache, has fs aux data */
	PG_mappedtodisk,	/* Has blocks allocated on-disk */
	PG_reclaim,		/* To be reclaimed asap */
	PG_swapbacked,		/* Page is backed by RAM/swap */
	PG_unevictable,		/* Page is "unevictable"  */
#ifdef CONFIG_MMU
	PG_mlocked,		/* Page is vma mlocked */
#endif
#ifdef CONFIG_ARCH_USES_PG_UNCACHED
	PG_uncached,		/* Page has been mapped as uncached */
#endif
#ifdef CONFIG_MEMORY_FAILURE
	PG_hwpoison,		/* hardware poisoned page. Don't touch */
#endif
#if defined(CONFIG_PAGE_IDLE_FLAG) && defined(CONFIG_64BIT)
	PG_young,
	PG_idle,
#endif
#ifdef CONFIG_ARCH_USES_PG_ARCH_X
	PG_arch_2,
	PG_arch_3,
#endif
	__NR_PAGEFLAGS,

	PG_readahead = PG_reclaim,

	/*
	 * Depending on the way an anonymous folio can be mapped into a page
	 * table (e.g., single PMD/PUD/CONT of the head page vs. PTE-mapped
	 * THP), PG_anon_exclusive may be set only for the head page or for
	 * tail pages of an anonymous folio. For now, we only expect it to be
	 * set on tail pages for PTE-mapped THP.
	 */
	PG_anon_exclusive = PG_mappedtodisk,

	/* Filesystems */
	PG_checked = PG_owner_priv_1,

	/* SwapBacked */
	PG_swapcache = PG_owner_priv_1,	/* Swap page: swp_entry_t in private */

	/* Two page bits are conscripted by FS-Cache to maintain local caching
	 * state.  These bits are set on pages belonging to the netfs's inodes
	 * when those inodes are being locally cached.
	 */
	PG_fscache = PG_private_2,	/* page backed by cache */

	/* XEN */
	/* Pinned in Xen as a read-only pagetable page. */
	PG_pinned = PG_owner_priv_1,
	/* Pinned as part of domain save (see xen_mm_pin_all()). */
	PG_savepinned = PG_dirty,
	/* Has a grant mapping of another (foreign) domain's page. */
	PG_foreign = PG_owner_priv_1,
	/* Remapped by swiotlb-xen. */
	PG_xen_remapped = PG_owner_priv_1,

	/* non-lru isolated movable page */
	PG_isolated = PG_reclaim,

	/* Only valid for buddy pages. Used to track pages that are reported */
	PG_reported = PG_uptodate,

#ifdef CONFIG_MEMORY_HOTPLUG
	/* For self-hosted memmap pages */
	PG_vmemmap_self_hosted = PG_owner_priv_1,
#endif

	/*
	 * Flags only valid for compound pages.  Stored in first tail page's
	 * flags word.  Cannot use the first 8 flags or any flag marked as
	 * PF_ANY.
	 */

	/* At least one page in this folio has the hwpoison flag set */
	PG_has_hwpoisoned = PG_error,
	PG_hugetlb = PG_active,
	PG_large_rmappable = PG_workingset, /* anon or file-backed */
};

下面表格对上面代码中的字段一一做了解释:

标志位说明
PG_locked页面已锁定,不可被访问。通常表明有进程在进行硬盘 I/O 操作。
PG_referenced表示该页面刚刚被访问过,用于页面回收。
PG_uptodate页面的数据已经是最新的,无需更新。
PG_dirty页面的数据已被修改,需要写回到磁盘。
PG_lru页面在 LRU(Least Recently Used,最近最少使用)链表中。
PG_active表示该页在 active 链表上。PG_referenced 和 PG_active 共同控制了该页的活跃程度,在内存回收提供重要依据。
PG_workingset用于工作集管理,与页面活动性有关。
PG_waiters页面有等待者,检查等待队列。
PG_error页面发生了I/O错误。
PG_slab表示该页属于 slab 分配器,用于内核对象分配。
PG_owner_priv_1属于所有者使用的私有标志1。具体用途由所有者定义。
PG_arch_1架构特定的页面状态位1。
PG_reserved页面已保留,通常用于特殊页面,如内核映像、BIOS等。
PG_private如果是页缓存,表示该 struct page 的 private 指向了具体的对象。
PG_private_2如果是页缓存,包含文件系统辅助数据。
PG_writeback表示该页正在被内核的 pdflush 线程回写到硬盘中。
PG_head作为复合页面(compound page)的头部。
PG_mappedtodisk页面在磁盘上有分配的块。
PG_reclaim表示该页已经被内核选中即将被回收。
PG_swapbacked页面使用交换空间作为后备存储。
PG_unevictable页面是 “unevictable”,不会被换出。
PG_mlocked表示该页被进程通过 mlock 系统调用锁定在 VMA(虚拟内存区域),不会被换出。
PG_uncached页面已映射为无缓存。
PG_hwpoison页面被硬件损坏,不安全访问。
PG_young页面被访问过。
PG_idle页面处于空闲状态。
PG_arch_2架构特定的页面状态位2。
PG_arch_3架构特定的页面状态位3。
__NR_PAGEFLAGS页面标志的总数。
PG_readahead当进程顺序访问文件时,内核会预读若干相邻文件页数据到物理页中,该位表示该页是一个正在被内核预读的页。
PG_anon_exclusive用于匿名页面,表示页面是独占的。
PG_checked用于文件系统,表示页面已经被检查。
PG_swapcache用于交换空间,表示该物理内存页处于 swap cache 中。 struct page 的 private 指向 swap_entry_t 。
PG_fscache用于文件系统缓存,表示页面由缓存支持。
PG_pinned用于Xen虚拟化,表示页面被锁定为只读页表页。
PG_savepinned用于Xen虚拟化,表示页面在域保存期间被锁定。
PG_foreign用于Xen虚拟化,表示页面有另一个(外部)域的授权映射。
PG_xen_remapped用于Xen虚拟化,表示页面已被swiotlb-xen重新映射。
PG_has_hwpoisoned用于复合页面,表示至少有一个子页面在THP中被硬件污染。
PG_isolated用于非LRU孤立可移动页面。
PG_reported仅对伙伴页面有效,用于跟踪已报告的页面。
PG_vmemmap_self_hosted用于自托管的memmap页面。

页回收

每个node上,根据页的类型(文件的和匿名的)和活跃程度(最近是否被访问)分成5条链表,再加上不可回收的页,共五条链表:

image-20231030114810962

active 链表用来存放访问非常频繁的内存页(热页), inactive 链表用来存放访问不怎么频繁的内存页(冷页),当内存紧张的时候,内核就会优先将 inactive 链表中的内存页置换出去。内核在回收内存的时候,这两个列表中的回收优先级为:inactive 链表尾部 > inactive 链表头部 > active 链表尾部 > active 链表头部。

来看下相关代码定义:

// linux6.6  path: /include/linux/mmzone.h
typedef struct pglist_data {
   ......
    // 用于 LRU 操作的 lruvec 结构
    struct lruvec __lruvec;
    // 位掩码,用于存储各种标志
    unsigned long flags;
  ......
}pg_data_t

可以看出这些链都是有字段__lruvec,即page reclaimlru链所控制,再来看看__lruvec代码定义:

enum lru_list {
	LRU_INACTIVE_ANON = LRU_BASE,
	LRU_ACTIVE_ANON = LRU_BASE + LRU_ACTIVE,
	LRU_INACTIVE_FILE = LRU_BASE + LRU_FILE,
	LRU_ACTIVE_FILE = LRU_BASE + LRU_FILE + LRU_ACTIVE,
	LRU_UNEVICTABLE,
	NR_LRU_LISTS
};

struct lruvec {
	struct list_head		lists[NR_LRU_LISTS];
	spinlock_t			lru_lock;
	unsigned long			anon_cost;
	unsigned long			file_cost;
	atomic_long_t			nonresident_age;
	unsigned long			refaults[ANON_AND_FILE];
	unsigned long			flags;
#ifdef CONFIG_LRU_GEN
	struct lru_gen_folio		lrugen;
	struct lru_gen_mm_state		mm_state;
#endif
#ifdef CONFIG_MEMCG
	struct pglist_data *pgdat;
#endif
}

从上述代码可以得出:

  • lists[NR_LRU_LISTS] 链表数组,包含五条链表,文件页、匿名页的activeinactive,不可回收的页;
  • lru_lock 作用是防止并发的自旋锁;
  • anon_cost 回收的dirty匿名页的数量,file_cost 回收的dirty文件页的数量;
  • nonresident_ageinactive移出页的数量,包括页面从inactive链表升级到active链表和页面从inactive链表移出回收;

lists[NR_LRU_LISTS] 链表数组包含的五条链表,我们称为 LRU 链表(LRU算法),这些链表串联的是stuct pagelru字段,stuct pagelru字段定义如下:

struct page {
  ......
  	struct list_head lru;  // 用于双向链表的 LRU(Least Recently Used)页面列表
  	atomic_t _refcount;
  ......
}
  • struct list_head lru 属性就是用来指向物理页被放置在了哪个链表上;
  • atomic_t _refcount 属性用来记录内核中引用该物理页的次数,表示该物理页的活跃程度,值越大表示该物理页越活跃。

至此,我们可以将上述内容转化为直观的图来表示:

image-20231030122820189

另外 pageflag成员使用了两个标志PG_referencedPG_active两个标志标识页面的活跃程度:

  • PG_active 标识活跃程度,0表示 inactive链, 1表示active链;
  • PG_referenced 标志位标识最近是否被访问过 ,0 表示最近未被访问过,1 表示最近被访问过。

page通过FIFO的方式插入activeinactive链。

除此之外,文件页和匿名页在链表中的行为略有不同:

  • 文件页第一次被访问时会被挂在 inactive 链表的头部;

    • 如果它继续被访问,则会被提升至 active 链表的尾部;
    • 如果它没有被访问,则随着别的文件页加入头部,它会被推到 inactive 链表尾部,如果再次被访问,则会直接被提升到 active 链表的头部;
  • 匿名页第一次被访问时会被挂在 active 链表的尾部,因为匿名页换出成本高;

当内存紧张时,内核先从 active 链表的尾部开始扫描,将一些不活跃的物理页降级挂到 inactive 链表头部,然后回收 inactive 链表尾部的物理页。

这里的回收类型,对文件页和匿名页来说是不同的:

  • 文件页是把脏数据写回硬盘,然后回收物理页,没有脏数据直接回收;
  • 匿名页回收逻辑是将物理页换出(Swap)到硬盘,然后回收物理页。

内核引入swappiness参数来控制页面置换 Swap 的积极程度,swappiness 取值范围为 0100,默认为 60,通过下面命令查看:

[root@VM-16-10-centos ~]# cat /proc/sys/vm/swappiness
60
  • swappiness数值越大,Swap 的积极程度越高,越倾向回收匿名页;
  • swappiness数值越小,Swap 的积极程度越低,越倾向回收文件页,因为不倾向回收匿名页,只能回收文件页;

当内存压力非常大时,即使swappiness设置为 0,也还会发生 Swap。 可以通过下面命令动态修改swappiness

sysctl -w vm.swappiness=100

就这样简单的介绍完了物理内存大概知识点,不展开深入了。

参考资料:

[bin的技术小屋](javascript:void(0)😉 https://mp.weixin.qq.com/s?__biz=Mzg2MzU3Mjc3Ng==&mid=2247486879&idx=1&sn=0bcc59a306d59e5199a11d1ca5313743&chksm=ce77cbd8f90042ce06f5086b1c976d1d2daa57bc5b768bac15f10ee3dc85874bbeddcd649d88&scene=178&cur_album_id=2559805446807928833#rd

补给站Linux内核 https://www.bilibili.com/read/cv15659604/

yintianyu https://tinylab.org/riscv-sparsemem/

科英 https://zhuanlan.zhihu.com/p/655262271

https://elixir.bootlin.com/linux/v6.6-rc6/source/include/linux/mmzone.h#L1261

「Linux加油站」 https://blog.csdn.net/m0_74282605/article/details/128876049

kevin内核随笔 https://blog.csdn.net/weixin_49382066/article/details/130704158

  • 7
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值