# 4.7 深入理解 Linux 物理内存管理

作者:公众号@bin的技术小屋

来源:一步一图带你深入理解 Linux 物理内存管理 (opens new window)

# 1. 前文回顾

在上篇文章 《深入理解 Linux 虚拟内存管理》 (opens new window) 中,我分别从进程用户态和内核态的角度详细深入地为大家介绍了 Linux 内核如何对进程虚拟内存空间进行布局以及管理的相关实现。在我们深入理解了虚拟内存之后,那么何不顺带着也探秘一下物理内存的管理呢?

所以本文的目的是在深入理解虚拟内存管理的基础之上继续带大家向前奋进,一举击破物理内存管理的知识盲区,使大家能够俯瞰整个 Linux 内存管理子系统的整体全貌。

而在正式开始物理内存管理的主题之前,我觉得有必须在带大家回顾下上篇文章中介绍的虚拟内存管理的相关知识,方便大家来回对比虚拟内存和物理内存,从而可以全面整体地掌握 Linux 内存管理子系统。

在上篇文章的一开始,我首先为大家展现了我们应用程序频繁接触到的虚拟内存地址,清晰地为大家介绍了到底什么是虚拟内存地址,以及虚拟内存地址分别在 32 位系统和 64 位系统中的具体表现形式:

image.png

image.png

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

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

image.png

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

image.png

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

32 位机器.png

64 位机器.png

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

image.png

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

32位系统中虚拟内存空间整体布局.png

64位系统中虚拟内存空间整体布局.png

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

内存IO单位.png

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

CPU读取内存.png

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

本文概要.png

# 2. 从 CPU 角度看物理内存模型

在前边的文章中,我曾多次提到内核是以页为基本单位对物理内存进行管理的,通过将物理内存划分为一页一页的内存块,每页大小为 4K。一页大小的内存块在内核中用 struct page 结构体来进行管理,struct page 中封装了每页内存块的状态信息,比如:组织结构,使用信息,统计信息,以及与其他结构的关联映射信息等。

而为了快速索引到具体的物理内存页,内核为每个物理页 struct page 结构体定义了一个索引编号:PFN(Page Frame Number)。PFN 与 struct page 是一一对应的关系。

内核提供了两个宏来完成 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 。这种内存模型就叫做平坦内存模型 FLATMEM 。

image.png

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

在平坦内存模型下 ,page\_to\_pfn 与 pfn\_to\_page 的计算逻辑就非常简单,本质就是基于 mem\_map 数组进行偏移操作。

#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)
#endif

ARCH\_PFN\_OFFSET 是 PFN 的起始偏移量。

Linux 早期使用的就是这种内存模型,因为在 Linux 发展的早期所需要管理的物理内存通常不大(比如几十 MB),那时的 Linux 使用平坦内存模型 FLATMEM 来管理物理内存就足够高效了。

内核中的默认配置是使用 FLATMEM 平坦内存模型。

# 2.2 DISCONTIGMEM 非连续内存模型

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

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

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

而每个 struct page 结构大部分情况下需要占用 40 字节(struct page 结构在不同场景下内存占用会有所不同,这一点我们后面再说),如果物理内存中存在的大块的地址空洞,那么为这些空洞而分配的 struct page 将会占用大量的内存空间,导致巨大的浪费。

image.png

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

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

image.png

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

typedef struct pglist_data {
   #ifdef CONFIG_FLATMEM
	  struct page *node_mem_map;
   #endif
}

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

由于引入了 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 内存模型一模一样了。

#if 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;					\
})

# 2.3 SPARSEMEM 稀疏内存模型

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

image.png

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

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

在内核中用 struct mem\_section 结构体表示 SPARSEMEM 模型中的 section。

struct mem_section {
	unsigned long section_mem_map;
        ...
}

由于 section 被用作管理小粒度的连续内存块,这些小的连续物理内存在 section 中也是通过数组的方式被组织管理,每个 struct mem\_section 结构体中有一个 section\_mem\_map 指针用于指向 section 中管理连续内存的 page 数组。

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

#ifdef CONFIG_SPARSEMEM_EXTREME
extern struct mem_section *mem_section[NR_SECTION_ROOTS];

image.png

在 SPARSEMEM 稀疏内存模型下 page\_to\_pfn 与 pfn\_to\_page 的计算逻辑又发生了变化。

  • 在 page\_to\_pfn 的转换中,首先需要通过 page\_to\_section 根据 struct page 结构定位到 mem\_section 数组中具体的 section 结构。然后在通过 section\_mem\_map 定位到具体的 PFN。

在 struct page 结构中有一个 unsigned long flags 属性,在 flag 的高位 bit 中存储着 page 所在 mem\_section 数组中的索引,从而可以定位到所属 section。

  • 在 pfn\_to\_page 的转换中,首先需要通过 \_\_pfn\_to\_section 根据 PFN 定位到 mem\_section 数组中具体的 section 结构。然后在通过 PFN 在 section\_mem\_map 数组中定位到具体的物理页 Page 。

PFN 的高位 bit 存储的是全局数组 mem\_section 中的 section 索引,PFN 的低位 bit 存储的是 section\_mem\_map 数组中具体物理页 page 的索引。

#if 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

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

