深入理解Linux物理内存管理

        参考文章《一步一图带你深入理解 Linux 物理内存管理》,并结合个人理解以及自己撰写的关于Linux物理内存系列的文章,丰富完善得出该篇文章。

一、前文回顾

        在上文深入理解Linux虚拟内存管理中,从进程用户态和内核态的角度,为大家介绍了Linux内核如何对虚拟内存空间进行划分,布局和管理的,同时结合虚拟内存描述符mm_struct和虚拟内存区域(VMA)描述符vm_area_struct,介绍了如何实现上述三个操作。

        在深入理解了Linux虚拟内存管理之后,本文开始介绍Linux物理内存管理。至于物理内存管理相关的三大结构(struct pglist_data,struct zone和struct page),我已有三篇专门的文档:Linux 物理内存管理涉及的三大结构体之struct pglist_dataLinux 物理内存管理涉及的三大结构体之struct zoneLinux 物理内存管理涉及的三大结构体之struct page做详细的介绍,本文就不会有过于深入的细节和源码涉及,本文主要从原理概念出发,结合kernel-5.4源代码,对Linux物理内存管理做个整体介绍。最终让大家对整个Linux 内存管理子系统有个全面的了解。

        在介绍之前,回顾一下Linux虚拟内存管理的一下知识点。上篇文章首先为大家展现了应用程序频繁接触到的虚拟内存地址,清晰地为大家介绍了到底什么是虚拟内存地址,以及虚拟内存地址分别在 32 位系统和 64 位系统中的具体表现形式:

        在我们清楚了虚拟内存地址这个基本概念之后,随后又抛出了一个问题:为什么我们要通过虚拟内存地址访问内存而不是直接通过物理地址访问?

        原来是在多进程系统中直接操作物理内存地址的话,我们需要精确地知道每一个变量的位置都被安排在了哪里,而且还要注意当前进程在和多个进程同时运行的时候,不能共用同一个地址,否则就会造成地址冲突。

        而虚拟内存空间的引入正是为了解决多进程地址冲突的问题,使得进程与进程之间的虚拟内存地址空间相互隔离,互不干扰。每个进程都认为自己独占所有内存空间,将多进程之间的协同相关细节统统交给内核中的内存管理模块来处理,极大地解放了程序员的心智负担。这一切都是因为虚拟内存能够为进程提供内存地址空间隔离的功劳。 

        在我们清楚了虚拟内存空间引入的意义之后,我紧接着为大家介绍了进程用户态虚拟内存空间分别在 32 位机器和 64 位机器上的布局情况: 

        在了解了用户态虚拟内存空间的布局之后,紧接着我们又介绍了 Linux 内核如何对用户态虚拟内存空间进行管理以及相应的管理数据结构:

        在介绍完用户态虚拟内存空间的布局以及管理之后,我们随后又介绍了内核态虚拟内存空间的布局情况,并结合之前介绍的用户态虚拟内存空间,得到了 Linux 虚拟内存空间分别在 32 位和 64 位系统中的整体布局情况:

        在虚拟内存全部介绍完毕之后,为了能够承上启下,笔者继续在上篇文章的最后一个小节从计算机组成原理的角度介绍了物理内存的物理组织结构,方便让大家理解到底什么是真正的物理内存 ?物理内存地址到底是什么 ?由此为本文的主题 —— 物理内存的管理 ,埋下伏笔。

        最后我介绍了 CPU 如何通过物理内存地址向物理内存读写数据的完整过程:

        在我们回顾完上篇文章介绍的用户态和内核态虚拟内存空间的管理,以及物理内存在计算机中的真实组成结构之后,下面我就来正式地为大家介绍本文的主题 —— Linux 内核如何对物理内存进行管理。

二、从CPU角度看物理内存模型

        我们知道,内核是以页为基本单位来管理物理内存的,其将物理内存划分为一页一页的内存块,每页大小默认是4KB。每个物理页,内核使用struct page结构体来描述它,struct page里面封装了每页内存块的状态信息,比如:组织结构,使用信息,统计信息,以及与其他结构的关联映射信息等。至于物理内存模型定义,实际就是从CPU角度看,系统物理内存的分布情况。

        为了快速索引定位到具体的物理内存页,kernel为每个物理页struct page结构体分配了一个索引编号PFN(Page Frame Number)。PFN 与 struct page 是一一对应的关系。根据物理地址计算PFN方式:假设物理内存从0地址开始,那么PFN等于0的那个页帧就是0地址(物理地址)开始的那个page。假设物理内存从x地址开始,那么页帧号码就是(x>>PAGE_SHIFT)。

        内核提供了两个宏来完成 PFN 与 物理页结构体 struct page 之间的相互转换。它们分别是 page_to_pfn 与 pfn_to_page。内核中如何组织管理这些物理内存页 struct page 的方式我们称之为物理内存模型,不同的物理内存模型,应对的场景以及 page_to_pfn 与 pfn_to_page 的计算逻辑都是不一样的。

2.1 FLATMEM 平坦内存模型

        我们先把物理内存想象成一片地址连续的存储空间,在这一大片地址连续的内存空间中,内核将这块内存空间分为一页一页的内存块 struct page 。

        由于这块物理内存是连续的,物理地址也是连续的,划分出来的这一页一页的物理页必然也是连续的,并且每页的大小都是固定的,所以我们很容易想到用一个数组来组织这些连续的物理内存页 struct page 结构,其在数组中对应的下标即为 PFN 从CPU 角度看,这种物理内存是连续且没有空洞,对应的物理内存模型就叫做平坦内存模型 FLATMEM 。

        内核中使用了一个 mem_map 的全局数组用来组织所有划分出来的物理内存页。mem_map 全局数组的下标就是相应物理页对应的 PFN 。在Linux 物理内存管理涉及的三大结构体之struct pglist_data中,有个成员变量struct page *node_mem_map,其指向的就是该node中所有struct page组成的mem_map数组。

        在平坦内存模型下 ,page_to_pfn 与 pfn_to_page 的计算逻辑就非常简单,本质就是基于 mem_map 数组进行偏移操作。ARCH_PFN_OFFSET 是 PFN 的起始偏移量,默认是0。

//include/asm-generic/memory_model.h
#if defined(CONFIG_FLATMEM)

#ifndef ARCH_PFN_OFFSET
#define ARCH_PFN_OFFSET		(0UL)
#endif

#if defined(CONFIG_FLATMEM)

#define __pfn_to_page(pfn) (mem_map + ((pfn)-ARCH_PFN_OFFSET))
#define __page_to_pfn(page) ((unsigned long)((page)-mem_map) + ARCH_PFN_OFFSET)

#elif defined(CONFIG_DISCONTIGMEM)
......

        Linux 早期使用的就是这种内存模型,因为在 Linux 发展的早期所需要管理的物理内存通常不大(比如几十 MB),那时的 Linux 使用平坦内存模型 FLATMEM 来管理物理内存就足够高效了。kernel-5.4中的默认配置是使用SPARSEMEM 稀疏内存模型。以手机为例,adb shell登录手机之后,通过zcat /proc/config.gz就可以看到该手机的内核配置了,其中Memory Management options下面就是内存模型的配置,也可以佐证默认使用稀疏内存模型,而且默认是SPARSEMEM_VMEMMAP

#
# Memory Management options
#
CONFIG_SELECT_MEMORY_MODEL=y
# CONFIG_FLATMEM_MANUAL is not set
//这里开始
CONFIG_SPARSEMEM_MANUAL=y
CONFIG_SPARSEMEM=y
CONFIG_SPARSEMEM_EXTREME=y
CONFIG_SPARSEMEM_VMEMMAP_ENABLE=y
CONFIG_SPARSEMEM_VMEMMAP=y
//
CONFIG_HAVE_FAST_GUP=y
CONFIG_ARCH_KEEP_MEMBLOCK=y
CONFIG_MEMORY_ISOLATION=y
CONFIG_MEMORY_HOTPLUG=y
CONFIG_MEMORY_HOTPLUG_SPARSE=y
# CONFIG_MEMORY_HOTPLUG_DEFAULT_ONLINE is not set

2.2 DISCONTIGMEM 非连续内存模型

        FLATMEM 平坦内存模型只适合管理一整块连续的物理内存,而对于多块非连续的物理内存来说使用 FLATMEM 平坦内存模型进行管理则会造成很大的内存空间浪费。

        因为 FLATMEM 平坦内存模型是利用 mem_map 这样一个全局数组来组织这些被划分出来的物理页 page 的,而对于物理内存存在大量不连续的内存地址区间这种情况时,这些不连续的内存地址区间就形成了内存空洞。

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

        而每个 struct page 结构大部分情况下需要占用 64 字节,在Linux 物理内存管理涉及的三大结构体之struct page第二节有介绍,如果物理内存中存在的大块的地址空洞,那么为这些空洞而分配的 struct page 将会占用大量的内存空间,导致巨大的浪费。

        为了组织和管理这些不连续的物理内存,内核于是引入了 DISCONTIGMEM 非连续内存模型,用来消除这些不连续的内存地址空洞对 mem_map 的空间浪费。

        在 DISCONTIGMEM 非连续内存模型中,内核将物理内存从宏观上划分成了一个一个的节点 node (微观上还是一页一页的物理页),每个 node 节点管理一块连续的物理内存。这样一来这些连续的物理内存页均被划归到了对应的 node 节点中管理,就避免了内存空洞造成的空间浪费。

        内核中使用 struct pglist_data 表示用于管理连续物理内存的 node 节点(内核假设 node 中的物理内存是连续的),既然每个 node 节点中的物理内存是连续的,于是在每个 node 节点中还是采用 FLATMEM 平坦内存模型的方式来组织管理物理内存页。这也就是为什么每个 node 节点中包含一个 struct page *node_mem_map 数组,用来组织管理 node 中的连续物理内存页。

//include/linux/mmzone.h
typedef struct pglist_data {
#ifdef CONFIG_FLAT_NODE_MEM_MAP	/* means !SPARSEMEM */
	struct page *node_mem_map;
#endif
......
}

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

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

  • 通过 arch_pfn_to_nid 可以根据物理页的 PFN 定位到物理页所在 node。

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

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

//include/asm-generic/memory_model.h
......
#elif defined(CONFIG_DISCONTIGMEM)

#ifndef arch_pfn_to_nid
#define arch_pfn_to_nid(pfn)	pfn_to_nid(pfn)
#endif

#ifndef arch_local_page_offset
#define arch_local_page_offset(pfn, nid)	\
	((pfn) - NODE_DATA(nid)->node_start_pfn) //获得在mem_map数组中的偏移量
#endif

#endif /* CONFIG_DISCONTIGMEM */

//这里
......
#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);\  //根据mem_map的起始位置加上偏移量找到对应的page
})

#define __page_to_pfn(pg)						\
({	const struct page *__pg = (pg);					\
	struct pglist_data *__pgdat = NODE_DATA(page_to_nid(__pg));	\  //找到该page对应的node节点
	(unsigned long)(__pg - __pgdat->node_mem_map) +			\
	 __pgdat->node_start_pfn;					\ //根据该page物理地址减去mem_map数组头地址,得到偏移量,再加上node起始的PFN,得到对应的PFN
})

#elif defined(CONFIG_SPARSEMEM_VMEMMAP)
......

2.3 SPARSEMEM 稀疏内存模型

        随着内存技术的发展,内核可以支持物理内存的热插拔了(后面会介绍),这样一来物理内存的不连续就变为常态了,那么在上小节介绍的 DISCONTIGMEM 内存模型中,其每个 node 中的物理内存也不一定都是连续的。

        而且每个 node 中都有一套完整的内存管理系统(node->zone->page),如果 node 数目多的话,那这个开销就大了,Linux 物理内存管理涉及的三大结构体之struct pglist中的前言部分我们知道,node最大数目是2^10,于是就有了对连续物理内存更细粒度的管理需求,为了能够更灵活地管理粒度更小的连续物理内存,SPARSEMEM 稀疏内存模型就此登场了。

        SPARSEMEM 稀疏内存模型的核心思想就是对粒度更小的连续内存块进行精细的管理,用于管理连续内存块的单元被称作 section 。物理页大小为 4k 的情况下, section 的大小为 128M ,物理页大小为 16k 的情况下, section 的大小为 512M。

        在内核中用 struct mem_section 结构体表示 SPARSEMEM 模型中的 section。由于 section 被用作管理小粒度的连续内存块,这些小的连续物理内存在 section 中继续通过数组的方式被组织管理每个 struct mem_section 结构体中有一个 section_mem_map 指针用于指向 section 中管理连续内存的 page 数组

        从这里我们也可知道,在SPARSEMEM 模型中,struct page依附于section结构,而不是node结构了,不过从Linux 物理内存管理涉及的三大结构体之struct page的2.1节可知,node结构在该模型中是依然存在的,因为有些物理内存参数依旧保存在node结构里面,可以通过section找到page的node id。

//include/linux/mmzone.h
struct mem_section {
	unsigned long section_mem_map;
        ...
}

        SPARSEMEM 内存模型中的这些所有的 mem_section 会被存放在一个全局数组mem_section中,如下代码所示,并且每个 mem_section 都可以在系统运行时改变 offline / online (下线 / 上线)状态,以便支持内存的热插拔(hotplug)功能。

        从下面代码可以看到在使能了CONFIG_SPARSEMEM_EXTREME后,全局数组mem_section就是一个二维指针了,这是因为使能了SPARSEMEM_EXTREME,意味着系统物理内存特别稀疏,如果继续使用固定长度的二维数组就特别浪费内存,而使用指针则方便了很多。

//include/linux/mmzone.h || mm/sparse.c
#ifdef CONFIG_SPARSEMEM_EXTREME
#define SECTIONS_PER_ROOT       (PAGE_SIZE / sizeof (struct mem_section))
#else
#define SECTIONS_PER_ROOT	1
#endif

#define SECTION_NR_TO_ROOT(sec)	((sec) / SECTIONS_PER_ROOT)
#define NR_SECTION_ROOTS	DIV_ROUND_UP(NR_MEM_SECTIONS, SECTIONS_PER_ROOT)
#define SECTION_ROOT_MASK	(SECTIONS_PER_ROOT - 1)

#ifdef CONFIG_SPARSEMEM_EXTREME //使能了就是一个二维指针,未使能就是一个二维数组,实际上作用是一样的
extern struct mem_section **mem_section;
#else
extern struct mem_section mem_section[NR_SECTION_ROOTS][SECTIONS_PER_ROOT];
#endif

        在 SPARSEMEM 稀疏内存模型下 page_to_pfn 与 pfn_to_page 的计算逻辑又发生了变化。多了一个section的概念,让转换变成了PFN<--->Section<--->page。

  • 在 page_to_pfn 的转换中,首先,通过 page_to_section 根据 struct page 结构从全局数组mem_section中找到具体的 section 结构。在 struct page 结构中有一个成员变量 unsigned long flags,在 flags 的高位 bit 中存储着 page 所在 mem_section 全局数组中的索引,从而可以定位到所属 section。然后再通过 struct mem_section 结构体里面的section_mem_map 定位保存page的数组,从而定位到具体的 PFN。
  • 在 pfn_to_page 的转换中,首先,通过 __pfn_to_section 根据 PFN 从全局数组mem_section 中具体的 section 结构。然后再通过 PFN 在 section_mem_map 中定位到具体的物理页 Page 。
//include/asm-generic/memory_model.h
......
#elif defined(CONFIG_SPARSEMEM)
/*
 * Note: section's mem_map is encoded to reflect its start_pfn.
 * section[i].section_mem_map == mem_map's address - start_pfn;
 */
