关于linux-kernel:一步一图带你深入理解-Linux-物理内存管理

6次阅读

共计 60076 个字符,预计需要花费 151 分钟才能阅读完成。

1. 前文回顾

在上篇文章《深刻了解 Linux 虚拟内存治理》中,笔者别离从过程用户态和内核态的角度具体深刻地为大家介绍了 Linux 内核如何对过程虚拟内存空间进行布局以及治理的相干实现。在咱们深刻了解了虚拟内存之后,那么何不顺带着也探秘一下物理内存的治理呢?

所以本文的目标是在深刻了解虚拟内存治理的根底之上持续带大家向前奋进,一举击破物理内存治理的常识盲区,使大家可能鸟瞰整个 Linux 内存管理子系统的整体全貌。

而在正式开始物理内存治理的主题之前,笔者感觉有必须在带大家回顾下上篇文章中介绍的虚拟内存治理的相干常识,不便大家来回比照虚拟内存和物理内存,从而能够全面整体地把握 Linux 内存管理子系统。

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

在咱们分明了虚拟内存地址这个基本概念之后,随后笔者又抛出了一个问题:为什么咱们要通过虚拟内存地址拜访内存而不是间接通过物理地址拜访?

原来是在多过程零碎中间接操作物理内存地址的话,咱们须要准确地晓得每一个变量的地位都被安顿在了哪里,而且还要留神以后过程在和多个过程同时运行的时候,不能共用同一个地址,否则就会造成地址抵触。

而虚拟内存空间的引入正是为了解决多过程地址抵触的问题,使得过程与过程之间的虚拟内存地址空间互相隔离,互不烦扰。每个过程都认为本人独占所有内存空间,将多过程之间的协同相干细节通通交给内核中的内存治理模块来解决,极大地解放了程序员的心智累赘。这一切都是因为虚拟内存可能为过程提供内存地址空间隔离的功绩。

在咱们分明了虚拟内存空间引入的意义之后,笔者紧接着为大家介绍了 过程用户态 虚拟内存空间别离在 32 位机器和 64 位机器上的布局状况:

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

在介绍完用户态虚拟内存空间的布局以及治理之后,咱们随后又介绍了 内核态 虚拟内存空间的布局状况,并联合之前介绍的 用户态 虚拟内存空间,失去了 Linux 虚拟内存空间别离在 32 位和 64 位零碎中的整体布局状况:

在虚拟内存全副介绍结束之后,为了可能承前启后,于是笔者持续在上篇文章的最初一个大节从计算机组成原理的角度介绍了物理内存的物理组织构造,不便让大家了解到底什么是真正的物理内存?物理内存地址到底是什么?由此为本文的主题 —— 物理内存的治理,埋下伏笔~~~

最初笔者介绍了 CPU 如何通过物理内存地址向物理内存读写数据的残缺过程:

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

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_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 中的物理内存也不肯定都是间断的。

而且每个 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];

在 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 技术栈中间件优雅停机方案设计与实现全景图》一文中咱们探讨服务优雅启动,停机时提到的:优雅停机永远比优雅启动要思考的场景要简单的多,因为停机的时候,线上的服务正在承载着生产的流量须要确保做到业务无损。

同样的情理,物理内存插入比拟好说,艰难的是物理内存的动静插入,因为此时行将要被插入的物理内存中可能曾经为过程调配了物理页,如何妥善安置这些曾经被调配的物理页是一个辣手的问题。

前边咱们介绍 SPARSEMEM 内存模型的时候提到,每个 mem_section 都能够在零碎运行时扭转 offline,online 状态,以便反对内存的热插拔(hotplug)性能。当 mem_section offline 时, 内核会把这部分内存隔离开, 使得该局部内存不可再被应用, 而后再把 mem_section 中曾经调配的内存页迁徙到其余 mem_section 的内存上.。

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

这一点在过程的用户空间是没有问题的,因为过程在用户空间拜访内存都是依据虚拟内存地址通过页表找到对应的物理内存地址,这些迁徙之后的物理页,尽管物理内存地址发生变化,然而内核通过批改相应页表中虚拟内存地址与物理内存地址之间的映射关系,能够保障虚拟内存地址不会扭转。

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

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

既然是这些不可迁徙的物理页导致内存无奈插入,那么咱们能够把内存分一下类,将内存依照物理页是否可迁徙,划分为不可迁徙页,可回收页,可迁徙页。