# 2.3.1 物理内存热插拔

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

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

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

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

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

  • 物理热插拔阶段:这个阶段主要是从物理上将内存硬件插入(hot-add),拔出(hot-remove)主板的过程,其中涉及到硬件和内核的支持。
  • 逻辑热插拔阶段:这一阶段主要是由内核中的内存管理子系统来负责,涉及到的主要工作为:如何动态的上线启用(online)刚刚 hot-add 的内存,如何动态下线(offline)刚刚 hot-remove 的内存。

物理内存拔出的过程需要关注的事情比插入的过程要多的多,实现起来也更加的困难, 这就好比在《Java 技术栈中间件优雅停机方案设计与实现全景图》 (opens new window) 一文中我们讨论服务优雅启动,停机时提到的:优雅停机永远比优雅启动要考虑的场景要复杂的多,因为停机的时候,线上的服务正在承载着生产的流量需要确保做到业务无损。

同样的道理,物理内存插入比较好说,困难的是物理内存的动态拔出,因为此时即将要被拔出的物理内存中可能已经为进程分配了物理页,如何妥善安置这些已经被分配的物理页是一个棘手的问题。

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

image.png

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

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

image.png

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

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

既然是这些不可迁移的物理页导致内存无法拔出,那么我们可以把内存分一下类,将内存按照物理页是否可迁移,划分为不可迁移页,可回收页,可迁移页。

大家这里需要记住一点,内核会将物理内存按照页面是否可迁移的特性进行分类,我后面在介绍内核如何避免内存碎片的时候还会在提到

然后在这些可能会被拔出的内存中只分配那些可迁移的内存页,这些信息会在内存初始化的时候被设置,这样一来那些不可迁移的页就不会包含在可能会拔出的内存中,当我们需要将这块内存热拔出时, 因为里边的内存页全部是可迁移的, 从而使内存可以被拔除。

# 3. 从 CPU 角度看物理内存架构

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

在本小节中我为大家提供一个新的视角,这一次我们把物理内存看成一个整体,从 CPU 访问物理内存的角度来看一下物理内存的架构,并从 CPU 与物理内存的相对位置变化来看一下不同物理内存架构下对性能的影响。

# 3.1 一致性内存访问 UMA 架构

我们在上篇文章 《深入理解 Linux 虚拟内存管理》 (opens new window)的 “ 8.2 CPU 如何读写主存” 小节中提到 CPU 与内存之间的交互是通过总线完成的。

CPU与内存之间的总线结构.png

  • 首先 CPU 将物理内存地址作为地址信号放到系统总线上传输。随后 IO bridge 将系统总线上的地址信号转换为存储总线上的电子信号。
  • 主存感受到存储总线上的地址信号并通过存储控制器将存储总线上的物理内存地址 A 读取出来。
  • 存储控制器通过物理内存地址定位到具体的存储器模块,从 DRAM 芯片中取出物理内存地址对应的数据。
  • 存储控制器将读取到的数据放到存储总线上,随后 IO bridge 将存储总线上的数据信号转换为系统总线上的数据信号,然后继续沿着系统总线传递。
  • CPU 芯片感受到系统总线上的数据信号,将数据从系统总线上读取出来并拷贝到寄存器中。

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

image.png

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

这里的一致性是指同一个 CPU 对所有内存的访问的速度是一样的。即一致性内存访问 UMA(Uniform Memory Access)。

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

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

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

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

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

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

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

image.png

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

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

# 3.2.1 NUMA 的内存分配策略

NUMA 的内存分配策略是指在 NUMA 架构下 CPU 如何请求内存分配的相关策略,比如:是优先请求本地内存节点分配内存呢 ?还是优先请求指定的 NUMA 节点分配内存 ?是只能在本地内存节点分配呢 ?还是允许当本地内存不足的情况下可以请求远程 NUMA 节点分配内存 ?

| 内存分配策略 | 策略描述 |

| --- | --- |

| MPOL\_BIND | 必须在绑定的节点进行内存分配,如果内存不足,则进行 swap |

| MPOL\_INTERLEAVE | 本地节点和远程节点均可允许分配内存 |

| MPOL\_PREFERRED | 优先在指定节点分配内存,当指定节点内存不足时,选择离指定节点最近的节点分配内存 |

| MPOL\_LOCAL (默认) | 优先在本地节点分配,当本地节点内存不足时,可以在远程节点分配内存 |

我们可以在应用程序中通过 libnuma 共享库中的 API 调用 set\_mempolicy 接口设置进程的内存分配策略。

#include <numaif.h>

long set_mempolicy(int mode, const unsigned long *nodemask,
                          unsigned long maxnode);
  • mode : 指定 NUMA 内存分配策略。
  • nodemask:指定 NUMA 节点 Id。
  • maxnode:指定最大 NUMA 节点 Id,用于遍历远程节点,实现跨 NUMA 节点分配内存。

libnuma 共享库 API 文档:https://man7.org/linux/man-pages/man3/numa.3.html#top\_of\_page

set\_mempolicy 接口文档:https://man7.org/linux/man-pages/man2/set\_mempolicy.2.html

# 3.2.2 NUMA 的使用简介

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

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

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

# 3.2.2.1 查看 NUMA 相关信息