#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;		\
})
#endif /* CONFIG_FLATMEM/DISCONTIGMEM/SPARSEMEM */

        讲完了SPARSEMEM 模型关于转换PFN<--->Section<--->page的经典方案,这里再将一下,在SPARSEMEM 模型中,kernel新增一种转换方案(CONFIG_SPARSEMEM_VMEMMAP)。

        上面的经典方案最大的问题是依赖struct page里面的flags成员变量,但是flags这64bit数目不一定够用,因为这个flag中承载了太多的信息,各种page flag,node id,zone id,last_cpupid现在又增加一个section id,在不同的architecture中无法实现一致性的算法,所以kernel就新增了SPARSEMEM_VMEMMAP方案。

        从下面代码中可知,x86_64和ARM64关于vmemmap的起始值定义是不同的。不过关于PFN<--->page的转换是一致的,相当于PFN就是这个struct page数组的index,而vmemmap是这个struct page数组的头。关于vmemmap,其是保存存放struct page数组(该数组包含了所有物理内存跨度空间page)的一段虚拟地址空间,vmemmap指向该数组的起始位置。关于VMEMMAP_START可参考kernel内存泄漏分析方法里面所讲的memory layout。

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

//arch/arm64/include/asm/pgtable.h
#define vmemmap			((struct page *)VMEMMAP_START - (memstart_addr >> PAGE_SHIFT))

//include/asm-generic/memory_model.h
......
#elif defined(CONFIG_SPARSEMEM_VMEMMAP)

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

        对于经典的SPARSEMEM 模型,一个section的struct page数组所占用的内存来自直接映射区域,页表在初始化的时候就建立好了,分配了虚拟地址相当于知道了struct page的物理地址。但是,对于SPARSEMEM_VMEMMAP而言,虚拟地址一开始就分配好了,是从vmemmap开始的一段连续的虚拟地址空间,每一个page都有一个对应的struct page,当然,只有虚拟地址,没有物理地址。因此,对于这种SPARSEMEM_VMEMMAP,开销会稍微大一些(多了个建立映射的过程)

        从以上的内容介绍中,我们可以看出 SPARSEMEM 稀疏内存模型已经完全覆盖了前两个内存模型的所有功能,因此稀疏内存模型可被用于所有内存布局的情况。

        需要强调的是:

        1、除了SPARSEMEM_VMEMMAP管理的struct page对应的虚拟地址空间内核是单独划分出来的,位于vmemmap中,但struct page结构体本身占用物理内存是来自直接映射区域,因为64位系统,虚拟地址给空间足够,完全支持线性映射。其余模型管理struct page所占用的物理内存位于直接映射区域(线性映射区域),对应的虚拟地址空间也是来自直接映射区域,因此操作系统不需要为其建立重复建立page table了。

        2、所有物理内存模型,最底层那部分都是使用数组来管理struct page,平坦内存模型的mem_map ,非连续内存模型的node_mem_map稀疏内存模型的section_mem_map/vmemmap

        最后这个SPARSEMEM 稀疏内存模型定义:从CPU角度看,随着内存技术的发展,物理内存热插拔已成常态,面对粒度更小的连续物理内存,为了做到更灵活的管理,使用section结构来管理更细碎的物理内存,同时避免了内存地址空洞和过多node节点带来开销,此时对应的物理内存模型就是SPARSEMEM稀疏内存模型。

2.3.1  物理内存热插拔

        前面提到随着内存技术的发展,物理内存的热插拔 hotplug 在内核中得到了支持,由于物理内存可以动态的从主板中插入以及拔出,所以导致了物理内存的不连续已经成为常态,因此内核引入 SPARSEMEM 稀疏内存模型以应对这种情况,提供对更小粒度的连续物理内存的灵活管理能力。

        本小节就为大家介绍一下物理内存热插拔 hotplug 功能在内核中的实现原理,作为 SPARSEMEM 稀疏内存模型的扩展内容补充。

        在大规模的集群中,尤其是现在处于云原生的时代,为了实现集群资源的动态均衡,可以通过物理内存热插拔的功能实现集群机器物理内存容量的动态增减。

        集群的规模一大,那么物理内存出故障的几率也会大大增加,物理内存的热插拔对提供集群高可用性也是至关重要的

        从总体上来讲,内存的热插拔分为两个阶段:

  • 物理热插拔阶段:这个阶段主要是从物理上将内存硬件插入(hot-add),拔出(hot-remove)主板的过程,其中涉及到硬件和内核的支持。

  • 逻辑热插拔阶段:这一阶段主要是由内核中的内存管理子系统来负责,涉及到的主要工作为:如何动态的上线启用(online)刚刚 hot-add 的内存,如何动态下线(offline)刚刚 hot-remove 的内存。

        物理内存拔出的过程需要关注的事情比插入的过程要多的多,实现起来也更加的困难。因为此时即将要被拔出的物理内存中可能已经为进程分配了物理页,如何妥善安置这些已经被分配的物理页是一个棘手的问题。

        前边我们介绍 SPARSEMEM 内存模型的时候提到,每个 mem_section 都可以在系统运行时改变 offline/online 状态,以便支持内存的热插拔(hotplug)功能。 当 mem_section offline 时, 内核会把这部分内存隔离开, 使得该部分内存不可再被使用, 然后再把 mem_section 中已经分配的内存页迁移到其他 mem_section 的内存上。

        但是这里会有一个问题,就是并非所有的物理页都可以迁移,因为迁移意味着物理内存地址的变化,而内存的热插拔应该对进程来说是透明的,所以这些迁移后的物理页映射的虚拟内存地址是不能变化的。

        这一点在进程的用户空间是没有问题的,因为进程在用户空间访问物理内存都是根据虚拟内存地址通过页表找到对应的物理内存地址,这些迁移之后的物理页,虽然物理内存地址发生变化,但是内核通过修改相应页表中虚拟内存地址与物理内存地址之间的映射关系,可以保证虚拟内存地址不会改变。

        但是在内核态的虚拟地址空间中,有一段直接映射区,在这段虚拟内存区域中虚拟地址与物理地址是直接映射的关系,虚拟内存地址直接减去一个固定的偏移量(在X86 32位系统中是0xC000 0000 ) 就得到了物理内存地址。

        直接映射区中的物理页的虚拟地址会随着物理内存地址变动而变动, 因此这部分物理页是无法轻易迁移的,然而不可迁移的页会导致内存无法被拔除,因为无法妥善安置被拔出内存中已经为进程分配的物理页。那么内核是如何解决这个头疼的问题呢?

        既然是这些不可迁移的物理页导致内存无法拔出,那么我们可以把内存分类,将内存按照物理页是否可迁移,划分为不可迁移页,可回收页,可迁移页。然后在这些可能会被拔出的内存中只分配那些可迁移的内存页,这些信息会在内存初始化的时候被设置,这样一来那些不可迁移的页就不会包含在可能会拔出的内存中,当我们需要将这块内存热拔出时, 因为里边的内存页全部是可迁移的, 从而使内存可以被拔除。在include/linux/mmzone.h文件里面定义了迁移类型enum migratetype。

三、从 CPU 角度看物理内存架构

        在上节中为大家介绍了三种物理内存模型,这三种物理内存模型是从 CPU 的视角来看待物理内存内部是如何布局,组织以及管理的,主角是物理内存。

        这节为大家提供一个新的视角,这一次我们把物理内存看成一个整体,从 CPU 访问物理内存的角度来看一下物理内存的架构,并从 CPU 与物理内存的相对位置变化来看一下不同物理内存架构下对性能的影响。实际上关于这部分内容在Linux 物理内存管理涉及的三大结构体之struct pglistLinux 物理内存管理涉及的三大结构体之struct page的前言部分已经大概介绍了,这里再做个补充和丰富。

3.1 一致性内存访问 UMA 架构

        在文章深入理解Linux虚拟内存管理中的8.2节中,我们提到CPU跟内存的数据交互是通过总线来完成的

  • 首先 CPU 通过MMU将虚拟地址转换成物理地址,将物理内存地址作为地址信号放到系统总线上传输。随后 IO bridge 将系统总线上的地址信号转换为存储总线上的地址信号。

  • 主存感受到存储总线上的地址信号并通过存储控制器将存储总线上的物理内存地址 A 读取出来。

  • 存储控制器通过物理内存地址定位到具体的存储器模块,从 DRAM 芯片中取出物理内存地址对应的数据。

  • 存储控制器将读取到的数据放到存储总线上,随后 IO bridge 将存储总线上的数据信号转换为系统总线上的数据信号,然后继续沿着系统总线传递。

  • CPU 芯片感受到系统总线上的数据信号,将数据从系统总线上读取出来并拷贝到寄存器中。 

         上图展示的是单核 CPU 访问内存的架构图,那么在多核服务器中多个 CPU 与内存之间的架构关系又是什么样子的呢?

        在 UMA 架构下,多核服务器中的多个 CPU 位于总线的一侧,所有的内存条组成一大片内存位于总线的另一侧,所有的 CPU 访问内存都要过总线,而且距离都是一样的,由于所有 CPU 对内存的访问距离都是一样的,所以在 UMA 架构下所有 CPU 访问内存的速度都是一样的。这种访问模式称为 SMP(Symmetric multiprocessing),即对称多处理器。CPU的这访问方式叫SMP,物理内存的这种架构叫做UMA。这里的一致性是指同一个 CPU 对所有内存的访问的速度是一样的。即一致性内存访问 UMA(Uniform Memory Access)。

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

  1. 总线的带宽压力会越来越大,随着 CPU 个数的增多导致每个 CPU 可用带宽会减少

  2. 总线的长度也会因此而增加,进而增加访问延迟

  3. 随着 CPU 个数的增加,CPU 调度竞争更激烈,继续共享内存,将导致每个 CPU 访问内存的时间会增加

        UMA 架构的优点很明显就是结构简单,所有的 CPU 访问内存速度都是一致的,都必须经过总线。然而它的缺点我刚刚也提到了,就是随着处理器核数的增多,总线的带宽压力会越来越大。解决办法就只能扩宽总线,然而成本十分高昂,未来可能仍然面临带宽压力。

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

3.2 非一致性内存访问 NUMA 架构

        在 NUMA 架构下,内存就不是一整片的了,从软件层面被划分成了一个一个的内存节点 (NUMA 节点),每个 CPU 都有属于自己的本地内存节点,CPU 访问自己的本地内存不需要经过总线,因此访问速度是最快的。当 CPU 自己的本地内存不足时,CPU 就需要跨节点通过QPI总线去访问其他内存节点,这种情况下 CPU 访问内存就会慢很多

        在 NUMA 架构下,任意一个 CPU 都可以访问全部的内存节点,访问自己的本地内存节点是最快的,但访问其他内存节点就会慢很多,这就导致了 CPU 访问内存的速度不一致,所以叫做非一致性内存访问架构。

        如上图所示,CPU 和它的本地内存组成了 NUMA 节点,CPU 与 CPU 之间通过 QPI(Intel QuickPath Interconnect)点对点完成互联,在 CPU 的本地内存不足的情况下,CPU 需要通过 QPI 访问远程 NUMA 节点上的内存控制器从而在远程内存节点上分配内存,这就导致了远程访问比本地访问多了额外的延迟开销(需要通过 QPI 遍历远程 NUMA 节点)。

        在 NUMA 架构下,只有 DISCONTIGMEM 非连续内存模型和 SPARSEMEM 稀疏内存模型是可用的。而 UMA 架构下,前面介绍的三种内存模型都可以配置使用。在手机和普通电脑中,一般使用的都是UMA架构,只有在大型服务器上,考虑到CPU访问内存的性能和扩展性,才使用NUMA结构。而且实际上,UMA结构可以看成只有一个NUMA节点的特殊NUMA架构。

3.2.1 NUMA 的内存分配策略

        NUMA 的内存分配策略是指在 NUMA 架构下 CPU 如何请求内存分配的相关策略,比如:是优先请求本地内存节点分配内存呢 ?还是优先请求指定的 NUMA 节点分配内存 ?是只能在本地内存节点分配呢 ?还是允许当本地内存不足的情况下可以请求远程 NUMA 节点分配内存 ?在mm/mempolicy.c文件开头就有介绍,这里总结一下,如下。

内存分配策略策略描述
MPOL_BIND必须在绑定的节点进行内存分配,如果绑定节点内存不足,也无法从其他节点分配
MPOL_INTERLEAVE本地节点和远程节点均可允许分配内存
MPOL_PREFERRED优先在指定节点分配内存,当指定节点内存不足时,选择离指定节点最近的节点分配内存
MPOL_LOCAL (默认)优先在本地节点分配,当本地节点内存不足时,可以在远程节点分配内存

        应用程序可通过系统调用set_mempolicy来调用kernel_set_mempolicy接口设置进程的内存分配策略。其中mode表示 NUMA 内存分配策略,nodemask表示 NUMA 节点 Id。maxnode表示最大 NUMA 节点 Id,用于遍历远程节点,实现跨 NUMA 节点分配内存。

//mm/mempolicy.c
/* Set the process memory policy */
static long kernel_set_mempolicy(int mode, const unsigned long __user *nmask,
				 unsigned long maxnode)
{
	int err;
	nodemask_t nodes;
	unsigned short flags;

	flags = mode & MPOL_MODE_FLAGS;
	mode &= ~MPOL_MODE_FLAGS;
	if ((unsigned int)mode >= MPOL_MAX)
		return -EINVAL;
	if ((flags & MPOL_F_STATIC_NODES) && (flags & MPOL_F_RELATIVE_NODES))
		return -EINVAL;
	err = get_nodes(&nodes, nmask, maxnode);
	if (err)
		return err;
	return do_set_mempolicy(mode, flags, &nodes);
}

SYSCALL_DEFINE3(set_mempolicy, int, mode, const unsigned long __user *, nmask,
		unsigned long, maxnode)
{
	return kernel_set_mempolicy(mode, nmask, maxnode);
}

3.2.2 NUMA 的使用简介

        在知道了物理内存的 NUMA 架构以及在 NUMA 架构下的内存分配策略之后,本小节为大家介绍下如何正确的利用 NUMA 提升我们应用程序的性能。

        前边我们介绍了这么多的理论知识,但是理论的东西总是很虚,正所谓眼见为实,大家一定想亲眼看一下 NUMA 架构在计算机中的具体表现形式,比如:在支持 NUMA 架构的机器上到底有多少个 NUMA 节点?每个 NUMA 节点包含哪些 CPU 核,具体是怎样的一个分布情况?

        前面也提到 CPU 在访问本地 NUMA 节点中的内存时,速度是最快的。但是当访问远程 NUMA 节点,速度就会相对很慢,那么到底有多慢?本地节点与远程节点之间的访问速度差异具体是多少 ?

3.2.2.1 查看 NUMA 相关信息

        这里需要使用到numactl工具,默认Linux系统是没有安装的,需要手动去安装,安装指令:sudo apt install numactl。相关安装教程网络上有。工具使用文档参考:numactl(8) - Linux manual page

        对于我们上面提到的问题,通过numactl -H 命令可以给出答案。

available: 4 nodes (0-3)
node 0 cpus: 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 
node 0 size: 64794 MB
node 0 free: 55404 MB

node 1 cpus: 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31
node 1 size: 65404 MB
node 1 free: 58642 MB

node 2 cpus: 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47
node 2 size: 65404 MB
node 2 free: 61181 MB

node 3 cpus:  48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63
node 3 size: 65402 MB
node 3 free: 55592 MB