大家这里须要记住一点,内核会将物理内存依照页面是否可迁徙的个性进行分类,笔者前面在介绍内核如何防止内存碎片的时候还会在提到

而后在这些可能会被插入的内存中只调配那些可迁徙的内存页,这些信息会在内存初始化的时候被设置,这样一来那些不可迁徙的页就不会蕴含在可能会插入的内存中,当咱们须要将这块内存热插入时, 因为里边的内存页全副是可迁徙的, 从而使内存能够被拔除。

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

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

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

3.1 一致性内存拜访 UMA 架构

咱们在上篇文章《深刻了解 Linux 虚拟内存治理》的“8.2 CPU 如何读写主存”大节中提到 CPU 与内存之间的交互是通过总线实现的。

  • 首先 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-pa…

set_mempolicy 接口文档:https://man7.org/linux/man-pa…

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-pa…

针对以上具体问题,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-pa…

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-pa…

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 节点 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 节点内的所有的物理内存页。

node_start_pfn 指向 NUMA 节点内第一个物理页的 PFN,零碎中所有 NUMA 节点中的物理页都是顺次编号的,每个物理页的 PFN 都是 全局惟一的(不只是其所在 NUMA 节点内惟一)

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

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

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

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

咱们都晓得内核对物理内存的治理都是以页为最小单位来治理的,每页默认 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 位零碎中的内核虚拟内存空间太大了(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 这样的虚拟内存区域呢

因为随着零碎的运行会随同着不同大小的物理内存页的调配和开释,这种内存不规则的调配开释随着零碎的长时间运行就会导致内存碎片,内存碎片会使得零碎在明明有足够内存的状况下,仍然无奈为过程调配适合的内存。

如上图所示,如果当初零碎一共有 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 节点中的内存规整与回收

内存能够说是计算机系统中最为贵重的资源了,再怎么多也不够用,当零碎运行工夫长了之后,难免会遇到内存缓和的时候,这时候就须要内核将那些不常常应用的内存页面回收起来,或者将那些能够迁徙的页面进行内存规整,从而能够腾出间断的物理内存页面供内核调配。

内核会为每个 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 的状态,用来反对内存的热插拔。

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 节点中的物理内存区域

在前边《4.3 NUMA 节点物理内存区域的划分》大节的介绍中,因为理论的计算机体系结构受到硬件方面的制约,间接限度了页框的应用形式。于是内核会依据各个物理内存区域的性能不同,将 NUMA 节点内的物理内存划分为:ZONE_DMA,ZONE_DMA32,ZONE_NORMAL,ZONE_HIGHMEM 这几个物理内存区域。

ZONE_MOVABLE 区域是内核从逻辑上的划分,区域中的物理页面来自于上述几个内存区域,目标是防止内存碎片和反对内存热插拔(前边笔者曾经介绍过了)。

咱们能够通过 cat /proc/zoneinfo | grep Node 命令来查看 NUMA 节点中内存区域的散布状况:

笔者应用的服务器是 64 位,所以不蕴含 ZONE_HIGHMEM 区域。

通过 cat /proc/zoneinfo 命令来查看零碎中各个 NUMA 节点中的各个内存区域的内存应用状况:

下图中咱们以 NUMA Node 0 中的 ZONE_NORMAL 区域为例阐明,大家只须要浏览一个大略,图中每个字段的含意笔者会在本大节的前面一一为大家介绍~~~

内核中用于形容和治理 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 中的内存布局,以及内存对齐和压缩指针的原理及利用》。

笔者为了使大家可能更好地了解内核如何应用 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 节点。

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. 另一种内存调配场景,过程是不容许阻塞的,内存调配的申请必须马上失去满足,比方执行中断处理程序或者执行持有自旋锁等临界区内的代码时,过程就不容许睡眠,因为中断程序无奈被从新调度。这时就须要内核提前为这些外围操作预留一部分内存,当内存缓和时,能够应用这部分预留的内存给这些操作调配。
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 这些都是针对物理内存进行的划分,所谓的低位内存区域和高位内存区域其实还是依照物理内存地址从低到高进行排列布局:

依据物理内存地址的高下,低位内存区域到高位内存区域的程序顺次是: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 命令查看:

从左到右别离代表了 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:则示意各个内存区域预留内存大小。

此外咱们还能够通过 sysctl 对内核参数 lowmem_reserve_ratio 进行动静调整,这样内核会依据新的 lowmem_reserve_ratio 动静从新计算各个内存区域的预留内存大小。

后面介绍的物理内存区域内被搭档零碎所治理的物理页数量 managed_pages 的计算形式就通过 present_pages 减去这些预留的物理内存页 reserved_pages 失去的。

调整内核参数的多种办法,笔者在《从 Linux 内核角度探秘 JDK NIO 文件读写实质》一文中的 “13.6 脏页回写参数的相干配置形式 ” 大节中曾经具体介绍过了,感兴趣的同学能够在回看下。

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

内存资源是零碎中最贵重的系统资源,是无限的。当内存资源缓和的时候,零碎的应答办法无非就是三种:

  1. 产生 OOM,内核间接将零碎中占用大量内存的过程,将 OOM 优先级最高的过程干掉,开释出这个过程占用的内存供其余更须要的过程调配应用。
  2. 内存回收,将不常常应用到的内存回收,腾挪进去的内存供更须要的过程调配应用。
  3. 内存规整,将可迁徙的物理页面进行迁徙规整,打消内存碎片。从而取得更大的一片间断物理内存空间供过程调配。

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

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

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

然而当过程曾经对文件页进行批改过但还没来得及同步回磁盘,此时文件页就是脏页,不能间接进行回收,须要先将脏页回写到磁盘中能力进行回收。

咱们能够在过程中通过 fsync() 零碎调用将指定文件的所有脏页同步回写到磁盘,同时内核也会依据肯定的条件唤醒专门用于回写脏页的 pflush 内核线程。

对于文件页相干的具体内容,感兴趣的同学能够回看下笔者的这篇文章《从 Linux 内核角度探秘 JDK NIO 文件读写实质》。

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

当内存资源缓和须要对不常常应用的那些匿名页进行回收时,因为匿名页的背地没有一个磁盘中的文件做依靠,所以匿名页不能像文件页那样间接回收,无论匿名页是不是脏页,都须要先将匿名页中的数据先保留在磁盘空间中,而后在对匿名页进行回收。

并把释放出来的这部分内存调配给更须要的过程应用,当过程再次拜访这块内存时,在从新把之前匿名页中的数据从磁盘空间中读取到内存就能够了,而这块磁盘空间能够是独自的一片磁盘分区(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(页高阈值)。

这三条水位线定义在 /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 预留内存大小。

  • 当该物理内存区域的残余内存容量高于 _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 节点中不同内存区域中的水位线:

其中大部分字段的含意笔者曾经在后面的章节中为大家介绍过了,上面咱们只介绍和本大节内容相干的字段含意:

  • 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。

通常状况下 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。

如果计算出的 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 调整水位线的间距

为了防止内核的间接内存回收 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。

那么如何应用 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 中的内存布局,以及内存对齐和压缩指针的原理及利用》一文中为大家介绍 CPU 的高速缓存时曾提到过,依据摩尔定律:芯片中的晶体管数量每隔 18 个月就会翻一番。导致 CPU 的性能和处理速度变得越来越快,而晋升 CPU 的运行速度比晋升内存的运行速度要容易和便宜的多,所以就导致了 CPU 与内存之间的速度差距越来越大。

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 高速缓存中的物理内存页,冷页是热页的后备选项。

笔者先以内核版本 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;    /* 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,当列表中没有其余成员时,内核会从新填充高速缓存。

以上则是内核版本 2.6.25 之前治理 CPU 高速缓存冷热页的相干数据结构,咱们看到在 2.6.25 之前,内核是应用两个 per_cpu_pages 构造来别离治理冷页和热页汇合的

起初内核开发人员通过测试发现,用两个列表来治理冷热页,并不会比用一个列表集中管理冷热页带来任何的实质性益处,因而在内核版本 2.6.25 之后,将冷页和热页的治理合并在了一个列表中,热页放在列表的头部,冷页放在列表的尾部。

在内核 5.0 的版本中,struct zone 构造中去掉了原来应用 struct per_cpu_pageset 数,因为 struct per_cpu_pageset 构造中别离治理了冷页和热页。

struct zone {
    struct per_cpu_pages    __percpu *per_cpu_pageset;

    int pageset_high;
    int pageset_batch;

} ____cacheline_internodealigned_in_smp;

间接应用 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 */
        
        ............. 省略............

    /* Lists of pages, one per migrate type stored on the pcp-lists */
    struct list_head lists[NR_PCP_LISTS];
};

后面咱们提到,内核为了最大水平的避免内存碎片,将物理内存页面依照是否可迁徙的个性分为了多种迁徙类型:可迁徙,可回收,不可迁徙。在 struct per_cpu_pages 构造中,每一种迁徙类型都会对应一个冷热页链表。

6. 内核如何形容物理内存页

通过前边几个大节的介绍,我想大家当初应该对 Linux 内核整个内存治理框架有了一个总体上的意识。

如上图所示,在 NUMA 架构下内存被划分成了一个一个的内存节点(NUMA Node),在每个 NUMA 节点中,内核又依据节点内物理内存的性能用处不同,将 NUMA 节点内的物理内存划分为四个物理内存区域别离是:ZONE_DMA,ZONE_DMA32,ZONE_NORMAL,ZONE_HIGHMEM。其中 ZONE_MOVABLE 区域是逻辑上的划分,次要是为了避免内存碎片和反对内存的热插拔。

物理内存区域中治理的就是物理内存页(Linux 内存治理的最小单位),后面咱们介绍的内核对物理内存的换入,换出,回收,内存映射等操作的单位就是页。内核为每一个物理内存区域调配了一个搭档零碎,用于治理该物理内存区域下所有物理内存页面的调配和开释。

Linux 默认反对的物理内存页大小为 4KB,在 64 位体系结构中还能够反对 8KB,有的处理器还能够反对 4MB,反对物理地址扩大 PAE 机制的处理器上还能够反对 2MB。

那么 Linux 为什么会默认采纳 4KB 作为规范物理内存页的大小呢

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

那么零碎反对 4KB,8KB,2MB,4MB 等大小的物理页面,它们都是 2 的整数次幂,为啥偏偏要选 4KB 呢?

因为后面提到,在内存缓和的时候,内核会将不常常应用到的物理页面进行换入换出等操作,还有在内存与文件映射的场景下,都会波及到与磁盘的交互,数据在磁盘中组织模式也是依据一个磁盘块一个磁盘块来治理的,4kB 和 4MB 都是磁盘块大小的整数倍,但在大多数状况下,内存与磁盘之间传输小块数据时会更加的高效,所以综上所述内核会采纳 4KB 作为默认物理内存页大小。


假如咱们有 4G 大小的物理内存,每个物理内存页大小为 4K,那么这 4G 的物理内存会被内核划分为 1M 个物理内存页,内核应用一个 struct page 的构造体来形容物理内存页,而每个 struct page 构造体占用内存大小为 40 字节,那么内核就须要用额定的 40 * 1M = 40M 的内存大小来形容物理内存页。

对于 4G 物理内存而言,这额定的 40M 内存占比绝对较小,这个代价勉强能够承受,然而对内存斤斤计较的内核来说,还是会尽最大致力想尽一切办法来管制 struct page 构造体的大小。

因为对于 4G 的物理内存来说,内核就须要应用 1M 个物理页面来治理,1M 个物理页的数量曾经是十分宏大的了,因而在后续的内核迭代中,对于 struct page 构造的任何渺小改变,都可能导致用于治理物理内存页的 struct page 实例所须要的内存暴涨。

回忆一下咱们经验过的很多简单业务零碎,因为业务逻辑曾经非常复杂,在加上业务版本与日俱增的迭代,整个业务零碎曾经变得异样简单,在这种类型的业务零碎中,咱们常常会应用一个十分宏大的类来包装全量的业务响应信息用以应答各种简单的场景,然而这个类曾经蕴含了太多太多的业务字段了,而且这些业务字段在有的场景中会用到,在有的场景中又不会用到,前面还可能持续长期减少很多字段。零碎的保护就这样变得越来越艰难。

相比下面业务零碎开发中随便地减少改变类中的字段,在内核中必定是不会容许这样的行为产生的。struct page 构造是内核中拜访最为频繁的一个构造体,就好比是 Linux 世界里最热闹的地段,在这个最热闹的地段租间房子,那租金堪称是相当的高,同样的情理,内核在 struct page 构造体中减少一个字段的代价也是十分之大,该构造体中每个字段中的每个比特,内核用的都是酣畅淋漓。

然而 struct page 构造同样会面临很多简单的场景,构造体中的某些字段在某些场景下有用,而在另外的场景下却没有用,而内核又不可能像业务零碎开发那样随便地为 struct page 构造减少字段,那么内核该如何应答这种状况呢?

上面咱们行将会看到 struct page 构造体里蕴含了大量的 union 构造,而 union 构造在 C 语言中被用于同一块内存依据不同场景保留不同类型数据的一种形式。内核之所以在 struct page 构造中应用 union,是因为一个物理内存页面在内核中的应用场景和应用形式是多种多样的。在这多种场景下,利用 union 尽最大可能使 struct page 的内存占用放弃在一个较低的程度。

struct page 构造堪称是内核中最为繁冗的一个构造体,利用在内核中的各种性能场景下,在本大节中一一解释分明各个字段的含意是不事实的,上面笔者只会列举 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;    
            // 用于开释复合页的析构函数,保留在首页中
            unsigned char compound_dtor;
            // 该复合页有多少个 page 组成
            unsigned char compound_order;
            // 该复合页被多少个过程应用,内存页反向映射的概念,首页中保留
            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;  // 内存页对应的虚拟内存地址
#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 字段。提到 struct address_space 构造,如果大家之前看过笔者《从 Linux 内核角度探秘 JDK NIO 文件读写实质》这篇文章的话,肯定不会对 struct address_space 感到生疏。

在内核中每个文件都会有一个属于本人的 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。

对相干查找细节感兴趣的同学能够在回看下笔者《从 Linux 内核角度探秘 JDK NIO 文件读写实质》文章中的《8. page cache 中查找缓存页》大节。

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

6.1 匿名页的反向映射

咱们通常所说的内存映射是正向映射,即从虚拟内存到物理内存的映射。而反向映射则是从物理内存到虚拟内存的映射,用于当某个物理内存页须要进行回收或迁徙时,此时须要去找到这个物理页被映射到了哪些过程的虚拟地址空间中,并断开它们之间的映射。

在没有反向映射的机制前,须要去遍历所有过程的虚拟地址空间中的映射页表,这个效率显然是很低下的。有了反向映射机制之后内核就能够间接找到该物理内存页到所有过程映射的虚拟地址空间 VMA,并从 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),并建设虚拟内存与物理内存之间的映射关系(正向映射)。

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|FAULT_FLAG_UNSHARE)) {if (!pte_write(entry))
            // 子过程缺页解决
            return do_wp_page(vmf);
    }