numactl 文档:https://man7.org/linux/man-pages/man8/numactl.8.html

针对以上具体问题,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 节点的内存访问命中率:

                           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:运行在本节点的进程跨节点在其他节点上分配内存的次数。

numastat 文档:https://man7.org/linux/man-pages/man8/numastat.8.html

# 3.2.2.2 绑定 NUMA 节点

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

numactl --membind=nodes  --cpunodebind=nodes  command
  • 通过 --membind 可以指定我们的应用程序只能在哪些具体的 NUMA 节点上分配内存,如果这些节点内存不足,则分配失败。
  • 通过 --cpunodebind 可以指定我们的应用程序只能运行在哪些 NUMA 节点上。
numactl --physcpubind=cpus  command

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

我们可以通过 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 文档:https://man7.org/linux/man-pages/man3/numa.3.html#top\_of\_page

# 4. 内核如何管理 NUMA 节点

在前边我们介绍物理内存模型和物理内存架构的时候提到过:在 NUMA 架构下,只有 DISCONTIGMEM 非连续内存模型和 SPARSEMEM 稀疏内存模型是可用的。而 UMA 架构下,前面介绍的三种内存模型均可以配置使用。

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

下面我先从最顶层的设计开始为大家介绍一下内核是如何管理这些 NUMA 节点的~~

image.png

NUMA 节点中可能会包含多个 CPU,这些 CPU 均是物理 CPU,这点大家需要注意一下。

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

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

内核中使用了 struct pglist\_data 这样的一个数据结构来描述 NUMA 节点,在内核 2.4 版本之前,内核是使用一个 pgdat\_list 单链表将这些 NUMA 节点串联起来的,单链表定义在 /include/linux/mmzone.h 文件中:

extern pg_data_t *pgdat_list;

每个 NUMA 节点的数据结构 struct pglist\_data 中有一个 next 指针,用于将这些 NUMA 节点串联起来形成 pgdat\_list 单链表,链表的末尾节点 next 指针指向 NULL。

typedef struct pglist_data {
    struct pglist_data *pgdat_next;
}

在内核 2.4 之后的版本中,内核移除了 struct pglist\_data 结构中的 pgdat\_next 之指针, 同时也删除了 pgdat\_list 单链表。取而代之的是,内核使用了一个大小为 MAX\_NUMNODES ,类型为 struct pglist\_data 的全局数组 node\_data[] 来管理所有的 NUMA 节点。

全局数组 node\_data[] 定义在文件 /arch/arm64/include/asm/mmzone.h中:

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

NODE\_DATA(nid) 宏可以通过 NUMA 节点的 nodeId,找到对应的 struct pglist\_data 结构。

node\_data[] 数组大小 MAX\_NUMNODES 定义在 /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)

UMA 架构下 NODES\_SHIFT 为 0 ,所以内核中只用一个 NUMA 节点来管理所有物理内存。

# 4.2 NUMA 节点描述符 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;
        .............
}

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

struct page 类型的数组 node\_mem\_map 中包含了 NUMA节点内的所有的物理内存页。

image.png

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

image.png

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

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

image.png

以上内容是我从整体上为大家介绍的 NUMA 节点如何管理节点内部的本地内存。事实上内核还会将 NUMA 节点中的本地内存做近一步的划分。那么为什么要近一步划分呢?

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

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

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

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

因此直接映射区的前 16M 专门让内核用来为 DMA 分配内存,这块 16M 大小的内存区域我们称之为 ZONE\_DMA。

用于 DMA 的内存必须从 ZONE\_DMA 区域中分配。

image.png

而直接映射区中剩下的部分也就是从 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 位系统中的内核虚拟内存空间太大了(128T),都可以进行直接映射。

以上这些物理内存区域的划分定义在 /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 这样的虚拟内存区域呢

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

image.png

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

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

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

image.png

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

image.png

ZONE\_NORMAL、ZONE\_HIGHMEM 和 ZONE\_MOVABLE 是可以出现在所有 NUMA 节点上的。

image.png

node\_zones[MAX\_NR\_ZONES] 数组包含了 NUMA 节点中的所有物理内存区域,物理内存区域在内核中的数据结构是 struct zone 。

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

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

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

内存可以说是计算机系统中最为宝贵的资源了,再怎么多也不够用,当系统运行时间长了之后,难免会遇到内存紧张的时候,这时候就需要内核将那些不经常使用的内存页面回收起来,或者将那些可以迁移的页面进行内存规整,从而可以腾出连续的物理内存页面供内核分配。

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

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

        ..........
} 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 进程周期性规整内存时使用到的等待队列。

本小节我主要为大家介绍 NUMA 节点的数据结构 struct pglist\_data。详细的内存回收会在本文后面的章节单独介绍。

# 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];

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

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
#ifdef CONFIG_MOVABLE_NODE
	N_MEMORY,		/* The node has memory(regular, high, movable) */
#else
	N_MEMORY = N_HIGH_MEMORY,
#endif
	N_CPU,		/* The node has one or more cpus */
	NR_NODE_STATES
};

N\_POSSIBLE 表示 NUMA 节点在某个时刻可以变为 online 状态,N\_ONLINE 表示 NUMA 节点当前的状态为 online 状态。