node distances:
node   0   1   2   3
  0:  10  16  32  33
  1:  16  10  25  32
  2:  32  25  10  16
  3:  33  32  16  10

        numactl -H 命令可以查看服务器的 NUMA 配置,上图中的服务器配置共包含 4 个 NUMA 节点(0 - 3),每个 NUMA 节点中包含 16个 CPU 核心,本地内存大小约为 64G。

        最后 node distances: 这一栏,node distances 给出了不同 NUMA 节点之间的访问距离,对角线上的值均为本地节点的访问距离 10 。比如 [0,0] 表示 NUMA 节点 0 的本地内存访问距离。

        可以很明显的看到当出现跨 NUMA 节点访问的时候,访问距离就会明显增加,比如节点 0 访问节点 1 的距离 [0,1] 是16,节点 0 访问节点 3 的距离 [0,3] 是 33。距离越远,跨 NUMA 节点内存访问的延时越大。应用程序运行时应减少跨 NUMA 节点访问内存。

        此外我们还可以通过 numactl -s 来查看 NUMA 的内存分配策略设置

policy: default
preferred node: current

        通过 numastat 还可以查看各个 NUMA 节点的内存访问命中率,这个指令安装了numactl工具就有,打包在一起的。使用参考文档:numastat(8) - Linux manual page

                           node0           node1            node2           node3
numa_hit              1296554257       918018444         1296574252       828018454
numa_miss                8541758        40297198           7544751        41267108
numa_foreign            40288595         8550361          41488585         8450375
interleave_hit             45651           45918            46654           49718
local_node            1231897031       835344122         1141898045       915354158
other_node              64657226        82674322           594657725       82675425 

        其中列表参数介绍如下:

  • numa_hit :内存分配在该节点中成功的次数。
  • numa_miss : 内存分配在该节点中失败的次数。
  • numa_foreign:表示其他 NUMA 节点本地内存分配失败,跨节点(numa_miss)来到本节点分配内存的次数。
  • interleave_hit : 在 MPOL_INTERLEAVE 策略下,在本地节点分配内存的次数。

  • local_node:进程在本地节点分配内存成功的次数。

  • other_node:运行在本节点的进程跨节点在其他节点上分配内存的次数。

3.2.2.2 绑定 NUMA 节点

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

        通过 --membind 可以指定我们的应用程序只能在哪些具体的 NUMA 节点上分配内存,如果这些节点内存不足,则分配失败。通过 --cpunodebind 可以指定我们的应用程序只能运行在哪些 NUMA 上。指令格式如下。

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

        另外我们还可以通过 --physcpubind 将我们的应用程序绑定到具体的物理 CPU 上这个选项后边指定的参数我们可以通过 cat /proc/cpuinfo 输出信息中的 processor 这一栏查看。例如:通过 numactl --physcpubind= 0-15 ./numatest.out 命令将进程 numatest 绑定到 0~15 CPU 上执行。

numactl --physcpubind=cpus  command

        我们可以通过 numactl 命令将 numatest 进程的运行分别绑定在相同的 NUMA 节点上和不同的 NUMA 节点上,运行观察。

numactl --membind=0 --cpunodebind=0 ./numatest.out
numactl --membind=0 --cpunodebind=1 ./numatest.out

        显然是绑定在相同 NUMA 节点的进程运行会更快,因为通过前边对 NUMA 架构的介绍,我们知道 CPU 访问本地 NUMA 节点的内存是最快的。

        除了 numactl 这个工具外,我们还可以通过共享库 libnuma 在程序中进行 NUMA 相关的操作。这里就不演示了,感兴趣可以查看下 libnuma 的 API 文档:numa(3) - Linux manual page

四、内核如何管理NUMA节点

        在前边介绍物理内存模型和物理内存架构的时候提到过:在 NUMA 架构下,只有 DISCONTIGMEM 非连续内存模型和 SPARSEMEM 稀疏内存模型是可用的。而 UMA 架构下,三种内存模型均可以配置使用。而且内核的物理内存管理子系统会把UMA 架构当做只有一个 NUMA 节点的伪 NUMA 架构。因此,无论是 NUMA 架构还是 UMA 架构在内核中都是使用相同的数据结构来组织管理的,做到统一管理。这部分内容实际上在Linux 物理内存管理涉及的三大结构体之struct pglist_data有详细的介绍了,这里按需再次归纳整理一下。

        下面先从最顶层的设计开始为大家介绍一下内核是如何管理这些 NUMA 节点的。注意点:每个NUMA 节点中可能会包含多个 CPU,这些 CPU 均是物理 CPU。

4.1 内核如何统一组织 NUMA 节点

        首先来看第一个问题,内核是如何将这些NUMA节点统一管理起来的?

        内核使用struct pglist_data 数据结构来描述 NUMA 节点,称为memory node。在早期的kernel-2.4版本之前,内核是使用一个pgdat_list 单链表将这些 NUMA 节点串联起来的

//include/linux/mmzone.h 
extern pg_data_t *pgdat_list;

        那时,每个NUMA节点的struct pglist_data里面包含一个pgdat_next指针,用于将系统中所有NUMA节点串联起来组成pgdat_list单链表,链表的末尾节点next指针指向NULL。

//include/linux/mmzone.h
typedef struct pglist_data {
    struct pglist_data *pgdat_next;
}

        在kernel-2.4版本之后,内核移除了struct pglist_data里面的pgdat_next指针,同时也不再使用pgdat_list 单链表管理所有这些 NUMA 节点。取而代之的,就是在Linux 物理内存管理涉及的三大结构体之struct pglist_data中"一、前言"提到的长度为MAX_NUMNODES,类型为 struct pglist_data *的全局数组 node_data[MAX_NUMNODES] 来管理所有的 NUMA 节点。

         全局数组 node_data[] 定义如下,NODE_DATA(nid) 宏可以通过 NUMA 节点的 node id,找到对应的 struct pglist_data 结构。此外,在struct pglist_data数据结构中,参数int node_id表示的就是当前NUMA节点的编号,相当于node_data数组的index。

//arch/arm64/mm/numa.c
struct pglist_data *node_data[MAX_NUMNODES] __read_mostly;

//arch/arm64/include/asm/mmzone.h
#define NODE_DATA(nid)		(node_data[(nid)])

         MAX_NUMNODES 定义如下,UMA 架构下 NODES_SHIFT 为 0 ,所以内核中只用一个 NUMA 节点来管理所有物理内存。手机和普通电脑就是只有NUMA节点。

//mm/Kconfig
#
# Both the NUMA code and DISCONTIGMEM use arrays of pg_data_t's
# to represent different areas of memory.  This variable allows
# those dependencies to exist individually.
#
config NEED_MULTIPLE_NODES
	def_bool y
	depends on DISCONTIGMEM || NUMA
 
//arch/arm64/Kconfig
config NODES_SHIFT
	int "Maximum NUMA Nodes (as a power of 2)"
	range 1 10
	default "4"
	depends on NEED_MULTIPLE_NODES
	help
	  Specify the maximum number of NUMA Nodes available on the target
	  system.  Increases memory reserved to accommodate various tables.

//include/linux/numa.h
#ifdef CONFIG_NODES_SHIFT
#define NODES_SHIFT     CONFIG_NODES_SHIFT
#else
#define NODES_SHIFT     0
#endif
 
#define MAX_NUMNODES    (1 << NODES_SHIFT)

4.2 NUMA节点描述符 pglist_data 结构

        这里截取Linux 物理内存管理涉及的三大结构体之struct pglist_data中关于pglist_data数据结构的部分内容。更多参数介绍可以看前文。

typedef struct pglist_data {
        .............
    // NUMA 节点id
    int node_id;
    // 指向 NUMA 节点内管理所有物理页 page 的数组
    struct page *node_mem_map;
    // NUMA 节点内第一个物理页的 pfn
    unsigned long node_start_pfn;
    // NUMA 节点内所有可用的物理页个数(不包含内存空洞)
    unsigned long node_present_pages;
    // NUMA 节点内所有的物理页个数(包含内存空洞)
    unsigned long node_spanned_pages; 
    // 保证多进程可以并发安全的访问 NUMA 节点
    spinlock_t node_size_lock;
        .............
} pg_data_t;

         node_id 表示 当前 NUMA 节点的 id,我们可以通过 numactl -H 命令的输出结果查看节点 id。从 0 开始依次对 NUMA 节点进行编号。

        struct page 类型的数组 node_mem_map 中包含了当前 NUMA节点内的所有的物理内存页。

        node_start_pfn 指向 NUMA 节点内第一个物理页的 PFN,系统中所有 NUMA 节点中的物理页都是依次编号的,每个物理页的 PFN 都是全局唯一的(不仅仅只是在其所在 NUMA 节点内唯一)。

        node_present_pages 用于统计 NUMA 节点内所有真正可用的物理页面数量(不包含内存空洞)。 

        由于 NUMA 节点内包含的物理内存并不总是连续的,可能会包含一些内存空洞,node_spanned_pages 则是用于统计 NUMA 节点内所有的内存页,包含不连续的物理内存地址(内存空洞)的页面数。 

4.3 NUMA节点物理内存区域的划分

        该小节的详细内容可以看文章《Linux 物理内存管理涉及的三大结构体之struct zone》。        

        我们都知道内核对物理内存的管理都是以页帧为最小单位来管理的,每页帧默认 4K 大小,理想状况下任何种类的数据都可以存放在任何页框中,没有什么限制。比如:存放内核数据,用户数据,磁盘缓冲数据等。

        但是实际的计算机体系结构受到硬件方面的制约,间接导致限制了页框的使用方式。

        比如在 X86 体系结构下,ISA 总线的 DMA (直接内存存取)控制器,只能对内存的前16M 进行寻址,这就导致了 ISA 设备不能在整个 32 位地址空间中执行 DMA,只能使用物理内存的前 16M 进行 DMA 操作。

        因此直接映射区的前 16M 专门让内核用来为 DMA 分配内存,这块 16M 大小的内存区域我们称之为 ZONE_DMA。用于 DMA 的内存必须从 ZONE_DMA 区域中分配。

        而直接映射区中剩下的部分也就是从 16M 到 896M(不包含 896M)这段区域,我们称之为 ZONE_NORMAL。从字面意义上我们可以了解到,这块区域包含的就是正常的页框(没有任何使用限制)。

        ZONE_NORMAL 由于也是属于直接映射区的一部分,对应的物理内存 16M 到 896M 这段区域也是被直接映射至内核态虚拟内存空间中的 3G + 16M 到 3G + 896M 这段虚拟内存上。

        而物理内存 896M 以上的区域被内核划分为 ZONE_HIGHMEM 区域,我们称之为高端内存。

        由于内核虚拟内存空间中的前 896M 虚拟内存已经被直接映射区所占用,而在 32 体系结构下内核虚拟内存空间总共也就 1G 的大小,这样一来内核剩余可用的虚拟内存空间就变为了 1G - 896M = 128M。

        显然物理内存中剩下的这 3200M 大小的 ZONE_HIGHMEM 区域无法继续通过直接映射的方式映射到这 128M 大小的虚拟内存空间中。

        这样一来物理内存中的 ZONE_HIGHMEM 区域就只能采用动态映射的方式映射到 128M 大小的内核虚拟内存空间中,也就是说只能动态的一部分一部分的分批映射,先映射正在使用的这部分,使用完毕解除映射,接着映射其他部分。

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

  1. ZONE_DMA:用于那些无法对全部物理内存进行寻址的硬件设备,进行 DMA 时的内存分配。例如前边介绍的 ISA 设备只能对物理内存的前 16M 进行寻址。该区域的长度依赖于具体的处理器类型。

  2. ZONE_DMA32:与 ZONE_DMA 区域类似,该区域内的物理页面可用于执行 DMA 操作,不同之处在于该区域是提供给 32 位设备(只能寻址 4G 物理内存)执行 DMA 操作时使用的。该区域只在 64 位系统中起作用,因为只有在 64 位系统中才会专门为 32 位设备提供专门的 DMA 区域。

  3. ZONE_NORMAL:这个区域的物理页都可以直接映射到内核中的虚拟内存,由于是线性映射,内核可以直接进行访问。

  4. ZONE_HIGHMEM:这个区域包含的物理页就是我们说的高端内存,内核不能直接访问这些物理页,这些物理页需要动态映射进内核虚拟内存空间中(非线性映射)。该区域只在 32 位系统中才会存在,因为 64 位系统中的内核虚拟内存空间太大了(x86架构:128T,arm64架构:256T),都可以进行直接映射。

        以上这些物理内存区域的划分在kernel-5.4中定义如下:

//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_type里面还有两个区域ZONE_MOVABLE 和 ZONE_DEVICE。

        ZONE_DEVICE 是为支持热插拔设备而分配的非易失性内存( Non Volatile Memory ),意味着断电数据不会消失,也可用于内核崩溃时保存相关的调试信息。

        ZONE_MOVABLE 是内核定义的一个虚拟内存区域该区域中的物理页可以来自于上边介绍的几种真实的物理区域。该区域中的页全部都是可以迁移的,主要是为了防止内存碎片(可优化内存迁移)和支持内存的热插拔(前面讲了,可插拔内存要求都是可迁移)当然需要注意:可迁移的物理页不一定都在ZONE_MOVABLE,但ZONE_MOVABLE 里面的物理页一定是可迁移的。

        这里详细解释一下为什么内核在有了这些实际的物理内存区域后,还要划分出一个虚拟的物理内存区域ZONE_MOVABLE?

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

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

        如果这些物理页处于 ZONE_MOVABLE 区域,它们就可以被迁移,内核可以通过迁移页面来避免内存碎片的问题: 

        内核通过迁移页面来规整内存,这样就可以避免内存碎片,从而得到一大片连续的物理内存,以满足内核对大块连续内存分配的请求。所以这就是内核需要根据物理页面是否能够迁移的特性,而划分出 ZONE_MOVABLE 区域的目的

        到这里,我们已经清楚了 NUMA 节点中物理内存区域的划分,下面我们继续回到 struct pglist_data 结构中看下内核如何在 NUMA 节点中组织这些划分出来的内存区域

typedef struct pglist_data {
  // NUMA 节点中的物理内存区域个数
	int nr_zones; 
  // NUMA 节点中的物理内存区域
	struct zone node_zones[MAX_NR_ZONES];
  // NUMA 节点的备用列表
	struct zonelist node_zonelists[MAX_ZONELISTS];
} pg_data_t;

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

        事实上,基本上只有第一个 NUMA 节点可以包含所有的物理内存区域,其它的节点并不能包含所有的区域类型,因为有些内存区域比如:ZONE_DMA,ZONE_DMA32 必须从物理内存的起点开始。这些在物理内存开始的区域可能已经被划分到第一个 NUMA 节点了,后面的物理内存才会被依次划分给接下来的 NUMA 节点。因此后面的 NUMA 节点并不会包含 ZONE_DMA,ZONE_DMA32 区域。

        ZONE_NORMAL、ZONE_HIGHMEM 和 ZONE_MOVABLE 是可以出现在所有 NUMA 节点上的。

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

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

        当然,各个 NUMA 节点之间的内存分配情况我们可以通过前边介绍的 numastat 命令查看。

4.4 NUMA 节点中的内存回收与内存规整

        物理内存是计算机系统中最为宝贵的资源,再怎么多也不够用,当系统运行时间长了之后,难免会遇到内存紧张的时候,这时候就需要内核将那些不经常使用的内存页面回收起来,或者将那些可以迁移的页面进行内存规整,从而可以腾出连续的物理内存页面供内核分配。关于该小节,更多内容参考文章《Linux 物理内存管理涉及的三大结构体之struct pglist_data》和《Linux 物理内存管理涉及的三大结构体之struct zone》里面有详细的介绍。

        内核会为每个 NUMA 节点分配一个 kswapd 进程用于回收不经常使用的页面,还会为每个 NUMA 节点分配一个 kcompactd 进程用于内存规整以避免内存碎片。