这里咱们次要关注 do_anonymous_page 函数,正是在这里内核实现了 struct anon_vma 构造和 struct anon_vma_chain 构造的创立以及相干匿名页反向映射数据结构的互相关联。

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

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

在 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 之间的映射关系。

匿名页的 struct page 中的 mapping 指针指向的是 struct anon_vma 构造。

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

只有咱们实现了 anon_vma 与 vm_area_struct 之间的关联,那么 page 到 vm_area_struct 之间的映射就建设起来了,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,而后遍历 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,从这个名字上咱们很容易就能猜到这个列表 same_vma 中存储的 anon_vma_chain 对应的 VMA 全都是一样的,而列表元素 anon_vma_chain 中的 anon_vma 却是不一样的。内核用这样一个链表构造 same_vma 存储了过程相应虚拟内存区域 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);

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

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

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 的整条反向映射链路。

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;
    // 转换为 address_space 指针赋值给 page 构造中的 mapping 字段
    page->mapping = (struct address_space *) anon_vma;
    // page 构造中的 index 示意该匿名页在虚拟内存区域 vma 中的偏移
    page->index = linear_page_index(vma, address);
}

当初让咱们再次回到本大节《6.1 匿名页的反向映射》的开始,再来看这段话,是不是感到十分清晰了呢~~

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

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