我们在本文《2.3.1 物理内存热插拔》小节中提到,在稀疏内存模型中,NUMA 节点的状态可以在系统运行的过程中随时切换 online ,offline 的状态,用来支持内存的热插拔。

image.png

N\_NORMAL\_MEMORY 表示节点没有高端内存,只有 ZONE\_NORMAL 内存区域。

N\_HIGH\_MEMORY 表示节点有 ZONE\_NORMAL 内存区域或者有 ZONE\_HIGHMEM 内存区域。

N\_MEMORY 表示节点有 ZONE\_NORMAL,ZONE\_HIGHMEM,ZONE\_MOVABLE 内存区域。

N\_CPU 表示节点包含一个或多个 CPU。

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

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 节点。

#define for_each_node_state(__node, __state) \
	for_each_node_mask((__node), node_states[__state])

比如:for\_each\_online\_node 用于迭代所有 online 的 NUMA 节点:

#define for_each_online_node(node) for_each_node_state(node, N_ONLINE)

# 5. 内核如何管理 NUMA 节点中的物理内存区域

image.png

在前边《4.3 NUMA 节点物理内存区域的划分》小节的介绍中,由于实际的计算机体系结构受到硬件方面的制约,间接限制了页框的使用方式。于是内核会根据各个物理内存区域的功能不同,将 NUMA 节点内的物理内存划分为:ZONE\_DMA,ZONE\_DMA32,ZONE\_NORMAL,ZONE\_HIGHMEM 这几个物理内存区域。

ZONE\_MOVABLE 区域是内核从逻辑上的划分,区域中的物理页面来自于上述几个内存区域,目的是避免内存碎片和支持内存热插拔(前边我已经介绍过了)。

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

image.png

我使用的服务器是 64 位,所以不包含 ZONE\_HIGHMEM 区域。

通过 cat /proc/zoneinfo 命令来查看系统中各个 NUMA 节点中的各个内存区域的内存使用情况:

下图中我们以 NUMA Node 0 中的 ZONE\_NORMAL 区域为例说明,大家只需要浏览一个大概,图中每个字段的含义我会在本小节的后面一一为大家介绍~~~

image.png

内核中用于描述和管理 NUMA 节点中的物理内存区域的结构体是 struct zone,上图中显示的 ZONE\_NORMAL 区域中,物理内存使用统计的相关数据均来自于 struct zone 结构体,我们先来看一下内核对 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 来填充字节,将这四个部分,分别填充到不同的 CPU 高速缓存行(cache line)中,使得它们各自独占 cache line,提高访问性能。

根据前边物理内存区域划分的相关内容介绍,我们知道内核会把 NUMA 节点中的物理内存区域顶多划分为 ZONE\_DMA,ZONE\_DMA32,ZONE\_NORMAL,ZONE\_HIGHMEM 这几个物理内存区域。因此 struct zone 的实例在内核中会相对比较少,通过 ZONE\_PADDING 填充字节,带来的 struct zone 结构体实例内存占用增加是可以忽略不计的。

在结构体的最后内核还是用了 ____cacheline_internodealigned_in_smp 编译器关键字来实现最优的高速缓存行对齐方式。

关于 CPU 高速缓存行对齐的详细内容,感兴趣的同学可以回看下我之前的文章 《一文聊透对象在JVM中的内存布局,以及内存对齐和压缩指针的原理及应用》 (opens new window)

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

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 节点中划分的物理内存区域连接起来。

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

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

image.png

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

image.png

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

数组 free\_area[MAX\_ORDER] 是伙伴系统的核心数据结构,我会在后面的系列文章中详细为大家介绍伙伴系统的实现。

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

image.png

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

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

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

  1. 当进程请求内核分配内存时,如果此时内存比较充裕,那么进程的请求会被立刻满足,如果此时内存已经比较紧张,内核就需要将一部分不经常使用的内存进行回收,从而腾出一部分内存满足进程的内存分配的请求,在这个回收内存的过程中,进程会一直阻塞等待。
  2. 另一种内存分配场景,进程是不允许阻塞的,内存分配的请求必须马上得到满足,比如执行中断处理程序或者执行持有自旋锁等临界区内的代码时,进程就不允许睡眠,因为中断程序无法被重新调度。这时就需要内核提前为这些核心操作预留一部分内存,当内存紧张时,可以使用这部分预留的内存给这些操作分配。