//include/linux/mmzone.h
typedef struct pglist_data {
        .........
    // 页面回收进程
    wait_queue_head_t kswapd_wait;
    struct task_struct *kswapd;
    // 内存规整进程
    wait_queue_head_t kcompactd_wait;
    struct task_struct *kcompactd;

        ..........
} pg_data_t;

        NUMA 节点描述符 struct pglist_data 结构中的 struct task_struct *kswapd 属性用于指向内核为 NUMA 节点分配的 kswapd 进程。

        kswapd_wait 用于 kswapd 进程周期性回收页面时使用到的等待队列。

        同理 struct task_struct *kcompactd 用于指向内核为 NUMA 节点分配的 kcompactd 进程。

        kcompactd_wait 用于 kcompactd 进程周期性规整内存时使用到的等待队列。

4.5 NUMA 节点的状态 node_states

        如果系统中的 NUMA 节点多于一个,内核会维护一个位图 node_states,用于维护各个 NUMA 节点的状态信息。注意:如果系统中只有一个 NUMA 节点,则没有节点位图。

        节点位图以及节点的状态掩码值定义如下:

//include/linux/nodemask.h
typedef struct { DECLARE_BITMAP(bits, MAX_NUMNODES); } nodemask_t;

extern nodemask_t node_states[NR_NODE_STATES];

        对于节点的状态可通过以下掩码表示:

//include/linux/nodemask.h
/*
 * Bitmasks that are kept for all the nodes.
 */
enum node_states {
	N_POSSIBLE,		/* The node could become online at some point */
	N_ONLINE,		/* The node is online */
	N_NORMAL_MEMORY,	/* The node has regular memory */
#ifdef CONFIG_HIGHMEM
	N_HIGH_MEMORY,		/* The node has regular or high memory */
#else
	N_HIGH_MEMORY = N_NORMAL_MEMORY,
#endif
	N_MEMORY,		/* The node has memory(regular, high, movable) */
	N_CPU,		/* The node has one or more cpus */
	N_GENERIC_INITIATOR,	/* The node has one or more Generic Initiators */
	NR_NODE_STATES
};

        N_POSSIBLE 表示 NUMA 节点在某个时刻可以变为 online 状态,N_ONLINE 表示 NUMA 节点当前的状态为 online 状态。在前面《2.3.1 物理内存热插拔》中,我们介绍过,稀疏内存模型中,NUMA 节点的状态可以在系统运行的过程中随时切换 online ,offline 的状态,用来支持内存的热插拔。

        N_NORMAL_MEMORY 表示节点没有高端内存,只有 ZONE_NORMAL 内存区域。

        N_HIGH_MEMORY 表示节点有 ZONE_NORMAL 内存区域或者有 ZONE_HIGHMEM 内存区域。

        N_MEMORY 表示节点有 ZONE_NORMAL,ZONE_HIGHMEM,ZONE_MOVABLE 内存区域。

        N_CPU 表示节点包含一个或多个 CPU。

        N_GENERIC_INITIATOR 表示节点包含一个或多个Generic Initiators,关于Generic Initiators的概念参考:ACPI: Support Generic Initiator proximity domains,这里不做多的介绍。

        此外内核还提供了两个辅助函数用于设置或者清除指定节点的特定状态:

//include/linux/nodemask.h
static inline void node_set_state(int node, enum node_states state)
static inline void node_clear_state(int node, enum node_states state)

        内核提供了 for_each_node_state 宏用于迭代处于特定状态的所有 NUMA 节点。

//include/linux/nodemask.h
#define for_each_node_state(__node, __state) \
	for_each_node_mask((__node), node_states[__state])

        比如:for_each_online_node 用于迭代所有 online 的 NUMA 节点,for_each_node 用于遍历所有NUMA节点。                

//include/linux/nodemask.h
#define for_each_online_node(node) for_each_node_state(node, N_ONLINE)

#define for_each_node(node)	   for_each_node_state(node, N_POSSIBLE)

五、内核如何管理 NUMA 节点中的物理内存区域

        如前面4.3节介绍,实际的计算机体系结构受硬件方面的制约,间接限制了页框的使用方式。因而,内核会根据各个物理内存区域的功能不同,将 NUMA 节点内的物理内存划分为:ZONE_DMA,ZONE_DMA32,ZONE_NORMAL,ZONE_HIGHMEM ,ZONE_DEVICE这几个物理内存区域。ZONE_MOVABLE 区域是内核从逻辑上的划分,区域中的物理页面来自于上述几个内存区域,目的是避免内存碎片和支持内存热插拔。

        我们可以通过 cat /proc/zoneinfo | grep Node 命令来查看 NUMA 节点中物理内存区域的分布情况。 由于均是64位系统,因而没有ZONE_HIGHMEM物理内存区域。手机端只有一个NUMA节点,服务器有两个NUMA节点。我们也可以通过 cat /proc/zoneinfo 命令来查看系统中各个 NUMA 节点中的各个内存区域的内存使用情况,里面的字段的含义可参考《Linux 物理内存管理涉及的三大结构体之struct zone》。

//Android手机 adb shell后,执行指令
OP55FF:/ # cat /proc/zoneinfo | grep Node
cat /proc/zoneinfo | grep Node
Node 0, zone    DMA32
Node 0, zone   Normal
Node 0, zone  Movable

//Linux服务器
User@Ubuntu-149-19:/proc$ cat zoneinfo | grep Node
Node 0, zone      DMA
Node 0, zone    DMA32
Node 0, zone   Normal
Node 1, zone   Normal

        内核中用于描述和管理 NUMA 节点中的物理内存区域的结构体是 struct zone,cat /proc/zoneinfo 命令 物理内存使用统计的相关数据均来自于 struct zone 结构体,如下是kernel对 struct zone 结构体的整体布局情况。

struct zone {

    .............省略..............

    ZONE_PADDING(_pad1_)

    .............省略..............

    ZONE_PADDING(_pad2_)

    .............省略..............

    ZONE_PADDING(_pad3_)

    .............省略..............

} ____cacheline_internodealigned_in_smp;

        由于 struct zone 结构体在内核中是一个访问非常频繁的结构体,在多处理器系统中,会有不同的 CPU 同时大量频繁的访问 struct zone 结构体中的不同字段

        因此内核对 struct zone 结构体的设计是相当考究的,将这些频繁访问的字段信息归类为 4 个部分,并通过 ZONE_PADDING 来分割。目的是通过 ZONE_PADDING 来填充字节,首先将这四个部分进行cacheline对齐,其次,让其分别填充到CPU 的不同高速缓存行(cache line)中,即缓存行的地址不同,使得它们各自独占 cache line,提高访问性能。如果在相同cache line上,多核时,某个CPU发生写操作,会出现cache失效问题,导致其他CPU需要重新从内存中读取最新的数据到cache line,这是很影响性能的,而且这个失效信息的发送和应答也是耗时间的。在kernel-6.1中,ZONE_PADDING变成了CACHELINE_PADDING,不过功能还是一样。

        内核之所以敢这么做,也是因为每个NUMA节点最多就4个实际的物理内存区域ZONE,其在struct zone结构体里面利用ZONE_PADDING 填充字节,带来的struct zone实例的内存增加是很小的,可以忽略不计,不像struct page结构体实例数量众多,结构体增加任何变量需要深思熟虑。在结构体的最后内核还是用了 ____cacheline_internodealigned_in_smp 编译器关键字来实现最优的高速缓存行对齐方式。

        为了使大家能够更好地理解内核如何使用 struct zone 结构体来描述物理内存区域,我把结构体中的字段按照一定的层次结构重新排列介绍,这并不是原生的字段对齐方式,这一点需要大家注意!!

//include/linux/mmzone.h
struct zone {
    // 防止并发访问该内存区域
    spinlock_t      lock;
    // 内存区域名称:Normal,DMA,HighMem
    const char      *name;
    // 指向该内存区域所属的 NUMA 节点
    struct pglist_data  *zone_pgdat;
    // 属于该内存区域中的第一个物理页 PFN
    unsigned long       zone_start_pfn;
    // 该内存区域中所有的物理页个数(包含内存空洞)
    unsigned long       spanned_pages;
    // 该内存区域所有可用的物理页个数(不包含内存空洞)
    unsigned long       present_pages;
    // 被伙伴系统所管理的物理页数
    atomic_long_t       managed_pages;
    // 伙伴系统的核心数据结构
    struct free_area    free_area[MAX_ORDER];
    // 该内存区域内存使用的统计信息
    atomic_long_t       vm_stat[NR_VM_ZONE_STAT_ITEMS];
} ____cacheline_internodealigned_in_smp;

        struct zone 是会被内核频繁访问的一个结构体,在多核处理器中,多个 CPU 会并发访问 struct zone,为了防止并发访问,内核使用了一把 spinlock_t lock 自旋锁来防止并发错误以及不一致问题。

        name 属性会根据该内存区域的类型不同保存内存区域的名称,比如:Normal ,DMA,HighMem 等。

        前边我们介绍 NUMA 节点的描述符 struct pglist_data 的时候提到,pglist_data 通过 struct zone 类型的数组 node_zones 将 NUMA 节点中划分的物理内存区域连接起来。

//include/linux/mmzone.h
typedef struct pglist_data {
    // NUMA 节点中的物理内存区域个数
    int nr_zones; 
    // NUMA 节点中的物理内存区域
    struct zone node_zones[MAX_NR_ZONES];
} pg_data_t;

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

        NUMA 节点 struct pglist_data 结构中的 node_start_pfn 指向 NUMA 节点内第一个物理页的 PFN。同理物理内存区域 struct zone 结构中的 zone_start_pfn 指向的是该内存区域内所管理的第一个物理页面 PFN 。

        后面的属性也和 NUMA 节点对应的字段含义一样,比如:spanned_pages 表示该内存区域内所有的物理页总数(包含内存空洞),通过 spanned_pages = zone_end_pfn - zone_start_pfn 计算得到。

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

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

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

        数组 free_area[MAX_ORDER] 是伙伴系统的核心数据结构。

        vm_stat 维护了该内存区域物理内存的使用统计信息,前边介绍的 cat /proc/zoneinfo命令的输出数据就来源于这个 vm_stat。

5.1 物理内存区域中的预留内存

        除了前边介绍的关于物理内存区域的这些基本信息之外,每个物理内存区域 struct zone 还为操作系统预留了一部分内存,这部分预留的物理内存用于内核的一些核心操作,这些操作无论如何是不允许内存分配失败的。

        什么意思呢?内核中关于内存分配的场景无外乎有两种方式:

  1. 当进程请求内核分配内存时,如果此时内存比较充裕,那么进程的请求会被立刻满足,如果此时内存已经比较紧张,内核就需要将一部分不经常使用的内存进行回收,从而腾出一部分内存满足进程的内存分配的请求,在这个回收内存的过程中,进程会一直阻塞等待。

  2. 另一种内存分配场景,进程是不允许阻塞的,内存分配的请求必须马上得到满足,比如执行中断处理程序或者执行持有自旋锁等临界区内的代码时,进程就不允许睡眠,因为中断程序无法被重新调度。这时就需要内核提前为这些核心操作预留一部分内存,当内存紧张时,可以使用这部分预留的内存给这些操作分配。

//include/linux/mmzone.h
struct zone {
             ...........

    unsigned long nr_reserved_highatomic;
    long lowmem_reserve[MAX_NR_ZONES];
            
             ...........
}

        nr_reserved_highatomic 表示的是该内存区域内预留内存的大小,范围为 128 到 65536 KB 之间(32 page到16384 page之间,1page = 4K)。

        lowmem_reserve 数组则是用于规定每个内存区域必须为自己保留的物理页数量,防止更高位的内存区域对自己的内存空间进行过多的侵占挤压。

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

        因为物理内存区域比如前边介绍的 ZONE_DMA,ZONE_DMA32,ZONE_NORMAL,ZONE_HIGHMEM 这些都是针对物理内存进行的划分,所谓的低位内存区域和高位内存区域其实还是按照物理内存地址从低到高进行排列布局:

        根据物理内存地址的高低,低位内存区域到高位内存区域的顺序依次是:ZONE_DMA,ZONE_DMA32,ZONE_NORMAL,ZONE_HIGHMEM。

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

        一些用于特定功能的物理内存必须从特定的内存区域中进行分配,比如外设的 DMA 控制器就必须从 ZONE_DMA 或者 ZONE_DMA32 中分配内存。

        但是一些用于常规用途的物理内存则可以从多个物理内存区域中进行分配,这是内核的fallback机制,当 ZONE_HIGHMEM 区域中的内存不足时,内核可以从 ZONE_NORMAL 进行内存分配,ZONE_NORMAL 区域内存不足时可以进一步降级到 ZONE_DMA 区域进行分配。

        而低位内存区域中的内存总是宝贵的,内核肯定希望这些用于常规用途的物理内存从常规内存区域中进行分配,这样能够节省 ZONE_DMA 区域中的物理内存保证 DMA 操作的内存使用需求,但是如果内存很紧张了,高位内存区域中的物理内存不够用了,那么内核就会去占用挤压其他内存区域中的物理内存从而满足内存分配的需求。

        但是内核又不会允许高位内存区域对低位内存区域的无限制挤压占用,因为毕竟低位内存区域有它特定的用途,所以每个内存区域会给自己预留一定的内存,防止被高位内存区域挤压占用。而每个内存区域为自己预留的这部分内存就存储在 lowmem_reserve 数组中。当然如果我们在页面分配时,通过GFP标志位(__GFP_THISNODE)做出一定限制,就只能在指定node里面的低位zone里面去找,避免遍历所有node找其他node低位zone要内存的情况。

        关于 lowmem_reserve 更多的内容在《Linux 物理内存管理涉及的三大结构体之struct zone》的2.4节已经详细介绍了,感兴趣的可以看下。

