1、概述

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 。

内核中使用了一个 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 将会占用大量的内存空间,导致巨大的浪费。

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

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

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

typedef struct pglist_data {#ifdef CONFIG_FLATMEMstruct 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 中的物理内存也不一定都是连续的。

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

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

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

struct mem_section {/** This is, logically, a pointer to an array of struct* pages.  However, it is stored with some other magic.* (see sparse.c::sparse_init_one_section())** Additionally during early boot we encode node id of* the location of the section here to guide allocation.* (see sparse.c::memory_present())** Making it a UL at least makes someone do a cast* before using it wrong.*/unsigned long section_mem_map;/* See declaration of similar field in struct zone */unsigned long *pageblock_flags;
#ifdef CONFIG_PAGE_EXTENSION/** If SPARSEMEM, pgdat doesn't have page_ext pointer. We use* section. (see page_ext.h about this.)*/struct page_ext *page_ext;unsigned long pad;
#endif/** WARNING: mem_section must be a power-of-2 in size for the* calculation and use of SECTION_ROOT_MASK to make sense.*/
};

由于 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];

在 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 的内存。

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

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

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

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

image.png

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

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

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

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

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

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

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

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

3.1 一致性内存访问 UMA 架构

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

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

  • 首先 CPU 将物理内存地址作为地址信号放到系统总线上传输。随后 IO bridge 将系统总线上的地址信号转换为存储总线上的电子信号。

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

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

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

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

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

在 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 访问内存的速度不一致,所以叫做非一致性内存访问架构。

如上图所示,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 MBnode 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 MBnode 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 MBnode 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 MBnode distances:
node   0   1   2   30:  10  16  32  331:  16  10  25  322:  32  25  10  163:  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 节点的~~

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 节点idint node_id;// 指向 NUMA 节点内管理所有物理页 page 的数组struct page *node_mem_map;// NUMA 节点内第一个物理页的 pfnunsigned 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_DMAZONE_DMA,
#endif
#ifdef CONFIG_ZONE_DMA32ZONE_DMA32,
#endifZONE_NORMAL,
#ifdef CONFIG_HIGHMEMZONE_HIGHMEM,
#endifZONE_MOVABLE,
#ifdef CONFIG_ZONE_DEVICEZONE_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_HIGHMEMN_HIGH_MEMORY,  /* The node has regular or high memory */
#elseN_HIGH_MEMORY = N_NORMAL_MEMORY,
#endif
#ifdef CONFIG_MOVABLE_NODEN_MEMORY,  /* The node has memory(regular, high, movable) */
#elseN_MEMORY = N_HIGH_MEMORY,
#endifN_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 节点中的物理内存区域

共享存储型多处理机有两种模型

  • 均匀存储器存取(Uniform-Memory-Access,简称UMA)模型                   (一致存储器访问结构)

  • 非均匀存储器存取(Nonuniform-Memory-Access,简称NUMA)模型        (非一致存储器访问结构)

UMA模型

各CPU共享相同的物理内存(各CPU与一个集中的存储器和I/O总线相连),每个 CPU访问内存中的任何地址所需时间是相同的,物理存储器被所有处理机均匀共享。这就是为什么称它为均匀存储器存取的原因

均匀共享存储器有时候也称之为一致存储访问,一致性意指无论在什么时候,处理器只能为内存的每个数据保持或共享唯一一个数值。

缺点:

UMA模型的最大特点就是共享。在该模型下,所有资源都是共享的,包括CPU、内存、I/O等。也正是由于这种特性,导致了UMA模型可伸缩性非常有限,因为内存是共享的,CPUs都会通过一条内存总线连接到内存上,这时,当多个CPU同时访问同一个内存块时就会产生冲突,因此当存储器和I/O接口达到饱和的时候,增加处理器并不能获得更高的性能。

UMA模型

NUMA模型的基本特征是具有多个CPU模块(称为节点),每个节点又由多个CPU core(如4个)组成,并具有本地内存、I/O接口等,所以可以支持CPU对本地内存的快速访问。

各个节点之间可以通过互联模块(如称为Crossbar Switch)进行连接和信息交互,这样可以支持对其他节点中的本地内存的访问,当然这时访问远的内存就要比访问本地内存慢些,这也是非一致存储访问NUMA的由来。

优点:

NUMA模型的最大优势是伸缩性。与UMA不同的是,NUMA具有多条内存总线,可以通过限制任何一条内存总线上的CPU数量以及依靠高速互连来连接各个节点,从而缓解UMA的瓶颈。NUMA理论上可以无限扩展的,但由于访问远地内存的延时远远超过访问本地内存,所以当CPU数量增加时,系统性能无法线性增加。

ref:

服务器体系(SMP, NUMA, MPP)与共享存储器架构(UMA和NUMA) - Smah - 博客园

内存管理 | 基础一 - 知乎