struct zone {
             ...........

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

nr\_reserved\_highatomic 表示的是该内存区域内预留内存的大小,范围为 128 到 65536 KB 之间。

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

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

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

image.png

根据物理内存地址的高低,低位内存区域到高位内存区域的顺序依次是:ZONE\_DMA,ZONE\_DMA32,ZONE\_NORMAL,ZONE\_HIGHMEM。

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

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

但是一些用于常规用途的物理内存则可以从多个物理内存区域中进行分配,当 ZONE\_HIGHMEM 区域中的内存不足时,内核可以从 ZONE\_NORMAL 进行内存分配,ZONE\_NORMAL 区域内存不足时可以进一步降级到 ZONE\_DMA 区域进行分配。

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

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

每个内存区域是按照一定的比例来计算自己的预留内存的,这个比例我们可以通过 cat /proc/sys/vm/lowmem_reserve_ratio 命令查看:

image.png

从左到右分别代表了 ZONE\_DMA,ZONE\_DMA32,ZONE\_NORMAL,ZONE\_MOVABLE,ZONE\_DEVICE 物理内存区域的预留内存比例。

我使用的服务器是 64 位,所以没有 ZONE\_HIGHMEM 区域。

那么每个内存区域如何根据各自的 lowmem\_reserve\_ratio 来计算各自区域中的预留内存大小呢

为了让大家更好的理解,下面我们以 ZONE\_DMA,ZONE\_NORMAL,ZONE\_HIGHMEM 这三个物理内存区域举例,它们的 lowmem\_reserve\_ratio 分别为 256,32,0。它们的大小分别是:8M,64M,256M,按照每页大小 4K 计算它们区域里包含的物理页个数分别为:2048, 16384, 65536。

| | lowmem\_reserve\_ratio | 内存区域大小 | 物理内存页个数 |

| --- | --- | --- | --- |

| ZONE\_DMA | 256 | 8M | 2048 |

| ZONE\_NORMAL | 32 | 64M | 16384 |

| ZONE\_HIGHMEM | 0 | 256M | 65536 |

  • ZONE\_DMA 为防止被 ZONE\_NORMAL 挤压侵占,而为自己预留的物理内存页为:16384 / 256 = 64
  • ZONE\_DMA 为防止被 ZONE\_HIGHMEM 挤压侵占而为自己预留的物理内存页为:(65536 + 16384) / 256 = 320
  • ZONE\_NORMAL 为防止被 ZONE\_HIGHMEM 挤压侵占而为自己预留的物理内存页为:65536 / 32 = 2048

各个内存区域为防止被高位内存区域过度挤压占用,而为自己预留的内存大小,我们可以通过前边 cat /proc/zoneinfo 命令来查看,输出信息的 protection:则表示各个内存区域预留内存大小。

image.png

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

前面介绍的物理内存区域内被伙伴系统所管理的物理页数量 managed\_pages 的计算方式就通过 present\_pages 减去这些预留的物理内存页 reserved\_pages 得到的。

调整内核参数的多种方法,我在《从 Linux 内核角度探秘 JDK NIO 文件读写本质》 (opens new window) 一文中的 "13.6 脏页回写参数的相关配置方式" 小节中已经详细介绍过了,感兴趣的同学可以在回看下。

# 5.2 物理内存区域中的水位线

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

  1. 产生 OOM,内核直接将系统中占用大量内存的进程,将 OOM 优先级最高的进程干掉,释放出这个进程占用的内存供其他更需要的进程分配使用。
  2. 内存回收,将不经常使用到的内存回收,腾挪出来的内存供更需要的进程分配使用。
  3. 内存规整,将可迁移的物理页面进行迁移规整,消除内存碎片。从而获得更大的一片连续物理内存空间供进程分配。

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

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

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

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

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

关于文件页相关的详细内容,感兴趣的同学可以回看下我的这篇文章 《从 Linux 内核角度探秘 JDK NIO 文件读写本质》 (opens new window)

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

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

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

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

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

综上所述,物理内存区域中的内存回收分为文件页回收(通过 pflush 内核线程)和匿名页回收(通过 kswapd 内核进程)。Swap 机制主要针对的是匿名页回收。

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

事实上 Linux 提供了一个 swappiness 的内核选项,我们可以通过 cat /proc/sys/vm/swappiness 命令查看,swappiness 选项的取值范围为 0 到 100,默认为 60。

swappiness 用于表示 Swap 机制的积极程度,数值越大,Swap 的积极程度越高,内核越倾向于回收匿名页。数值越小,Swap 的积极程度越低。内核就越倾向于回收文件页。

注意: swappiness 只是表示 Swap 积极的程度,当内存非常紧张的时候,即使将 swappiness 设置为 0 ,也还是会发生 Swap 的。

那么到底什么时候内存才算是紧张的?紧张到什么程度才开始 Swap 呢?这一切都需要一个量化的标准,于是就有了本小节的主题 —— 物理内存区域中的水位线。

内核会为每个 NUMA 节点中的每个物理内存区域定制三条用于指示内存容量的水位线,分别是:WMARK\_MIN(页最小阈值), WMARK\_LOW (页低阈值),WMARK\_HIGH(页高阈值)。

image.png

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

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

#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)

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

struct zone {
    // 物理内存区域中的水位线
    unsigned long _watermark[NR_WMARK];
    // 优化内存碎片对内存分配的影响,可以动态改变内存区域的基准水位线。
    unsigned long watermark_boost;

} ____cacheline_internodealigned_in_smp;

注意:下面提到的物理内存区域的剩余内存是需要刨去上小节介绍的 lowmem\_reserve 预留内存大小。

image.png