5.2 物理内存区域中水位线

        物理内存资源是系统中最宝贵的系统资源,是有限的。当内存资源紧张的时候,系统的应对方法无非就是四种:

  1. 产生 OOM,内核直接将系统中占用大量内存的进程,将 OOM 优先级最高的进程干掉,释放出这个进程占用的内存供其他更需要的进程分配使用。

  2. 内存回收,将不经常使用到的内存回收,腾挪出来的内存供更需要的进程分配使用。

  3. 内存规整,将可迁移的物理页面进行迁移规整,消除内存碎片。从而获得更大的一片连续物理内存空间供进程分配。

  4. 内存压缩,将不常用应用或者进程的内存,进行压缩,提高内存利用率。内存压缩是牺牲CPU时间为代价的,因为压缩、解压缩需要CPU时间。例如将一个不常用应用150M内存压缩后只占用50M,节省出100M内存。kernel常用的内存压缩为zram。手机现在一般都有内存拓展功能(即设备存储空间充足时,将部分存储空间拓展为同等大小的运行内存),这个功能一般都是先将运行内存中一些非关键应用和进程进行zram压缩,然后再swap到这个被拓展的存储空间中。这个被用来拓展的存储空间相当就是个swap分区,因为是专用的,属于swap disk

        我们都知道,内核将物理内存划分成一页一页的单位进行管理(每页 4K 大小)。内存回收的单位也是按页来的。在内核中,物理内存页有两种类型:文件页和匿名页,针对这两种类型的物理内存页,内核会有不同的回收机制。

        第一种就是文件页,所谓文件页就是其物理内存页中的数据来自于磁盘中的文件,当我们进行文件读取的时候,内核会根据局部性原理将读取的磁盘数据缓存在 page cache 中,page cache 里存放的就是文件页。当进程再次读取读文件页中的数据时,内核直接会从 page cache 中获取并拷贝给进程,省去了读取磁盘的开销。page in操作。

        对于文件页的回收通常会比较简单,因为文件页中的数据来自于磁盘,所以当回收文件页的时候直接回收就可以了,当进程再次读取文件页时,大不了再从磁盘中重新读取就是了。page out操作。

        但是当进程已经对文件页进行修改过但还没来得及同步回磁盘,此时文件页就是脏页,不能直接进行回收,需要先将脏页回写到磁盘中才能进行回收。

        我们可以在进程中通过 fsync() 系统调用将指定文件的所有脏页同步回写到磁盘,同时内核也会根据一定的条件唤醒专门用于回写脏页的 pflush 内核线程

        而另外一种物理页类型是匿名页,所谓匿名页就是它背后并没有一个磁盘中的文件作为数据来源,匿名页中的数据都是通过进程运行过程中产生的,比如我们应用程序中动态分配的堆内存。

        当内存资源紧张需要对不经常使用的那些匿名页进行回收时,因为匿名页的背后没有一个磁盘中的文件做依托,所以匿名页不能像文件页那样直接回收,无论匿名页是不是脏页,都需要先将匿名页中的数据先保存在磁盘空间中,然后在对匿名页进行回收

        并把释放出来的这部分内存分配给更需要的进程使用,当进程再次访问这块内存时,再重新把之前匿名页中的数据从磁盘空间中读取到内存就可以了,而这块磁盘空间可以是单独的一片磁盘分区(Swap disk)或者是一个特殊的文件(Swap file),在《linux kernel内存管理之/proc/meminfo下参数介绍》介绍SwapCached有涉及。匿名页的回收机制就是我们经常看到的 Swap 机制

        所谓的页面换出就是在 Swap 机制下,当内存资源紧张时,内核就会把不经常使用的这些匿名页中的数据写入到 Swap disk或者 Swap file中。从而释放这些数据所占用的内存空间。

        所谓的页面换入就是当进程再次访问那些被换出的数据时,内核会重新将这些数据从 Swap 分区或者 Swap 文件中读取到内存中来。

        综上所述,物理内存区域中的内存回收分为文件页回收和匿名页回收。Swap 机制主要针对的是匿名页回收。kswapd进程对文件页和匿名页都有进行异步回收,不过大部分情况是匿名页。

        那么当内存紧张的时候,内核到底是该回收文件页呢?还是该回收匿名页呢

        事实上 Linux 提供了一个 swappiness 的内核选项,我们可以通过 cat /proc/sys/vm/swappiness 命令查看,swappiness 选项的取值范围为 0 到 100,默认为 60,Android手机将其设置到100,linux 服务器将其设置为5。

//Android手机
OP55FF:/proc/sys/vm # cat swappiness
cat swappiness
100

//Linux服务器
User@Ubuntu-149-19:/proc/sys/vm$ cat swappiness
5

        swappiness 用于表示 Swap 机制的积极程度,数值越大,Swap 的积极程度越高,内核越倾向于回收匿名页。数值越小,Swap 的积极程度越低,内核就越倾向于回收文件页。注意: swappiness 只是表示 Swap 机制的积极程度,当内存非常紧张的时候,即使将 swappiness 设置为 0 ,也还是会发生 Swap 的。

        那到底什么时候内存才算紧张?紧张到什么程度才开始用swap内存回收?这个量化标准就是本节要讲的物理内存区域的水位线。kernel会为每个NUMA节点的每个物理内存区域ZONE定义三条用于指示内存容量的水位线,分别是WMARK_MIN,WMARK_LOW,WMARK_HIGH。

        关于更多水位线的内容,包括水位线的计算,min_free_kbytes 的计算逻辑,计算水位线_setup_per_zone_wmarks函数介绍以及watermark_scale_factor参数,在《Linux 物理内存管理涉及的三大结构体之struct zone》的2.1和2.2节已经详细介绍,感兴趣的可以看下。

5.3 物理内存区域中的冷热页

        我们都知道CPU的运行速度是要远大于物理内存的运行速度的。如,寄存器是离 CPU 最近的,CPU 在访问寄存器的时候速度近乎于 0 个时钟周期,访问速度最快,基本没有时延。而访问内存则需要 50 - 200 个时钟周期。

        所以为了弥补 CPU 与内存之间巨大的速度差异,提高CPU的处理效率和吞吐,于是我们引入了 L1 , L2 , L3 三级高速缓存集成到 CPU 中。CPU 访问高速缓存仅需要用到 1 - 30 个时钟周期,CPU 中的高速缓存是对内存热点数据的一个缓存。

        CPU 访问高速缓存的速度比访问内存的速度快大约10倍,引入高速缓存的目的在于消除CPU与内存之间的速度差距,CPU 用高速缓存来用来存放内存中的热点数据。

        另外我们根据程序的时间局部性原理可以知道,内存的数据一旦被访问,那么它很有可能在短期内被再次访问,如果我们把经常访问的物理内存页缓存在 CPU 的高速缓存中,那么当进程再次访问的时候就会直接命中 CPU 的高速缓存,避免了进一步对内存的访问,极大提升了应用程序的性能。

        程序局部性原理表现为:时间局部性和空间局部性。时间局部性是指如果程序中的某条指令一旦执行,则不久之后该指令可能再次被执行;如果某块数据被访问,则不久之后该数据可能再次被访问。空间局部性是指一旦程序访问了某个存储单元,则不久之后,其附近的存储单元也将被访问。

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

        这里先给出一个定义,所谓的热页就是已经加载进 CPU 高速缓存中的物理内存页,所谓的冷页就是还未加载进 CPU 高速缓存中的物理内存页,冷页是热页的后备选项。 

        我先以kernel-2.6之前的冷热页相关的管理逻辑为大家讲解,因为这个版本的逻辑比较直观,大家更容易理解。在这个基础之上,我再介绍kernel-5.4对于冷热页管理的逻辑,差别不是很大。

struct zone {
    struct per_cpu_pageset	pageset[NR_CPUS];
}

        在kernel-2.6之前,物理内存区域 struct zone 包含了一个 struct per_cpu_pageset 类型的数组 pageset。其中内核关于冷热页的管理全部封装在 struct per_cpu_pageset 结构中。

        因为每个 CPU 都有自己独立的高速缓存,所以每个 CPU 对应一个 per_cpu_pageset 结构,pageset 数组容量 NR_CPUS 是一个可以在编译期间配置的宏常数,表示内核可以支持的最大 CPU个数,注意该值并不是系统实际存在的 CPU 数量。

        在 NUMA 内存架构下,每个物理内存区域都是属于一个特定的 NUMA 节点,NUMA 节点中包含了一个或者多个 CPUNUMA 节点中的每个内存区域会关联到一个特定的 CPU 上,但 struct zone 结构中的 pageset 数组包含的是系统中所有 CPU 的高速缓存页。因为虽然一个内存区域关联到了 NUMA 节点中的一个特定 CPU 上,但是其他CPU 依然可以访问该内存区域中的物理内存页,因此其他 CPU 上的高速缓存仍然可以包含该内存区域中的物理内存页。

        每个 CPU 都可以访问系统中的所有物理内存页,尽管访问速度不同(这在前边我们介绍 NUMA 架构的时候已经介绍过),因此特定的物理内存区域 struct zone 不仅要考虑到所属 NUMA 节点中相关的 CPU,还需要照顾到系统中的其他 CPU。

        在表示每个 CPU 高速缓存结构 struct per_cpu_pageset 中有一个 struct per_cpu_pages 类型的数组 pcp,容量为 2。 数组 pcp 索引 0 表示该内存区域加载进 CPU 高速缓存的热页集合,索引 1 表示该内存区域中还未加载进 CPU 高速缓存的冷页集合。

struct per_cpu_pageset {
	struct per_cpu_pages pcp[2];	/* 0: hot.  1: cold */
}

        因此,struct per_cpu_pages 结构则是最终用于管理 CPU 高速缓存中的热页,冷页集合的数据结构:

struct per_cpu_pages {
	int count;		/* number of pages in the list */
	int high;		/* high watermark, emptying needed */
	int batch;		/* chunk size for buddy add/remove */
	struct list_head list;	/* the list of pages */
};
  • int count :表示集合中包含的物理页数量,如果该结构是热页集合,则表示加载进 CPU 高速缓存中的物理页面个数。

  • struct list_head list :该 list 是一个双向链表,保存了当前 CPU 的热页或者冷页

  • int batch:每次批量向 CPU 高速缓存填充或者释放的物理页面个数。

  • int high:如果集合中页面的数量 count 值超过了 high 的值,那么表示 list 中的页面太多了,内核会从高速缓存中释放 batch 个页面到物理内存区域中的伙伴系统中。

  • int low : 在之前更老的版本中,per_cpu_pages 结构还定义了一个 low 下限值,如果 count 低于 low 的值,那么内核会从伙伴系统中申请 batch 个页面填充至当前 CPU 的高速缓存中。之后的版本中取消了 low ,内核对容量过低的页面集合并没有显示的使用水位值 low,当列表中没有其他成员时,内核会重新填充高速缓存。

        以上则是kernel-2.6之前管理 CPU 高速缓存冷热页的相关数据结构,我们看到在kernel-2.6之前,内核是使用两个 per_cpu_pages 结构来分别管理冷页和热页集合。

        后来内核开发人员通过测试发现,用两个列表来管理冷热页,并不会比用一个列表集中管理冷热页带来任何的实质性好处,因此在kernel-2.6之后,将冷页和热页的管理合并在了一个列表中,热页放在列表的头部,冷页放在列表的尾部。

        在kernel-5.4中, struct zone 结构中依然使用struct per_cpu_pageset,不过用__percpu替换掉了NR_CPUS不过在kernel-5.15中,其直接使用了struct per_cpu_pageset,因为 struct per_cpu_pageset 结构是最终管理per cpu 高速缓存冷热页集合的结构。

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

        根据最新社区patch,已经支持page order<3的使用pcp技术,之前只支持order=0(即分配一个page时,per CPU可以直接list中取)的。关于batch值是zone管理内存和的0.25‰, 但不能大于1MB,同时要2的n次方-1对齐,否则有时平台会有cache情况问题,high为batch的6倍。更多内容可看《Linux 物理内存管理涉及的三大结构体之struct zone》的2.7节。

struct zone {
 	struct per_cpu_pageset __percpu *pageset;
};


struct per_cpu_pages {
	int count;		/* number of pages in the list 当前per CPU page list中page数目*/
	int high;		/* high watermark, emptying needed 当前list中page的上限,超过了则还batch个page给当前zone*/
	int batch;		/* chunk size for buddy add/remove 如果list是空的,则从当前zone中一些找patch个page放到list里面*/
 
	/* Lists of pages, one per migrate type stored on the pcp-lists */
//每个zone下面有不同order,而每个order下的物理内存块分为不同的迁移类型,其中MIGRATE_UNMOVABLE,MIGRATE_MOVABLE,MIGRATE_RECLAIMABLE和MIGRATE_CMA(使能了CONFIG_CMA)迁移类型都有per CPU page list
//根据最新社区patch,已经支持page order<3的使用pcp技术,之前只支持order=0(即分配一个page时,per CPU可以直接list中取)的 
	struct list_head lists[MIGRATE_PCPTYPES];
};
 
struct per_cpu_pageset {
	struct per_cpu_pages pcp;//per_cpu_pageset结构体的核心
#ifdef CONFIG_NUMA
	s8 expire;
	u16 vm_numa_stat_diff[NR_VM_NUMA_STAT_ITEMS];
#endif
#ifdef CONFIG_SMP
	s8 stat_threshold;
	s8 vm_stat_diff[NR_VM_ZONE_STAT_ITEMS];
#endif
};

六、内核如何描述物理内存页

        经过前边几个小节的介绍,大家现在应该对 Linux 内核整个内存管理框架有了一个总体上的认识。

        如上图所示,在 NUMA 架构下内存被划分成了一个一个的内存节点(NUMA Node),在每个 NUMA 节点中,内核又根据节点内物理内存的功能用途不同,将 NUMA 节点内的物理内存划分为六个物理内存区域分别是:ZONE_DMA,ZONE_DMA32,ZONE_NORMAL,ZONE_HIGHMEM,ZONE_MOVABLE,ZONE_DEVICE。其中 ZONE_MOVABLE 区域是逻辑上的划分,主要是为了防止内存碎片和支持内存的热插拔。ZONE_DEVICE 是为支持热插拔设备而分配的非易失性内存( Non Volatile Memory ),意味着断电数据不会消失,也可用于内核崩溃时保存相关的调试信息。

        物理内存区域中管理的就是物理内存页( Linux 内存管理的最小单位),前面我们介绍的内核对物理内存的换入,换出,回收,内存映射等操作的单位就是页。内核为每一个物理内存区域分配了一个伙伴系统,用于管理该物理内存区域下所有物理内存页面的分配和释放。

        Linux 默认支持的物理内存页大小为 4KB,在 64 位体系结构中还可以支持 8KB/16KB/64KB,在《kernel内存泄漏分析方法》有介绍ARM64架构在arch/arm64/Kconfig的配置,有的处理器还可以支持 4MB,支持物理地址扩展 PAE 机制的处理器上还可以支持 2MB。

        那么 Linux 为什么会默认采用 4KB 作为标准物理内存页的大小呢,而且绝大情况下,基本使用的就是这个默认值?

        首先关于物理页面的大小,Linux 规定必须是 2 的整数次幂,因为 2 的整数次幂可以将一些数学运算转换为移位操作,比如乘除运算可以通过移位操作来实现,这样效率更高。

        那么系统支持 4KB,8KB,2MB,4MB 等大小的物理页面,它们都是 2 的整数次幂,为啥偏偏要选 4KB 呢?

        因为前面提到,在内存紧张的时候,内核会将不经常使用到的物理页面进行换入换出等操作,还有在内存与文件映射的场景下,都会涉及到与磁盘的交互,数据在磁盘中组织形式也是根据一个磁盘块一个磁盘块来管理的,4kB 和 4MB 都是磁盘块大小的整数倍,但在大多数情况下,内存与磁盘之间传输小块数据时会更加的高效,所以综上所述内核会采用 4KB 作为默认物理内存页大小。在Linux中,可通过getconf PAGESIZE 或者 getconf PAGE_SIZE命令来获得系统的物理页大小,单位是KB。

Android手机 adb shell进入后,执行Linux 命令
XX55FF:/ # getconf PAGESIZE
getconf PAGESIZE
4096
XX55FF:/ # getconf PAGE_SIZE
getconf PAGE_SIZE
4096