struct page 构造中的 struct address_space *mapping 指针的最低位如何置 1,又如何置 0 呢?要害在上面这条语句:

    struct anon_vma *anon_vma = vma->anon_vma;
    // 低地位 1
    anon_vma = (void *) anon_vma + PAGE_MAPPING_ANON;

anon_vma 指针加上 PAGE_MAPPING_ANON,并转换为 address_space 指针,这样可确保 address_space 指针的低位为 1 示意匿名页。

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

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

PAGE_MAPPING_ANON 常量定义在内核 /include/linux/page-flags.h 文件中:

#define PAGE_MAPPING_ANON    0x1

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

内核能够通过这个技巧间接查看 page 构造中的 mapping 指针的最低位来判断该物理内存页到底是匿名页还是文件页

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

那匿名页的 page 构造中的 index 属性示意什么呢?咱们接着来看 linear_page_index 函数:

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 - vma->vm_start) >> PAGE_SHIFT;
    pgoff += vma->vm_pgoff;
    return pgoff;
}

逻辑很简略,就是示意匿名页在对应过程虚拟内存区域 VMA 中的偏移。

在本大节最初,还有一个与反向映射相干的重要属性就是 page 构造中的 _mapcount。

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

通过本大节具体的介绍,我想大家当初曾经猜到 _mapcount 字段的含意了,咱们晓得一个物理内存页能够映射到多个过程的虚拟内存空间中,比方:共享内存映射,父子过程的创立等。page 与 VMA 是一对多的关系,这里的 _mapcount 就示意该物理页映射到了多少个过程的虚拟内存空间中。