  • 当该物理内存区域的剩余内存容量高于 \_watermark[WMARK\_HIGH] 时,说明此时该物理内存区域中的内存容量非常充足,内存分配完全没有压力。
  • 当剩余内存容量在 \_watermark[WMARK\_LOW] 与\_watermark[WMARK\_HIGH] 之间时,说明此时内存有一定的消耗但是还可以接受,能够继续满足进程的内存分配需求。
  • 当剩余内容容量在 \_watermark[WMARK\_MIN] 与 \_watermark[WMARK\_LOW] 之间时,说明此时内存容量已经有点危险了,内存分配面临一定的压力,但是还可以满足进程的内存分配要求,当给进程分配完内存之后,就会唤醒 kswapd 进程开始内存回收,直到剩余内存高于 \_watermark[WMARK\_HIGH] 为止。

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

  • 当剩余内容容量低于 \_watermark[WMARK\_MIN] 时,说明此时的内容容量已经非常危险了,如果进程在这时请求内存分配,内核就会进行直接内存回收,这时请求进程会同步阻塞等待,直到内存回收完毕。

位于 \_watermark[WMARK\_MIN] 以下的内存容量是预留给内核在紧急情况下使用的,这部分内存就是我们在 《5.1 物理内存区域中的预留内存》小节中介绍的预留内存 nr\_reserved\_highatomic。

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

image.png

其中大部分字段的含义我已经在前面的章节中为大家介绍过了,下面我们只介绍和本小节内容相关的字段含义:

  • free 就是该物理内存区域内剩余的内存页数,它的值和后面的 nr\_free\_pages 相同。
  • min、low、high 就是上面提到的三条内存水位线:\_watermark[WMARK\_MIN],\_watermark[WMARK\_LOW] ,\_watermark[WMARK\_HIGH]。
  • nr\_zone\_active\_anon 和 nr\_zone\_inactive\_anon 分别是该内存区域内活跃和非活跃的匿名页数量。
  • nr\_zone\_active\_file 和 nr\_zone\_inactive\_file 分别是该内存区域内活跃和非活跃的文件页数量。

# 5.3 水位线的计算

在上小节中我们介绍了内核通过对物理内存区域设置内存水位线来决定内存回收的时机,那么这三条内存水位线的值具体是多少,内核中是根据什么计算出来的呢?

事实上 WMARK\_MIN,WMARK\_LOW ,WMARK\_HIGH 这三个水位线的数值是通过内核参数 /proc/sys/vm/min_free_kbytes 为基准分别计算出来的,用户也可以通过 sysctl 来动态设置这个内核参数。

内核参数 min\_free\_kbytes 的单位为 KB 。

image.png

通常情况下 WMARK\_LOW 的值是 WMARK\_MIN 的 1.25 倍,WMARK\_HIGH 的值是 WMARK\_LOW 的 1.5 倍。而 WMARK\_MIN 的数值就是由这个内核参数 min\_free\_kbytes 来决定的。

下面我们就来看下内核中关于 min\_free\_kbytes 的计算方式:

# 5.4 min\_free\_kbytes 的计算逻辑

以下计算逻辑是针对 64 位系统中内存区域水位线的计算,在 64 位系统中没有高端内存 ZONE\_HIGHMEM 区域。

min\_free\_kbytes 的计算逻辑定义在内核文件 /mm/page_alloc.cinit_per_zone_wmark_min 方法中,用于计算最小水位线 WMARK\_MIN 的数值也就是这里的 min\_free\_kbytes (单位为 KB)。 水位线的单位是物理内存页的数量。

int __meminit init_per_zone_wmark_min(void)
{
  // 低位内存区域(除高端内存之外)的总和
	unsigned long lowmem_kbytes;
  // 待计算的 min_free_kbytes
	int new_min_free_kbytes;

  // 将低位内存区域内存容量总的页数转换为 KB
	lowmem_kbytes = nr_free_buffer_pages() * (PAGE_SIZE >> 10);
  // min_free_kbytes 计算逻辑:对 lowmem_kbytes * 16 进行开平方
	new_min_free_kbytes = int_sqrt(lowmem_kbytes * 16);
  // min_free_kbytes 的范围为 128 到 65536 KB 之间
	if (new_min_free_kbytes > user_min_free_kbytes) {
		min_free_kbytes = new_min_free_kbytes;
		if (min_free_kbytes < 128)
			min_free_kbytes = 128;
		if (min_free_kbytes > 65536)
			min_free_kbytes = 65536;
	} 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);
	}
  // 计算内存区域内的三条水位线
	setup_per_zone_wmarks();
  // 计算内存区域的预留内存大小,防止被高位内存区域过度挤压占用
	setup_per_zone_lowmem_reserve();
        .............省略................
	return 0;
}
core_initcall(init_per_zone_wmark_min)

首先我们需要先计算出当前 NUMA 节点中所有低位内存区域(除高端内存之外)中内存总容量之和。也即是说 lowmem\_kbytes 的值为: ZONE\_DMA 区域中 managed\_pages + ZONE\_DMA32 区域中 managed\_pages + ZONE\_NORMAL 区域中 managed\_pages 。

lowmem\_kbytes 的计算逻辑在 nr_free_zone_pages 方法中:

/**
 * nr_free_zone_pages - count number of pages beyond high watermark
 * @offset: The zone index of the highest zone
 *
 * nr_free_zone_pages() counts the number of counts pages which are beyond the
 * high watermark within all zones at or below a given zone index.  For each
 * zone, the number of pages is calculated as:
 *     managed_pages - high_pages
 */