Linux服务器
User@Ubuntu-149-19:~$ getconf PAGE_SIZE
4096
User@Ubuntu-149-19:~$ getconf PAGESIZE
4096

        假设我们有 4G 大小的物理内存,每个物理内存页大小为 4K,那么这 4G 的物理内存会被内核划分为 1M 个物理内存页,内核使用一个 struct page 的结构体来描述物理内存页,而如《Linux 物理内存管理涉及的三大结构体之struct page》里面提及的:每个 struct page 结构体占用内存大小为 64 字节,那么内核就需要用额外的 64 * 1M = 64M 的内存大小来描述物理内存页

        对于 4G 物理内存而言,这额外的 64M 内存占比相对较小,这个代价勉强可以接受,但是对内存锱铢必较的内核来说,还是会尽最大努力想尽一切办法来控制 struct page 结构体的大小。

        因为对于 4G 的物理内存来说,内核就需要使用 1M 个物理页面来管理,1M 个物理页的数量已经是非常庞大的了,特别是后面手机/普通电脑物理内存达到16GB,甚至32/64GB的也有,因此在后续的内核迭代中,对于 struct page 结构的任何微小改动,都可能导致用于管理物理内存页的 struct page 实例所需要的内存暴涨,因而内核对struct page结构体的增删改查都非常的严格。在控制结构体成员变量数目的同时,也使用了联合体union来增加内存复用和对struct page->flags成员进行充分利用,从而减少整个结构体占用内存的大小,对其里面已有结构的充分利用也体现的淋漓尽致

        这里只列出struct page结构体本节要讲的常用成员变量,对于struct page结构体所有字段的详细介绍,感兴趣的同学可以看下《Linux 物理内存管理涉及的三大结构体之struct page》。

struct page {
    // 存储 page 的定位信息以及相关标志位
    unsigned long flags;        

    union {
        struct {    /* Page cache and anonymous pages */
            // 用来指向物理页 page 被放置在了哪个 lru 链表上
            struct list_head lru;
            // 如果 page 为文件页的话,低位为0,指向 page 所在的 page cache
            // 如果 page 为匿名页的话,低位为1,指向其对应虚拟地址空间的匿名映射区 anon_vma
            struct address_space *mapping;
            // 如果 page 为文件页的话,index 为 page 在 page cache 中的索引
            // 如果 page 为匿名页的话,表示匿名页在对应进程虚拟内存区域 VMA 中的偏移
            pgoff_t index;
            // 在不同场景下,private 指向的场景信息不同
            unsigned long private;
        };
        
        struct {    /* slab, slob and slub */
            union {
                // 用于指定当前 page 位于 slab 中的哪个具体管理链表上。
                struct list_head slab_list;
                struct {
                    // 当 page 位于 slab 结构中的某个管理链表上时,next 指针用于指向链表中的下一个 page
                    struct page *next;
#ifdef CONFIG_64BIT
                    // 表示 slab 中总共拥有的 page 个数
                    int pages;  
                    // 表示 slab 中拥有的特定类型的对象个数
                    int pobjects;   
#else
                    short int pages;
                    short int pobjects;
#endif
                };
            };
            // 用于指向当前 page 所属的 slab 管理结构
            struct kmem_cache *slab_cache; 
        
            // 指向 page 中的第一个未分配出去的空闲对象
            void *freelist;     
            union {
                // 指向 page 中的第一个对象
                void *s_mem;    
                struct {            /* SLUB */
                    // 表示 slab 中已经被分配出去的对象个数
                    unsigned inuse:16;
                    // slab 中所有的对象个数
                    unsigned objects:15;
                    // 当前内存页 page 被 slab 放置在 CPU 本地缓存列表中,frozen = 1,否则 frozen = 0
                    unsigned frozen:1;
                };
            };
        };
        struct {    /* 复合页 compound page 相关*/
            // 复合页的尾页指向首页
            unsigned long compound_head;    
            // 用于释放复合页的析构函数,保存在第一个tail page中
            unsigned char compound_dtor;
            // 该复合页有多少个 page 组成
            unsigned char compound_order;
            // 该复合页被多少个进程使用,内存页反向映射的概念,第一个tail page中保存
            atomic_t compound_mapcount;
        };

        // 表示 slab 中需要释放回收的对象链表
        struct rcu_head rcu_head;
    };

    union {     /* This union is 4 bytes in size. */
        // 表示该 page 映射了多少个进程的虚拟内存空间,一个 page 可以被多个进程映射
        atomic_t _mapcount;

    };

    // 内核中引用该物理页的次数,表示该物理页的活跃程度。
    atomic_t _refcount;

#if defined(WANT_PAGE_VIRTUAL)
    void *virtual;  // 内存页对应的虚拟内存地址,这个涉及到highmem概念,对于64位 linux 系统不用考虑
#endif /* WANT_PAGE_VIRTUAL */

} _struct_page_alignment;

        下面我就来为大家介绍下 struct page 结构在不同场景下的使用方式:

        第一种使用方式是内核直接分配使用一整页的物理内存,根据前面5.2小节,可知内核中的物理内存页有两种类型,分别用于不同的场景:

  1. 一种是匿名页,匿名页背后并没有一个磁盘中的文件作为数据来源,匿名页中的数据都是通过进程运行过程中产生的,匿名页直接和进程虚拟地址空间建立映射供进程使用。

  2. 另外一种是文件页文件页中的数据来自于磁盘中的文件,文件页需要先关联一个磁盘中的文件然后再和进程虚拟地址空间建立映射供进程使用,使得进程可以通过操作虚拟内存实现对文件的操作,这就是我们常说的内存文件映射。

struct page {
    // 如果 page 为文件页的话,低位为0,指向 page 所在的 page cache
    // 如果 page 为匿名页的话,低位为1,指向其对应虚拟地址空间的匿名映射区 anon_vma
    struct address_space *mapping;
    // 如果 page 为文件页的话,index 为 page 在 page cache 中的索引
    // 如果 page 为匿名页的话,表示匿名页在对应进程虚拟内存区域 VMA 中的偏移
    pgoff_t index; 
}

        我们首先来介绍下 struct page 结构中的 struct address_space *mapping 字段。在内核中每个文件都会有一个属于自己的 page cache(页高速缓存)页高速缓存在内核中的结构体就是这个 struct address_space。它被文件的 inode 所持有。

        如果当前物理内存页 struct page 是一个文件页的话,那么 mapping 指针的最低位会被设置为 0 指向该内存页关联文件的 struct address_space(页高速缓存),pgoff_t index 字段表示该内存页 page 在页高速缓存 page cache 中的 index 索引。内核会利用这个 index 字段从 page cache 中查找该物理内存页。同时该 pgoff_t index 字段也表示该内存页中的文件数据在文件内部的偏移 offset。偏移单位为 page size。

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

6.1 匿名页的反向映射

        我们通常所说的内存映射是正向映射,即从虚拟内存到物理内存的映射。而反向映射则是从物理内存到虚拟内存的映射用于当某个物理内存页需要进行回收或迁移时,此时需要去找到这个物理页被映射到了哪些进程的虚拟地址空间中,并断开它们之间的映射,这样好回收或迁移物理内存页。这个反向映射跟struct page的_mapcount还有些关系,本小节最后会讲。

        在没有反向映射的机制前,需要去遍历所有进程的虚拟地址空间中的映射页表,这个效率显然是很低下的。有了反向映射机制之后内核就可以直接找到该物理内存页到所有进程映射的虚拟地址空间 VMA ,并从 VMA 使用的进程页表中取消映射。

        关于VMA相关内容,已经在《深入理解Linux虚拟内存管理》第五章节详解介绍过了。如下图所示,进程的虚拟内存空间在内核中使用 struct mm_struct 结构表示,进程的虚拟内存空间包含了一段一段的虚拟内存区域 VMA,比如我们经常接触到的堆,栈。内核中使用 struct vm_area_struct 结构来描述这些虚拟内存区域。

        这里我只列举出 struct vm_area_struct 结构中与匿名页反向映射相关的字段属性。        

struct vm_area_struct {  

    struct list_head anon_vma_chain;
    struct anon_vma *anon_vma;   
}

        这里大家可能会感到好奇,既然内核中有了 struct vm_area_struct 结构来描述虚拟内存区域,那不管是文件页也好,还是匿名页也好,它们映射的虚拟地址所属区域都可以使用 struct vm_area_struct 结构体来进行描述,为什么还要 struct anon_vma 结构和 struct anon_vma_chain 结构?这两个结构到底是干嘛的?如何利用它俩来完成匿名内存页的反向映射呢?

        根据前几篇文章的内容我们知道,进程利用 fork 系统调用创建子进程的时候,内核会将父进程的虚拟内存空间相关的内容拷贝到子进程的虚拟内存空间中,此时子进程的虚拟内存空间和父进程的虚拟内存空间是一模一样的,其中虚拟内存空间中映射的物理内存页也是一样的,在内核中都是同一份,在父进程和子进程之间共享(包括 anon_vma 和 anon_vma_chain)。

        当进程在向内核申请内存的时候,内核首先会为进程申请的这块内存创建初始化一段虚拟内存区域 struct vm_area_struct 结构,但是并不会为其分配真正的物理内存。

        当进程开始访问这段虚拟内存时,内核会产生缺页中断在缺页中断处理函数中才会去真正的分配物理内存(这时才会为子进程创建自己的 anon_vma 和 anon_vma_chain),并建立虚拟内存与物理内存之间的映射关系(正向映射)。