6.2 内存页回收相干属性

咱们接着来看 struct page 中剩下的其余属性,咱们晓得物理内存页在内核中分为匿名页和文件页,在《5.2 物理内存区域中的水位线》大节中,笔者还提到过两个重要的链表别离为:active 链表和 inactive 链表。

其中 active 链表用来寄存拜访十分频繁的内存页(热页),inactive 链表用来寄存拜访不怎么频繁的内存页(冷页),当内存缓和的时候,内核就会优先将 inactive 链表中的内存页置换进来。

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

咱们能够通过 cat /proc/zoneinfo 命令来查看不同 NUMA 节点中不同内存区域中的 active 链表和 inactive 链表中物理内存页的个数:

  • nr_zone_active_anon 和 nr_zone_inactive_anon 别离是该内存区域内沉闷和非沉闷的匿名页数量。
  • nr_zone_active_file 和 nr_zone_inactive_file 别离是该内存区域内沉闷和非沉闷的文件页数量。

为什么会有 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 链表尾部的页面就会被置换进来。

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

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

在本文《5.2 物理内存区域中的水位线》大节中,笔者为大家介绍了一个叫做 swappiness 的内核参数,咱们能够通过 cat /proc/sys/vm/swappiness 命令查看,swappiness 选项的取值范畴为 0 到 100,默认为 60。