static unsigned long nr_free_zone_pages(int offset)
{
	struct zoneref *z;
	struct zone *zone;

	unsigned long sum = 0;
    // 获取当前 NUMA 节点中的所有物理内存区域 zone
	struct zonelist *zonelist = node_zonelist(numa_node_id(), GFP_KERNEL);
    // 计算所有物理内存区域内 managed_pages - high_pages 的总和
	for_each_zone_zonelist(zone, z, zonelist, offset) {
		unsigned long size = zone->managed_pages;
		unsigned long high = high_wmark_pages(zone);
		if (size > high)
			sum += size - high;
	}
    // lowmem_kbytes 的值
	return sum;
}

nr\_free\_zone\_pages 方法上面的注释大家可能看的有点蒙,这里需要为大家解释一下,nr\_free\_zone\_pages 方法的计算逻辑本意是给定一个 zone index (方法参数 offset),计算范围为:这个给定 zone 下面的所有低位内存区域。

nr\_free\_zone\_pages 方法会计算这些低位内存区域内在 high watermark 水位线之上的内存容量( managed\_pages - high\_pages )之和。作为该方法的返回值。

但此时我们正准备计算这些水位线,水位线还没有值,所以此时这个方法的语义就是计算低位内存区域内被伙伴系统所管理的内存容量( managed\_pages )之和。也就是我们想要的 lowmem\_kbytes。

接下来在 init\_per\_zone\_wmark\_min 方法中会对 lowmem\_kbytes \* 16 进行开平方得到 new\_min\_free\_kbytes。

image.png

如果计算出的 new\_min\_free\_kbytes 大于用户设置的内核参数值 /proc/sys/vm/min_free_kbytes ,那么最终 min\_free\_kbytes 就是 new\_min\_free\_kbytes。如果小于用户设定的值,那么就采用用户指定的 min\_free\_kbytes 。

min\_free\_kbytes 的取值范围限定在 128 到 65536 KB 之间。

随后内核会根据这个 min\_free\_kbytes 在 setup\_per\_zone\_wmarks() 方法中计算出该物理内存区域的三条水位线。

最后在 setup\_per\_zone\_lowmem\_reserve() 方法中计算内存区域的预留内存大小,防止被高位内存区域过度挤压占用。该方法的逻辑就是我们在《5.1 物理内存区域中的预留内存》小节中提到的内容。

# 5.5 setup\_per\_zone\_wmarks 计算水位线

这里我们依然不会考虑高端内存区域 ZONE\_HIGHMEM。

物理内存区域内的三条水位线:WMARK\_MIN,WMARK\_LOW,WMARK\_HIGH 的最终计算逻辑是在 __setup_per_zone_wmarks 方法中完成的:

static void __setup_per_zone_wmarks(void)
{
  // 将 min_free_kbytes 转换为页
	unsigned long pages_min = min_free_kbytes >> (PAGE_SHIFT - 10);
  // 所有低位内存区域 managed_pages 之和
	unsigned long lowmem_pages = 0;
	struct zone *zone;
	unsigned long flags;

	/* Calculate total number of !ZONE_HIGHMEM pages */
	for_each_zone(zone) {
		if (!is_highmem(zone))
			lowmem_pages += zone->managed_pages;
	}

  // 循环计算各个内存区域中的水位线
	for_each_zone(zone) {
		u64 tmp;
		tmp = (u64)pages_min * zone->managed_pages;
  // 计算 WMARK_MIN 水位线的核心方法
		do_div(tmp, lowmem_pages);
		if (is_highmem(zone)) {
            ...........省略高端内存区域............
		} else {
    // WMARK_MIN水位线
			zone->watermark[WMARK_MIN] = tmp;
		}
  // 这里可暂时忽略
		tmp = max_t(u64, tmp >> 2,
			    mult_frac(zone->managed_pages,
				      watermark_scale_factor, 10000));

		zone->watermark[WMARK_LOW]  = min_wmark_pages(zone) + tmp;
		zone->watermark[WMARK_HIGH] = min_wmark_pages(zone) + tmp * 2;
	}
}

在 for\_each\_zone 循环内依次遍历 NUMA 节点中的所有内存区域 zone,计算每个内存区域 zone 里的内存水位线。其中计算 WMARK\_MIN 水位线的核心逻辑封装在 do\_div 方法中,在 do\_div 方法中会先计算每个 zone 内存容量之间的比例,然后根据这个比例去从 min\_free\_kbytes 中划分出对应 zone 的 WMARK\_MIN 水位线来。

比如:当前 NUMA 节点中有两个 zone :ZONE\_DMA 和 ZONE\_NORMAL,内存容量大小分别是:100 M 和 800 M。那么 ZONE\_DMA 与 ZONE\_NORMAL 之间的比例就是 1 :8。

根据这个比例,ZONE\_DMA 区域里的 WMARK\_MIN 水位线就是:min\_free\_kbytes \ 1 / 8 。ZONE\_NORMAL 区域里的 WMARK\_MIN 水位线就是:min\_free\_kbytes \ 7 / 8

计算出了 WMARK\_MIN 的值,那么接下来 WMARK\_LOW, WMARK\_HIGH 的值也就好办了,它们都是基于 WMARK\_MIN 计算出来的。