//mm/memory.c
static vm_fault_t handle_pte_fault(struct vm_fault *vmf)
{
        .............

	if (!vmf->pte) {
		if (vma_is_anonymous(vmf->vma))
            // 处理匿名页缺页
			return do_anonymous_page(vmf);
		else
            // 处理文件页缺页
			return do_fault(vmf);
	}

        .............

	if (vmf->flags & FAULT_FLAG_WRITE) {
		if (!pte_write(entry))
            // 子进程缺页处理
			return do_wp_page(vmf);
	}

        这里我们主要关注 do_anonymous_page 函数,正是在这里内核完成了 struct anon_vma 结构和 struct anon_vma_chain 结构的创建以及相关匿名页反向映射数据结构的相互关联

//mm/memory.c
static vm_fault_t do_anonymous_page(struct vm_fault *vmf)
{
	struct vm_area_struct *vma = vmf->vma;
	struct page *page;	

        ........省略虚拟内存到物理内存正向映射相关逻辑.........

	if (unlikely(anon_vma_prepare(vma)))
		goto oom;

	page = alloc_zeroed_user_highpage_movable(vma, vmf->address);

	if (!page)
		goto oom;
  // 建立反向映射关系
	__page_add_new_anon_rmap(page, vma, vmf->address, false);

        ........省略虚拟内存到物理内存正向映射相关逻辑.........
}

        在 do_anonymous_page 匿名页缺页处理函数中会为 struct vm_area_struct 结构创建匿名页相关的 struct anon_vma 结构和 struct anon_vma_chain 结构

        并在 anon_vma_prepare 函数中实现 anon_vma 和 anon_vma_chain 之间的关联 ,随后调用 alloc_zeroed_user_highpage_movable 从伙伴系统中获取物理内存页 struct page,并在 __page_add_new_anon_rmap 函数中完成 struct page 与 anon_vma 的关联(这里是反向映射关系建立的关键)。

        在介绍匿名页反向映射源码实现之前,我先来为大家介绍一下相关的两个重要数据结构 struct anon_vma 和 struct anon_vma_chain,方便大家理解为何 struct page 与 anon_vma 关联起来就能实现反向映射?

        前面我们提到,匿名页的反向映射关键就是建立物理内存页 struct page 与进程虚拟内存空间区域 VMA 之间的映射关系。(1)匿名页的 struct page 中的 mapping 指针指向的是 struct anon_vma 结构。

struct page {
    struct address_space *mapping; 
    pgoff_t index;  
}

        只要我们实现了 anon_vma 与 vm_area_struct 之间的关联,那么 page 到 vm_area_struct 之间的映射就建立起来了,(2)struct anon_vma_chain 结构做的事情就是建立 anon_vma 与 vm_area_struct 之间的关联关系。

struct anon_vma_chain {
    // 匿名页关联的进程虚拟内存空间区域(vma属于一个特定的进程,多个进程多个vma)
    struct vm_area_struct *vma;
    // 匿名页 page 指向的 anon_vma
    struct anon_vma *anon_vma;
    struct list_head same_vma;   
    struct rb_node rb;         
    unsigned long rb_subtree_last;
#ifdef CONFIG_DEBUG_VM_RB
    unsigned long cached_vma_start, cached_vma_last;
#endif
};

        struct anon_vma_chain 结构通过其中的 vma 指针和 anon_vma 指针将相关的匿名页与其映射的进程虚拟内存空间区域关联了起来。

        从目前来看匿名页 struct page 算是与 anon_vma 建立了关系,又通过 anon_vma_chain 将 anon_vma 与 vm_area_struct 建立了关系。那么就剩下最后一道关系需要打通了,就是如何通过 anon_vma 找到 anon_vma_chain 进而找到 vm_area_struct 呢?这就需要我们将 anon_vma 与 anon_vma_chain 之间的关系也打通。

        我们知道每个匿名页对应唯一的 anon_vma 结构,但是一个匿名物理页可以映射到不同进程的虚拟内存空间中,每个进程的虚拟内存空间都是独立的,也就是说不同的进程就会有不同的 VMA,但对应的匿名页是唯一的。

        不同的 VMA 意味着同一个匿名页 anon_vma 就会对应多个 anon_vma_chain。那么如何通过一个 anon_vma 找到和他关联的所有 anon_vma_chain 呢?找到了这些 anon_vma_chain 也就意味着 struct page 找到了与它关联的所有进程虚拟内存空间 VMA。

        我们看看能不能从 struct anon_vma 结构中寻找一下线索:

struct anon_vma {
    struct anon_vma *root;      /* Root of this anon_vma tree */
    struct rw_semaphore rwsem; 
    atomic_t refcount;
    unsigned degree;
    struct anon_vma *parent;    /* Parent of this anon_vma */
    struct rb_root rb_root; /* Interval tree of private "related" vmas */
};

        我们重点来看 struct anon_vma 结构中的 rb_root 字段,struct anon_vma 结构中管理了一颗红黑树,这颗红黑树上管理的全部都是与该 anon_vma 关联的 anon_vma_chain。我们可以通过 struct page 中的 mapping 指针找到 anon_vma,然后(3)遍历 anon_vma 中的这颗红黑树 rb_root ,从而找到与其关联的所有 anon_vma_chain。

struct anon_vma_chain {
    // 匿名页关联的进程虚拟内存空间(vma属于一个特定的进程,多个进程多个vma)
    struct vm_area_struct *vma;
    // 匿名页 page 指向的 anon_vma
    struct anon_vma *anon_vma;
    // 指向 vm_area_struct 中的 anon_vma_chain 链表
    struct list_head same_vma;   
    // anon_vma 管理的红黑树中该 anon_vma_chain 对应的红黑树节点
    struct rb_node rb;         
};

        struct anon_vma_chain 结构中的 rb 字段表示其在对应 anon_vma 管理的红黑树中的节点。

        到目前为止,物理内存页 page 到与其映射的进程虚拟内存空间 VMA,这样一种一对多的映射关系现在就算建立起来了。

        而 vm_area_struct 表示的只是进程虚拟内存空间中的一段虚拟内存区域,这块虚拟内存区域中可能会包含多个匿名页所以 VMA 与物理内存页 page 也是有一对多的映射关系存在而这个映射关系在哪里保存呢?

        大家可能注意到前面的 struct anon_vma_chain 结构中还有一个链表结构 same_vma,其指向 vm_area_struct 中的 anon_vma_chain 链表,从这个名字上我们很容易就能猜到这个链表 same_vma 中存储的 anon_vma_chain 对应的 VMA 全都是一样的,而列表元素 anon_vma_chain 中的 anon_vma 却是不一样的。内核用一个链表结构 same_vma 存储了进程相应虚拟内存区域 VMA 中所包含的所有匿名页。所以,这个VMA跟物理内存页page的一对多的映射关系就保存在same_vma里面

        struct vm_area_struct 结构中的 struct list_head anon_vma_chain 指向的也是这个链表 same_vma。

struct vm_area_struct {  
    // 存储该 VMA 中所包含的所有匿名页 anon_vma
    struct list_head anon_vma_chain;
    // 用于快速判断 VMA 有没有对应的匿名 page
    // 一个 VMA 可以包含多个 page,但是该区域内的所有 page 只需要一个 anon_vma 来反向映射即可。
    struct anon_vma *anon_vma;   
}

        现在整个匿名页到进程虚拟内存空间的反向映射链路关系,我就为大家梳理清楚了,下面我们接着回到 do_anonymous_page 函数中,来逐一验证上述映射逻辑

static vm_fault_t do_anonymous_page(struct vm_fault *vmf)
{
	struct vm_area_struct *vma = vmf->vma;
	struct page *page;	

        ........省略虚拟内存到物理内存正向映射相关逻辑.........

	if (unlikely(anon_vma_prepare(vma)))
		goto oom;

	page = alloc_zeroed_user_highpage_movable(vma, vmf->address);

	if (!page)
		goto oom;
  // 建立反向映射关系
	__page_add_new_anon_rmap(page, vma, vmf->address, false);

        ........省略虚拟内存到物理内存正向映射相关逻辑.........
}

        在 do_anonymous_page 中首先会调用 anon_vma_prepare 方法来为匿名页创建 anon_vma 实例和 anon_vma_chain 实例,并建立它们之间的关联关系。

static inline int anon_vma_prepare(struct vm_area_struct *vma)
{
	//如果该虚拟内存区域有对应的匿名页了,说明已经创建anon_vma,说明已经正向映射了,
	//那么就不用再创建anon_vam和anon_vma_chain,直接返回0
	if (likely(vma->anon_vma))
		return 0;
	
	return __anon_vma_prepare(vma);
}

int __anon_vma_prepare(struct vm_area_struct *vma)
{
    // 获取进程虚拟内存空间
	struct mm_struct *mm = vma->vm_mm;
    // 准备为匿名页分配 anon_vma 以及 anon_vma_chain
	struct anon_vma *anon_vma, *allocated;
	struct anon_vma_chain *avc;
    // 分配 anon_vma_chain 实例
	avc = anon_vma_chain_alloc(GFP_KERNEL);
	if (!avc)
		goto out_enomem;
    // 在相邻的虚拟内存区域 VMA 中查找可复用的 anon_vma
	anon_vma = find_mergeable_anon_vma(vma);
	allocated = NULL;
	if (!anon_vma) {
        // 没有可复用的 anon_vma 则创建一个新的实例
		anon_vma = anon_vma_alloc();
		if (unlikely(!anon_vma))
			goto out_enomem_free_avc;
		allocated = anon_vma;
	}

	anon_vma_lock_write(anon_vma);
	/* page_table_lock to protect against threads */
	spin_lock(&mm->page_table_lock);
	if (likely(!vma->anon_vma)) {
        // VMA 中的 anon_vma 属性就是在这里赋值的
		vma->anon_vma = anon_vma;
        // 建立反向映射关联 重点这里!!!
		anon_vma_chain_link(vma, avc, anon_vma);
		/* vma reference or self-parent link for new root */
		anon_vma->degree++;
		allocated = NULL;
		avc = NULL;
	}
        .................
}

        anon_vma_prepare 方法中调用 anon_vma_chain_link 方法来建立 anon_vma,anon_vma_chain,vm_area_struct 三者之间的关联关系:

static void anon_vma_chain_link(struct vm_area_struct *vma,
				struct anon_vma_chain *avc,
				struct anon_vma *anon_vma)
{
    // 通过 anon_vma_chain 关联 anon_vma 和对应的 vm_area_struct
	avc->vma = vma;
	avc->anon_vma = anon_vma;
    // 将 vm_area_struct 中的 anon_vma_chain 链表加入到 anon_vma_chain 中的 same_vma 链表中
	list_add(&avc->same_vma, &vma->anon_vma_chain);
    // 将初始化好的 anon_vma_chain 加入到 anon_vma 管理的红黑树 rb_root 中
	anon_vma_interval_tree_insert(avc, &anon_vma->rb_root);
}

        到现在为止还缺关键的最后一步,就是打通匿名内存页 page 到 vm_area_struct 之间的关系,首先我们需要调用 alloc_zeroed_user_highpage_movable 函数从伙伴系统中申请一个匿名页。当获取到 page 实例之后,通过 __page_add_new_anon_rmap 最终建立起 page 到 vm_area_struct 的整条反向映射链路

//include/linux/page-flags.h
#define PAGE_MAPPING_ANON	0x1 //低位置1
#define PAGE_MAPPING_MOVABLE	0x2
#define PAGE_MAPPING_KSM	(PAGE_MAPPING_ANON | PAGE_MAPPING_MOVABLE)
#define PAGE_MAPPING_FLAGS	(PAGE_MAPPING_ANON | PAGE_MAPPING_MOVABLE)

//mm/rmap.c 
void __page_add_new_anon_rmap(struct page *page,
	struct vm_area_struct *vma, unsigned long address, bool compound)
{
	int nr = compound ? thp_nr_pages(page) : 1;

	__SetPageSwapBacked(page);
	......
	//关键函数
	__page_set_anon_rmap(page, vma, address, 1);
}

static void __page_set_anon_rmap(struct page *page,
    struct vm_area_struct *vma, unsigned long address, int exclusive)
{
    struct anon_vma *anon_vma = vma->anon_vma;
           .........省略..............
    // 低位置 1
    anon_vma = (void *) anon_vma + PAGE_MAPPING_ANON;//PAGE_MAPPING_ANON宏定义值为 0X1
    // 转换为 address_space 指针赋值给 page 结构中的 mapping 字段
    page->mapping = (struct address_space *) anon_vma;
    // page 结构中的 index 表示该匿名页在虚拟内存区域 vma 中的偏移
    page->index = linear_page_index(vma, address);
}

        现在我们再次回到本小节的开始,再来看这段话,是不是感到非常清晰。

如果当前物理内存页 struct page 是一个匿名页的话,那么 mapping 指针的最低位会被设置为 1 , 指向该匿名页在进程虚拟内存空间中的匿名映射区域 struct anon_vma 结构(每个匿名页对应唯一的 anon_vma 结构,注意:虽然一个匿名物理页可以映射到不同进程的虚拟内存空间中即不同VMA,但是anon_vma就只有一个,只是此时每个VMA都有该anon_vma对应的anon_vma_chain),用于物理内存到虚拟内存的反向映射。

如果当前物理内存页 struct page 是一个文件页的话,那么 mapping 指针的最低位会被设置为 0 ,指向该内存页关联文件的 struct address_space(页高速缓存)。pgoff_t index 字段表示该内存页 page 在页高速缓存中的 index 索引,也表示该内存页中的文件数据在文件内部的偏移 offset。偏移单位为 page size。

        至于struct page 结构中的 struct address_space *mapping 指针的最低位如何置 1 ,又如何置 0 呢?关键在下面这条语句。anon_vma 指针加上 PAGE_MAPPING_ANON ,并转换为 address_space 指针,这样可确保 address_space 指针的低位为 1 表示匿名页。

    // 低位置 1
    anon_vma = (void *) anon_vma + PAGE_MAPPING_ANON;//PAGE_MAPPING_ANON宏定义值为 0X1
    // 转换为 address_space 指针赋值给 page 结构中的 mapping 字段
    page->mapping = (struct address_space *) anon_vma;

        address_space 指针在转换为 anon_vma 指针的时候可通过如下语句实现:

anon_vma = (struct anon_vma *) (mapping - PAGE_MAPPING_ANON)

        而对于文件页来说,page 结构的 mapping 指针最低位本来就是 0 ,因为 address_space 类型的指针实现总是对齐至 sizeof(long),因此在 Linux 支持的所有计算机上,指向 address_space 实例的指针最低位总是为 0 。

        因而,内核可以通过这个技巧直接检查 page 结构中的 mapping 指针的最低位来判断该物理内存页到底是匿名页还是文件页。

        前面说了文件页的 page 结构的 index 属性表示该内存页 page 在磁盘文件中的偏移 offset ,偏移单位为 page size 。

        那匿名页的 struct page 结构中的 index 属性表示什么呢?我们接着来看 linear_page_index 函数。逻辑很简单,就是表示匿名页在对应进程虚拟内存区域 VMA 中的偏移。注意:如果该页属于per_cpu_page,那么无论匿名页和文件页此时这个index保存的是页迁移类型migratetype(见set_pcppage_migratetype()函数。

static inline pgoff_t linear_page_index(struct vm_area_struct *vma,
					unsigned long address)
{
	pgoff_t pgoff;
	if (unlikely(is_vm_hugetlb_page(vma)))
		return linear_hugepage_index(vma, address);
	pgoff = (address - READ_ONCE(vma->vm_start)) >> PAGE_SHIFT;
	pgoff += READ_ONCE(vma->vm_pgoff);
	return pgoff;
}

        在本小节最后,还有一个与反向映射相关的重要属性就是 page 结构中的 _mapcount。

struct page {
    struct address_space *mapping; 
    pgoff_t index;  
    // 表示该 page 映射了多少个进程的虚拟内存空间,一个 page 可以被多个进程映射
    atomic_t _mapcount
}

        经过本小节前面详细的介绍,我想_mapcount 字段的含义很明显了。我们前面知道一个物理内存页可以映射到多个进程的虚拟内存空间中,比如:共享内存映射,父子进程的创建等。page 与 VMA 是一对多的关系,这里的 _mapcount 就表示该物理页映射到了多少个进程的虚拟内存空间中。而反向映射中,通过这个参数我们就可以知道该物理页被映射到了多少个进程里面,那么在内存回收和迁移时,就需要跟这些进程虚拟内存空间一一切断映射,才能回收和迁移成功,关于该参数更详细内容,感兴趣可看《Linux 物理内存管理涉及的三大结构体之struct page》的2.3节。

6.2 内存页回收相关属性

        我们继续介绍struct page中的其他参数,前面我们已经知道了物理页分为匿名页和文件页。这里我再提两个重要的链表:LRU active和LRU inactive链表,这个跟物理内存页回收相关。其中 active 链表用来存放访问非常频繁的内存页(热页), inactive 链表用来存放访问不怎么频繁的内存页(冷页),当内存紧张的时候,内核就会优先将 inactive 链表中的内存页置换出去。

        内核在回收内存的时候,这两个列表中的回收优先级为:inactive 链表尾部 > inactive 链表头部 > active 链表尾部 > active 链表头部。

        我们可以通过cat /proc/zoneinfo查看不用NUMA节点中不同内存区域中的active 链表和 inactive 链表中物理内存页的个数。

        其中,nr_active_anon 和 nr_inactive_anon 分别是该NUMA节点内活跃和非活跃的匿名页数量。nr_active_file 和 nr_inactive_file 分别是该NUMA节点内活跃和非活跃的文件页数量。

nr_zone_active_anon 和 nr_zone_inactive_anon 分别是该内存区域内活跃和非活跃的匿名页数量。nr_zone_active_file 和 nr_zone_inactive_file 分别是该内存区域内活跃和非活跃的文件页数量。

        从下面zoneinfo可以发现,如果一个NUMA节点内物理内存区域有多个,那么节点的值是所有物理内存区域对应值之和。如果节点只有一个物理内存区域,那么是相等。注意:kernel-4.8之后,关于LRU链表的管理从zone上移到NUMA node了,在《Linux 物理内存管理涉及的三大结构体之struct pglist_data》的2.23有讲。

Node 0, zone      DMA
  per-node stats
      nr_inactive_anon 87787
      nr_active_anon 131343
      nr_inactive_file 4940567
      nr_active_file 3924819
      ......
  pages free     3949
        min      126
        low      157
        high     188
      ......
      nr_free_pages 3949
      nr_zone_inactive_anon 0
      nr_zone_active_anon 0
      nr_zone_inactive_file 0
      nr_zone_active_file 0
      ......
Node 0, zone   DMA32
  pages free     79853
        min      12995
        low      16243
        high     19491
      ......
      nr_free_pages 79853
      nr_zone_inactive_anon 2612
      nr_zone_active_anon 22602
      nr_zone_inactive_file 161381
      nr_zone_active_file 31110
      ......
Node 0, zone   Normal
  pages free     3953716
        min      509331
        low      636663
        high     763995
      ......
      nr_free_pages 3953716
      nr_zone_inactive_anon 85175
      nr_zone_active_anon 108741
      nr_zone_inactive_file 4779186
      nr_zone_active_file 3893709

Node 1, zone   Normal
  per-node stats
      nr_inactive_anon 36150
      nr_active_anon 42170
      nr_inactive_file 5507744
      nr_active_file 4180727
      ......
  pages free     5216084
        min      526122
        low      657652
        high     789182
      ......
      nr_free_pages 5216084
      nr_zone_inactive_anon 36150
      nr_zone_active_anon 42170
      nr_zone_inactive_file 5507744
      nr_zone_active_file 4180727
      ......

        那为什么会有 active 链表和 inactive 链表?

        内存回收的关键是如何实现一个高效的页面替换算法 PFRA (Page Frame Replacement Algorithm) ,提到页面替换算法大家可能立马会想到 LRU (Least-Recently-Used) 算法LRU 算法的核心思想就是那些最近最少使用的页面,在未来的一段时间内可能也不会再次被使用,所以在内存紧张的时候,会优先将这些最近最少使用的页面置换出去。在这种情况下其实一个 active 链表就可以满足我们的需求。

        但是这里会有一个严重的问题,LRU 算法更多的是在时间维度上的考量,突出最近最少使用,但是它并没有考量到使用频率的影响,假设有这样一种状况,就是一个页面被疯狂频繁的使用,毫无疑问它肯定是一个热页,但是这个页面最近的一次访问时间离现在稍微久了一点点,此时进来大量的页面,这些页面的特点是只会使用一两次,以后将再也不会用到。

        在这种情况下,根据 LRU 的语义这个之前频繁地被疯狂访问的页面就会被置换出去了(本来应该将这些大量一次性访问的页面置换出去的),当这个页面在不久之后要被访问时,此时已经不在内存中了,还需要在重新置换进来,造成性能的损耗。这种现象也叫 Page Thrashing(页面颠簸)。

         因此,内核为了将页面使用频率这个重要的考量因素加入进来,于是就引入了 active 链表和 inactive 链表工作原理如下:

  1. 首先 inactive 链表的尾部存放的是访问频率最低并且最少访问的页面,在内存紧张的时候,这些页面被置换出去的优先级是最大的。

  2. 对于文件页来说,当它被第一次读取的时候,内核会将它放置在 inactive 链表的头部,如果它继续被访问,则会提升至 active 链表的尾部。如果它没有继续被访问,则会随着新文件页的进入,内核会将它慢慢的推到 inactive 链表的尾部如果此时再次被访问则会直接被提升到 active 链表的头部。大家可以看出此时页面的使用频率这个因素已经被考量了进来。

  3. 对于匿名页来说,当它被第一次读取的时候,内核会直接将它放置在 active 链表的尾部,注意不是 inactive 链表的头部,这里和文件页不同。因为匿名页的换出 Swap Out 成本会更大,内核会对匿名页更加优待。当匿名页再次被访问的时候就会被被提升到 active 链表的头部。

  4. 当遇到内存紧张的情况需要换页时,内核会从 active 链表的尾部开始扫描,将一定量的页面降级到 inactive 链表头部,这样一来原来位于 inactive 链表尾部的页面就会被置换出去。

        为什么会把 active 链表和 inactive 链表分成两类,一类是匿名页,一类是文件页

        在本文 5.2小节中,介绍了一个叫做 swappiness 的内核参数, 我们可以通过 cat /proc/sys/vm/swappiness 命令查看,swappiness 选项的取值范围为 0 到 100,老内核版本,最新的内核版本现在是0-200了,手机默认是100,Linux服务器是5。

        原因:原生的0-100还是老的kernel限定,当时zram还没普遍用起来,swap只能靠交换到硬盘里,开销很高,所以主要还是回收文件页。现在有zram,回收匿名页开销低了很多,这个限制也早就改到0-200了。实际上在0-100的时期,最后计算也是按200来算的,只是那时候swap开销比较大,所以回收匿名页最大也就只允许占一半比例。

[PATCH 03/14] mm: allow swappiness that prefers reclaiming anon over the file workingset - Johannes Weiner

        swappiness 用于表示 Swap 机制的积极程度,数值越大,Swap 的积极程度,越高越倾向于回收匿名页。数值越小,Swap 的积极程度越低,越倾向于回收文件页。之所以引入swappiness来控制内核回收的倾向,是因为回收匿名页和回收文件页的代价是不一样的,回收匿名页代价会更高一点,从匿名页和文件页的定义也可以看这个代价注意: swappiness 只是表示 Swap 积极的程度,当内存非常紧张的时候,即使将 swappiness 设置为 0 ,也还是会发生 Swap 的。

        现在回来继续这个问题。

        假设我们现在只有 active 链表和 inactive 链表,不对这两个链表进行匿名页和文件页的归类,在需要页面置换的时候,内核会先从 active 链表尾部开始扫描,当 swappiness 被设置为 0 时,内核只会置换文件页,不会置换匿名页,注意:只有当置换文件页仍然不满足时,才会如前面注意说的,进行swap。

        由于 active 链表和 inactive 链表没有进行物理页面类型的归类,所以链表中既会有匿名页也会有文件页,如果链表中有大量的匿名页的话,内核就会不断的跳过这些匿名页去寻找文件页,并将文件页替换出去,这样从性能上来说肯定是低效的。

        因此内核将 active 链表和 inactive 链表按照匿名页和文件页进行了归类,当 swappiness 被设置为 0 时,内核只需要去 LRU_ACTIVE_FILE 和 LRU_INACTIVE_FILE 链表中扫描即可,提升了性能。内核中定义的5种LRU链表如下。

//include/linux/mmzone.h
/*
 * We do arithmetic on the LRU lists in various places in the code,
 * so it is important to keep the active lists LRU_ACTIVE higher in
 * the array than the corresponding inactive lists, and to keep
 * the *_FILE lists LRU_FILE higher than the corresponding _ANON lists.
 *
 * This has to be kept in sync with the statistics in zone_stat_item
 * above and the descriptions in vmstat_text in mm/vmstat.c
 */
#define LRU_BASE 0
#define LRU_ACTIVE 1
#define LRU_FILE 2

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
};

        除了以上介绍的四种 LRU 链表(匿名页的 active 链表,inactive 链表和文件页的active 链表, inactive 链表)之外,内核还有一种LRU链表,比如进程可以通过 mlock() 等系统调用把内存页锁定在内存里,保证该内存页无论如何不会被置换出去(不能page out/swap out),比如出于安全或者性能的考虑,页面中可能会包含一些敏感的信息不想被置换到磁盘上导致泄密,或者一些频繁访问的内存页必须一直贮存在内存中。包括VM_LOCKED的内存页、SHM_LOCK的共享内存页(又被统计在”Mlocked”中)和ramfs。

        当这些被锁定在内存中的页面很多时,内核在扫描 active 链表的时候也不得不跳过这些页面,所以内核又将这些被锁定的页面单独拎出来放在一个独立的链表中。

        现在五种用于存放 page 的LRU链表已经介绍完了,内核会根据不同的情况将一个物理页放置在这五种链表其中一个上。那么对于物理页的 struct page 结构中就需要有一个属性用来标识该物理页究竟被内核放置在哪个链表上。

struct page {
   struct list_head lru;
   atomic_t _refcount;
}

        struct list_head lru 属性就是用来指向物理页被放置在了哪个链表上。

        atomic_t _refcount 属性用来记录内核中引用该物理页的次数,表示该物理页的活跃程度。

6.3 物理内存页属性和状态的标志位 flag

        在本文 《2.3 SPARSEMEM 稀疏内存模型》 小节中,我们知道,内核为了能够更灵活地管理粒度更小的连续物理内存,于是就此引入了 SPARSEMEM 稀疏内存模型。

        SPARSEMEM 稀疏内存模型的核心思想就是提供对粒度更小的连续内存块进行精细的管理,用于管理连续内存块的单元被称作 section 。内核中用于描述 section 的数据结构是 struct mem_section。

        由于 section 被用作管理小粒度的连续内存块,这些小的连续物理内存在 section 中也是通过数组的方式被组织管理(图中 struct page 类型的数组)。

        每个 struct mem_section 结构体中有一个 section_mem_map 指针用于指向连续内存的 page 数组。而所有的 mem_section 也会被存放在一个全局的数组 mem_section 中。

        那么给定一个具体的 struct page,在稀疏内存模型中内核如何定位到这个物理内存页到底属于哪个 mem_section 呢 ?这是第一个问题~~

        在《五、内核如何管理 NUMA 节点中的物理内存区域》小节中讲到了内存的架构,在 NUMA 架构下,物理内存被划分成了一个一个的内存节点(NUMA 节点),在每个 NUMA 节点内部又将其所管理的物理内存按照功能不同划分成了不同的内存区域 zone,每个内存区域管理一片用于特定具体功能的物理内存 page。物理内存在内核中管理的层级关系为:None -> Zone -> page。

        那么在 NUMA 架构下,给定一个具体的 struct page,内核又该如何确定该物理内存页究竟属于哪个 NUMA 节点,属于哪块内存区域 zone 呢这是第二个问题。

        关于以上我提出的两个问题所需要的定位信息全部存储在 struct page 结构中的 flags 字段中。前边我们提到,struct page 是 Linux 世界里最繁华的地段,这里的地价非常昂贵,所以 page 结构中这些字段里的每一个比特,内核都会物尽其用。

struct page {
    unsigned long flags;
} 

        因此这个 unsigned long 类型的 flags 字段,这64比特长度中不仅包含上面提到的定位信息还会包括物理内存页的一些属性和标志位。这些section,node,zone,还有last_cpuid定位信息在flags中具体占用的宽度以及标志位信息,均在《Linux 物理内存管理涉及的三大结构体之struct page》的2.1节有详细的介绍,感兴趣的可以看下。注意:所有定位信息长度肯定不止只有8位。

        另外在很多情况下,内核通常需要等待物理页 page 的某个状态改变,才能继续恢复工作,内核提供了如下两个辅助函数,来实现在特定状态的阻塞等待:

static inline void wait_on_page_locked(struct page *page)
static inline void wait_on_page_writeback(struct page *page)

        当物理页面在锁定的状态下,进程调用了 wait_on_page_locked 函数,那么进程就会阻塞等待直到页面解锁。

        当物理页面正在被内核回写到磁盘的过程中,进程调用了 wait_on_page_writeback 函数就会进入阻塞状态直到脏页数据被回写到磁盘之后被唤醒。

6.4 复合页 compound_page 相关属性

        我们知道 Linux 管理内存的最小单位是 page,通常每个 page 描述 4K 大小的物理内存,但在一些对于内存敏感的使用场景中用户往往期望使用一些大页大页定义:通过两个或者多个物理上连续的内存页 page 组装成的一个比普通内存页 page 更大的页。

        因为大页要比普通的 4K 内存页要大很多,一次物理内存分配顶的上普通页的多次分配,所以在分配内存遇到缺页中断的情况就会相对减少,由于减少了缺页中断,当然性能会更高。

        另外,由于大页比普通页要大,所以大页页需要的页表项要比普通页要少,页表项里保存了虚拟内存地址与物理内存地址的映射关系,当 CPU 访问内存的时候需要频繁通过 MMU 访问页表项获取物理内存地址,由于要频繁访问,所以页表项一般会缓存在 TLB 中,因为大页需要的页表项较少,所以节约了 TLB 的空间同时降低了 TLB 缓存 MISS 的概率,从而加速了内存访问。

        还有一个使用大页受益场景就是,当一个内存占用很大的进程(比如 Redis)通过 fork 系统调用创建子进程的时候,会拷贝父进程的相关资源,其中就包括父进程的页表,由于大页页使用的页表项少,所以拷贝的时候性能会提升不少。

        以上就是大页存在的原因以及使用的场景,但在 Linux 内存管理架构中都是统一通过 struct page 来管理内存,而大页却是通过两个或者多个物理上连续的内存页 page 组装成的一个比普通内存页 page 更大的页,那么大页的管理与普通页的管理如何统一呢?

        这就引出了本小节的主题——复合页 compound_page,下面就来看下 Linux 如何通过统一的 struct page 结构来描述这些复合页(compound_page):

        虽然复合页(compound_page)是由多个物理上连续的普通 page 组成的,但是在内核的视角里它还是被当做一个特殊内存页来看待。

        下图所示,是由 4 个连续的普通内存页 page 组成的一个 compound_page:

        组成复合页的第一个 page 我们称之为首页(Head Page),其余的均称之为尾页(Tail Page)。

        我们来看一下 struct page 中用的相对多的关于描述 compound_page 的相关字段。更多详细信息,感兴趣的可以看《Linux 物理内存管理涉及的三大结构体之struct page》里面2.2.4和2.2.5的内容。

        首页对应的 struct page 结构里的 flags 会被设置为 PG_head,表示这是复合页的第一页。同时复合页中的所有尾页都会通过其对应的 struct page 结构中的 compound_head 指向首页,首页这个字段为0,这样通过首页和尾页就组装成了一个完整的复合页 compound_page 。

struct page {      
      // 首页 page 中的 flags 会被设置为 PG_head 表示复合页的第一页
      unsigned long flags;	
      // 其余尾页会通过该字段指向首页
      unsigned long compound_head;   
      // 用于释放复合页的析构函数,保存在第一个tail page中
      unsigned char compound_dtor;
      // 该复合页有多少个 page 组成,order 还是分配阶的概念,第一个tail page中保存
      // 本例中的 order = 2 表示由 4 个普通页组成
      unsigned char compound_order;
      // 该复合页被多少个进程使用,内存页反向映射的概念,第一个tail page中保存
      atomic_t compound_mapcount;
}

6.5 Slab 对象池相关属性

        本小节只对slab进行一个简单的介绍。更多详细的内容可看我写的slab系列文章:《Linux 物理内存管理涉及的三大结构体之struct page》《slub allocator工作原理》《SLUB内存管理之slub初始化》《SLUB内存管理的4个主要接口函数介绍(1)》《SLUB内存管理的4个主要接口函数介绍(2)》《SLUB内存管理的4个主要接口函数介绍(3)》《SLUB内存管理的4个主要接口函数介绍(4)》。

        内核中对物理内存页的分配使用有两种方式,一种是一页一页的分配使用,这种以页为单位的分配方式内核会向相应内存区域 zone 里的伙伴系统申请以及释放

        另一种方式就是只分配小块的内存,不需要一下分配一页的内存,比如前边章节中提到的 struct page ,anon_vma_chain ,anon_vma ,vm_area_struct 结构实例的分配,这些结构通常就是几十个字节大小,并不需要按页来分配。

        为了满足类似这种小内存分配的需要,Linux 内核使用 slab allocator 分配器来分配,slab 就好比一个对象池,内核中的数据结构对象都对应于一个 slab 对象池,用于分配这些固定类型对象所需要的内存。

        注意:slub是slab中的一种,slob也是slab中的一种。有时候用slab来统称slab, slub和slob。slab, slub和slob仅仅是分配内存策略不同,内存管理基本一致。kernel现在默认是使用按照slub方式进行分配。

        它的基本原理是从伙伴系统中申请一整页内存,然后划分成多个大小相等的小块内存被 slab 所管理。这样一来 slab 就和物理内存页 page 发生了关联,由于 slab 管理的单元是物理内存页 page 内进一步划分出来的小块内存,所以当 page 被分配给相应 slab 结构之后,struct page 里也会存放 slab 相关的一些管理数据。

        关于struct page中跟slab相关的数据在《Linux 物理内存管理涉及的三大结构体之struct page》的2.2.3节有详细介绍,这里不做多的赘述,感兴趣的可以去看下。

总结

        到这里,关于 Linux 物理内存管理的相关内容就为大家介绍完了,本文内容比较多,尤其是物理内存页反向映射相关的内容比较复杂,涉及到的关联关系比较多,现在再带大家总结一下本文的主要内容,方便大家复习回顾:

        在本文的开始,我首先从 CPU 角度为大家介绍了三种物理内存模型:FLATMEM 平坦内存模型,DISCONTIGMEM 非连续内存模型,SPARSEMEM 稀疏内存模型。

        随后我又接着介绍了两种物理内存架构:一致性内存访问 UMA 架构,非一致性内存访问 NUMA 架构。

        在这个基础之上,又按照内核对物理内存的组织管理层次,分别介绍了 Node 节点,物理内存区域 zone ,物理内存页page等相关内核结构。它们的层次如下图所示:

        在把握了物理内存的总体架构之后,又引出了众多物理内存区域zone细节性的内容,比如:物理内存区域的管理与划分,物理内存区域中的预留内存,物理内存区域中的水位线及其计算方式,物理内存区域中的冷热页。

        最后,我详细介绍了内核如何通过 struct page 结构来描述物理内存页,比如匿名页的反向映射,内存页回收的相关属性,跟物理内存页属性和状态相关的flags,最后是复合页和slab的属性介绍,其中匿名页反向映射的内容比较复杂,需要大家多多梳理回顾一下。

参考资料

深入理解Linux虚拟内存管理

一步一图带你深入理解 Linux 物理内存管理

一文掌握 Linux 内存管理

Linux内存模型

ACPI: Support Generic Initiator proximity domains

Linux 物理内存管理涉及的三大结构体之struct pglist_data

Linux 物理内存管理涉及的三大结构体之struct zone

Linux 物理内存管理涉及的三大结构体之struct page

  • 2
    点赞
  • 11
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
Linux虚拟内存管理Linux操作系统中的一个重要组成部分,它允许操作系统和应用程序将物理内存和硬盘空间结合使用,从而扩展可用内存的大小。光盘则是一种存储介质,它使用激光技术读取数据。 在 Linux虚拟内存管理中,操作系统将物理内存划分为一系列固定大小的页框,通常为4KB。当应用程序需要访问数据时,操作系统会将相应的页框映射到该进程的虚拟地址空间中。如果所需的页面不在内存中,操作系统将使用页面置换算法选择一个较长时间未使用的页面进行替换,并将修改过的页面写回硬盘。 而光盘的读取与虚拟内存管理类似。当需要读取光盘上的数据时,光学驱动器通过激光技术读取数据,然后将其存储到物理内存中的特定位置。类似地,虚拟内存管理也能将硬盘上的数据加载到物理内存中。不同的是,光盘是一种非易失性存储介质,而虚拟内存则是为了扩展可用内存大小而使用的一种机制。 深入理解 Linux虚拟内存管理意味着了解虚拟内存管理的原理、数据结构和算法。这包括页表、虚拟内存地址转换、页面置换算法(如LRU或Clock算法)、写回脏页面等。掌握这些知识可以帮助我们更好地理解操作系统如何管理内存,提高系统的性能和效率。 总结而言,深入理解 Linux虚拟内存管理是理解操作系统中的重要概念之一。而光盘则是一种存储介质,与虚拟内存管理类似,但用途不同。通过深入学习虚拟内存管理,我们可以更好地理解操作系统如何管理内存,提高系统性能和效率。

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值