swappiness 用于示意 Swap 机制的踊跃水平,数值越大,Swap 的踊跃水平,越高越偏向于 回收匿名页 。数值越小,Swap 的踊跃水平越低,越偏向于 回收文件页

因为回收匿名页和回收文件页的代价是不一样的,回收匿名页代价会更高一点,所以引入 swappiness 来管制内核回收的偏向。

留神:swappiness 只是示意 Swap 踊跃的水平,当内存十分缓和的时候,即便将 swappiness 设置为 0,也还是会产生 Swap 的。

假如咱们当初只有 active 链表和 inactive 链表,不对这两个链表进行匿名页和文件页的归类,在须要页面置换的时候,内核会先从 active 链表尾部开始扫描,当 swappiness 被设置为 0 时,内核只会置换文件页,不会置换匿名页。

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

因而内核将 active 链表和 inactive 链表依照匿名页和文件页进行了归类,当 swappiness 被设置为 0 时,内核只须要去 nr_zone_active_file 和 nr_zone_inactive_file 链表中扫描即可,晋升了性能。

其实除了以上笔者介绍的四种 LRU 链表(匿名页的 active 链表,inactive 链表和文件页的 active 链表,inactive 链表)之外,内核还有一种链表,比方过程能够通过 mlock() 等零碎调用把内存页锁定在内存里,保障该内存页无论如何不会被置换进来,比方出于平安或者性能的思考,页面中可能会蕴含一些敏感的信息不想被 swap 到磁盘上导致泄密,或者一些频繁拜访的内存页必须始终储存在内存中。

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

当初笔者为大家介绍五种用于寄存 page 的链表,内核会依据不同的状况将一个物理页搁置在这五种链表其中一个上。那么对于物理页的 struct page 构造中就须要有一个属性用来标识该物理页到底被内核搁置在哪个链表上。

struct page {
   struct list_head lru;
   atomic_t _refcount;
}

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

atomic_t _refcount 属性用来记录内核中援用该物理页的次数,示意该物理页的沉闷水平。

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

struct page {unsigned long flags;} 

在本文《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 呢?这是第一个问题~~

笔者在《5. 内核如何治理 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 字段中不仅蕴含下面提到的定位信息还会包含物理内存页的一些属性和标记位。flags 字段的高 8 位用来示意 struct page 的定位信息,残余低位示意特定的标记位。

struct page 与其所属下层构造转换的相应函数定义在 /include/linux/mm.h 文件中:

static inline unsigned long page_to_section(const struct page *page)
{return (page->flags >> SECTIONS_PGSHIFT) & SECTIONS_MASK;
}

static inline pg_data_t *page_pgdat(const struct page *page)
{return NODE_DATA(page_to_nid(page));
}

static inline struct zone *page_zone(const struct page *page)
{return &NODE_DATA(page_to_nid(page))->node_zones[page_zonenum(page)];
}

在咱们介绍完了 flags 字段中高位存储的地位定位信息之后,接下来就该来介绍下在低位比特中示意的物理内存页的那些标记位~~

物理内存页的这些标记位定义在内核 /include/linux/page-flags.h文件中:

enum pageflags {
    PG_locked,        /* Page is locked. Don't touch. */
    PG_referenced,
    PG_uptodate,
    PG_dirty,
    PG_lru,
    PG_active,
    PG_slab,
    PG_reserved,
    PG_compound,
    PG_private,        
    PG_writeback,        
    PG_reclaim,        
#ifdef CONFIG_MMU
    PG_mlocked,        /* Page is vma mlocked */
    PG_swapcache = PG_owner_priv_1,    

        ................
};
  • PG_locked 示意该物理页面曾经被锁定,如果该标记地位位,阐明有使用者正在操作该 page , 则内核的其余局部不容许拜访该页,这能够避免内存治理呈现竞态条件,例如:在从硬盘读取数据到 page 时。
  • PG_mlocked 示意该物理内存页被过程通过 mlock 零碎调用锁定常驻在内存中,不会被置换进来。
  • PG_referenced 示意该物理页面刚刚被拜访过。
  • PG_active 示意该物理页位于 active list 链表中。PG_referenced 和 PG_active 独特管制了零碎应用该内存页的沉闷水平,在内存回收的时候这两个信息十分重要。
  • PG_uptodate 示意该物理页的数据曾经从块设施中读取到内存中,并且期间没有出错。
  • PG_readahead 当过程在程序拜访文件的时候,内核会预读若干相邻的文件页数据到 page 中,物理页 page 构造设置了该标记位,示意它是一个正在被内核预读的页。相干具体内容可回看笔者之前的这篇文章《从 Linux 内核角度探秘 JDK NIO 文件读写实质》
  • PG_dirty 物理内存页的脏页标识,示意该物理内存页中的数据曾经被过程批改,但还没有同步会磁盘中。笔者在《从 Linux 内核角度探秘 JDK NIO 文件读写实质》一文中也具体介绍过。
  • PG_lru 示意该物理内存页当初被搁置在哪个 lru 链表上,比方:是在 active list 链表中?还是在 inactive list 链表中?
  • PG_highmem 示意该物理内存页是在高端内存中。
  • PG_writeback 示意该物理内存页正在被内核的 pdflush 线程回写到磁盘中。详情可回看文章《从 Linux 内核角度探秘 JDK NIO 文件读写实质》。
  • PG_slab 示意该物理内存页属于 slab 分配器所治理的一部分。
  • PG_swapcache 示意该物理内存页处于 swap cache 中。struct page 中的 private 指针这时指向 swap_entry_t。
  • PG_reclaim 示意该物理内存页曾经被内核选中行将要进行回收。
  • PG_buddy 示意该物理内存页是闲暇的并且被搭档零碎所治理。
  • PG_compound 示意物理内存页属于复合页的其中一部分。
  • PG_private 标记被置位的时候示意该 struct page 构造中的 private 指针指向了具体的对象。不同场景指向的对象不同。

除此之外内核还定义了一些规范宏,用来查看某个物理内存页 page 是否设置了特定的标记位,以及对这些标记位的操作,这些宏在内核中的实现都是原子的,命名格局如下:

  • PageXXX(page):查看 page 是否设置了 PG_XXX 标记位
  • SetPageXXX(page):设置 page 的 PG_XXX 标记位
  • ClearPageXXX(page):革除 page 的 PG_XXX 标记位
  • TestSetPageXXX(page):设置 page 的 PG_XXX 标记位,并返回原值

另外在很多状况下,内核通常须要期待物理页 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 的相干字段:

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

首页对应的 struct page 构造里的 flags 会被设置为 PG_head,示意这是复合页的第一页。