WMARK\_LOW 的值是 WMARK\_MIN 的 1.25 倍,WMARK\_HIGH 的值是 WMARK\_LOW 的 1.5 倍。

此外,大家可能对下面这段代码比较有疑问?

      /*
         * Set the kswapd watermarks distance according to the
         * scale factor in proportion to available memory, but
         * ensure a minimum size on small systems.
         */
        tmp = max_t(u64, tmp >> 2,
                mult_frac(zone->managed_pages,
                      watermark_scale_factor, 10000));

这段代码主要是通过内核参数 watermark\_scale\_factor 来调节水位线:WMARK\_MIN,WMARK\_LOW,WMARK\_HIGH 之间的间距,那么为什么要调整水位线之间的间距大小呢?

# 5.6 watermark\_scale\_factor 调整水位线的间距

image.png

为了避免内核的直接内存回收 direct reclaim 阻塞进程影响系统的性能,所以我们需要尽量保持内存区域中的剩余内存容量尽量在 WMARK\_MIN 水位线之上,但是有一些极端情况,比如突然遇到网络流量增大,需要短时间内申请大量的内存来存放网络请求数据,此时 kswapd 回收内存的速度可能赶不上内存分配的速度,从而造成直接内存回收 direct reclaim,影响系统性能。

在内存分配过程中,剩余内存容量处于 WMARK\_MIN 与 WMARK\_LOW 水位线之间会唤醒 kswapd 进程来回收内存,直到内存容量恢复到 WMARK\_HIGH 水位线之上。

剩余内存容量低于 WMARK\_MIN 水位线时就会触发直接内存回收 direct reclaim。

而剩余内存容量高于 WMARK\_LOW 水位线又不会唤醒 kswapd 进程,因此 kswapd 进程活动的关键范围在 WMARK\_MIN 与 WMARK\_LOW 之间,而为了应对这种突发的网络流量暴增,我们需要保证 kswapd 进程活动的范围大一些,这样内核就能够时刻进行内存回收使得剩余内存容量较长时间的保持在 WMARK\_HIGH 水位线之上。

这样一来就要求 WMARK\_MIN 与 WMARK\_LOW 水位线之间的间距不能太小,因为 WMARK\_LOW 水位线之上就不会唤醒 kswapd 进程了。

因此内核引入了 /proc/sys/vm/watermark_scale_factor 参数来调节水位线之间的间距。该内核参数默认值为 10,最大值为 3000。

image.png

那么如何使用 watermark\_scale\_factor 参数调整水位线之间的间距呢?

水位线间距计算公式:(watermark\_scale\_factor / 10000) \* managed\_pages 。

        zone->watermark[WMARK_MIN] = tmp;
        // 水位线间距的计算逻辑
        tmp = max_t(u64, tmp >> 2,
                mult_frac(zone->managed_pages,
                      watermark_scale_factor, 10000));

        zone->watermark[WMARK_LOW]  = min_wmark_pages(zone) + tmp;
        zone->watermark[WMARK_HIGH] = min_wmark_pages(zone) + tmp * 2;

在内核中水位线间距计算逻辑是:(WMARK\_MIN / 4) 与 (zone\_managed\_pages \* watermark\_scale\_factor / 10000) 之间较大的那个值。

用户可以通过 sysctl 来动态调整 watermark\_scale\_factor 参数,内核会动态重新计算水位线之间的间距,使得 WMARK\_MIN 与 WMARK\_LOW 之间留有足够的缓冲余地,使得 kswapd 能够有时间回收足够的内存,从而解决直接内存回收导致的性能抖动问题

# 5.7 物理内存区域中的冷热页

之前我在《一文聊透对象在JVM中的内存布局,以及内存对齐和压缩指针的原理及应用》 (opens new window) 一文中为大家介绍 CPU 的高速缓存时曾提到过,根据摩尔定律:芯片中的晶体管数量每隔 18 个月就会翻一番。导致 CPU 的性能和处理速度变得越来越快,而提升 CPU 的运行速度比提升内存的运行速度要容易和便宜的多,所以就导致了 CPU 与内存之间的速度差距越来越大。

CPU 与 内存之间的速度差异到底有多大呢? 我们知道寄存器是离 CPU 最近的,CPU 在访问寄存器的时候速度近乎于 0 个时钟周期,访问速度最快,基本没有时延。而访问内存则需要 50 - 200 个时钟周期。

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

CPU缓存结构.png

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

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

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

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

image.png

本小节标题中所谓的热页就是已经加载进 CPU 高速缓存中的物理内存页,所谓的冷页就是还未加载进 CPU 高速缓存中的物理内存页,冷页是热页的后备选项。

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

struct zone {
    struct per_cpu_pageset	pageset[NR_CPUS];
}

在 2.6.25 版本之前的内核源码中,物理内存区域 struct zone 包含了一个 struct per\_cpu\_pageset 类型的数组 pageset。其中内核关于冷热页的管理全部封装在 struct per\_cpu\_pageset 结构中。

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

在 NUMA 内存架构下,每个物理内存区域都是属于一个特定的 NUMA 节点,NUMA 节点中包含了一个或者多个 CPU,NUMA 节点中的每个内存区域会关联到一个特定的 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;	/* th