Linux内存管理之UMA模型和NUMA模型 - Mr-xxx - 博客园

LDD-LinuxDeviceDrivers/study/kernel/02-memory/01-description/01-memory at master · gatieme/LDD-LinuxDeviceDrivers · GitHub

Linux内存管理之UMA模型和NUMA模型相关推荐

  1. Linux内存管理:内存描述之内存区域zone

    目录 1 前景回顾 1.1 UMA和NUMA两种模型 1.2 (N)UMA模型中linux内存的机构 1.3 Linux如何描述物理内存 1.4 用pd_data_t描述内存节点node 1.5 今日 ...

  2. Linux内存管理:NUMA技术详解(非一致内存访问架构)

    图片来源:https://zhuanlan.zhihu.com/p/68465952 <Linux内存管理:转换后备缓冲区(TLB)原理> <内存管理:Linux Memory Ma ...

  3. 万字整理,图解Linux内存管理所有知识点

    Linux的内存管理可谓是学好Linux的必经之路,也是Linux的关键知识点,有人说打通了内存管理的知识,也就打通了Linux的任督二脉,这一点不夸张.有人问网上有很多Linux内存管理的内容,为什 ...

  4. 一文掌握 Linux 内存管理

    作者:dengxuanshi,腾讯 IEG 后台开发工程师 以下源代码来自 linux-5.10.3 内核代码,主要以 x86-32 为例. Linux 内存管理是一个很复杂的"工程&quo ...

  5. Linux内存管理:知识点总结(ARM64)

    https://mp.weixin.qq.com/s/7zFrBuJUK9JMQP4TmymGjA 目录 Linux内存管理之CPU访问内存的过程 虚拟地址转换为物理地址的本质 Linux内存初始化 ...

  6. Linux内存管理:内存描述之高端内存

    <Linux内存管理:内存描述之内存节点node> <Linux内存管理:内存描述之内存区域zone> <Linux内存管理:内存描述之内存页面page> < ...

  7. Linux内存管理:内存描述之内存页面page

    <Linux内存管理:内存描述之内存节点node> <Linux内存管理:内存描述之内存区域zone> <Linux内存管理:内存描述之内存页面page> 目录 1 ...

  8. Linux内存管理:内存描述之内存节点node

    <Linux内存管理:内存描述之内存区域zone> <Linux内存管理:内存描述之内存节点node> 目录 1 前景回顾 1.1 UMA和NUMA两种模型 1.2 (N)UM ...

  9. linux内存管理(六)-伙伴分配器

    linux内存三大分配器:引导内存分配器,伙伴分配器,slab分配器 伙伴分配器 当系统内核初始化完毕后,使用页分配器管理物理页,当使用的页分配器是伙伴分配器,伙伴分配器的特点是算法简单且高效,支持内 ...

最新文章

  1. 在PHP中实现StringBuilder类
  2. android自定义弹框效果合集,android 自定义弹出框AlertDialog ,很炫的哦
  3. C++中的二阶构造模式
  4. Gridcontrol新增行选中有关问题
  5. 未来教育计算机二级Excel解析,Excel操作小技巧,助你学好计算机二级office!
  6. jdk switch 枚举_JDK 12开关表达式遇到意外的枚举值
  7. 电脑文件夹加密软件_上海靠谱电脑资料加密软件解决方案
  8. ios 分段 判断 小说阅读器_还在用别的小说阅读器?今天教你用Python制作简易小说阅读器!...
  9. UVALive6050 Primes【素数筛选+前缀和】
  10. Codeforces Round #161 (Div. 2) B. Squares
  11. JS生成二维码,以下介绍3种方法
  12. 基于JSP网上购书系统
  13. bootstrap导航栏.nav和.navbar区别
  14. android studio USB连接华为手机不显示调试信息问题
  15. c语言泊松分酒编码,泊松分酒原理
  16. 听说,在巴别鸟评审文件特别快?
  17. Learning from Interpretable Analysis:Attention-Based Knowledge Tracing
  18. 又有2名博士入选华为“天才少年”!学霸日常科研计划表曝光
  19. 分支限界解决旅行商tsp问题
  20. python使用python-docx自动化操作word

热门文章

  1. 绿色石化高质量发展 茂名天源石化碳三碳四资源利用项目开工
  2. 在 vue 中使用 SVG 建立图标系统并且使用
  3. Ubuntu20.04安装NVIDIA显卡驱动、CUDA、CUDNN及突破NVENC并发限制
  4. 唯一插件化Replugin源码及原理深度剖析--插件的安装、加载原理
  5. 玩转“抖音”的10种内容策划套路!
  6. 改造WinRE 从隐藏分区安装Win7
  7. (2019春)软件构造:雨课堂试卷(第3章)
  8. 每日算法_4月11日_02
  9. 一个元素位于另一个元素之上,点击上面的元素引发下面元素事件操作
  10. 文件操作(第二节文件的写入和读取)