另外首页中还保留对于复合页的一些额定信息,比方用于开释复合页的析构函数会保留在首页 struct page 构造里的 compound_dtor 字段中,复合页的调配阶 order 会保留在首页中的 compound_order 中,以及用于批示复合页的援用计数 compound_pincount,以及复合页的反向映射个数(该复合页被多少个过程的页表所映射)compound_mapcount 均在首页中保留。

复合页中的所有尾页都会通过其对应的 struct page 构造中的 compound_head 指向首页,这样通过首页和尾页就组装成了一个残缺的复合页 compound_page。

6.5 Slab 对象池相干属性

本大节只是对 slab 的一个简略介绍,大家有个大略的印象就能够了,前面笔者会有一篇专门的文章为大家具体介绍 slab 的相干实现细节,到时候还会在从新具体介绍 struct page 中的相干属性。

内核中对内存页的调配应用有两种形式,一种是一页一页的调配应用,这种以页为单位的调配形式内核会向相应内存区域 zone 里的搭档零碎申请以及开释。

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

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

它的基本原理是从搭档零碎中申请一整页内存,而后划分成多个大小相等的小块内存被 slab 所治理。这样一来 slab 就和物理内存页 page 产生了关联,因为 slab 治理的单元是物理内存页 page 内进一步划分进去的小块内存,所以当 page 被调配给相应 slab 构造之后,struct page 里也会寄存 slab 相干的一些治理数据。

struct page {

        struct {    /* slab, slob and slub */
            union {
                struct list_head slab_list;
                struct {    /* Partial pages */
                    struct page *next;
#ifdef CONFIG_64BIT
                    int pages;  /* Nr of pages left */
                    int pobjects;   /* Approximate count */
#else
                    short int pages;
                    short int pobjects;
#endif
                };
            };
            struct kmem_cache *slab_cache; /* not slob */
            /* Double-word boundary */
            void *freelist;     /* first free object */
            union {
                void *s_mem;    /* slab: first object */
                struct {            /* SLUB */
                    unsigned inuse:16;
                    unsigned objects:15;
                    unsigned frozen:1;
                };
            };
        };

}
  • struct list_head slab_list:slab 的治理构造中有泛滥用于治理 page 的链表,比方:齐全闲暇的 page 链表,齐全调配的 page 链表,局部调配的 page 链表,slab_list 用于指定以后 page 位于 slab 中的哪个具体链表上。
  • struct page *next:当 page 位于 slab 构造中的某个治理链表上时,next 指针用于指向链表中的下一个 page。
  • int pages : 示意 slab 中总共领有的 page 个数。
  • int pobjects:示意 slab 中领有的特定类型的对象个数。
  • struct kmem_cache *slab_cache:用于指向以后 page 所属的 slab 治理构造,通过 slab_cache 将 page 和 slab 关联起来。
  • void *freelist:指向 page 中的第一个未调配进来的闲暇对象,后面介绍过,slab 向搭档零碎申请一个或者多个 page,并将一整页 page 划分出多个大小相等的内存块,用于存储特定类型的对象。
  • void *s_mem:指向 page 中的第一个对象。
  • unsigned inuse:示意 slab 中曾经被调配进来的对象个数,当该值为 0 时,示意 slab 中所治理的对象全都是闲暇的,当所有的闲暇对象达到肯定数目,该 slab 就会被搭档零碎回收掉。
  • unsigned objects:slab 中所有的对象个数。
  • unsigned frozen : 以后内存页 page 被 slab 搁置在 CPU 本地缓存列表中,frozen = 1,否则 frozen = 0。

总结

到这里,对于 Linux 物理内存治理的相干内容笔者就为大家介绍完了,本文的内容比拟多,尤其是物理内存页反向映射相干的内容比较复杂,波及到的关联关系比拟多,当初笔者在带大家总结一下本文的次要内容,不便大家温习回顾:

在本文的开始,笔者首先从 CPU 角度为大家介绍了三种物理内存模型:FLATMEM 平坦内存模型,DISCONTIGMEM 非间断内存模型,SPARSEMEM 稠密内存模型。

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

在这个根底之上,又依照内核对物理内存的组织治理档次,别离介绍了 Node 节点,物理内存区域 zone 等相干内核构造。它们的档次如下图所示:

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

最初,笔者具体介绍了内核如何通过 struct page 构造来形容物理内存页,其中匿名页反向映射的内容比较复杂,须要大家多多梳理回顾一下。

好了,本文的内容到这里就全副完结了,感激大家的急躁观看,咱们下篇文章见~~~

正文完
 0