在虚拟内存文章中,我们知道进程的虚拟内存布局以及相关知识。
为了能够承上启下,我们下面从计算机组成原理的角度介绍物理内存的相关概念,以便后续能够将虚拟内存与物理内存知识进行关联串联,使自己更深入的了解内存管理相关知识点,最后对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
结构体定义了一个索引编号:PFN
(Page Frame Number
)。
内核提供了两个接口来完成 PFN
与 物理页结构体 struct page
之间的相互转换。它们分别是 page_to_pfn
与 pfn_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_pfn
与 pfn_to_page
的计算逻辑都是不一样的。
从处理器(CPU
)角度看到的物理内存分布,内核管理不同内存模型的方式存在差异。内存管理子系统当中有3
种内存模型:
Flat Memory
):内存的物理地址空间是连续的,没有空洞;Discontiguous Memory
):内存的物理地址空间存在空洞,这种模型可以高效地处理空洞;Sparse Memory
):内存的物理地址空间存在空洞,如果需要支持内存热插拔,只能选择稀疏内存模型。内存的物理地址空间是连续的,没有空洞,那么这种计算机系统的内存模型就是Flat memory
。
内核中使用了一个 mem_map
的全局数组用来组织所有划分出来的物理内存页。mem_map
全局数组的下标就是相应物理页对应的 PFN
。
在平坦内存模型下 ,PFN
和mem_map
数组index
的关系是线性的, 因此从PFN
到对应的page
数据结构是非常容易的。page_to_pfn
与 pfn_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)
从 PFN
到 struct page
的地址,只需在 struct page
数组的基地址 mem_map
的基础上,加上 PFN
(再减去体系结构定义的偏移量 ARCH_PFN_OFFSET
,以适配不从 0x0
地址开始的物理空间)即可;而从 struct page
的地址到 PFN
也仅仅是把上述公式进行一下移项变换而已。
FLATMEM
模型的优点是结构简单,而且 pfn_to_page()
和 page_to_pfn()
只需进行两次加减法运算,十分高效。但另一方面,现代的 SoC
中拥有不连续的物理地址空间的现象很普遍(即物理地址空间有「空洞」),而 FLATMEM
认为物理地址是连续的,这使得即使某些页帧所对应的物理地址并没有实际的内存,Linux
也要为其分配 struct page
结构体,十分浪费内存资源。
所以一种名为不连续内存( DISCONTIGMEM
) 的内存模型诞生了。
不连续内存(Discontiguous Memory
)是一种内存模型,与传统的连续内存模型不同。在传统的内存模型中,内存是一块连续的地址空间,数据和程序都存储在这个连续的地址范围内。然而,不连续内存模型允许数据和程序在内存中分散存储在不同的地方,而不必依赖于连续的地址分配。
这种内存模型通常用于特定的计算环境和需求,比如某些嵌入式系统、虚拟内存管理以及非传统的存储体系结构中。
假设如果我们还是采取平坦内存(Flat Memory
)这种模型去解决不连续的地址空间,会发生什么问题呢?
由于用于组织物理页的底层数据结构是 mem_map
数组,数组的特性又要求这些物理页是连续的,所以只能为这些内存地址空洞也分配 struct page
结构用来填充数组使其连续。如下图:
而每个 struct page
结构大部分情况下需要占用空间,如果物理内存中存在的大块的地址空洞,那么为这些空洞而分配的 struct page
将会占用大量的内存空间,导致巨大的浪费。
为了解决这个浪费问题,引入了不连续内存(Discontiguous Memory
)模型,该模型将内存划分为一个个node
,每个 node
节点管理一块连续的物理内存,连续的物理内存页均被划归到了对应的 node
节点中管理,就避免了内存空洞造成的空间浪费。
这些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_pfn
与 pfn_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 年被移除。在此就不详细展开说明了。
内存模型也是一个演进过程,刚开始的时候,使用平坦内存(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_map
的page
数组依附于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_64
和 RISC-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_EXTREME
和 SPARSEMEM_VMEMMAP
。
如果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
被定义为 1
,mem_section
二维数组实际上就是长度为 NR_MEM_SECTIONS
的一维数组。经典 SPARSEMEM
模型中 struct mem_section
的组织结构如下:
每一个 struct mem_section
都有一个编号,叫做 section_nr
,定义方式为物理地址右移 PA_SECTION_SHIFT
位,PA_SECTION_SHIFT
的值就等于 SECTION_SIZE_BITS
。
因此从 PFN
与 section_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_section
中 section_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_section
的 struct page
数组地址;pnum
是该 mem_section
的 section_nr
,即它的编号。在 sparse_encode_mem_map()
内部,将 mem_map
和 section_nr
转换得到的 PFN
做差值,结果则为函数的返回值,最终写入 section_mem_map
结构体成员中。这样就将该 mem_section
的初始 PFN
也编码进其中,其主要是,以后进行转换时可通过 PFN
作为 section_mem_map
的索引,快速得到 struct page
的地址;或者通过 struct page
的地址,快速得到 PFN
。
讲完上面的内容,我们就可以容易的理解经典SPARSEMEM
模型的page_to_pfn
与 pfn_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];
}
从 PFN
到 struct page
的步骤:
__pfn_to_section
根据 PFN
定位到得到该 PFN
对应的 section_nr
(即mem_section
数组中的索引);mem_section
数组中,获得下标为 section_nr
的 struct mem_section
;struct mem_section
中的 section_mem_map
成员中编码的 flags
去掉,再利用 PFN
作为下标进行索引(即地址 + PFN
),即可得到 struct page
的地址。从 struct page
到 PFN
的步骤:
page_to_section
根据 struct page
结构定位到 mem_section
数组中具体的 Section
结构;struct page
地址与 section_mem_map
成员的差值,即为 PFN
。经典 SPARSEMEM
模型虽然解决了DISCONTIGMEM
的问题,但仍有两大问题:
经典 SPARSEMEM
模型的 mem_section
数组是固定分配的,在 32
位 架构下,共 128
个,这样的开销还可以接受;但在 64
位 架构下,其数量达到 536,870,912
个,实在是浪费空间十分严重;
尽管已经做了非常「巧妙」的编码,经典 SPARSEMEM
模型的 pfn_to_page()
和 page_to_pfn()
与 FLATMEM
相比,仍然较为复杂。就 pfn_to_page()
来说,前者需要 2
次加法操作、1 次移位操作、1
次按位与操作和 1 次内存读取操作;而后者只需 1
次加法操作和 1
次减法操作即可。
因此SPARSEMEM
模型的两个扩展版本:SPARSEMEM_EXTREME
和 SPARSEMEM_VMEMMAP
解决了上述两个问题。
SPARSEMEM_EXTREME
扩展是为了解决上文中提到的 SPARSEMEM
的第 1
个问题而诞生的。
我们来上面说过,SPARSEMEM_EXTREME
扩展是否启用是根据CONFIG_SPARSEMEM_EXTREME
编译选项来决定的,当CONFIG_SPARSEMEM_EXTREME
为true 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_ROOT
个struct 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_section
是NULL
,则它计算出要分配的内存大小和对齐要求,并使用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
组织结构:
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
映射区:
内核预留了2^30^
的位置,而一个page struct
的大小大概是64B
,即vmemmap
最多可以存放:
1TB / 64B * 4KB = 16TB
也就是说,在X86_64
架构上,最大支持的内存是16TB
。关于映射部分将单独出来讲解,后续再涉及。
这章节内容介绍两种多处理器系统内存架构:均匀内存访问架构(UMA
)和非均匀内存访问架构(NUMA
)。它们都是 SMP
(对称多处理器,Symmetric multiprocessing
)架构的具体实现。
UMA
(Uniform Memory Access
)均匀内存访问架构。所有 CPU
都是经过总线到内存控制器再到物理内存,访问相同的物理内存,并且访问距离和时间也相同。
下图是一个典型的 x86 UMA
内存架构,四路 CPU
通过前端系统总线(FSB, Front Side Bus
)和主板上北桥(North Bridge
)芯片中内存控制器 (MCH, Memory Controller Hub
) 相连,再与物理内存相连:
但是随着多核技术的发展,服务器上的 CPU
个数会越来越多,而 UMA
架构下所有 CPU
都是需要通过总线来访问内存的,这样总线很快就会成为性能瓶颈,主要体现在以下两个方面:
CPU
个数的增多导致每个 CPU
可用带宽会减少;为了解决以上问题,提高 CPU
访问内存的性能和扩展性,于是引入了一种新的架构:非一致性内存访问 NUMA
(Non-uniform memory access
)。
NUMA
(Non-Uniform Memory Access
)非均匀内存访问架构。内存划分为多个块(NUMA
节点),每个 CPU
到不同内存块距离有远近之分,距离一个 CPU
近的内存块称为该 CPU
的本地内存;距离相对远的内存块称为该 CPU
的非本地内存(也叫远端内存)。在 NUMA
架构下,任意一个 CPU
都可以访问全部的内存节点,访问自己的本地内存节点是最快的,但访问其他内存节点就会慢很多,这就导致了 CPU
访问内存的速度不一致,所以叫做非一致性内存访问架构。
如下图所示,NUMA
内存架构把 CPU
和本地内存封装在一个 Node
节点里,并且将内存控制器芯片被集成到 CPU
内部,CPU
间通过 QPI
(QuickPath Interconnect
)链路相连。每个 CPU
访问本地内存非常快,没有了总线,相当于直接访问。但是有时例如本地内存空间不足等情况,一个 CPU
可以通过 QPI
访问另一个 CPU
所在 Node
节点内的本地内存,也就是一个 CPU
可以访问非本地内存。有的架构将PCI-E
总线资源(IOH
)也集成到了 CPU
内部:
一个 Node
节点由一个物理 CPU
、本地内存和本地 IO
资源组成。一个物理 CPU
由多个 CPU Core
(核心)和一个 UnCore
部分组成。每个 CPU Core
一般有 2
个 CPU Thread
,也称逻辑 CPU Core
(核心)。
Core
内部的逻辑运算单元(ALU
)、浮点运算单元(FPU
)、L1
和 L2
缓存;Uncore
集成了内存控制器 iMC
(Integrated Memory Controller
)、PCIe Root Complex
、QPI
控制器、L3
缓存和 CBox
(负责缓存一致性),及其它外设控制器NUMA
的内存分配策略决定内存分配时的行为,例如优先请求本地内存节点分配内存呢 ?还是优先请求指定的 NUMA
节点分配内存 ?是只能在本地内存节点分配呢 ?还是允许当本地内存不足的情况下可以请求远程 NUMA
节点分配内存 ?
下面列出了几种分配策略:
内存分配策 | 策略描述 |
---|---|
MPOL_DEFAULT | 先从本节点分配内存,如果失败去系统认为比较近的其他节点分配内存。 |
MPOL_BIND | 必须在指定的一个或多个节点分配内存,如果分配失败,即使其他节点有内存也会进行 Swap 或 OOM。 |
MPOL_INTERLEAVE | 从指定的一个或多个节点内交错分配内存。 |
MPOL_PREFERRED | 优先在指定一个或多个节点内分配内存,当分配失败时去其他节点分配内存。 |
MPOL_LOCAL(默认) | 与 MPOL_DEFAULT 相似,也是优先在本地节点分配,当分配失败时去其他节点分配内存。 |
我们可以调用 libnuma
库中的 set_mempolicy
接口设置进程的内存分配策略:
#include
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
无论是 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_SHIFT
为 0
,所以内核中只用一个 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
NUMA
节点的 id
,我们可以通过 numactl -H
命令的输出结果查看节点 id
。从 0
开始依次对 NUMA
节点进行编号;NUMA
节点内第一个物理页的 PFN
,系统中所有 NUMA
节点中的物理页都是依次编号的,每个物理页的 PFN
都是全局唯一的(不只是其所在 NUMA 节点内唯一);NUMA
节点内所有真正可用的物理页面数量(不包含内存空洞);NUMA
节点内所有的内存页,包含不连续的物理内存地址(内存空洞)的页面数;zone
),并不是所有区域都会被填充,但它是完整的区域列表。它是一个区域数组,大小为 MAX_NR_ZONES
,数组索引就是区域的类型,不是每个节点包含所有类型的区域,所以说数组中存在没有被填充的元素,下文会介绍区域类型定含义。node_zones
数组被填充元素的数目;node_zones
。目的是当本地节点内存不足时,需要分配其他节点的本地内存;kswapd
进程,用于回收不经常使用的页;kswapd
进程周期性回收页面时使用到的等待队列;kcompactd
进程,用于规整避免内存碎片;kcompactd
进程周期性规整内存时使用到的等待队列。下面用图来展示节点的相关关联图:
在一个理想的计算机系统中, 一个页框(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_DMA32 | 同 ZONE_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_DMA
和 ZONE_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
如图:
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
};
字段解释如下:
online
状态;online
状态;ZONE_NORMAL
内存区域;ZONE_HIGHMEM
内存区域;ZONE_NORMAL
或 ZONE_HIGHMEM
内存区域;ZONE_NORMAL
、ZONE_HIGHMEM
和 ZONE_MOVABLE
内存区域;CPU
;除了上面命令外,通过下面命令可以查看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
结构。
由于内核中 struct zone
数量比较少,多个 CPU
同时读写器中的字段就会比较频繁,就会带来缓存失效,然后去内存读写数据,造成延时增加,也称伪共享。为了降低缓存失效的概率,使用 3
个ZONE_PADDING
把 struct 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_zones
将 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
这些物理内存区域也会通过 struct zone
中的 zone_pgdat
指向自己所属的 NUMA
节点:
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
节点内部又将其所管理的物理内存按照功能不同划分成了不同的内存区域,每个内存区域管理一片用于具体功能的物理内存,而内核会为每一个内存区域分配一个伙伴系统用于管理该内存区域下物理内存的分配和释放:
进程申请内存时,如果内存充裕,则立刻获得内存;如果内存紧张时,有以下两种情况:
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_DMA
、ZONE_DMA32
、ZONE_NORMAL
、ZONE_HIGHMEM
、ZONE_MOVABLE
和 ZONE_DEVICE
,其实与 zone_type
枚举值定义的顺序是一致的。
因为一些特定的操作,例如 DMA
等,必须在 ZONE_DMA
或 ZONE_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_DMA
,ZONE_DMA32
,ZONE_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
计算方式以及结果:
此外我们还可以通过 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]
数组,其中存放着该zone
的min
、low
和high
三种内存水位线。
简单来说,它们是衡量当前系统剩余内存是否充足的一个标尺。当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
),水位处在不同的水位线时处理逻辑如下:
WMARK_HIGH
之上时,表示该内存区域的内存非常充足,分配内存毫无压力;WMARK_HIGH
与WMARK_LOW
之间时,表示内存内存正常,可以满足内存分配;WMARK_LOW
与WMARK_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_MIN
、WMARK_LOW
和WMARK_HIGH
水位线都是通过内核参min_free_kbytes
(单位为 KB
)分别计算得到,使用sysctl
可以动态设置这个参数,达到动态控制水位线的目的。
[root@VM-16-10-centos ~]# cat /proc/sys/vm/min_free_kbytes
67584
通常情况下 WMARK_LOW
的值是 WMARK_MIN
的 1.25
倍,WMARK_HIGH
的值是 WMARK_LOW
的 1.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部分:
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`之和。
重新计算新的 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
如果new_min_free_kbytes
大于user_min_free_kbytes
,那么更新min_free_kbytes
为new_min_free_kbytes
,并且调整其值处在 [128,262144]
区间,即小于 128
则等于 128
,大于 262144
则等于 262144
,不大不小则不变。这里的user_min_free_kbytes
就是用户通过sysctl
设置的内存参数/proc/sys/vm/min_free_kbytes
的值。
根据这个 min_free_kbytes
在 setup_per_zone_wmarks()
方法中计算出该物理内存区域的三条水位线WMARK_MIN
,WMARK_LOW
,WMARK_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_MIN
和 WMARK_LOW
之间的距离过小,导致极端情况(例如短时间大量网络数据到来)直接同时打穿这两条水位线,给进程带来性能抖动。因为水位低于 WMARK_LOW
启用 kswapd
进程异步回收内存,不阻塞申请进程,低于 WMARK_MIN
内核直接回收(direct reclaim
)内存,阻塞申请进程。
所以要尽量扩大 WMARK_MIN
和 WMARK_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_HIGH
和 WMARK_LOW
分别是 WMARK_MIN
的 1.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_MIN
、WMARK_LOW
和WMARK_HIGH
都计算出来了。
根据摩尔定律:芯片中的晶体管数量每隔 18
个月就会翻一番,导致 CPU
的性能和处理速度变得越来越快,而内存的性能在缓慢的改进。随着时间的发展,内存和cpu
性能的差距会越来越大,就像剪刀的口子一样,越张越大。即使今天也是如此,多核时代,CPU
频率不再提高,但是芯片内处理器核的数目提高了,对内存带宽的需求也越来越高。
CPU
和内存速度的“剪刀差”,我们加入cache
来提供稳定的数据流,减小延迟。多层存储器结构,利用了局部性原理,并在存储器技术性能和成本做了折中,结合不同处理器的应用场景,形成了不同处理器的存储层次。
那么在 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
结构中,每一种迁移类型都会对应一个冷热页链表。关于页的内容下面会进行详细分析。
页是内存管理当中最小单位,页面中的内存其物理地址是连续的。内核对物理内存的换入,换出,回收,内存映射等操作的单位就是页。内核为每一个物理内存区域分配了一个伙伴系统,用于管理该物理内存区域下所有物理内存页面的分配和释放。
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
结构划分如下几块:
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
结构),用于物理内存到虚拟内存的反向映射。
说到映射,虚拟内存到物理内存的映射称为正向映射,页表中的映射关系就是正向映射。那么反过来,物理内存到虚拟内存的映射就是反向映射,一个物理页可能映射到多个进程的虚拟地址空间中,是一对多的关系。
当进程通过malloc
和new
等函数申请内存时,其实内核根本没有为其分配物理内存,而是为进程申请的这块内存创建初始化一段虚拟内存区域struct vm_area_struct
结构体。当后面进程真正使用这块内存时会产生缺页中断,缺页中断函数才会分配真正的物理内存,并完成正向和反向映射,正向映射存在页表里,反向映射存在struct page
的mapping
中。struct page
的_mapcount
字段表示该物理页映射到了多少个进程的虚拟内存空间中。
关于正向和反向映射过程在后续章节中会单独详细介绍。
文件页(Page Cache
)中的数据均来自硬盘文件,目的是降低读写硬盘的延时,文件页需要先关联一个硬盘文件,然后再和进程虚拟地址空间建立映射存储在页表内,进程通过操作虚拟内存实现对文件的操作,也称为内存映射文件(Memory-mapped File
)。
struct page
中mapping
字段最低位为 0
表示文件页。
mapping
指向该文件页关联文件的struct address_space
(被文件的 inode
所持有),pgoff_t index
字段表示该文件页在struct address_space
中的索引。内核会通过 index
字段从 struct address_space
中查找该文件页。
涉及文件系统,这里就不过多介绍了。
在Linux
内核中,我们用page
来描述一页,这一页通常是4KB
。如果内核都是4KB
的单页,那就简单归一了。但是有些特殊情况需要将两个或更多物理上连续的页面组合成一个单元,在许多方面可以将其视为单个更大的页面,这种页面我们称为复合页(Compound Pages
)。
云计算时代来了,大页内存在服务器上的应用越来越多了。
下面是复合页和普通页的优势对比:
复合页的优势:
fork
函数创建子进程是拷贝页表的开销小。TLB
的空间,并且提升了 TLB
缓存命中率,从而加快访问速度。普通页的优势:
前面提到复合页本质上是由多个连续的普通页拼接而成,第一个物理页称为首页(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 Page
的 page
结构体中 flags
字段中 PG_head
位会被置成 1
,表示该页是复合页的首页。所有的Tail Page
的page
结构体中的 compound_head
都指向Head Page
地址。
在此,就不详细介绍下去了,内容比较多,一时半会讲不完,这边知道这个页类型即可。
struct page
结构中的 flags
定义如下:
struct page {
unsigned long flags;
......
} _struct_page_alignment;
flags
字段是个长度为 64 位的字段,但是其包含了很多逻辑,每个 bit
在不同场景下含义可能发生变化,里面不仅包含了很多标志位,还根据不同内存模型和内核参数包含了 section
、node id
和 zone
不同的组合形式,主要 有5
种形式。
每种形式中都有ZONE
,其长度是变长的,根据系统中区域类型的数量而定,取值由 0
到 3
。代码如下:
// 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
呢?
KASAN
(Kernel Address Sanitizer
)是一种用于检测操作系统内核中的内存错误的工具。具体来说,KASAN
旨在帮助发现和修复内核代码中的内存访问问题,如缓冲区溢出、使用未初始化的内存、释放后再次访问内存等。KASAN
是 Linux
内核中的一个重要工具,它有助于提高内核代码的稳定性和安全性。
KASAN
的工作原理是在内存分配和释放操作中,为每个分配的内存块添加特殊的标签或影子内存。这些标签与实际数据存储在一起,并用于跟踪内存访问。当内核代码尝试访问分配的内存时,KASAN
会检查相应的标签,以查看是否存在任何错误或违规访问。如果发现问题,KASAN
将生成相应的错误报告,帮助开发人员找到和修复问题。
当开启了 CONFIG_KASAN_SW_TAGS
或 CONFIG_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
:
非sparse
稀疏内存模型或sparse vmemmap
的稀疏内存模型
NODE
在 NUMA
架构中表示该 page
所属的 Node
节点的 id
,如果是非 NUMA
系统则为 0
,ZONE
表示该 page
所属的内存区域(zone
)。KASAN
用于内存监测,低位为众多 FLAGS
标志位,中间剩余部分为保留位。
在 1 基础上开启 LAST_CPUPID
// 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
字段,表示上一次访问的 CPU
和 PID
。如果其他字段太长,就关闭 LAST_CPUPID
字段。
非 sparse vmemmap
的稀疏内存模型
// linux6.6 path: /include/linux/page-flags-layout.h
#ifdef CONFIG_SPARSEMEM
#include
#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;
}
在 3 基础上开启 LAST_CPUPID
在 3
基础上增加了 LAST_CPUPID
字段,表示上一次访问的 CPU
和 PID
。
稀疏内存模型不支持 NUMA
在 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
,那么 NODE
为 0
位,代码如下:
// 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
条链表,再加上不可回收的页,共五条链表:
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 reclaim
的lru
链所控制,再来看看__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]
链表数组,包含五条链表,文件页、匿名页的active
和inactive
,不可回收的页;lru_lock
作用是防止并发的自旋锁;anon_cost
回收的dirty
匿名页的数量,file_cost
回收的dirty
文件页的数量;nonresident_age
从inactive
移出页的数量,包括页面从inactive
链表升级到active
链表和页面从inactive
链表移出回收;lists[NR_LRU_LISTS]
链表数组包含的五条链表,我们称为 LRU
链表(LRU
算法),这些链表串联的是stuct page
的 lru
字段,stuct page
的 lru
字段定义如下:
struct page {
......
struct list_head lru; // 用于双向链表的 LRU(Least Recently Used)页面列表
atomic_t _refcount;
......
}
struct list_head lru
属性就是用来指向物理页被放置在了哪个链表上;atomic_t _refcount
属性用来记录内核中引用该物理页的次数,表示该物理页的活跃程度,值越大表示该物理页越活跃。至此,我们可以将上述内容转化为直观的图来表示:
另外 page
的flag
成员使用了两个标志PG_referenced
和PG_active
两个标志标识页面的活跃程度:
PG_active
标识活跃程度,0
表示 inactive
链, 1
表示active
链;PG_referenced
标志位标识最近是否被访问过 ,0
表示最近未被访问过,1
表示最近被访问过。page
通过FIFO
的方式插入active
和inactive
链。
除此之外,文件页和匿名页在链表中的行为略有不同:
文件页第一次被访问时会被挂在 inactive
链表的头部;
active
链表的尾部;inactive
链表尾部,如果再次被访问,则会直接被提升到 active
链表的头部;匿名页第一次被访问时会被挂在 active
链表的尾部,因为匿名页换出成本高;
当内存紧张时,内核先从 active
链表的尾部开始扫描,将一些不活跃的物理页降级挂到 inactive
链表头部,然后回收 inactive
链表尾部的物理页。
这里的回收类型,对文件页和匿名页来说是不同的:
Swap
)到硬盘,然后回收物理页。内核引入swappiness
参数来控制页面置换 Swap
的积极程度,swappiness
取值范围为 0
到 100
,默认为 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