内存管理

Linux内存管理框架图

早期内存分配

在早期的计算机中,要运行一个程序,会把这些程序全都装入内存,程序都是直接运行在内存上的,也就是说程序中访问的内存地址都是实际的物理内存地址。当计算机同时运行多个程序时,必须保证这些程序用到的内存总量要小于计算机实际物理内存的大小。那当程序同时运行多个程序时,操作系统是如何为这些程序分配内存的呢?下面通过实例来说明当时的内存分配方法: 某台计算机总的内存大小是128M,现在同时运行两个程序A和B,A需占用内存10M,B需占用内存110。计算机在给程序分配内存时会采取这样的方法:先将内存中的前10M分配给程序A,接着再从内存中剩余的118M中划分出110M分配给程序B。这种分配方法可以保证程序A和程序B都能运行,但是这种简单的内存分配策略问题很多。 问题1:进程地址空间不隔离。由于程序都是直接访问物理内存,所以恶意程序可以随意修改别的进程的内存数据,以达到破坏的目的。有些非恶意的,但是有bug的程序也可能不小心修改了其它程序的内存数据,就会导致其它程序的运行出现异常。这种情况对用户来说是无法容忍的,因为用户希望使用计算机的时候,其中一个任务失败了,至少不能影响其它的任务。 问题2:内存使用效率低。在A和B都运行的情况下,如果用户又运行了程序C,而程序C需要20M大小的内存才能运行,而此时系统只剩下8M的空间可供使用,所以此时系统必须在已运行的程序中选择一个将该程序的数据暂时拷贝到硬盘上,释放出部分空间来供程序C使用,然后再将程序C的数据全部装入内存中运行。可以想象得到,在这个过程中,有大量的数据在装入装出,导致效率十分低下。 问题3:程序运行的地址不确定。当内存中的剩余空间可以满足程序C的要求后,操作系统会在剩余空间中随机分配一段连续的20M大小的空间给程序C使用,因为是随机分配的,所以程序运行的地址是不确定的。

分段

为了解决上述问题,人们想到了一种变通的方法,就是增加一个中间层,利用一种间接的地址访问方法访问物理内存。按照这种方法,程序中访问的内存地址不再是实际的物理内存地址,而是一个虚拟地址,然后由操作系统将这个虚拟地址映射到适当的物理内存地址上。这样,只要操作系统处理好虚拟地址到物理内存地址的映射,就可以保证不同的程序最终访问的内存地址位于不同的区域,彼此没有重叠,就可以达到内存地址空间隔离的效果。 当创建一个进程时,操作系统会为该进程分配一个4GB大小的虚拟进程地址空间。之所以是4GB,是因为在32位的操作系统中,一个指针长度是4字节,而4字节指针的寻址能力是从0x00000000~0xFFFFFFFF,最大值0xFFFFFFFF表示的即为4GB大小的容量。与虚拟地址空间相对的,还有一个物理地址空间,这个地址空间对应的是真实的物理内存。如果你的计算机上安装了512M大小的内存,那么这个物理地址空间表示的范围是0x00000000~0x1FFFFFFF。当操作系统做虚拟地址到物理地址映射时,只能映射到这一范围,操作系统也只会映射到这一范围。当进程创建时,每个进程都会有一个自己的4GB虚拟地址空间。要注意的是这个4GB的地址空间是“虚拟”的,并不是真实存在的,而且每个进程只能访问自己虚拟地址空间中的数据,无法访问别的进程中的数据,通过这种方法实现了进程间的地址隔离。那是不是这4GB的虚拟地址空间应用程序可以随意使用呢?很遗憾,在Windows系统下,这个虚拟地址空间被分成了4部分:NULL指针区、用户区、64KB禁入区、内核区。应用程序能使用的只是用户区而已,大约2GB左右(最大可以调整到3GB)。内核区为2GB,内核区保存的是系统线程调度、内存管理、设备驱动等数据,这部分数据供所有的进程共享,但应用程序是不能直接访问的。 人们之所以要创建一个虚拟地址空间,目的是为了解决进程地址空间隔离的问题。但程序要想执行,必须运行在真实的内存上,所以,必须在虚拟地址与物理地址间建立一种映射关系。这样,通过映射机制,当程序访问虚拟地址空间上的某个地址值时,就相当于访问了物理地址空间中的另一个值。人们想到了一种分段(Sagmentation)的方法,它的思想是在虚拟地址空间和物理地址空间之间做一一映射。比如说虚拟地址空间中某个10M大小的空间映射到物理地址空间中某个10M大小的空间。这种思想理解起来并不难,操作系统保证不同进程的地址空间被映射到物理地址空间中不同的区域上,这样每个进程最终访问到的 物理地址空间都是彼此分开的。通过这种方式,就实现了进程间的地址隔离。还是以实例说明,假设有两个进程A和B,进程A所需内存大小为10M,其虚拟地址空间分布在0x00000000到0x00A00000,进程B所需内存为100M,其虚拟地址空间分布为0x00000000到0x06400000。那么按照分段的映射方法,进程A在物理内存上映射区域为0x00100000到0x00B00000,,进程B在物理内存上映射区域为0x00C00000到0x07000000。于是进程A和进程B分别被映射到了不同的内存区间,彼此互不重叠,实现了地址隔离。从应用程序的角度看来,进程A的地址空间就是分布在0x00000000到0x00A00000,在做开发时,开发人员只需访问这段区间上的地址即可。应用程序并不关心进程A究竟被映射到物理内存的那块区域上了,所以程序的运行地址也就是相当于说是确定的了。 图二显示的是分段方式的内存映射方法。 分段方式的内存映射方法 这种分段的映射方法虽然解决了上述中的问题一和问题三,但并没能解决问题二,即内存的使用效率问题。在分段的映射方法中,每次换入换出内存的都是整个程序,这样会造成大量的磁盘访问操作,导致效率低下。所以这种映射方法还是稍显粗糙,粒度比较大。实际上,程序的运行有局部性特点,在某个时间段内,程序只是访问程序的一小部分数据,也就是说,程序的大部分数据在一个时间段内都不会被用到。基于这种情况,人们想到了粒度更小的内存分割和映射方法,这种方法就是分页(Paging)。 虽然在地址空间中每个分段的地址都是连续的,但实际上,每个分段映射到物理内存地址时是独立的,段与段之间可以不连续。这是因为CPU为每个段都使用一对(即两个)特殊的寄存器:基址寄存器和界限寄存器。

分页

分页的基本方法是,将地址空间分成许多的页。每页的大小由CPU决定,然后由操作系统选择页的大小。目前Inter系列的CPU支持4KB或4MB的页大小,而PC上目前都选择使用4KB。按这种选择,4GB虚拟地址空间共可以分成1048576个页,512M的物理内存可以分为131072个页。显然虚拟空间的页数要比物理空间的页数多得多。 在分段的方法中,每次程序运行时总是把程序全部装入内存,而分页的方法则有所不同。分页的思想是程序运行时用到哪页就为哪页分配内存,没用到的页暂时保留在硬盘上。当用到这些页时再在物理地址空间中为这些页分配内存,然后建立虚拟地址空间中的页和刚分配的物理内存页间的映射。 Linux下,一个可执行文件的装载过程可以很好地展示分页机制的实现: 当一个可执行文件被执行时(例如通过exec()系统调用),Linux内核需要将该文件从磁盘加载到内存中,并为其创建一个新的进程上下文。这包括加载文件的代码段、数据段、以及初始化栈空间和堆空间。 分页机制通过将可执行文件的虚拟地址空间分为页,内核将这些虚拟页映射到物理内存页中。整个过程涉及到虚拟内存管理和页表的使用。 Linux使用虚拟内存机制为每个进程提供独立的地址空间。虚拟内存空间被划分为多个区域,每个区域代表进程的不同部分,例如文本段(代码)、数据段、堆和栈。

  • 文本段:包含可执行文件的指令。

  • 数据段:包含全局变量和已初始化的数据。

  • 堆(Heap):用于动态内存分配。

  • 栈(Stack):用于函数调用时的局部变量和函数调用链。 为了将虚拟地址空间映射到物理内存,Linux通过页表来管理。每个进程都有自己的页表,包含虚拟地址到物理地址的映射关系。

  • 一级页表:负责管理虚拟地址空间中的大块区域。

  • 二级页表:进一步将这些大块区域划分为更小的物理内存页。 当一个进程访问某个虚拟地址时,内核通过页表找到对应的物理内存地址。 在可执行文件的装载过程中,分页机制的作用主要体现在:

  • 懒加载:当进程第一次访问某个虚拟地址时,内核通过页错误(Page Fault)中断将对应的页从磁盘加载到内存。这种按需加载(Demand Paging)节省了内存,因为只有真正使用的页才会加载到内存中。 -段映射:可执行文件被分为多个段,例如.text(代码段)、.data(数据段)等。每个段被映射到进程的虚拟地址空间中,不同段可以映射到不同的物理内存页,并且具有不同的权限(例如,代码段通常是只读的)。 分页机制实现了内存的高效管理,具有以下几个优点:

  • 隔离性:每个进程的虚拟内存空间是独立的,进程之间不会互相干扰。

  • 内存保护:通过设置页表条目的访问权限,可以保护内存区域不被非法访问。

  • 内存共享:不同进程可以共享相同的物理内存页,例如共享库,通过映射到不同的虚拟地址空间实现代码段的共享。 为了加速页表查找,处理器通常有一个称为“Translation Lookaside Buffer (TLB)”的高速缓存。TLB缓存了最近使用的页表条目,从而减少了每次访问内存时都进行页表查找的开销。

虚拟内存

单片机是没有操作系统的,所以每次写完代码,都需要借助工具把程序烧录进去,这样程序才能跑起来。 单片机的 CPU 是直接操作内存的「物理地址」。要想在内存中同时运行两个程序是不可能的。 关键的问题是这两个程序都引用了绝对物理地址,而这正是我们最需要避免的。 可以把进程所使用的地址「隔离」开来,即让操作系统为每个进程分配独立的一套「虚拟地址」,但是有个前提每个进程都不能访问物理地址,至于虚拟地址最终怎么落到物理内存里,对进程来说是透明的。 操作系统会提供一种机制,将不同进程的虚拟地址和不同内存的物理地址映射起来。 如果程序要访问虚拟地址的时候,由操作系统转换成不同的物理地址,这样不同的进程运行的时候,写入的是不同的物理地址,这样就不会冲突了。 两种地址的概念:

  • 程序所使用的内存地址叫做虚拟内存地址(Virtual Memory Address)

  • 实际存在硬件里面的空间地址叫物理内存地址(Physical Memory Address) 操作系统引入了虚拟内存,进程持有的虚拟地址会通过CPU芯片中的内存管理单元(MMU)的映射关系,来转换变成物理地址,然后再通过物理地址访问内存。 芯片内存管理 操作系统是如何管理虚拟地址与物理地址之间的关系: 主要有两种方式,分别是内存分段和内存分页,分段是比较早提出的。

内存分段

linux进程的虚拟地址空间

程序是由若干个逻辑分段组成的,如可由代码分段、数据分段、栈段、堆段组成。不同的段是有不同的属性的,所以就用分段(Segmentation)的形式把这些段分离出来。 一个典型的程序通常包含以下几个主要的逻辑分段:

  • 代码段(Text Segment / Code Segment) 包含程序的可执行指令。这个段是只读的,因为程序的代码在运行时不应该被修改,比如编译后的机器码,包括所有函数和逻辑指令

  • 数据段(Data Segment) 存储程序中已初始化的全局变量和静态变量。这个段在程序运行时可以读写。 子段:.data 段:存放已初始化的全局变量和静态变量 .bss 段:存放未初始化的全局变量和静态变量,这部分的内存在程序加载时初始化为零。

  • 堆(Heap Segment) 用于动态内存分配。当程序在运行时需要分配内存(如调用 malloc()或new),内存会从堆段分配。堆的大小可以动态增长或收缩。如动态创建的对象、动态数组等,程序员负责管理堆内存的分配和释放,否则会导致内存泄漏。

  • 栈(Stack Segment) 用于存储函数的局部变量、函数调用的参数和返回地址。每次函数调用时,会在栈上分配空间,函数返回时,栈上的空间会自动释放。如局部变量、函数参数等。栈是自动管理的,遵循后进先出(LIFO)的原则。编译器会将函数参数放入寄存器来优化程序,只有寄存器放不下的参数才使用栈帧来保存。 其中代码段和数据段的大小在编译时就已经确定,堆栈段和堆段段的大小可能在运行时确定。堆栈段的大小一般在程序运行时动态调整,但内存管理系统通常会为堆栈预留一个合理的初始大小,并根据需求扩展或收缩。堆的大小是动态的,随着程序的malloc/free或new/delete操作而变化。然而,在一个特定时刻,堆的大小是可以确定的,尽管它可能在程序运行期间增长或缩小。段表中的这些信息在程序加载时确定,并在程序运行期间动态更新。 C代码和内存布局 分段机制下的虚拟地址由两部分组成,段选择因子和段内偏移量。 内存分段 段选择因子和段内偏移量:

  • 段选择子就保存在段寄存器里面。段选择子里面最重要的是段号,用作段表的索引。段表里面保存的是这个段的基地址、段的界限和特权等级等。

  • 虚拟地址中的段内偏移量应该位于0和段界限之间,如果段内偏移量是合法的,就将段基地址加上段内偏移量得到物理内存地址。 虚拟地址是通过段表与物理地址进行映射的,分段机制会把程序的虚拟地址分成4个段,每个段在段表中有一个项,在这一项找到段的基地址,再加上偏移量,于是就能找到物理内存中的地址。 内存分段地址映射 如果要访问段3中偏移量500的虚拟地址,我们可以计算出物理地址为,段3基地址7000+偏移量500=7500。 小结: cpu中存在段寄存器,该寄存器结构中包含段选择因子和段内偏移量,段选择因子中有段号、特权等标记位,根据段号查找段表找到段基地址,再综合段基地址和段内偏移量就可以找到要访问的内存地址了。 分段的办法很好,解决了程序本身不需要关心具体的物理内存地址的问题,但它也有一些不足之处:

  • 第一个就是内存碎片的问题。

  • 第二个就是内存交换的效率低的问题 内存碎片问题: 假设有 1G 的物理内存,用户执行了多个程序,其中

  • 游戏占用了 512MB 内存

  • 浏览器占用了 128MB 内存

  • 音乐占用了 256 MB 内存 这个时候,如果我们关闭了浏览器,则空闲内存还有 1024 - 512 - 256 = 256MB 如果这个256MB不是连续的,被分成了两段128MB内存,这就会导致没有空间再打开一个200MB的程序。 内存碎片问题 内存碎片主要分为,内部内存碎片和外部内存碎片。 内部碎片是指在内存分配时,分配的内存块比实际需要的要大,导致多余的内存空间无法使用,从而形成浪费。 外部碎片是指内存中空闲的空间被分割成许多小块,虽然这些小块总和足够大,但由于它们不连续,无法满足较大块内存的分配需求,导致内存分配失败。 内存分段管理可以做到段根据实际需求分配内存,所以有多少需求就分配多大的段所以不会出现内部内存碎片。但是由于每个段的长度不固定,所以多个段未必能恰好使用所有的内存空间,会产生了多个不连续的小物理内存,导致新的程序无法被装载,所以会出现外部内存碎片的问题。 解决「外部内存碎片」的问题就是内存交换。 可以把音乐程序占用的那256MB内存写到硬盘上,然后再从硬盘上读回来到内存里。不过再读回的时候,我们不能装载回原来的位置,而是紧紧跟着那已经被占用了的512MB内存后面。这样就能空缺出连续的256MB空间,于是新的200MB程序就可以装载进来。 这个内存交换空间,在Linux系统里,也就是我们常看到的Swap空间,这块空间是从硬盘划分出来的,用于内存与硬盘的空间交换。 分段为什么会导致内存交换效率低的问题 对于多进程的系统来说,用分段的方式,外部内存碎片是很容易产生的,产生了外部内存碎片,那不得不重新 Swap内存区域,这个过程会产生性能瓶颈。 因为硬盘的访问速度要比内存慢太多了,每一次内存交换,我们都需要把一大段连续的内存数据写到硬盘上。 如果内存交换的时候,交换的是一个占内存空间很大的程序,这样整个机器都会显得卡顿。 为了解决内存分段的「外部内存碎片和内存交换效率低」的问题,就出现了内存分页。 禁用swap:

  • 临时禁用 swapoff -a

  • 永久禁用 编辑/etc/fstab文件,注释掉swap类型的行 内存分段 段内偏移量在32位架构中通常占用32位,可以覆盖整个4GB的地址空间

内存分页

分段的好处就是能产生连续的内存空间,但是会出现「外部内存碎片和内存交换的空间太大」的问题。 分页是把整个虚拟和物理内存空间切成一段段固定尺寸的大小。这样一个连续并且尺寸固定的内存空间,我们叫页(Page)。在Linux下,每一页的大小为4KB。 分页是一种内存管理技术,用于将物理内存分割为固定大小的块,称为页框,同时将虚拟内存分割为相同大小的块,称为页。每一页(虚拟内存中的单位)映射到物理内存中的一个页框。虚拟地址通过页表转换成物理地址,从而访问实际存储的数据。 虚拟地址与物理地址之间通过页表来映射,如下图: 内存分页 页表是存储在内存里的,内存管理单元 (MMU)就做将虚拟内存地址转换成物理地址的工作。 而当进程访问的虚拟地址在页表中查不到时,系统会产生一个缺页异常,进入系统内核空间分配物理内存、更新进程页表,最后再返回用户空间,恢复进程的运行。 分页是怎么解决分段的「外部内存碎片和内存交换效率低」的问题? 内存分页由于内存空间都是预先划分好的,也就不会像内存分段一样,在段与段之间会产生间隙非常小的内存,这正是分段会产生外部内存碎片的原因。而采用了分页,页与页之间是紧密排列的,所以不会有外部碎片。 但是,因为内存分页机制分配内存的最小单位是一页,即使程序不足一页大小,我们最少只能分配一个页,所以页内会出现内存浪费,所以针对内存分页机制会有内部内存碎片的现象。 如果内存空间不够,操作系统会把其他正在运行的进程中的「最近没被使用」的内存页面给释放掉,也就是暂时写在硬盘上,称为换出(Swap Out)。一旦需要的时候,再加载进来,称为换入(Swap In)。所以,一次性写入磁盘的也只有少数的一个页或者几个页,不会花太多时间,内存交换的效率就相对比较高。 换入换出webp 分页的方式使得我们在加载程序的时候,不再需要一次性都把程序加载到物理内存中。我们完全可以在进行虚拟内存和物理内存的页之间的映射之后,并不真的把页加载到物理内存里,而是只有在程序运行中,需要用到对应虚拟内存页里面的指令和数据时,再加载到物理内存里面去。 分页机制下,虚拟地址和物理地址是如何映射的? 在分页机制下,虚拟地址分为两部分,页号和页内偏移。页号作为页表的索引,页表包含物理页每页所在物理内存的基地址,这个基地址与页内偏移的组合就形成了物理内存地址。 物理内存地址 对于一个内存地址转换,其实就是这样三个步骤:

  • 把虚拟内存地址,切分成页号和偏移量

  • 根据页号,从页表里面,找到对应的页表项

  • 计算页表项的物理地址=页号*页表项大+页表基地址

  • 从内存中读取解析页表项内容,查询对应的物理页号

  • 直接拿物理页号,加上前面的偏移量,就得到了物理内存地址 一级内存分页 页号仅仅是逻辑上的索引,它告诉我们要查找的是页表中的哪个项,页表项地址是物理内存中的一个地址,存放了页表项的实际内容 物理页地址是从0开始的,所有由页框号*物理页大小可以直接定位物理页物理地址 举例:假设虚拟地址为0x12345678,页表基地址为0x12345678 首先将16进制虚拟地址转换为2进制,并划分为两段(20位页号+12位偏移量) 二进制: 0001 0010 0011 0100 0101 0110 0111 1000 前22位页号为0001 0010 0011 0100 0101(十进制74565) 页内偏移量 10 0111 1000(十进制1656) 假设页表项中页框的物理基地址为 0x000B8000 计算页表项地址= 0x000B8000 + 298261*4B = 0x000B8678 查找页表根据页表项地址找到页框号对应的物理地址比如0xCAFEB000 再加上偏移量就是最后的物理地址=0xCAFEB000 + 1656

现代操作系统采用每个进程维护独立页表的机制,这样可以保证进程之间的地址空间隔离、灵活的内存管理以及系统的安全性和稳定性。 下面举个例子,虚拟内存中的页通过页表映射为了物理内存中的页: 物理地址计算举例 简单的分页有什么缺陷吗? 有空间上的缺陷。因为操作系统是可以同时运行非常多的进程的,那这不就意味着页表会非常的庞大。 在32位的环境下,虚拟地址空间共有4GB,假设一个页的大小是4KB(2^12),业内地址要用12位来表示,剩余 20 位表示页号,那么就需要大约100万(2^20)个页,每个「页表项」需要4个字节大小来存储,那么整个4GB空间的映射就需要有4MB的内存来存储页表。 这4MB大小的页表,看起来也不是很大。但是要知道每个进程都是有自己的虚拟地址空间的,也就说都有自己的页表。 那么,100个进程的话,就需要400MB的内存来存储页表,这是非常大的内存了,更别说64位的环境了。 对于64位的操作系统,页表项大小为8B,页大小为4KB,那就需要2**64/2**12=2*52个页表项,那单个页表的大小为2**528B,换算后就是32PB,相当庞大。

多级页表

要解决上面的问题,就需要采用一种叫作多级页表(Multi-Level Page Table)的解决方案。 对于单页表的实现方式,在32位和页大小4KB的环境下,一个进程的页表需要装下100多万个「页表项」,并且每个页表项是占用4字节大小的,于是相当于每个页表需占用4MB大小的空间。 我们把这个 100 多万个「页表项」的单级页表再分页,将页表(一级页表)分为1024个页表(二级页表),每个表(二级页表)中包含1024个「页表项」,形成二级分页。如下图所示: 多级页表 页表基地址寄存器(PTBR)在二级分页中指向的是顶级的页目录表的起始地址,根据页表基地址+页号可以找到页表目录项中对应的页表项 举例:假设虚拟地址为0x12345678 首先将16进制虚拟地址转换为2进制,并划分为三段 页目录索引 (PDI): 高10位0001001000 (十进制: 72) 页表索引 (PTI): 中间10位1101000101 (十进制: 837) 页内偏移 (Offset): 低12位011001111000 (十进制: 1656) 开始进行地址转换 由页目录索引查找一级页目录,从页目录中找到72号页表对应的物理地址,假设为0x0000A000 然后根据二级页表索引计算物理页框号地址=0x0000A000 + 837 * 4B = 0x000B8000 最后计算物理地址 将页框地址 0x000B8000 与页内偏移1656相加 物理地址 = 0x000B8000 + 0x00000678 = 0x000B8678 分了二级表,映射 4GB 地址空间就需要 4KB(一级页表)+ 4MB(二级页表)的内存,这样占用空间不是更大了吗? 当然如果4GB的虚拟地址全部都映射到了物理内存上的话,二级分页占用空间确实是更大了,但是,我们往往不会为一个进程分配那么多内存。 每个进程都有4GB的虚拟地址空间,而显然对于大多数程序来说,其使用到的空间远未达到4GB,因为会存在部分对应的页表项都是空的,根本没有分配,对于已分配的页表项,如果存在最近一定时间未访问的页表,在物理内存紧张的情况下,操作系统会将页面换出到硬盘,也就是说不会占用物理内存。 如果使用了二级分页,一级页表就可以覆盖整个4GB虚拟地址空间,但如果某个一级页表的页表项没有被用到,也就不需要创建这个页表项对应的二级页表了,即可以在需要时才创建二级页表。做个简单的计算,假设只有20%的一级页表项被用到了,那么页表占用的内存空间就只有 4KB(一级页表) + 20%*4MB(二级页表)= 0.804MB 那么为什么不分级的页表就做不到这样节约内存呢? 从页表的性质来看,保存在内存中的页表承担的职责是将虚拟地址翻译成物理地址。假如虚拟地址在页表中找不到对应的页表项,计算机系统就不能工作了。所以页表一定要覆盖全部虚拟地址空间,不分级的页表就需要有100多万个页表项来映射,而二级分页则只需要1024个页表项(此时一级页表覆盖到了全部虚拟地址空间,二级页表在需要时创建)。 我们把二级分页再推广到多级页表,就会发现页表占用的内存空间更少了,这一切都要归功于对局部性原理的充分应用。 对于64位的系统,两级分页肯定不够了,就变成了四级目录,分别是:

  • 页全局目录(PML4):具有512个条目,每个条目8字节,管理512 GB的地址空间。

  • 页上级目录(PDPT):每个PML4条目指向一个页上级目录(PDPT),具有512个条目,每个条目 8 字节,管理1GB的地址空间。

  • 页目录(PD):每个PDPT条目指向一个页目录(PD),具有512个条目,每个条目8字节,管理2MB 的地址空间。

  • 页表(PT):每个页目录条目指向一个页表(PT),具有512个条目,每个条目8字节,管理4KB的地址空间。

  • 页内偏移量(Offset):用于定位页内的具体位置,通常是12位(4KB页大小)。 四级分页 虽然理论上64位操作系统可以使用两级分页,但出于效率、扩展性和内存管理的考虑,现代操作系统通常采用四级甚至更多级的分页机制。

TLB(Translation Lookaside Buffer)

多级页表虽然解决了空间上的问题,但是虚拟地址到物理地址的转换就多了几道转换的工序,这显然就降低了这俩地址转换的速度,也就是带来了时间上的开销。 程序是有局部性的,即在一段时间内,整个程序的执行仅限于程序中的某一部分。相应地,执行所访问的存储空间也局限于某个内存区域。 局部访问 我们就可以利用这一特性,把最常访问的几个页表项存储到访问速度更快的硬件,于是计算机科学家们,就在CPU芯片中,加入了一个专门存放程序最常访问的页表项的 Cache,这个Cache就是 TLB(Translation Lookaside Buffer),通常称为页表缓存、转址旁路缓存、快表等。 tlb 在CPU芯片里面,封装了内存管理单元(Memory Management Unit)芯片,它用来完成地址转换和 TLB的访问与交互。 有了TLB后,那么CPU在寻址时,会先查TLB,如果没找到,才会继续查常规的页表。TLB 的命中率其实是很高的,因为程序最常访问的页就那么几个。

段页式内存管理

内存分段和内存分页并不是对立的,它们是可以组合起来在同一个系统中使用的,那么组合起来后,通常称为段页式内存管理。 段页式内存管理实现的方式:

  • 先将程序划分为多个有逻辑意义的段,也就是前面提到的分段机制;

  • 接着再把每个段划分为多个页,也就是对分段划分出来的连续空间,再划分固定大小的页; 这样,地址结构就由段号、段内页号和页内位移三部分组成。 用于段页式地址变换的数据结构是每一个程序一张段表,每个段又建立一张页表,段表中的地址是页表的起始地址,而页表中的地址则为某页的物理页号,如图所示: 段页式 段页式地址变换中要得到物理地址须经过三次内存访问:

  • 第一次访问段表,得到页表起始地址;

  • 第二次访问页表,得到物理页号;

  • 第三次将物理页号与页内位移组合,得到物理地址。 可用软、硬件相结合的方法实现段页式地址变换,这样虽然增加了硬件成本和系统开销,但提高了内存的利用率。

Linux 内存布局

早期 Intel 的处理器从80286开始使用的是段式内存管理。但是很快发现,光有段式内存管理而没有页式内存管理是不够的,这会使它的X86系列会失去市场的竞争力。因此,在不久以后的 80386中就实现了页式内存管理。也就是说,80386 除了完成并完善从80286开始的段式内存管理的同时还实现了页式内存管理。 但是这个80386的页式内存管理设计时,没有绕开段式内存管理,而是建立在段式内存管理的基础上,这就意味着,页式内存管理的作用是在由段式内存管理所映射而成的地址上再加上一层地址映射。 由于此时由段式内存管理映射而成的地址不再是“物理地址”了,Intel 就称之为“线性地址”(也称虚拟地址)。于是,段式内存管理先将逻辑地址映射成线性地址,然后再由页式内存管理将线性地址映射成物理地址。 地址转换 这里说明下逻辑地址和线性地址:

  • 程序所使用的地址,通常是没被段式内存管理映射的地址,称为逻辑地址;

  • 通过段式内存管理映射的地址,称为线性地址,也叫虚拟地址; 逻辑地址是「段式内存管理」转换前的地址,线性地址则是「页式内存管理」转换前的地址。 Linux 内存主要采用的是页式内存管理,但同时也不可避免地涉及了段机制。 Linux系统中的每个段都是从0地址开始的整个4GB虚拟空间(32 位环境下),也就是所有的段的起始地址都是一样的。这意味着,Linux系统中的代码,包括操作系统本身的代码和应用程序代码,所面对的地址空间都是线性地址空间(虚拟地址),这种做法相当于屏蔽了处理器中的逻辑地址概念,段只被用于访问控制和内存保护。 Linux的虚拟地址空间是如何分布的? 在Linux操作系统中,虚拟地址空间的内部又被分为内核空间和用户空间两部分,不同位数的系统,地址空间的范围也不同。比如最常见的32位和64位系统,如下所示: 内存分配 通过这里可以看出:

  • 32位系统的内核空间占用1G,位于最高处,剩下的3G是用户空间;

  • 64位系统的内核空间和用户空间都是128T,分别占据整个内存空间的最高和最低处,剩下的中间部分是未定义的。 再来说说,内核空间与用户空间的区别:

  • 进程在用户态时,只能访问用户空间内存;

  • 只有进入内核态后,才可以访问内核空间的内存; 虽然每个进程都各自有独立的虚拟内存,但是每个虚拟内存中的内核地址,其实关联的都是相同的物理内存。这样,进程切换到内核态后,就可以很方便地访问内核空间内存。 用户空间分布的情况,以32位系统为例: 32位虚拟内存布局 通过这张图你可以看到,用户空间内存,从低到高分别是6种不同的内存段:

  • 代码段,包括二进制可执行代码;

  • 数据段,包括已初始化的静态常量和全局变量;

  • BSS段,包括未初始化的静态变量和全局变量;

  • 堆段,包括动态分配的内存,从低地址开始向上增长;

  • 文件映射段,包括动态库、共享内存等,从低地址开始向上增长

  • 栈段,包括局部变量和函数调用的上下文等。栈的大小是固定的,一般是8MB。当然系统也提供了参数,以便我们自定义大小; 代码段下面还有一段内存空间的(灰色部分),这一块区域是「保留区」,之所以要有保留区这是因为在大多数的系统里,我们认为比较小数值的地址不是一个合法地址,例如,我们通常在C的代码里会将无效的指针赋值为 NULL。因此,这里会出现一段不可访问的内存保留区,防止程序因为出现bug,导致读或写了一些小内存地址的数据,而使得程序跑飞。 在这7个内存段中,堆和文件映射段的内存是动态分配的。比如说,使用C标准库的malloc()或者mmap(),就可以分别在堆和文件映射段动态分配内存。

malloc分配内存

每个进程都各自有独立的虚拟内存,但是每个虚拟内存中的内核地址,其实关联的都是相同的物理内存。

malloc 是如何分配内存的?

实际上,malloc()并不是系统调用,而是C库里的函数,用于动态分配内存。 malloc 申请内存的时候,会有两种方式向操作系统申请堆内存。 方式一:通过brk()系统调用从堆分配内存 方式二:通过mmap()系统调用在文件映射区域分配内存; 方式一实现的方式很简单,就是通过brk()函数将「堆顶」指针向高地址移动,获得新的内存空间。 brk申请 方式二通过mmap() 系统调用中「私有匿名映射」的方式,在文件映射区分配一块内存,也就是从文件映射区“偷”了一块内存。如下图: mmap申请 malloc() 源码里默认定义了一个阈值:

  • 如果用户分配的内存小于128KB,则通过brk()申请内存;

  • 如果用户分配的内存大于128KB,则通过mmap()申请内存; 不同的 glibc 版本定义的阈值也是不同的。 malloc() 分配的是虚拟内存。 如果分配后的虚拟内存没有被访问的话,虚拟内存是不会映射到物理内存的,这样就不会占用物理内存了。 只有在访问已分配的虚拟地址空间的时候,操作系统通过查找页表,发现虚拟内存对应的页没有在物理内存中,就会触发缺页中断,然后操作系统会建立虚拟内存和物理内存之间的映射关系。 malloc() 在分配内存的时候,并不是老老实实按用户预期申请的字节数来分配内存空间大小,而是会预分配更大的空间作为内存池。 具体会预分配多大的空间,跟 malloc 使用的内存管理器有关系,我们就以 malloc 默认的内存管理器(Ptmalloc2)来分析。

#include <stdio.h>
#include <malloc.h>
int main() {
printf("使用cat /proc/%d/maps查看内存分配\n",getpid());
//申请1字节的内存
void *addr = malloc(1);
printf("此1字节的内存起始地址:%x\n", addr);
printf("使用cat /proc/%d/maps查看内存分配\n",getpid());
//将程序阻塞,当输入任意字符时才往下执行
getchar();
//释放内存
free(addr);
printf("释放了1字节的内存,但heap堆并不会释放\n");
getchar();
return 0;
}

可以通过 /proc//maps 文件查看进程的内存分布情况。在maps文件通过此 1 字节的内存起始地址过滤出了内存地址的范围。

cat /proc/3191/maps | grep d730
00d73000-00d94000 rw-p 00000000 00:00 0             [heap]

这个例子分配的内存小于 128 KB,所以是通过 brk() 系统调用向堆空间申请的内存,因此可以看到最右边有 [heap] 的标识。 可以看到,堆空间的内存地址范围是 00d73000-00d94000,这个范围大小是 132KB,也就说明了 malloc(1) 实际上预分配 132K 字节的内存。 程序里打印的内存起始地址是 d73010,而 maps 文件显示堆内存空间的起始地址是 d73000,为什么会多出来 0x10 (16字节)呢?

free 释放内存,会归还给操作系统吗?

在上面的进程往下执行,看看通过 free() 函数释放内存后,堆内存还在吗? 通过 free 释放内存后,堆内存还是存在的,并没有归还给操作系统。 这是因为与其把这 1 字节释放给操作系统,不如先缓存着放进 malloc 的内存池里,当进程再次申请 1 字节的内存时就可以直接复用,这样速度快了很多。 当进程退出后,操作系统就会回收进程的所有资源。 上面说的 free 内存后堆内存还存在,是针对 malloc 通过 brk() 方式申请的内存的情况。 如果 malloc 通过 mmap 方式申请的内存,free 释放内存后就会归归还给操作系统。 我们做个实验验证下, 通过 malloc 申请 128 KB 字节的内存,来使得 malloc 通过 mmap 方式来分配内存。

#include <stdio.h>
#include <malloc.h>
int main() {
  //申请1字节的内存
  void *addr = malloc(128*1024);
  printf("此128KB字节的内存起始地址:%x\n", addr);
  printf("此128KB字节的内存起始地址:%x\n", addr);
  //将程序阻塞,当输入任意字符时才往下执行
  getchar();
  //释放内存
  free(addr);
  printf("释放了128KB字节的内存,内存也归还给了操作系统\n");
  getchar();
  return 0;
}

查看进程的内存的分布情况,可以发现最右边没有 [heap] 标志,说明是通过 mmap 以匿名映射的方式从文件映射区分配的匿名内存。 再次查看该 128 KB 内存的起始地址,可以发现已经不存在了,说明归还给了操作系统. malloc 申请的内存,free 释放内存会归还给操作系统吗?

  • malloc 通过 brk() 方式申请的内存,free 释放内存的时候,并不会把内存归还给操作系统,而是缓存在 malloc 的内存池中,待下次使用;

  • malloc 通过 mmap() 方式申请的内存,free 释放内存的时候,会把内存归还给操作系统,内存得到真正的释放。

为什么不全部使用 mmap 来分配内存

因为向操作系统申请内存,是要通过系统调用的,执行系统调用是要进入内核态的,然后在回到用户态,运行态的切换会耗费不少时间。 所以,申请内存的操作应该避免频繁的系统调用,如果都用 mmap 来分配内存,等于每次都要执行系统调用。 另外,因为 mmap 分配的内存每次释放的时候,都会归还给操作系统,于是每次 mmap 分配的虚拟地址都是缺页状态的,然后在第一次访问该虚拟地址的时候,就会触发缺页中断。 也就是说,频繁通过 mmap 分配的内存话,不仅每次都会发生运行态的切换,还会发生缺页中断(在第一次访问虚拟地址后),这样会导致 CPU 消耗较大。 为了改进这两个问题,malloc 通过 brk() 系统调用在堆空间申请内存的时候,由于堆空间是连续的,所以直接预分配更大的内存来作为内存池,当内存释放的时候,就缓存在内存池中。 等下次在申请内存的时候,就直接从内存池取出对应的内存块就行了,而且可能这个内存块的虚拟地址与物理地址的映射关系还存在,这样不仅减少了系统调用的次数,也减少了缺页中断的次数,这将大大降低 CPU 的消耗。

既然brk那么牛逼,为什么不全部使用brk来分配

如果我们连续申请了 10k,20k,30k 这三片内存,如果 10k 和 20k 这两片释放了,变为了空闲内存空间,如果下次申请的内存小于 30k,那么就可以重用这个空闲内存空间。 brk内存实例 但是如果下次申请的内存大于30k,没有可用的空闲内存空间,必须向OS申请,实际使用内存继续增大。 因此,随着系统频繁地malloc和free,尤其对于小块内存,堆内将产生越来越多不可用的碎片,导致“内存泄露”。而这种“泄露”现象使用valgrind是无法检测出来的。 malloc 实现中,充分考虑了brk和mmap行为上的差异及优缺点,默认分配大块内存 (128KB)才使用 mmap分配内存空间。

free()函数只传入一个内存地址,为什么能知道要释放多大的内存?

malloc返回给用户态的内存起始地址比进程的堆空间起始地址多了16字节吗? 这个多出来的16字节就是保存了该内存块的描述信息,比如有该内存块的大小。 内存块的描述信息 这样当执行free()函数时,free会对传入进来的内存地址向左偏移16字节,然后从这个16字节的分析出当前的内存块的大小,自然就知道要释放多大的内存了。

内存满了,会发生什么?

内存分配的过程是怎样的?

应用程序通过malloc函数申请内存的时候,实际上申请的是虚拟内存,此时并不会分配物理内存。 当应用程序读写了这块虚拟内存,CPU就会去访问这个虚拟内存,这时会发现这个虚拟内存没有映射到物理内存,CPU就会产生缺页中断,进程会从用户态切换到内核态,并将缺页中断交给内核的Page Fault Handler(缺页中断函数)处理。 缺页中断处理函数会看是否有空闲的物理内存,如果有,就直接分配物理内存,并建立虚拟内存与物理内存之间的映射关系。 如果没有空闲的物理内存,那么内核就会开始进行回收内存的工作,回收的方式主要是两种:直接内存回收和后台内存回收。

  • 后台内存回收(kswapd):在物理内存紧张的时候,会唤醒kswapd内核线程来回收内存,这个回收内存的过程异步的,不会阻塞进程的执行。

  • 直接内存回收(direct reclaim):如果后台异步回收跟不上进程内存申请的速度,就会开始直接回收,这个回收内存的过程是同步的,会阻塞进程的执行。 如果直接内存回收后,空闲的物理内存仍然无法满足此次物理内存的申请,那么内核就会放最后的大招了触发 OOM (Out of Memory)机制。 OOM Killer 机制会根据算法选择一个占用物理内存较高的进程,然后将其杀死,以便释放内存资源,如果物理内存依然不足,OOM Killer 会继续杀死占用物理内存较高的进程,直到释放足够的内存位置。 内存分配流程

哪些内存可以被回收?

系统内存紧张的时候,就会进行回收内存的工作,那具体哪些内存是可以被回收的呢? 主要有两类内存可以被回收,而且它们的回收方式也不同。 文件页(File-backed Page) 内核缓存的磁盘数据(Buffer)和内核缓存的文件数据(Cache)都叫作文件页。大部分文件页,都可以直接释放内存,以后有需要时,再从磁盘重新读取就可以了。而那些被应用程序修改过,并且暂时还没写入磁盘的数据(也就是脏页),就得先写入磁盘,然后才能进行内存释放。所以,回收干净页的方式是直接释放内存,回收脏页的方式是先写回磁盘后再释放内存。 匿名页(Anonymous Page) 这部分内存没有实际载体,不像文件缓存有硬盘文件这样一个载体,比如堆、栈数据等。这部分内存很可能还要再次被访问,所以不能直接释放内存,它们回收的方式是通过Linux的Swap机制,Swap 会把不常访问的内存先写到磁盘中,然后释放这些内存,给其他更需要的进程使用。再次访问这些内存时,重新从磁盘读入内存就可以了。 文件页和匿名页的回收都是基于LRU算法,也就是优先回收不常访问的内存。LRU回收算法,实际上维护着 active 和 inactive 两个双向链表,其中:

  • active_list 活跃内存页链表,这里存放的是最近被访问过(活跃)的内存页

  • inactive_list 不活跃内存页链表,这里存放的是很少被访问(非活跃)的内存页 越接近链表尾部,就表示内存页越不常访问。这样,在回收内存时,系统就可以根据活跃程度,优先回收不活跃的内存。 活跃和非活跃的内存页,按照类型的不同,又分别分为文件页和匿名页。可以从 /proc/meminfo 中,查询它们的大小,比如:

# grep表示只保留包含active的指标(忽略大小写)
# sort表示按照字母顺序排序
cat /proc/meminfo | grep -i active | sort

回收内存带来的性能影响

回收内存有两种方式:

  • 一种是后台内存回收,也就是唤醒 kswapd 内核线程,这种方式是异步回收的,不会阻塞进程。

  • 一种是直接内存回收,这种方式是同步回收的,会阻塞进程,这样就会造成很长时间的延迟,以及系统的 CPU 利用率会升高,最终引起系统负荷飙高。 可被回收的内存类型有文件页和匿名页:

  • 文件页的回收:对于干净页是直接释放内存,这个操作不会影响性能,而对于脏页会先写回到磁盘再释放内存,这个操作会发生磁盘 I/O 的,这个操作是会影响系统性能的。

  • 匿名页的回收:如果开启了 Swap 机制,那么 Swap 机制会将不常访问的匿名页换出到磁盘中,下次访问时,再从磁盘换入到内存中,这个操作是会影响系统性能的。 可以看到,回收内存的操作基本都会发生磁盘 I/O 的,如果回收内存的操作很频繁,意味着磁盘 I/O 次数会很多,这个过程势必会影响系统的性能,整个系统给人的感觉就是很卡。

内存回收优化

调整文件页和匿名页的回收倾向

从文件页和匿名页的回收操作来看,文件页的回收操作对系统的影响相比匿名页的回收操作会少一点,因为文件页对于干净页回收是不会发生磁盘 I/O 的,而匿名页的 Swap 换入换出这两个操作都会发生磁盘I/O。 Linux 提供了一个 /proc/sys/vm/swappiness 选项,用来调整文件页和匿名页的回收倾向。 swappiness 的范围是 0-100,数值越大,越积极使用 Swap,也就是更倾向于回收匿名页;数值越小,越消极使用 Swap,也就是更倾向于回收文件页。

cat /proc/sys/vm/swappiness

一般建议 swappiness 设置为 0(默认值是 60),这样在回收内存的时候,会更倾向于文件页的回收,但是并不代表不会回收匿名页。

尽早触发 kswapd 内核线程异步回收内存

如何查看系统的直接内存回收和后台内存回收的指标? 可以使用 sar -B 1 命令来观察

  • pgscank/s : kswapd(后台回收线程) 每秒扫描的 page 个数

  • pgscand/s: 应用程序在内存申请过程中每秒直接扫描的 page 个数

  • pgsteal/s: 扫描的 page 中每秒被回收的个数(pgscank+pgscand) 如果系统时不时发生抖动,并且在抖动的时间段里如果通过 sar -B 观察到 pgscand 数值很大,那大概率是因为「直接内存回收」导致的。 针对这个问题,解决的办法就是,可以通过尽早的触发「后台内存回收」来避免应用程序进行直接内存回收。 什么条件下才能触发 kswapd 内核线程回收内存呢? 内核定义了三个内存阈值(watermark,也称为水位),用来衡量当前剩余内存(pages_free)是否充裕或者紧张,分别是:

  • 页最小阈值(pages_min);

  • 页低阈值(pages_low);

  • 页高阈值(pages_high); 这三个内存阈值会划分为四种内存使用情况,如下图: 内存阀值 kswapd 会定期扫描内存的使用情况,根据剩余内存(pages_free)的情况来进行内存回收的工作。 图中绿色部分:如果剩余内存(pages_free)大于 页高阈值(pages_high),说明剩余内存是充足的; 图中蓝色部分:如果剩余内存(pages_free)在页高阈值(pages_high)和页低阈值(pages_low)之间,说明内存有一定压力,但还可以满足应用程序申请内存的请求; 图中橙色部分:如果剩余内存(pages_free)在页低阈值(pages_low)和页最小阈值(pages_min)之间,说明内存压力比较大,剩余内存不多了。这时 kswapd0 会执行内存回收,直到剩余内存大于高阈值(pages_high)为止。虽然会触发内存回收,但是不会阻塞应用程序,因为两者关系是异步的。 图中红色部分:如果剩余内存(pages_free)小于页最小阈值(pages_min),说明用户可用内存都耗尽了,此时就会触发直接内存回收,这时应用程序就会被阻塞,因为两者关系是同步的。 可以看到,当剩余内存页(pages_free)小于页低阈值(pages_low),就会触发 kswapd 进行后台回收,然后 kswapd 会一直回收到剩余内存页(pages_free)大于页高阈值(pages_high)。 也就是说 kswapd 的活动空间只有 pages_low 与 pages_min 之间的这段区域,如果剩余内存低于了 pages_min 会触发直接内存回收,高于了 pages_high 又不会唤醒 kswapd。 页低阈值(pages_low)可以通过内核选项/proc/sys/vm/min_free_kbytes(该参数代表系统所保留空闲内存的最低限)来间接设置。 min_free_kbytes 虽然设置的是页最小阈值(pages_min),但是页高阈值(pages_high)和页低阈值(pages_low)都是根据页最小阈值(pages_min)计算生成的,它们之间的计算关系如下: pages_min = min_free_kbytes pages_low = pages_min5/4 pages_high = pages_min3/2 如果系统时不时发生抖动,并且通过 sar -B 观察到 pgscand 数值很大,那大概率是因为直接内存回收导致的,这时可以增大 min_free_kbytes 这个配置选项来及早地触发后台回收,然后继续观察 pgscand 是否会降为 0。 增大了 min_free_kbytes 配置后,这会使得系统预留过多的空闲内存,从而在一定程度上降低了应用程序可使用的内存量,这在一定程度上浪费了内存。极端情况下设置 min_free_kbytes 接近实际物理内存大小时,留给应用程序的内存就会太少而可能会频繁地导致 OOM 的发生。 所以在调整 min_free_kbytes 之前,需要先思考一下,应用程序更加关注什么,如果关注延迟那就适当地增大 min_free_kbytes,如果关注内存的使用量那就适当地调小 min_free_kbytes。

NUMA 架构下的内存回收策略

在 NUMA 架构下,当某个 Node 内存不足时,系统可以从其他 Node 寻找空闲内存,也可以从本地内存中回收内存。 具体选哪种模式,可以通过 /proc/sys/vm/zone_reclaim_mode 来控制。它支持以下几个选项:

  • 0 (默认值):在回收本地内存之前,在其他 Node 寻找空闲内存;

  • 1:只回收本地内存;

  • 2:只回收本地内存,在本地回收内存时,可以将文件页中的脏页写回硬盘,以回收内存。

  • 4:只回收本地内存,在本地回收内存时,可以用swap方式回收内存。 在使用 NUMA 架构的服务器,如果系统出现还有一半内存的时候,却发现系统频繁触发「直接内存回收」,导致了影响了系统性能,那么大概率是因为 zone_reclaim_mode 没有设置为 0 ,导致当本地内存不足的时候,只选择回收本地内存的方式,而不去使用其他 Node 的空闲内存。 虽然说访问远端 Node 的内存比访问本地内存要耗时很多,但是相比内存回收的危害而言,访问远端 Node 的内存带来的性能影响还是比较小的。因此,zone_reclaim_mode 一般建议设置为0。

如何保护一个进程不被 OOM 杀掉呢?

在系统空闲内存不足的情况,进程申请了一个很大的内存,如果直接内存回收都无法回收出足够大的空闲内存,那么就会触发 OOM 机制,内核就会根据算法选择一个进程杀掉。 Linux 到底是根据什么标准来选择被杀的进程呢?在 Linux 内核里有一个oom_badness()函数,它会把系统中可以被杀掉的进程扫描一遍,并对每个进程打分,得分最高的进程就会被首先杀掉。 进程得分的结果受下面这两个方面影响:

  • 第一,进程已经使用的物理内存页面数

  • 第二,每个进程的 OOM 校准值 。它是可以通过 /proc/[pid]/oom_score_adj来配置的。我们可以在设置 -1000 到 1000 之间的任意一个数值,调整进程被 OOM Kill 的几率。 函数 oom_badness() 里的最终计算方法是这样的: // points 代表打分的结果 // process_pages 代表进程已经使用的物理内存页面数 // oom_score_adj 代表 OOM 校准值 // totalpages 代表系统总的可用页面数 points =process_pages + oom_score_adj*totalpages/1000 用「系统总的可用页面数」乘以 「OOM 校准值 oom_score_adj」再除以 1000,最后再加上进程已经使用的物理页面数,计算出来的值越大,那么这个进程被 OOM Kill 的几率也就越大。 每个进程的 oom_score_adj 默认值都为 0,所以最终得分跟进程自身消耗的内存有关,消耗的内存越大越容易被杀掉。我们可以通过调整 oom_score_adj 的数值,来改成进程的得分结果:

  • 如果你不想某个进程被首先杀掉,那你可以调整该进程的 oom_score_adj,从而改变这个进程的得分结果,降低该进程被 OOM 杀死的概率。

  • 如果你想某个进程无论如何都不能被杀掉,那你可以将 oom_score_adj 配置为 -1000 我们最好将一些很重要的系统服务的 oom_score_adj 配置为 -1000,比如 sshd,因为这些系统服务一旦被杀掉,我们就很难再登陆进系统了。 但是,不建议将我们自己的业务程序的 oom_score_adj 设置为 -1000,因为业务程序一旦发生了内存泄漏,而它又不能被杀掉,这就会导致随着它的内存开销变大,OOM killer 不停地被唤醒,从而把其他进程一个个给杀掉。

在 4GB 物理内存的机器上,申请 8G 内存会怎么样?

操作系统虚拟内存大小

应用程序通过 malloc 函数申请内存的时候,实际上申请的是虚拟内存,此时并不会分配物理内存。 当应用程序读写了这块虚拟内存,这时会发现这个虚拟内存没有映射到物理内存, CPU 就会产生缺页中断,进程会从用户态切换到内核态,并将缺页中断交给内核的 Page Fault Handler (缺页中断函数)处理。 缺页中断处理函数会看是否有空闲的物理内存:

  • 如果有,就直接分配物理内存,并建立虚拟内存与物理内存之间的映射关系。

  • 如果没有空闲的物理内存,那么内核就会开始进行回收内存的工作,如果回收内存工作结束后,空闲的物理内存仍然无法满足此次物理内存的申请,那么内核就会放最后的大招了触发 OOM机制。 32 位操作系统和 64 位操作系统的虚拟地址空间大小是不同的,在 Linux 操作系统中,虚拟地址空间的内部又被分为内核空间和用户空间两部分,如下所示:

  • 32 位系统的内核空间占用 1G,位于最高处,剩下的 3G 是用户空间;

  • 64 位系统的内核空间和用户空间都是 128T,分别占据整个内存空间的最高和最低处,剩下的中间部分是未定义的。

32 位系统的场景

因为 32 位操作系统,进程最多只能申请 3 GB 大小的虚拟内存空间,所以进程申请 8GB 内存的话,在申请虚拟内存阶段就会失败。

64 位系统的场景

64 位操作系统,进程可以使用 128 TB 大小的虚拟内存空间,所以进程申请 8GB 内存是没问题的,因为进程申请内存是申请虚拟内存,只要不读写这个虚拟内存,操作系统就不会分配物理内存。 简单做个测试,我的服务器是 64 位操作系统,但是物理内存只有 2 GB: 我在机器上,连续申请 4 次 1 GB 内存,也就是一共申请了 4 GB 内存,注意下面代码只是单纯分配了虚拟内存,并没有使用该虚拟内存:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <errno.h>
#define MEM_SIZE 1024 * 1024 * 1024
int main() {
    char* addr[4];
    int i = 0;
    for(i = 0; i < 4; ++i) {
        addr[i] = (char*) malloc(MEM_SIZE);
        if(!addr[i]) {
            printf("执行 malloc 失败, 错误:%s\n",strerror(errno));
            return -1;
                    }
        printf("主线程调用malloc后,申请1gb大小得内存,此内存起始地址:0X%p\n", addr[i]);
      }
  //输入任意字符后,才结束
  getchar();
  return 0;
}

然后运行这个代码,可以看到,我的物理内存虽然只有 2GB,但是程序正常分配了 4GB 大小的虚拟内存: 可以通过下面这条命令查看进程(test)的虚拟内存大小:

ps aux | grep test

其中,VSZ 就代表进程使用的虚拟内存大小,RSS (Resident Set Size)代表进程使用的物理内存大小。可以看到,VSZ 大小为4198540,也就是 4GB 的虚拟内存。 使用 cat /proc/sys/vm/overcommit_memory 来查看这个参数,这个参数接受三个值: 如果值为 0(默认值),代表:Heuristic overcommit handling,它允许overcommit,但过于明目张胆的overcommit会被拒绝,比如malloc一次性申请的内存大小就超过了系统总内存。Heuristic的意思是“试探式的”,内核利用某种算法猜测你的内存申请是否合理,大概可以理解为单次申请不能超过freememory + free swap + pagecache的大小 + SLAB中可回收的部分 ,超过了就会拒绝overcommit。 如果值为 1,代表:Always overcommit. 允许overcommit,对内存申请来者不拒。 如果值为 2,代表:Don’t overcommit. 禁止overcommit。 如果你申请大内存的时候,不想被内核检测内存申请是否合理的算法干扰的话,将 overcommit_memory 设置为 1 就行。 那么将这个 overcommit_memory 设置为 1 之后,64 位的主机就可以申请接近 128T 虚拟内存了吗? 不一定,还得看你服务器的物理内存大小。读者的服务器物理内存是 2 GB,实验后发现,进程还没有申请到 128T 虚拟内存的时候就被杀死了。 注意,这次是 killed,而不是 Cannot Allocate Memory,说明并不是内存申请有问题,而是触发 OOM 了。 但是为什么会触发 OOM 呢? 那得看你的主机的「物理内存」够不够大了,即使 malloc 申请的是虚拟内存,只要不去访问就不会映射到物理内存,但是申请虚拟内存的过程中,还是使用到了物理内存(比如内核保存虚拟内存的数据结构,也是占用物理内存的),如果你的主机是只有 2GB 的物理内存的话,大概率会触发 OOM。 可以使用 top 命令,点击两下 m,通过进度条观察物理内存使用情况。 可以看到申请虚拟内存的过程中物理内存使用量一直在增长。 直到直接内存回收之后,也无法回收出一块空间供这个进程使用,这个时候就会触发 OOM,给所有能杀死的进程打分,分数越高的进程越容易被杀死。 在这里当然是这个进程得分最高,那么操作系统就会将这个进程杀死,所以最后会出现 killed,而不是Cannot allocate memory。 那么 2GB 的物理内存的 64 位操作系统,就不能申请128T的虚拟内存了吗? 其实可以,上面的情况是还没开启 swap 的情况。 使用 swapfile 的方式开启了 1GB 的 swap 空间之后再做实验: 发现出现了 Cannot allocate memory,但是其实到这里已经成功了,打开计算器计算一下,发现已经申请了 127.998T 虚拟内存了。 实际上我们是不可能申请完整个 128T 的用户空间的,因为程序运行本身也需要申请虚拟空间申请 127T 虚拟内存试试: 发现进程没有被杀死,也没有 Cannot allocate memory,也正好是 127T 虚拟内存空间。 在 top 中我们可以看到这个申请了127T虚拟内存的进程。

Swap 机制的作用

在 32 位/64 位操作系统环境下,申请的虚拟内存超过物理内存后会怎么样? 在 32 位操作系统,因为进程最大只能申请 3 GB 大小的虚拟内存,所以直接申请 8G 内存,会申请失败。 在 64 位操作系统,因为进程最大只能申请 128 TB 大小的虚拟内存,即使物理内存只有 4GB,申请 8G 内存也是没问题,因为申请的内存是虚拟内存。 程序申请的虚拟内存,如果没有被使用,它是不会占用物理空间的。当访问这块虚拟内存后,操作系统才会进行物理内存分配。 如果申请物理内存大小超过了空闲物理内存大小,就要看操作系统有没有开启 Swap 机制: 如果没有开启 Swap 机制,程序就会直接 OOM; 如果有开启 Swap 机制,程序可以正常运行。 什么是 Swap 机制? 当系统的物理内存不够用的时候,就需要将物理内存中的一部分空间释放出来,以供当前运行的程序使用。那些被释放的空间可能来自一些很长时间没有什么操作的程序,这些被释放的空间会被临时保存到磁盘,等到那些程序要运行时,再从磁盘中恢复保存的数据到内存中。 另外,当内存使用存在压力的时候,会开始触发内存回收行为,会把这些不常访问的内存先写到磁盘中,然后释放这些内存,给其他更需要的进程使用。再次访问这些内存时,重新从磁盘读入内存就可以了。 这种,将内存数据换出磁盘,又从磁盘中恢复数据到内存的过程,就是 Swap 机制负责的。 Swap 就是把一块磁盘空间或者本地文件,当成内存来使用,它包含换出和换入两个过程:

  • 换出(Swap Out) ,是把进程暂时不用的内存数据存储到磁盘中,并释放这些数据占用的内存;

  • 换入(Swap In),是在进程再次访问这些内存的时候,把它们从磁盘读到内存中来; 使用 Swap 机制优点是,应用程序实际可以使用的内存空间将远远超过系统的物理内存。由于硬盘空间的价格远比内存要低,因此这种方式无疑是经济实惠的。当然,频繁地读写硬盘,会显著降低操作系统的运行速率,这也是 Swap 的弊端。 Linux 中的 Swap 机制会在内存不足和内存闲置的场景下触发: 内存不足:当系统需要的内存超过了可用的物理内存时,内核会将内存中不常使用的内存页交换到磁盘上为当前进程让出内存,保证正在执行的进程的可用性,这个内存回收的过程是强制的直接内存回收Direct Page Reclaim。直接内存回收是同步的过程,会阻塞当前申请内存的进程。 内存闲置:应用程序在启动阶段使用的大量内存在启动后往往都不会使用,通过后台运行的守护进程(kSwapd),我们可以将这部分只使用一次的内存交换到磁盘上为其他内存的申请预留空间。kSwapd 是 Linux 负责页面置换Page replacement的守护进程,它也是负责交换闲置内存的主要进程,它会在空闲内存低于一定水位 (opens new window)时,回收内存页中的空闲内存保证系统中的其他进程可以尽快获得申请的内存。kSwapd 是后台进程,所以回收内存的过程是异步的,不会阻塞当前申请内存的进程。 Linux 提供了两种不同的方法启用 Swap,分别是 Swap 分区(Swap Partition)和 Swap 文件(Swapfile) Swap 分区是硬盘上的独立区域,该区域只会用于交换分区,其他的文件不能存储在该区域上,我们可以使用 swapon -s 命令查看当前系统上的交换分区; Swap 文件是文件系统中的特殊文件,它与文件系统中的其他文件也没有太多的区别; Swap 换入换出的是什么类型的内存? 内核缓存的文件数据,因为都有对应的磁盘文件,所以在回收文件数据的时候,直接写回到对应的文件就可以了。 但是像进程的堆、栈数据等,它们是没有实际载体,这部分内存被称为匿名页。而且这部分内存很可能还要再次被访问,所以不能直接释放内存,于是就需要有一个能保存匿名页的磁盘载体,这个载体就是 Swap 分区。 匿名页回收的方式是通过 Linux 的 Swap 机制,Swap 会把不常访问的内存先写到磁盘中,然后释放这些内存,给其他更需要的进程使用。再次访问这些内存时,重新从磁盘读入内存就可以了。 什么是 OOM? 内存溢出(Out Of Memory,简称OOM)是指应用系统中存在无法回收的内存或使用的内存过多,最终使得程序运行要用到的内存大于能提供的最大内存。此时程序就运行不了,系统会提示内存溢出。

预读失效和缓存污染

传统的LRU算法存在这两个问题:

  • 「预读失效」导致缓存命中率下降

  • 「缓存污染」导致缓存命中率下降 Redis的缓存淘汰算法则是通过实现LFU算法来避免「缓存污染」而导致缓存命中率下降的问题Redis没有预读机制. MySQL和Linux操作系统是通过改进LRU算法来避免预读失效和缓存污染而导致缓存命中率下降的问题。 MySQL和Linux操作系统是如何改进LRU算法的?

Linux和MySQL的缓存

Linux 操作系统的缓存

在应用程序读取文件的数据的时候,Linux 操作系统是会对读取的文件数据进行缓存的,会缓存在文件系统中的 Page Cache(如下图中的页缓存)。 虚拟文件系统 Page Cache 属于内存空间里的数据,由于内存访问比磁盘访问快很多,在下一次访问相同的数据就不需要通过磁盘 I/O 了,命中缓存就直接返回数据即可。 因此,Page Cache 起到了加速访问数据的作用。 内存中的缓存主要分为页缓存(Page Cache)和磁盘缓存(Disk Cache)等.

  • 页缓存(Page Cache) 页缓存是 Linux 内核用来加速磁盘 I/O 操作的内存区域。当程序读取文件时,Linux 内核会把文件的内容缓存到内存中,这样如果程序再次访问相同的文件内容,就可以直接从内存中读取,而不需要再次访问慢速的磁盘。

  • 磁盘缓存(Disk Cache) 磁盘缓存有时与页缓存混用,但它的主要任务是缓冲数据写入。Linux会将要写入磁盘的数据暂时存储在内存中,而不是立即写入磁盘。这样可以合并多个小的写入操作,从而减少对磁盘的访问次数,提高系统性能。 缓存(Cache):缓存的主要目的是加速读取操作。页缓存是一种典型的缓存机制,它把已经读取过的文件内容暂时存放在内存中,以便快速读取。 缓冲(Buffer):缓冲的主要目的是协调数据写入操作。缓冲区用于暂存要写入磁盘的数据,以便批量写入,减少写入的次数。

MySQL的缓存

MySQL 的数据是存储在磁盘里的,为了提升数据库的读写性能,Innodb 存储引擎设计了一个缓冲池Buffer Pool),Buffer Pool 属于内存空间里的数据。 缓冲池.drawio 有了缓冲池后: 当读取数据时,如果数据存在于 Buffer Pool 中,客户端就会直接读取 Buffer Pool 中的数据,否则再去磁盘中读取。 当修改数据时,首先是修改 Buffer Pool 中数据所在的页,然后将其页设置为脏页,最后由后台线程将脏页写入到磁盘。

传统LRU是如何管理内存数据的?

Linux的Page Cache 和 MySQL 的 Buffer Pool的大小是有限的,并不能无限的缓存数据,对于一些频繁访问的数据我们希望可以一直留在内存中,而一些很少访问的数据希望可以在某些时机可以淘汰掉,从而保证内存不会因为满了而导致无法再缓存新的数据,同时还能保证常用数据留在内存中。 要实现这个,最容易想到的就是 LRU(Least recently used)算法。 LRU算法一般是用「链表」作为数据结构来实现的,链表头部的数据是最近使用的,而链表末尾的数据是最久没被使用的。那么,当空间不够了,就淘汰最久没被使用的节点,也就是链表末尾的数据,从而腾出内存空间。 因为Linux的Page Cache和MySQL的Buffer Pool缓存的基本数据单位都是页(Page)单位,所以后续以「页」名称代替「数据」。 传统的LRU算法的实现思路是这样的:

  • 当访问的页在内存里,就直接把该页对应的LRU链表节点移动到链表的头部。

  • 当访问的页不在内存里,除了要把该页放入到LRU链表的头部,还要淘汰LRU链表末尾的页。 比如下图,假设LRU链表长度为5,LRU链表从左到右有编号为1,2,3,4,5的页。 lru 如果访问了3号页,因为3号页已经在内存了,所以把3号页移动到链表头部即可,表示最近被访问了。 lru2 而如果接下来,访问了8号页,因为8号页不在内存里,且LRU链表长度为5,所以必须要淘汰数据,以腾出内存空间来缓存8号页,于是就会淘汰末尾的5号页,然后再将8号页加入到头部。 lru3 传统的LRU算法并没有被Linux和MySQL使用,因为传统的LRU算法无法避免下面这两个问题:

  • 预读失效导致缓存命中率下降;

  • 缓存污染导致缓存命中率下降;

预读失效

预读机制

Linux 操作系统为基于 Page Cache 的读缓存机制提供预读机制,一个例子是: 应用程序只想读取磁盘上文件A的offset为0-3KB范围内的数据,由于磁盘的基本读写单位为 block(4KB),于是操作系统至少会读0-4KB的内容,这恰好可以在一个page中装下。 但是操作系统出于空间局部性原理(靠近当前被访问数据的数据,在未来很大概率会被访问到),会选择将磁盘块 offset[4KB,8KB)、[8KB,12KB)以及[12KB,16KB)都加载到内存,于是额外在内存中申请了3个page; 操作系统的预读机制: 操作系统预读机制 可以通过查看 /sys/block/[device_name]/queue/read_ahead_kb文件来了解和调整设备的预读大小。 默认预读大小通常为128KB(32 个 4 KB 块),但可以根据文件系统和设备的特点调整。Linux使用自适应预读,根据访问模式动态调整预读大小,预读大小可以在128到256个数据块或更大范围内变化。 上图中,应用程序利用read系统调动读取4KB数据,实际上内核使用预读机制(ReadaHead)机制完成了 16KB 数据的读取,也就是通过一次磁盘顺序读将多个Page数据装入Page Cache。 这样下次读取4KB数据后面的数据的时候,就不用从磁盘读取了,直接在Page Cache即可命中数据。因此,预读机制带来的好处就是减少了磁盘I/O 次数,提高系统磁盘I/O吞吐量。 MySQL Innodb存储引擎的Buffer Pool也有类似的预读机制,MySQL从磁盘加载页时,会提前把它相邻的页一并加载进来,目的是为了减少磁盘 IO。

预读失效问题

如果这些被提前加载进来的页,并没有被访问,相当于这个预读工作是白做了,这个就是预读失效。如果使用传统的LRU算法,就会把「预读页」放到LRU链表头部,而当内存空间不够的时候,还需要把末尾的页淘汰掉。 如果这些「预读页」如果一直不会被访问到,就会出现一个很奇怪的问题,不会被访问的预读页却占用了LRU 链表前排的位置,而末尾淘汰的页,可能是热点数据,这样就大大降低了缓存命中率 。 如何避免预读失效造成的影响? 我们不能因为害怕预读失效,而将预读机制去掉,大部分情况下,空间局部性原理还是成立的。 要避免预读失效带来影响,最好就是让预读页停留在内存里的时间要尽可能的短,让真正被访问的页才移动到 LRU链表的头部,从而保证真正被读取的热数据留在内存里的时间尽可能长。

预读失效原因

非顺序访问:预读通常假设数据会按顺序被访问。如果程序进行的是随机访问而不是顺序访问,那么预读的数据就无法被使用。 过度预读:当操作系统对即将读取的数据块数目预估过大时,可能会预读超出实际需求的块,导致预读的数据未被使用。 多任务并发:在多任务的并发环境下,预读失效的几率会增加,因为不同任务会频繁切换,导致之前预读的数据可能在切换回来之前就已经被回收。

预读失效避免

Linux操作系统和MySQL Innodb通过改进传统LRU链表来避免预读失效带来的影响,具体的改进分别如下:

  • Linux操作系统实现两个了LRU链表:活跃LRU链表(active_list)和非活跃LRU链表(inactive_list);

  • MySQL的Innodb存储引擎是在一个LRU链表上划分来2个区域:young区域和old区域。 这两个改进方式,设计思想都是类似的,都是将数据分为了冷数据和热数据,然后分别进行LRU算法。不再像传统的LRU算法那样,所有数据都只用一个LRU算法管理. Linux是如何避免预读失效带来的影响? Linux操作系统实现两个了LRU链表:活跃LRU链表(active_list)和非活跃LRU链表(inactive_list)。

  • active list 活跃内存页链表,这里存放的是最近被访问过(活跃)的内存页;

  • inactive list 不活跃内存页链表,这里存放的是很少被访问(非活跃)的内存页; 有了这两个LRU链表后,预读页就只需要加入到 inactive list 区域的头部,当页被真正访问的时候,才将页插入active list的头部。如果预读的页一直没有被访问,就会从inactive list移除,这样就不会影响 active list中的热点数据。 假设active list和inactive list的长度为5,目前内存中已经有如下10个页: active_inactive_list.drawio 现在有个编号为20的页被预读了,这个页只会被插入到 inactive list 的头部,而 inactive list 末尾的页(10号)会被淘汰掉。 即使编号为20的预读页一直不会被访问,它也没有占用到active list的位置,而且还会比active list中的页更早被淘汰出去。 如果20号页被预读后,立刻被访问了,那么就会将它插入到active list的头部,active list末尾的页(5号),会被降级到inactive list,作为inactive list的头部,这个过程并不会有数据被淘汰。 active_inactive_list2.drawio MySQL是如何避免预读失效带来的影响? MySQL的Innodb存储引擎是在一个 LRU 链表上划分来 2 个区域,young 区域 和 old 区域。 young 区域在 LRU 链表的前半部分,old 区域则是在后半部分,这两个区域都有各自的头和尾节点,如下图: young+old young 区域与 old 区域在 LRU 链表中的占比关系并不是一比一的关系,而是 63:37(默认比例)的关系。 划分这两个区域后,预读的页就只需要加入到 old 区域的头部,当页被真正访问的时候,才将页插入 young 区域的头部。如果预读的页一直没有被访问,就会从 old 区域移除,这样就不会影响 young 区域中的热点数据。 假设有一个长度为 10 的 LRU 链表,其中 young 区域占比 70 %,old 区域占比 30 %。 lrutwo.drawio 现在有个编号为 20 的页被预读了,这个页只会被插入到 old 区域头部,而 old 区域末尾的页(10号)会被淘汰掉。 lrutwo2 如果20号页一直不会被访问,它也没有占用到young区域的位置,而且还会比young区域的数据更早被淘汰出去。 如果20号页被预读后,立刻被访问了,那么就会将它插入到young区域的头部,young区域末尾的页(7号),会被挤到old区域,作为old区域的头部,这个过程并不会有页被淘汰。 lrutwo3

缓存污染

缓存污染定义

虽然 Linux (实现两个 LRU 链表)和MySQL(划分两个区域)通过改进传统的LRU数据结构,避免了预读失效带来的影响。 但是如果还是使用「只要数据被访问一次,就将数据加入到活跃 LRU 链表头部(或者 young 区域)」这种方式的话,那么还存在缓存污染的问题。 当我们在批量读取数据的时候,由于数据被访问了一次,这些大量数据都会被加入到「活跃LRU链表」里,然后之前缓存在活跃LRU链表(或者 young 区域)里的热点数据全部都被淘汰了,如果这些大量的数据在很长一段时间都不会被访问的话,那么整个活跃LRU链表(或者 young 区域)就被污染了。 缓存污染会带来什么问题? 缓存污染带来的影响就是很致命的,等这些热数据又被再次访问的时候,由于缓存未命中,就会产生大量的磁盘 I/O,系统性能就会急剧下降。 当某一个 SQL 语句扫描了大量的数据时,在 Buffer Pool 空间比较有限的情况下,可能会将 Buffer Pool里的所有页都替换出去,导致大量热数据被淘汰了,等这些热数据又被再次访问的时候,由于缓存未命中,就会产生大量的磁盘 I/O,MySQL 性能就会急剧下降。 缓存污染并不只是查询语句查询出了大量的数据才出现的问题,即使查询出来的结果集很小,也会造成缓存污染。 比如,在一个数据量非常大的表,执行了这条语句:

select * from t_user where name like "%xiaolin%";

可能这个查询出来的结果就几条记录,但是由于这条语句会发生索引失效,所以这个查询过程是全表扫描的,接着会发生如下的过程:

  • 从磁盘读到的页加入到 LRU 链表的 old 区域头部;

  • 当从页里读取行记录时,也就是页被访问的时候,就要将该页放到 young 区域头部;

  • 接下来拿行记录的 name 字段和字符串 xiaolin 进行模糊匹配,如果符合条件,就加入到结果集里;

  • 如此往复,直到扫描完表中的所有记录。 经过这一番折腾,由于这条SQL语句访问的页非常多,每访问一个页,都会将其加入young区域头部,那么原本 young区域的热点数据都会被替换掉,导致缓存命中率下降。那些在批量扫描时,而被加入到young区域的页,如果在很长一段时间都不会再被访问的话,那么就污染了young区域。 举个例子,假设需要批量扫描:21,22,23,24,25这五个页,这些页都会被逐一访问(读取页里的记录)。 lruthree.drawio 在批量访问这些页的时候,会被逐一插入到young区域头部。 lruthree1 可以看到,原本在young区域的6和7号页都被淘汰了,而批量扫描的页基本占满了young区域,如果这些页在很长一段时间都不会被访问,那么就对young区域造成了污染。 如果6和7号页是热点数据,那么在被淘汰后,后续有SQL再次读取6和7号页时,由于缓存未命中,就要从磁盘中读取了,降低了MySQL的性能,这就是缓存污染带来的影响。

避免缓存污染

前面的LRU算法只要数据被访问一次,就将数据加入活跃LRU链表(或者young区域),这种LRU算法进入活跃LRU链表的门槛太低了!正式因为门槛太低,才导致在发生缓存污染的时候,很容就将原本在活跃LRU链表里的热点数据淘汰了。 所以,只要我们提高进入到活跃LRU链表(或者young区域)的门槛,就能有效地保证活跃LRU链表(或者 young 区域)里的热点数据不会被轻易替换掉。 Linux操作系统和 MySQL Innodb 存储引擎分别是这样提高门槛的:

  • Linux操作系统:在内存页被访问第二次的时候,才将页从 inactive list升级到active list里。这种机制被称为二次机会(second-chance)算法。

  • MySQL Innodb:在内存页被访问第二次的时候,并不会马上将该页从old区域升级到young区域,因为还要进行停留在old区域的时间判断:

    • 如果第二次的访问时间与第一次访问的时间在1秒内(默认值),那么该页就不会被从old区域升级到 young 区域;

    • 如果第二次的访问时间与第一次访问的时间超过1秒,那么该页就会从old区域升级到young区域; 提高了进入活跃LRU链(或者young区域)的门槛后,就很好了避免缓存污染带来的影响。 在批量读取数据时候,如果这些大量数据只会被访问一次,那么它们就不会进入到活跃LRU链表(或者young区域),也就不会把热点数据淘汰,只会待在非活跃LRU链表(或者old区域)中,后续很快也会被淘汰。

总结

  1. 在改进的LRU算法中,Active列表的末位的页面并不会直接被淘汰。相反,它会首先被移动到Inactive列表,然后再根据内存压力和访问情况决定是否最终被淘汰。这个过程是为了确保那些最近不再被频繁访问的页面能够逐步从缓存中移除,同时也避免直接淘汰可能仍然有用的数据。

  2. 尽管改进的LRU算法通过Active和Inactive列表有效管理缓存,逐步淘汰不频繁访问的数据,但缓存污染仍然可能由于复杂的访问模式、内存压力波动、预读机制等原因发生。为了进一步减少缓存污染的影响,可以结合其他优化策略和机制来提高缓存的使用效率。

Linux 虚拟内存管理

通过内存管理这条主线我们把可以把操作系统的众多核心系统给拎出来,比如:进程管理子系统,网络子系统,文件子系统等。

虚拟内存地址概念

在计算机的世界里内存地址用来定义数据在内存中的存储位置的,内存地址也分为虚拟地址和物理地址。而虚拟地址也是人为设计的一个概念,类比我们现实世界中的收货地址,而物理地址则是数据在物理内存中的真实存储位置,类比现实世界中的城市,街道,小区的真实地理位置。 收货地址的格式:xx省xx市xx区xx街道xx小区xx室,它是按照地区层次递进的。同样,在计算机世界中的虚拟内存地址也有这样的递进关系。 以 Intel Core i7处理器为例,64位虚拟地址的格式为:全局页目录项(9位)+ 上层页目录项(9位)+ 中间页目录项(9位)+ 页表项(9位)+ 页内偏移(12位)。共48位组成的虚拟内存地址。 虚拟地址类比 32 位虚拟地址的格式为:页目录项(10位)+ 页表项(10位) + 页内偏移(12位)。共32位组成的虚拟内存地址。 32位操作系统虚拟内存 进程虚拟内存空间中的每一个字节都有与其对应的虚拟内存地址,一个虚拟内存地址表示进程虚拟内存空间中的一个特定的字节。

为什么要使用虚拟地址访问内存

假设现在没有虚拟内存地址,我们在程序中对内存的操作全都都是使用物理内存地址,在这种情况下,程序员就需要精确的知道每一个变量在内存中的具体位置,我们需要手动对物理内存进行布局,明确哪些数据存储在内存的哪些位置,除此之外我们还需要考虑为每个进程究竟要分配多少内存?内存紧张的时候该怎么办?如何避免进程与进程之间的地址冲突?等等一系列复杂且琐碎的细节。 然而在现代操作系统中往往支持多个进程,需要处理多进程之间的协同问题,在多进程系统中直接使用物理内存地址操作内存所带来的上述问题就变得非常复杂了。 java多进程操作内存实例 在直接操作物理内存的情况下,我们需要知道每一个变量的位置都被安排在了哪里,而且还要注意和多个进程同时运行的时候,不能共用同一个地址,否则就会造成地址冲突。 现实中一个程序会有很多的变量和函数,这样一来我们给它们都需要计算一个合理的位置,还不能与其他进程冲突,这就很复杂了。 程序局部性原理表现为:时间局部性和空间局部性。时间局部性是指如果程序中的某条指令一旦执行,则不久之后该指令可能再次被执行;如果某块数据被访问,则不久之后该数据可能再次被访问。空间局部性是指一旦程序访问了某个存储单元,则不久之后,其附近的存储单元也将被访问。 进程在运行之后,对于内存的访问不会一下子就要访问全部的内存,相反进程对于内存的访问会表现出明显的倾向性,更加倾向于访问最近访问过的数据以及热点数据附近的数据。 无论一个进程实际可以占用的内存资源有多大,根据程序局部性原理,在某一段时间内,进程真正需要的物理内存其实是很少的一部分,我们只需要为每个进程分配很少的物理内存就可以保证进程的正常执行运转。 而虚拟内存的引入正是要解决上述的问题,虚拟内存引入之后,进程的视角就会变得非常开阔,每个进程都拥有自己独立的虚拟地址空间,进程与进程之间的虚拟内存地址空间是相互隔离,互不干扰的。每个进程都认为自己独占所有内存空间,自己想干什么就干什么。 系统上还运行了哪些进程和我没有任何关系。这样一来我们就可以将多进程之间协同的相关复杂细节统统交给内核中的内存管理模块来处理,极大地解放了程序员的心智负担。这一切都是因为虚拟内存能够提供内存地址空间的隔离,极大地扩展了可用空间。 java虚拟内存分配 这样进程就以为自己独占了整个内存空间资源,给进程产生了所有内存资源都属于它自己的幻觉,这其实是 CPU 和操作系统使用的一个障眼法罢了,任何一个虚拟内存里所存储的数据,本质上还是保存在真实的物理内存里的。只不过内核帮我们做了虚拟内存到物理内存的这一层映射,将不同进程的虚拟地址和不同内存的物理地址映射起来。 当 CPU 访问进程的虚拟地址时,经过地址翻译硬件将虚拟地址转换成不同的物理地址,这样不同的进程运行的时候,虽然操作的是同一虚拟地址,但其实背后写入的是不同的物理地址,这样就不会冲突了。

进程虚拟内存空间

首先我们会想到的是一个进程运行起来是为了执行我们交代给进程的工作,执行这些工作的步骤我们通过程序代码事先编写好,然后编译成二进制文件存放在磁盘中,CPU 会执行二进制文件中的机器码来驱动进程的运行。所以在进程运行之前,这些存放在二进制文件中的机器码需要被加载进内存中,而用于存放这些机器码的虚拟内存空间叫做代码段。 虚拟内存空间1 在程序运行起来之后,总要操作变量吧,在程序代码中我们通常会定义大量的全局变量和静态变量,这些全局变量在程序编译之后也会存储在二进制文件中,在程序运行之前,这些全局变量也需要被加载进内存中供程序访问。所以在虚拟内存空间中也需要一段区域来存储这些全局变量。

  • 那些在代码中被我们指定了初始值的全局变量和静态变量在虚拟内存空间中的存储区域我们叫做数据段。

  • 那些没有指定初始值的全局变量和静态变量在虚拟内存空间中的存储区域我们叫做 BSS 段。这些未初始化的全局变量被加载进内存之后会被初始化为 0 值。 虚拟内存空间2 上面介绍的这些全局变量和静态变量都是在编译期间就确定的,但是我们程序在运行期间往往需要动态的申请内存,所以在虚拟内存空间中也需要一块区域来存放这些动态申请的内存,这块区域就叫做堆。注意这里的堆指的是 OS 堆并不是 JVM 中的堆。 虚拟内存空间3 除此之外,我们的程序在运行过程中还需要依赖动态链接库,这些动态链接库以 .so 文件的形式存放在磁盘中,比如 C 程序中的 glibc,里边对系统调用进行了封装。glibc 库里提供的用于动态申请堆内存的malloc 函数就是对系统调用 sbrk 和 mmap 的封装。这些动态链接库也有自己的对应的代码段,数据段,BSS 段,也需要一起被加载进内存中。 还有用于内存文件映射的系统调用 mmap,会将文件与内存进行映射,那么映射的这块内存(虚拟内存)也需要在虚拟地址空间中有一块区域存储。 这些动态链接库中的代码段,数据段,BSS 段,以及通过 mmap 系统调用映射的共享内存区,在虚拟内存空间的存储区域叫做文件映射与匿名映射区。 虚拟内存空间4 最后我们在程序运行的时候总该要调用各种函数吧,那么调用函数过程中使用到的局部变量和函数参数也需要一块内存区域来保存。这一块区域在虚拟内存空间中叫做栈。 虚拟内存空间5 内核根据进程运行的过程中所需要不同种类的数据而为其开辟了对应的地址空间。分别为:

  • 用于存放进程程序二进制文件中的机器指令的代码段(即CPU执行的机器指令)

  • 用于存放程序二进制文件中定义的已经初始化且初值不为0的全局变量和局部静态变量的数据段。数据段属于静态内存分配(静态存储区),可读可写。

  • 用于存放程序二进制文件中定义的未初始化的全局变量和静态局部变量的BSS段

  • 用于在程序运行过程中动态申请内存的堆。

  • 用于存放动态链接库以及内存映射区域的文件映射与匿名映射区。

  • 用于存放函数调用过程中的局部变量和函数参数的栈。 当全局变量被显式初始化为0时,编译器将这种情况视为未初始化的状态,并将其放置在BSS段。这是因为它们在程序启动时会被自动初始化为0,和未初始化的变量的处理方式相同。

内存映射段(mmap)

内核将硬盘文件的内容直接映射到内存, 任何应用程序都可通过Linux的mmap()系统调用请求这种映射。内存映射是一种方便高效的文件I/O方式.普通文件被映射到进程地址空间后,进程可以像访问普通内存一样对文件进行访问,不必再调用read()/write()等操作,因而被用于装载动态共享库。用户也可创建匿名内存映射,该映射没有对应的文件, 可用于存放程序数据 mmap/munmap是常用的一个系统调用,使用场景是:分配内存、读写大文件、连接动态库文件、多进程间共享内存。

每个进程都有自己独立的内存段,通常包括代码段、数据段、BSS 段、堆、栈等。操作系统通过虚拟内存技术为每个进程提供一个独立的、受保护的内存空间,使得进程彼此隔离,确保系统的稳定性、安全性和高效性。 虽然所有进程共享相同的内核空间,但用户态进程无法直接访问该空间,只有通过系统调用或硬件中断才能间接访问。操作系统通过内存保护机制确保用户态进程之间相互隔离,并且不允许直接操作内核空间,从而保护系统的稳定性和安全性。

Linux进程虚拟内存空间

32位机器上进程虚拟内存空间分布

在32位机器上,指针的寻址范围为2^32,所能表达的虚拟内存空间为4GB。所以在32位机器上进程的虚拟内存地址范围为:0x0000 0000 - 0xFFFF FFFF。 其中用户态虚拟内存空间为3GB,虚拟内存地址范围为:0x0000 0000 - 0xC000 000 。 内核态虚拟内存空间为1GB,虚拟内存地址范围为:0xC000 000 - 0xFFFF FFFF。 32位操作系统虚拟内存分布 但是用户态虚拟内存空间中的代码段并不是从 0x0000 0000 地址开始的,而是从 0x0804 8000 地址开始。 0x0000 0000 到 0x0804 8000 这段虚拟内存地址是一段不可访问的保留区,因为在大多数操作系统中,数值比较小的地址通常被认为不是一个合法的地址,这块小地址是不允许访问的。比如在C语言中我们通常会将一些无效的指针设置为 NULL,指向这块不允许访问的地址。 保留区的上边就是代码段和数据段,它们是从程序的二进制文件中直接加载进内存中的,BSS段中的数据也存在于二进制文件中,因为内核知道这些数据是没有初值的,所以在二进制文件中只会记录BSS段的大小,在加载进内存时会生成一段0填充的内存空间。 紧挨着BSS段的上边就是我们经常使用到的堆空间,从图中的红色箭头我们可以知道在堆空间中地址的增长方向是从低地址到高地址增长。 内核中使用start_brk标识堆的起始位置,brk 标识堆当前的结束位置。当堆申请新的内存空间时,只需要将 brk 指针增加对应的大小,回收地址时减少对应的大小即可。回收地址时减少对应的大小即可。 堆空间的上边是一段待分配区域,用于扩展堆空间的使用。接下来就来到了文件映射与匿名映射区域。进程运行时所依赖的动态链接库中的代码段,数据段,BSS 段就加载在这里。还有我们调用mmap映射出来的一段虚拟内存空间也保存在这个区域。注意:在文件映射与匿名映射区的地址增长方向是从高地址向低地址增长。 接下来用户态虚拟内存空间的最后一块区域就是栈空间了,在这里会保存函数运行过程所需要的局部变量以及函数参数等函数调用信息。栈空间中的地址增长方向是从高地址向低地址增长。每次进程申请新的栈地址时,其地址值是在减少的。 在内核中使用start_stack标识栈的起始位置,RSP寄存器中保存栈顶指针stack pointer,RBP寄存器中保存的是栈基地址。 在栈空间的下边也有一段待分配区域用于扩展栈空间,在栈空间的上边就是内核空间了,进程虽然可以看到这段内核空间地址,但是就是不能访问。

64位机器上进程虚拟内存空间分布

32位虚拟内存空间布局和64位虚拟内存空间布局都可以通过cat /proc/pid/maps或者pmap pid来查看某个进程的实际虚拟内存布局。 在目前的64位系统下只使用了48位来描述虚拟内存空间,寻址范围为2^48 ,所能表达的虚拟内存空间为256TB。 其中低128T表示用户态虚拟内存空间,虚拟内存地址范围为:0x0000 0000 0000 0000 - 0x0000 7FFF FFFF FFFF 。 高128T表示内核态虚拟内存空间,虚拟内存地址范围为:0xFFFF 8000 0000 0000 - 0xFFFF FFFF FFFFFFFF 。 这样一来就在用户态虚拟内存空间与内核态虚拟内存空间之间形成了一段0x0000 7FFF FFFF FFFF - 0xFFFF8000 0000 0000的地址空洞,我们把这个空洞叫做canonical address空洞。 64位内存地址空间 64位操作系统内存地址范围 kernel中内存空间分布描述 那么这个canonical address空洞是如何形成的呢? 在64位机器上的指针寻址范围为2^64,但是在实际使用中我们只使用了其中的低48位来表示虚拟内存地址,那么这多出的高16位就形成了这个地址空洞。 在低128T的用户态地址空间:0x0000 0000 0000 0000- 0x0000 7FFF FFFF FFFF范围中,所以虚拟内存地址的高16位全部为0 。 如果一个虚拟内存地址的高16位全部为0,那么我们就可以直接判断出这是一个用户空间的虚拟内存地址。 那么我们就可以直接判断出这是一个用户空间的虚拟内存地址。 同样的道理,在高128T的内核态虚拟内存空间:0xFFFF 8000 0000 0000 - 0xFFFF FFFF FFFF FFFF 范围中,所以虚拟内存地址的高16位全部为 1 。 也就是说内核态的虚拟内存地址的高16位全部为1 ,如果一个试图访问内核的虚拟地址的高16位不全为1,则可以快速判断这个访问是非法的。 这个高 16 位的空闲地址被称为canonical 。如果虚拟内存地址中的高16位全部为0(表示用户空间虚拟内存地址)或者全部为1(表示内核空间虚拟内存地址),这种地址的形式我们叫做 canonical form,对应的地址我们称作 canonical address 。 64位操作系统虚拟内存分布 64位系统中的虚拟内存布局和32位系统中的虚拟内存布局大体上是差不多的。主要不同的地方有三点:

  1. 就是前边提到的由高16位空闲地址造成的canonical address空洞。在这段范围内的虚拟内存地址是不合法的,因为它的高16位既不全为0也不全为1,不是一个canonical address,所以称之为canonical address 空洞。

  2. 在代码段跟数据段的中间还有一段不可以读写的保护段,它的作用是防止程序在读写数据段的时候越界访问到代码段,这个保护段可以让越界访问行为直接崩溃,防止它继续往下运行。

  3. 用户态虚拟内存空间与内核态虚拟内存空间分别占用128T,其中低128T分配给用户态虚拟内存空间,高 128T分配给内核态虚拟内存空间。

进程虚拟内存空间的管理

描述符

描述符是操作系统中用于管理和表示不同类型资源的关键结构。它们可以包括内存段描述符、页描述符、文件描述符、进程控制块、设备描述符和共享内存描述符等。每种描述符的用途和功能有所不同,但它们都扮演着资源管理和操作的重要角色。 在编程中,描述符提供了一种抽象层,使得程序可以通过统一的接口来管理和操作底层资源,如文件、网络连接、进程、共享内存、设备等。通过描述符,程序可以执行各种操作,而无需直接处理这些资源的复杂性。这使得编程更为简洁和安全,同时也提高了系统的可维护性和扩展性。

task_struct结构

进程在内核中的描述符task_struct结构:

struct task_struct {
  // 进程id
  pid_t				pid;
  // 用于标识线程所属的进程pid
  pid_t				tgid;
  // 进程打开的文件信息
  struct files_struct		*files;
  // 内存描述符表示进程虚拟地址空间
  struct mm_struct		*mm;
  .......... 省略 .......
  }

在进程描述符task_struct结构中,有一个专门描述进程虚拟地址空间的内存描述符mm_struct结构,这个结构体中包含了前边几个小节中介绍的进程虚拟内存空间的全部信息。 每个进程都有唯一的mm_struct结构体,也就是前边提到的每个进程的虚拟地址空间都是独立,互不干扰的。 当我们调用fork()函数创建进程的时候,表示进程地址空间的mm_struct结构会随着进程描述符task_struct的创建而创建。

long _do_fork(unsigned long clone_flags,
	      unsigned long stack_start,
	      unsigned long stack_size,
	      int __user *parent_tidptr,
	      int __user *child_tidptr,
	      unsigned long tls)
{
        ......... 省略 ..........
	struct pid *pid;
	struct task_struct *p;
        ......... 省略 ..........
// 为进程创建 task_struct结构,用父进程的资源填充task_struct信息
p = copy_process(clone_flags, stack_start, stack_size,child_tidptr, NULL, trace, tls, NUMA_NO_NODE);
        ......... 省略 ..........
}

随后会在copy_process函数中创建task_struct结构,并拷贝父进程的相关资源到新进程的task_struct 结构里,其中就包括拷贝父进程的虚拟内存空间mm_struct结构。这里可以看出子进程在新创建出来之后它的虚拟内存空间是和父进程的虚拟内存空间一模一样的,直接拷贝过来

static __latent_entropy struct task_struct *copy_process(
  					unsigned long clone_flags,
					unsigned long stack_start,
					unsigned long stack_size,
					int __user *child_tidptr,
					struct pid *pid,
					int trace,
					unsigned long tls,
					int node)
{
    struct task_struct *p;
    // 创建 task_struct 结构
    p = dup_task_struct(current, node);
        ....... 初始化子进程 ...........
        ....... 开始继承拷贝父进程资源  .......
    // 继承父进程打开的文件描述符
	retval = copy_files(clone_flags, p);
    // 继承父进程所属的文件系统
	retval = copy_fs(clone_flags, p);
    // 继承父进程注册的信号以及信号处理函数
	retval = copy_sighand(clone_flags, p);
	retval = copy_signal(clone_flags, p);
    // 继承父进程的虚拟内存空间
	retval = copy_mm(clone_flags, p);
    // 继承父进程的namespaces
	retval = copy_namespaces(clone_flags, p);
    // 继承父进程的IO信息
	retval = copy_io(clone_flags, p);
      ...........省略.........
    // 分配 CPU
    retval = sched_fork(clone_flags, p);
    // 分配 pid
pid = alloc_pid(p->nsproxy->pid_ns_for_children);
.     ..........省略.........
}

这里我们重点关注copy_mm 函数,正是在这里完成了子进程虚拟内存空间mm_struct结构的的创建以及初始化。

static int copy_mm(unsigned long clone_flags, struct task_struct *tsk)
{
   // 子进程虚拟内存空间,父进程虚拟内存空间
   struct mm_struct *mm, *oldmm;
   int retval;
   ..... 省略 ......
   tsk->mm = NULL;
   tsk->active_mm = NULL;
   // 获取父进程虚拟内存空间
   oldmm = current->mm;
   if (!oldmm)
   return 0;
  ...... 省略 ......
  // 通过 vfork 或者 clone 系统调用创建出的子进程(线程)和父进程共享虚拟内存空间
  if (clone_flags & CLONE_VM) {
    // 增加父进程虚拟地址空间的引用计数
    mmget(oldmm);
    // 直接将父进程的虚拟内存空间赋值给子进程(线程)
    // 线程共享其所属进程的虚拟内存空间
    mm = oldmm;
    goto good_mm;
  }
  	retval = -ENOMEM;
// 如果是fork 系统调用创建出的子进程,则将父进程的虚拟内存空间以及相关页表拷贝到子进程中的 mm_struct 结构中。
mm = dup_mm(tsk);
if (!mm)
		goto fail_nomem;
good_mm:
// 将拷贝出来的父进程虚拟内存空间 mm_struct 赋值给子进程
	tsk->mm = mm;
	tsk->active_mm = mm;
	return 0;
        ...... 省略 ......

copy_mm函数首先会将父进程的虚拟内存空间current->mm赋值给指针oldmm。然后通过dup_mm函数将父进程的虚拟内存空间以及相关页表拷贝到子进程的mm_struct结构中。最后将拷贝出来的mm_struct赋值给子进程的task_struct 结构。 通过fork()函数创建出的子进程,它的虚拟内存空间以及相关页表相当于父进程虚拟内存空间的一份拷贝,直接从父进程中拷贝到子进程中。 fork() 创建的子进程拥有与父进程相同的虚拟内存布局和内容,初始时子进程的页表也是父进程页表的拷贝。 但实际的物理内存并没有立即复制,而是通过写时拷贝机制延迟到需要修改时才进行。这种机制确保了内存的高效利用,同时也保证了进程之间的内存隔离。虽然 fork() 之后,子进程最初拥有与父进程相同的虚拟地址空间布局和页表内容,但由于虚拟地址空间的独立性,它们各自的页表也是独立的。当进程对内存进行修改时,写时拷贝机制会导致页表的不同映射,因此最终父子进程的页表会变得不一样。 虽然fork()之后,子进程最初拥有与父进程相同的虚拟地址空间布局和页表内容,但由于虚拟地址空间的独立性,它们各自的页表也是独立的。当进程对内存进行修改时,写时拷贝机制会导致页表的不同映射,因此最终父子进程的页表会变得不一样。虽然fork() 之后,子进程最初拥有与父进程相同的虚拟地址空间布局和页表内容,但由于虚拟地址空间的独立性,它们各自的页表也是独立的。当进程对内存进行修改时,写时拷贝机制会导致页表的不同映射,因此最终父子进程的页表会变得不一样。 而当我们通过vfork或者clone系统调用创建出的子进程,首先会设置CLONE_VM标识,这样来到copy_mm函数中就会进入if (clone_flags & CLONE_VM)条件中,在这个分支中会将父进程的虚拟内存空间以及相关页表直接赋值给子进程。这样一来父进程和子进程的虚拟内存空间就变成共享的了。也就是说父子进程之间使用的虚拟内存空间是一样的,并不是一份拷贝。新创建的线程不会有自己独立的虚拟内存空间. clone()可以创建线程,而vfork()并不直接用于创建线程。 尽管线程共享进程的虚拟内存空间,线程可以有自己的线程局部存储(Thread-Local Storage, TLS),这允许每个线程在共享的地址空间内维护它们自己的局部数据。例如,线程的栈区域通常是独立的,即每个线程有自己的一块栈空间,尽管它们都位于相同的虚拟地址空间中。 子进程共享了父进程的虚拟内存空间,这样子进程就变成了我们熟悉的线程,是否共享地址空间几乎是进程和线程之间的本质区别。Linux内核并不区别对待它们,线程对于内核来说仅仅是一个共享特定资源的进程而已。 内核线程和用户态线程的区别就是内核线程没有相关的内存描述符mm_struct内核线程对应的task_struct 结构中的mm域指向Null,所以内核线程之间调度是不涉及地址空间切换的。 当一个内核线程被调度时,它会发现自己的虚拟地址空间为Null,虽然它不会访问用户态的内存,但是它会访问内核内存,聪明的内核会将调度之前的上一个用户态进程的虚拟内存空间mm_struct直接赋值给内核线程,因为内核线程不会访问用户空间的内存,它仅仅只会访问内核空间的内存,所以直接复用上一个用户态进程的虚拟地址空间就可以避免为内核线程分配mm_struct和相关页表的开销,以及避免内核线程之间调度时地址空间的切换开销。 父进程与子进程的区别,进程与线程的区别,以及内核线程与用户态线程的区别其实都是围绕着这个mm_struct展开的。 每个进程的页表内容通常不同,因为每个进程都有自己独立的虚拟地址空间和内存映射。然而,在某些特定情况下(如共享内存、fork() 后的初始状态),多个进程的页表可能有部分相同的内容。

内核如何划分用户态和内核态虚拟内存空间

那么用户态的地址空间和内核态的地址空间在内核中是如何被划分的呢? 这就用到了进程的内存描述符mm_struct结构体中的task_size变量,task_size定义了用户态地址空间与内核态地址空间之间的分界线。

struct mm_struct {
  unsigned long task_size;/* size of task vm space */
}

32 位系统中用户地址空间和内核地址空间的分界线在0xC000 000地址处,那么自然进程的mm_struct结构中的task_size为0xC000 000。 我们来看下内核在/arch/x86/include/asm/page_32_types.h文件中关于TASK_SIZE的定义。

#define TASK_SIZE		__PAGE_OFFSET

如下图所示:__PAGE_OFFSET的值在32位系统下为0xC000 000。 32位系统task_size定义 64位系统中用户地址空间和内核地址空间的分界线在0x0000 7FFF FFFF F000 地址处,那么自然进程的mm_struct结构中的task_size为0x0000 7FFF FFFF F000 。 我们来看下内核在/arch/x86/include/asm/page_64_types.h文件中关于TASK_SIZE的定义。

#define TASK_SIZE (test_thread_flag(TIF_ADDR32) ? \
					IA32_PAGE_OFFSET : TASK_SIZE_MAX)
#define TASK_SIZE_MAX		task_size_max()
#define task_size_max()	 ((_AC(1,UL) << __VIRTUAL_MASK_SHIFT) - PAGE_SIZE)
#define __VIRTUAL_MASK_SHIFT	47

在task_size_max()的计算逻辑中1左移47位得到的地址是0x0000800000000000,然后减去一个 PAGE_SIZE(默认为 4K),就是0x00007FFFFFFFF000,共128T。所以在64位系统中的TASK_SIZE为 0x00007FFFFFFFF000 。 64 位虚拟内存空间的布局是和物理内存页 page 的大小有关的,物理内存页page 默认大小 PAGE_SIZE 为 4K。 PAGE_SIZE 定义在 /arch/x86/include/asm/page_types.h文件中:

/* PAGE_SHIFT determines the page size */
#define PAGE_SHIFT		12
#define PAGE_SIZE		(_AC(1,UL) << PAGE_SHIFT)

内核如何布局进程虚拟内存空间

内存空间与结构体 内核中采用了一个叫做内存描述符的mm_struct结构体来表示进程虚拟内存空间的全部信息。

struct mm_struct {
  unsigned long task_size;/* size of task vm space */
  unsigned long start_code, end_code, start_data, end_data;
  unsigned long start_brk, brk, start_stack;
  unsigned long arg_start, arg_end, env_start, env_end;
  unsigned long mmap_base; /* base of mmap area */
  unsigned long total_vm;  /* Total pages mapped */
  unsigned long locked_vm /* Pages that have PG_mlocked set */
  unsigned long  pinned_vm;  /* Refcount permanently increased */
  unsigned long data_vm; /* VM_WRITE & ~VM_SHARED & ~VM_STACK */
  unsigned long exec_vm; /* VM_EXEC & ~VM_WRITE & ~VM_STACK */
  unsigned long stack_vm;  /* VM_STACK */
         ...... 省略 ........
}

内核中用mm_struct结构体中的上述属性来定义上图中虚拟内存空间里的不同内存区域。 主要功能:

  • 管理进程的虚拟地址空间的整体布局。

  • 包含页表信息,用于虚拟地址到物理地址的转换。

  • 包含与内存管理相关的全局信息,如页表目录、进程的内存映射列表、内存分配器状态等。

start_code 和 end_code 定义代码段的起始和结束位置,程序编译后的二进制文件中的机器码被加载进内存之后就存放在这里。 start_data 和 end_data 定义数据段的起始和结束位置,二进制文件中存放的全局变量和静态变量被加载进内存中就存放在这里。 后面紧挨着的是 BSS 段,用于存放未被初始化的全局变量和静态变量,这些变量在加载进内存时会生成一段 0 填充的内存区域 (BSS 段), BSS 段的大小是固定的, 下面就是 OS 堆了,在堆中内存地址的增长方向是由低地址向高地址增长, start_brk 定义堆的起始位置,brk 定义堆当前的结束位置。 接下来就是内存映射区,在内存映射区内存地址的增长方向是由高地址向低地址增长,mmap_base 定义内存映射区的起始地址。进程运行时所依赖的动态链接库中的代码段,数据段,BSS 段以及我们调用mmap 映射出来的一段虚拟内存空间就保存在这个区域。 start_stack 是栈的起始位置在 RBP 寄存器中存储,栈的结束位置也就是栈顶指针 stack pointer 在 RSP 寄存器中存储。在栈中内存地址的增长方向也是由高地址向低地址增长。 arg_start 和 arg_end 是参数列表的位置, env_start 和 env_end 是环境变量的位置。它们都位于栈中的最高地址处。 分段结构体 在 mm_struct 结构体中除了上述用于划分虚拟内存区域的变量之外,还定义了一些虚拟内存与物理内存映射内容相关的统计变量,操作系统会把物理内存划分成一页一页的区域来进行管理,所以物理内存到虚拟内存之间的映射也是按照页为单位进行的。 mm_struct 结构体中的 total_vm 表示在进程虚拟内存空间中总共与物理内存映射的页的总数。 当内存吃紧的时候,有些页可以换出到硬盘上,而有些页因为比较重要,不能换出。locked_vm 就是被锁定不能换出的内存页总数,pinned_vm 表示既不能换出,也不能移动的内存页总数。 data_vm 表示数据段中映射的内存页数目,exec_vm 是代码段中存放可执行文件的内存页数目,stack_vm 是栈中所映射的内存页数目,这些变量均是表示进程虚拟内存空间中的虚拟内存使用情况。 pgd指向页全局目录(Page Global Directory),用于页表管理。

内核如何管理虚拟内存区域

一个新的结构体 vm_area_struct,正是这个结构体描述了这些虚拟内存区域VMA(virtual memory area) 主要功能:

  • 表示进程虚拟内存空间中的一个具体区域(VMA)。

  • 每个vm_area_struct实例代表一个连续的虚拟内存范围。

  • 管理该区域的权限(如读、写、执行权限)以及区域的处理方式(如是否为匿名内存、是否与文件关联等)。

struct vm_area_struct {
unsigned long vm_start;	/* Our start address within vm_mm. */
unsigned long vm_end;	/* The first byte after our end address within vm_mm. */
/*
* Access permissions of this VMA.
*/
pgprot_t vm_page_prot;
unsigned long vm_flags;
struct anon_vma *anon_vma;	 /* Serialized by page_table_lock */
struct file * vm_file;	 /* File we map to (can be NULL). */
unsigned long vm_pgoff; /* Offset (within vm_file) in PAGE_SIZE units */
void * vm_private_data;	/* was vm_pte (shared mem) */
/* Function pointers to deal with this struct. */
const struct vm_operations_struct *vm_ops;
}

每个vm_area_struct结构对应于虚拟内存空间中的唯一虚拟内存区域VMA,vm_start指向了这块虚拟内存区域的起始地址(最低地址),vm_start本身包含在这块虚拟内存区域内。vm_end指向了这块虚拟内存区域的结束地址(最高地址),而vm_end 本身包含在这块虚拟内存区域之外,所以vm_area_struct 结构描述的是 [vm_start,vm_end) 这样一段左闭右开的虚拟内存区域。 vm_area_struct

总结: mm_struct 管理的是进程的整个虚拟地址空间,包含了页表、内存映射和总体内存布局等信息。 vm_area_struct 管理的是具体的虚拟内存区域,一个进程的虚拟地址空间由多个vm_area_struct 组成。

定义虚拟内存区域的访问权限和行为规范

vm_page_prot和vm_flags都是用来标记vm_area_struct结构表示的这块虚拟内存区域的访问权限和行为规范。 内核会将整块物理内存划分为一页一页大小的区域,以页为单位来管理这些物理内存,每页大小默认4K。而虚拟内存最终也是要和物理内存一一映射起来的,所以在虚拟内存空间中也有虚拟页的概念与之对应,虚拟内存中的虚拟页映射到物理内存中的物理页。无论是在虚拟内存空间中还是在物理内存中,内核管理内存的最小单位都是页。 vm_page_prot偏向于定义底层内存管理架构中页这一级别的访问控制权限,它可以直接应用在底层页表中,它是一个具体的概念。 虚拟内存区域VMA由许多的虚拟页(page)组成,每个虚拟页需要经过页表的转换才能找到对应的物理页面。页表中关于内存页的访问权限就是由 vm_page_prot 决定的。 vm_flags则偏向于定于整个虚拟内存区域的访问权限以及行为规范。描述的是虚拟内存区域中的整体信息,而不是虚拟内存区域中具体的某个独立页面。它是一个抽象的概念。可以通过vma->vm_page_prot =vm_get_page_prot(vma->vm_flags)实现到具体页面访问权限vm_page_prot的转换 列举一些常用到的vm_flags |vm_flags|访问权限| |—|—| |VM_READ|可读| |VM_WRITE |可写| |VM_EXEC |可执行| |VM_SHARD |可多进程之间共享| |VM_IO |可映射至设备 IO 空间| |VM_RESERVED |内存区域不可被换出| |VM_SEQ_READ|内存区域可能被顺序访问| |VM_RAND_READ|内存区域可能被随机访问| VM_READ,VM_WRITE,VM_EXEC 定义了虚拟内存区域是否可以被读取,写入,执行等权限。 比如代码段这块内存区域的权限是可读,可执行,但是不可写。数据段具有可读可写的权限但是不可执行。堆则具有可读可写,可执行的权限(Java 中的字节码存储在堆中,所以需要可执行权限),栈一般是可读可写的权限,一般很少有可执行权限。而文件映射与匿名映射区存放了共享链接库,所以也需要可执行的权限。 vm_area_struct2 VM_SHARD用于指定这块虚拟内存区域映射的物理内存是否可以在多进程之间共享,以便完成进程间通讯。 设置这个值即为mmap的共享映射,不设置的话则为私有映射。 VM_IO 的设置表示这块虚拟内存区域可以映射至设备 IO 空间中。通常在设备驱动程序执行 mmap 进行IO 空间映射时才会被设置。 VM_RESERVED 的设置表示在内存紧张的时候,这块虚拟内存区域非常重要,不能被换出到磁盘中。 VM_SEQ_READ 的设置用来暗示内核,应用程序对这块虚拟内存区域的读取是会采用顺序读的方式进行,内核会根据实际情况决定预读后续的内存页数,以便加快下次顺序访问速度。 VM_RAND_READ 的设置会暗示内核,应用程序会对这块虚拟内存区域进行随机读取,内核则会根据实际情况减少预读的内存页数甚至停止预读。 我们可以通过 posix_fadvise,madvise 系统调用来暗示内核是否对相关内存区域进行顺序读取或者随机读取。 可以看到vm_flags就是定义整个虚拟内存区域的访问权限以及行为规范,而内存区域中内存的最小单位为页(4K),虚拟内存区域中包含了很多这样的虚拟页,对于虚拟内存区域VMA 设置的访问权限也会全部复制到区域中包含的内存页中。

关联内存映射中的映射关系

接下来的三个属性anon_vma,vm_file,vm_pgoff分别和虚拟内存映射相关,虚拟内存区域可以映射到物理内存上,也可以映射到文件中,映射到物理内存上我们称之为匿名映射,映射到文件中我们称之为文件映射

匿名映射与文件映射

虚拟内存映射到物理内存而没有关联到文件或外部资源的情况被称为“匿名映射”,因为它没有对应的文件或命名资源作为后备存储,仅用于内存分配。这类映射典型的用途包括堆、栈、以及某些类型的共享内存。 虚拟内存进行文件映射时,指的是将虚拟内存空间的某个区域映射到磁盘上的一个文件。这意味着对该内存区域的访问(读取或写入)实际上是在对文件的内容进行操作,而不是操作常规的物理内存。文件映射通常通过mmap() 系统调用来实现。 文件映射的工作原理 当虚拟内存空间被映射到一个磁盘文件时,虚拟地址对应的物理页面并不立即加载到内存中。相反,内核会维护一个映射关系,当进程访问这个虚拟地址时,内核会根据页表信息查找对应的物理页面:

  1. 页面不在内存:如果访问的页面还没有加载到物理内存,内核会触发一个缺页中断(page fault),从磁盘读取相应的数据块,并将其加载到物理内存中。

  2. 页面在内存: 如果访问的页面已经加载到了物理内存中,内核直接返回该页面的内容。

  3. 写回机制:对于写入操作,如果内存页被修改,内核会标记该页面为“脏页”。之后,内核会根据需要将修改后的数据写回到磁盘文件中。写回可能是同步的,也可能是异步的,这取决于映射时使用的标志(如 MAP_SHARED 或 MAP_PRIVATE)。 文件映射的用途

  4. 高效文件I/O:使用文件映射可以减少系统调用的开销,并且使文件I/O操作更加高效。通过 mmap() 将文件映射到内存后,进程可以像访问普通内存一样访问文件内容,而不需要使用 read() 或 write() 等系统调用。

  5. 共享内存:多个进程可以通过映射同一个文件到它们的地址空间来共享数据。如果使用 MAP_SHARED 标志,多个进程可以通过文件映射共享相同的物理内存页面,彼此之间进行通信和数据交换。

  6. 内存映射文件: 某些数据库和应用程序会使用内存映射文件来实现数据的高效加载和存储。例如,数据库可以将整个数据文件映射到内存,从而实现高效的查询和更新操作。 在Linux系统中,文件映射通常通过 mmap() 系统调用来完成。调用 mmap() 时,需要指定文件描述符、映射长度、保护权限、映射标志等参数。 共享库是多个程序或进程可以动态加载和共享使用的库文件。它通过动态链接在程序运行时加载,节省了内存和存储空间,并且可以方便地进行版本管理和更新。共享库是一种文件,它在虚拟内存中通过文件映射的方式进行处理。 当我们调用malloc申请内存时,如果申请的是小块内存(低于 128K)则会使用do_brk()系统调用通过调整堆中的brk指针大小来增加或者回收堆内存。 如果申请的是比较大块的内存(超过 128K)时,则会调用 mmap在上图虚拟内存空间中的文件映射与匿名映射区创建出一块VMA内存区域(这里是匿名映射)。这块匿名映射区域就用struct anon_vma结构表示。 当调用mmap进行文件映射时,vm_file属性就用来关联被映射的文件。这样一来虚拟内存区域就与映射文件关联了起来。vm_pgoff则表示映射进虚拟内存中的文件内容,在文件中的偏移。 vm_private_data则用于存储VMA中的私有数据。具体的存储内容和内存映射的类型有关.

针对虚拟内存区域的相关操作

struct vm_area_struct结构中还有一个vm_ops用来指向针对虚拟内存区域VMA的相关操作的函数指针。 当指定的虚拟内存区域被加入到进程虚拟内存空间中时,open函数会被调用 当虚拟内存区域VMA从进程虚拟内存空间中被删除时,close函数会被调用 当进程访问虚拟内存时,访问的页面不在物理内存中,可能是未分配物理内存也可能是被置换到磁盘中,这时就会产生缺页异常,fault函数就会被调用。 当一个只读的页面将要变为可写时,page_mkwrite 函数会被调用。 vm_operations_struct结构中定义的都是对虚拟内存区域VMA的相关操作函数指针。 内核中这种类似的用法其实有很多,在内核中每个特定领域的描述符都会定义相关的操作。针对Socket文件类型,这里的file_operations指向的是socket_file_ops。 ![Socket 文件类型](https://cdn.jsdelivr.net/gh/zysok2023/cloudImg/blogs/picture/Socket 文件类型.webp) 在ext4文件系统中管理的文件对应的file_operations指向ext4_file_operations,专门用于操作 ext4 文件系统中的文件。还有针对page cache页高速缓存相关操作定义的address_space_operations ext4_file_operations socket 相关的操作接口定义在inet_stream_ops函数集合中,负责对上给用户提供接口。而 socket 与内核协议栈之间的操作接口定义在struct sock中的sk_prot指针上,这里指向tcp_prot协议操作函数集合。 ![socket 相关的操作接口](https://cdn.jsdelivr.net/gh/zysok2023/cloudImg/blogs/picture/socket 相关的操作接口.webp) 对 socket 发起的系统 IO 调用时,在内核中首先会调用socket 的文件结构 struct file 中的file_operations文件操作集合,然后调用 struct socket 中的 ops 指向的 inet_stream_opssocket 操作函数,最终调用到struct sock 中 sk_prot 指针指向的 tcp_prot 内核协议栈操作函数接口集合。

虚拟内存区域在内核中是如何被组织的

在内核中其实是通过一个 struct vm_area_struct 结构的双向链表将虚拟内存空间中的这些虚拟内存区域VMA 串联起来的。 vm_area_struct结构中的vm_next,vm_prev指针分别指向VMA节点所在双向链表中的后继节点和前驱 节点,内核中的这个 VMA 双向链表是有顺序的,所有VMA节点按照低地址到高地址的增长方向排序。 双向链表中的最后一个VMA节点的vm_next指针指向NULL,双向链表的头指针存储在内存描述符struct mm_struct结构中的mmap中,正是这个 mmap 串联起了整个虚拟内存空间中的虚拟内存区域。 在每个虚拟内存区域 VMA 中又通过 struct vm_area_struct 中的 vm_mm 指针指向了所属的虚拟内存空间mm_struct。 vm_area_struct3 我们可以通过cat /proc/pid/maps或者 pmap pid 查看进程的虚拟内存空间布局以及其中包含的所有内存区域。这两个命令背后的实现原理就是通过遍历内核中的这个 vm_area_struct 双向链表获取的。 内核中关于这些虚拟内存区域的操作除了遍历之外还有许多需要根据特定虚拟内存地址在虚拟内存空间中查找特定的虚拟内存区域。 尤其在进程虚拟内存空间中包含的内存区域VMA比较多的情况下,使用红黑树查找特定虚拟内存区域的时间复杂度是 O(logN) ,可以显著减少查找所需的时间。 所以在内核中,同样的内存区域 vm_area_struct 会有两种组织形式,一种是双向链表用于高效的遍历,另一种就是红黑树用于高效的查找。 每个 VMA 区域都是红黑树中的一个节点,通过 struct vm_area_struct 结构中的 vm_rb 将自己连接到红黑树中。 而红黑树中的根节点存储在内存描述符 struct mm_struct 中的 mm_rb 中: vm_area_struct4

程序编译后的二进制文件如何映射到虚拟内存空间中

我们写的程序代码编译之后会生成一个 ELF 格式的二进制文件,这个二进制文件中包含了程序运行时所需要的元信息,比如程序的机器码,程序中的全局变量以及静态变量等。 这个 ELF 格式的二进制文件中的布局和我们前边讲的虚拟内存空间中的布局类似,也是一段一段的,每一段包含了不同的元数据。 磁盘文件中的段我们叫做 Section,内存中的段我们叫做 Segment,也就是内存区域。 磁盘文件中的这些 Section 会在进程运行之前加载到内存中并映射到内存中的 Segment。通常是多个Section 映射到一个 Segment。 比如磁盘文件中的.text,.rodata等一些只读的 Section,会被映射到内存的一个只读可执行的Segment里(代码段)。而 .data,.bss 等一些可读写的 Section,则会被映射到内存的一个具有读写权限的Segment 里(数据段,BSS 段)。 那么这些 ELF 格式的二进制文件中的 Section 是如何加载并映射进虚拟内存空间的呢? 内核中完成这个映射过程的函数是 load_elf_binary ,这个函数的作用很大,加载内核的是它,启动第一个用户态进程 init 的是它,fork 完了以后,调用 exec 运行一个二进制程序的也是它。当 exec 运行一个二进制程序的时候,除了解析 ELF 的格式之外,另外一个重要的事情就是建立上述提到的内存映射。

  • setup_new_exec 设置虚拟内存空间中的内存映射区域起始地址 mmap_base

  • setup_arg_pages 创建并初始化栈对应的 vm_area_struct 结构。setup_arg_pages 创建并初始化栈对应的 vm_area_struct 结构。

  • elf_map 将 ELF 格式的二进制文件中.text ,.data,.bss 部分映射到虚拟内存空间中的代码段,数据段,BSS 段中。

  • set_brk 创建并初始化堆对应的的 vm_area_struct 结构,设置 current->mm->start_brk = current->mm->brk,设置堆的起始地址 start_brk,结束地址 brk。 起初两者相等表示堆是空的。

  • load_elf_interp 将进程依赖的动态链接库 .so 文件映射到虚拟内存空间中的内存映射区域

  • 初始化内存描述符 mm_struct

内核虚拟内存空间

内核态虚拟内存空间是所有进程共享的,不同进程进入内核态之后看到的虚拟内存空间全部是一样的。 由于内核会涉及到物理内存的管理,所以很多人会想当然地认为只要进入了内核态就开始使用物理地址了,这就大错特错了,千万不要这样理解,进程进入内核态之后使用的仍然是虚拟内存地址,只不过在内核中使用的虚拟内存地址被限制在了内核态虚拟内存空间范围中.

32 位体系内核虚拟内存空间布局

直接映射区

在总共大小 1G 的内核虚拟内存空间中,位于最前边有一块 896M 大小的区域,我们称之为直接映射区或者线性映射区,地址范围为 3G – 3G + 896m. 之所以这块 896M 大小的区域称为直接映射区或者线性映射区,是因为这块连续的虚拟内存地址会映射到0 - 896M 这块连续的物理内存上。 也就是说 3G – 3G + 896m 这块 896M 大小的虚拟内存会直接映射到 0 - 896M 这块896M 大小的物理内存上,这块区域中的虚拟内存地址直接减去 0xC000 0000 (3G)就得到了物理内存地址。所以我们称这块区域为直接映射区。 直接内存映射 虽然这块区域中的虚拟地址是直接映射到物理地址上,但是内核在访问这段区域的时候还是走的虚拟内存地址,内核也会为这块空间建立映射页表。 内核态虚拟内存空间的前896M区域是直接映射到物理内存中的前896M区域中的,直接映射区中的映射关系是一比一映射。映射关系是固定的不会改变。 在这段896M大小的物理内存中,前 1M 已经在系统启动的时候被系统占用,1M 之后的物理内存存放的是内核代码段,数据段,BSS段(这些信息起初存放在 ELF格式的二进制文件中,在系统启动的时候被加载进内存)。 可以通过cat /proc/iomem命令查看具体物理内存布局情况。 当我们使用fork系统调用创建进程的时候,内核会创建一系列进程相关的描述符,比如之前提到的进程的核心数据结构task_struct,进程的内存空间描述符mm_struct,以及虚拟内存区域描述符vm_area_struct等。 这些进程相关的数据结构也会存放在物理内存前896M的这段区域中,当然也会被直接映射至内核态虚拟内存空间中的 3G – 3G + 896m 这段直接映射区域中。 内核内存空间结构1 当进程被创建完毕之后,在内核运行的过程中,会涉及内核栈的分配,内核会为每个进程分配一个固定大小的内核栈(一般是两个页大小,依赖具体的体系结构),每个进程的整个调用链必须放在自己的内核栈中,内核栈也是分配在直接映射区。 与进程用户空间中的栈不同的是,内核栈容量小而且是固定的,用户空间中的栈容量大而且可以动态扩展。内核栈的溢出危害非常巨大,它会直接悄无声息的覆盖相邻内存区域中的数据,破坏数据。 内核对物理内存的管理都是以页为最小单位来管理的,每页默认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 这段虚拟内存上。 直接内存映射2 这里的 ZONE_DMA 和 ZONE_NORMAL 是内核针对物理内存区域的划分。

ZONE_HIGHMEM 高端内存

而物理内存 896M 以上的区域被内核划分为 ZONE_HIGHMEM 区域,我们称之为高端内存。 本例中我们的物理内存假设为 4G,高端内存区域为 4G - 896M = 3200M,那么这块 3200M 大小的ZONE_HIGHMEM 区域该如何映射到内核虚拟内存空间中呢? 由于内核虚拟内存空间中的前 896M 虚拟内存已经被直接映射区所占用,而在 32 体系结构下内核虚拟内存空间总共也就1G 的大小,这样一来内核剩余可用的虚拟内存空间就变为了 1G - 896M = 128M。 显然物理内存中 3200M大小的 ZONE_HIGHMEM 区域无法继续通过直接映射的方式映射到这 128M 大小的虚拟内存空间中。 这样一来物理内存中的 ZONE_HIGHMEM 区域就只能采用动态映射的方式映射到 128M 大小的内核虚拟内存空间中,也就是说只能动态的一部分一部分的分批映射,先映射正在使用的这部分,使用完毕解除映射,接着映射其他部分。 直接内存映射3 内核虚拟内存空间中的 3G + 896M 这块地址在内核中定义为 high_memory,high_memory 往上有一段8M 大小的内存空洞。空洞范围为:high_memory 到 VMALLOC_START 。 VMALLOC_START 定义在内核源码/arch/x86/include/asm/pgtable_32_areas.h文件中:

#define VMALLOC_OFFSET	(8 * 1024 * 1024)
#define VMALLOC_OFFSET	(8 * 1024 * 1024)
vmalloc 动态映射区

VMALLOC_START 到 VMALLOC_END 之间的这块区域成为动态映射区。采用动态映射的方式映射物理内存中的高端内存。

#ifdef CONFIG_HIGHMEM
# define VMALLOC_END	(PKMAP_BASE - 2 * PAGE_SIZE)
#else
# define VMALLOC_END	(LDT_BASE_ADDR - 2 * PAGE_SIZE)
#endif

直接内存映射4 和用户态进程使用 malloc 申请内存一样,在这块动态映射区内核是使用 vmalloc 进行内存分配。由于之前介绍的动态映射的原因,vmalloc 分配的内存在虚拟内存上是连续的,但是物理内存是不连续的。通过页表来建立物理内存与虚拟内存之间的映射关系,从而可以将不连续的物理内存映射到连续的虚拟内存上。 由于 vmalloc 获得的物理内存页是不连续的,因此它只能将这些物理内存页一个一个地进行映射,在性能开销上会比直接映射大得多。

永久映射区

直接内存映射5 而在 PKMAP_BASE 到 FIXADDR_START 之间的这段空间称为永久映射区。在内核的这段虚拟地址空间中允许建立与物理高端内存的长期映射关系。比如内核通过 alloc_pages() 函数在物理内存的高端内存中申请获取到的物理内存页,这些物理内存页可以通过调用 kmap 映射到永久映射区中。 LAST_PKMAP 表示永久映射区可以映射的页数限制。

#define PKMAP_BASE
((LDT_BASE_ADDR - PAGE_SIZE) & PMD_MASK)
#define LAST_PKMAP 1024
固定映射区

直接内存映射6 内核虚拟内存空间中的下一个区域为固定映射区,区域范围为:FIXADDR_START 到 FIXADDR_TOP。FIXADDR_START 和 FIXADDR_TOP 定义在内核源码 /arch/x86/include/asm/fixmap.h文件中:

#define FIXADDR_START (FIXADDR_TOP - FIXADDR_SIZE)
extern unsigned long __FIXADDR_TOP; // 0xFFFF F000
#define FIXADDR_TOP ((unsigned long)__FIXADDR_TOP)

在内核虚拟内存空间的直接映射区中,直接映射区中的虚拟内存地址与物理内存前 896M 的空间的映射关系都是预设好的,一比一映射。 在固定映射区中的虚拟内存地址可以自由映射到物理内存的高端地址上,但是与动态映射区以及永久映射区不同的是,在固定映射区中虚拟地址是固定的,而被映射的物理地址是可以改变的。也就是说,有些虚拟地址在编译的时候就固定下来了,是在内核启动过程中被确定的,而这些虚拟地址对应的物理地址不是固定的。采用固定虚拟地址的好处是它相当于一个指针常量(常量的值在编译时确定),指向物理地址,如果虚拟地址不固定,则相当于一个指针变量。 那为什么会有固定映射这个概念呢 ? 比如:在内核的启动过程中,有些模块需要使用虚拟内存并映射到指定的物理地址上,而且这些模块也没有办法等待完整的内存管理模块初始化之后再进行地址映射。因此,内核固定分配了一些虚拟地址,这些地址有固定的用途,使用该地址的模块在初始化的时候,将这些固定分配的虚拟地址映射到指定的物理地址上去。

临时映射区

在内核虚拟内存空间中的最后一块区域为临时映射区,那么这块临时映射区是用来干什么的呢? 直接内存映射7 在 Buffered IO 模式下进行文件写入的时候,在下图中的第四步,内核会调用 iov_iter_copy_from_user_atomic 函数将用户空间缓冲区 DirectByteBuffer 中的待写入数据拷贝到 page cache 中。 ![Buffered IO 模式下进行文件写入](https://cdn.jsdelivr.net/gh/zysok2023/cloudImg/blogs/picture/Buffered IO 模式下进行文件写入.webp) 但是内核又不能直接进行拷贝,因为此时从 page cache 中取出的缓存页 page 是物理地址,而在内核中是不能够直接操作物理地址的,只能操作虚拟地址。 那怎么办呢?所以就需要使用 kmap_atomic 将缓存页临时映射到内核空间的一段虚拟地址上,这段虚拟地址就位于内核虚拟内存空间中的临时映射区上,然后将用户空间缓存区 DirectByteBuffer 中的待写入数据通过这段映射的虚拟地址拷贝到 page cache 中的相应缓存页中。这时文件的写入操作就已经完成了。 由于是临时映射,所以在拷贝完成之后,调用 kunmap_atomic 将这段映射再解除掉。

32位体系结构下 Linux 虚拟内存空间整体布局

![32位体系结构下 Linux 虚拟内存空间整体布局](https://cdn.jsdelivr.net/gh/zysok2023/cloudImg/blogs/picture/32位体系结构下 Linux 虚拟内存空间整体布局.webp)

64 位体系内核虚拟内存空间布局

内核虚拟内存空间在 32 位体系下只有 1G 大小,实在太小了,因此需要精细化的管理,于是按照功能分类划分除了很多内核虚拟内存区域,这样就显得非常复杂。 到了 64 位体系下,内核虚拟内存空间的布局和管理就变得容易多了,因为进程虚拟内存空间和内核虚拟内存空间各自占用 128T 的虚拟内存,实在是太大了,我们可以在这里边随意翱翔,随意挥霍。 因此在 64 位体系下的内核虚拟内存空间与物理内存的映射就变得非常简单,由于虚拟内存空间足够的大,即便是内核要访问全部的物理内存,直接映射就可以了,不在需要用到高端内存那种动态映射方式。 64 位系统中的 TASK_SIZE 为 0x00007FFFFFFFF000 64位虚拟内存空间分布 在 64 位系统中,只使用了其中的低 48 位来表示虚拟内存地址。其中用户态虚拟内存空间为低 128 T,虚拟内存地址范围为:0x0000 0000 0000 0000 - 0x0000 7FFF FFFF F000 。 内核态虚拟内存空间为高 128 T,虚拟内存地址范围为:0xFFFF 8000 0000 0000- 0xFFFF FFFF FFFF FFFF. 64位虚拟内存空间布局 64 位内核虚拟内存空间从0xFFFF 8000 0000 0000 开始到 0xFFFF 8800 0000 0000 这段地址空间是一个8T 大小的内存空洞区域。 紧着着 8T 大小的内存空洞下一个区域就是 64T 大小的直接映射区。这个区域中的虚拟内存地址减去PAGE_OFFSET 就直接得到了物理内存地址。PAGE_OFFSET 变量定义在/arch/x86/include/asm/page_64_types.h文件中: 从图中 VMALLOC_START 到 VMALLOC_END的这段区域是 32T 大小的 vmalloc 映射区,这里类似用户空间中的堆,内核在这里使用 vmalloc 系统调用申请内存。 VMALLOC_START 和 VMALLOC_END变量定义在/arch/x86/include/asm/pgtable_64_types.h文件中: 从 VMEMMAP_START开始是 1T 大小的虚拟内存映射区,用于存放物理页面的描述符 struct page 结构用来表示物理内存页。 VMEMMAP_START 变量定义在/arch/x86/include/asm/pgtable_64_types.h文件中: 从 __START_KERNEL_map 开始是大小为 512M 的区域用于存放内核代码段、全局变量、BSS 等。这里对应到物理内存开始的位置,减去 __START_KERNEL_map 就能得到物理内存的地址。这里和直接映射区有点像,但是不矛盾,因为直接映射区之前有 8T 的空洞区域,早就过了内核代码在物理内存中加载的位置。 __START_KERNEL_map变量定义在/arch/x86/include/asm/page_64_types.h文件中:

64位体系结构下 Linux 虚拟内存空间整体布局

![64位体系结构下 Linux 虚拟内存空间整体布局](https://cdn.jsdelivr.net/gh/zysok2023/cloudImg/blogs/picture/64位体系结构下 Linux 虚拟内存空间整体布局.webp)

到底什么是物理内存地址

我们平时所称的内存也叫随机访问存储器( random-access memory )也叫RAM 。而RAM分为两类: 一类是静态RAM( SRAM ),这类SRAM用于CPU高速缓存L1Cache,L2Cache,L3Cache。其特点是访问速度快,访问速度为1 - 30个时钟周期,但是容量小,造价高。 另一类则是动态RAM ( DRAM ),这类DRAM用于我们常说的主存上,其特点的是访问速度慢(相对高速缓存),访问速度为50 - 200个时钟周期,但是容量大,造价便宜些(相对高速缓存)。 内存由一个一个的存储器模块(memory module)组成,它们插在主板的扩展槽上。常见的存储器模块通常以 64 位为单位( 8 个字节)传输数据到存储控制器上或者从存储控制器传出数据。 内存条 内存条上黑色的元器件就是存储器模块(memory module)。多个存储器模块连接到存储控制器上,就聚合成了主存。 主存结构 而 DRAM 芯片就包装在存储器模块中,每个存储器模块中包含 8 个 DRAM 芯片,依次编号为 0 - 7 。 存储器模块 而每一个DRAM芯片的存储结构是一个二维矩阵,二维矩阵中存储的元素我们称为超单元(supercell),每个 supercell大小为一个字节(8 bit)。每个supercell都有一个坐标地址(i,j)。 i 表示二维矩阵中的行地址,在计算机中行地址称为RAS (row access strobe,行访问选通脉冲)。 j表示二维矩阵中的列地址,在计算机中列地址称为CAS (column access strobe,列访问选通脉冲)。 下图中的supercell的RAS = 2,CAS = 2。 超单元 图中DRAM芯片包含了两个地址引脚( addr ),因为我们要通过RAS,CAS来定位要获取的 supercell 。还有8个数据引脚(data),因为DRAM芯片的IO单位为一个字节(8 bit),所以需要8个data引脚从DRAM芯片传入传出数据。

DRAM芯片的访问

我们现在就以读取上图中坐标地址为(2,2)的 supercell 为例,来说明访问 DRAM 芯片的过程。 访问DRAM芯片的过程 首先存储控制器将行地址 RAS = 2 通过地址引脚发送给 DRAM 芯片。 DRAM 芯片根据 RAS = 2 将二维矩阵中的第二行的全部内容拷贝到内部行缓冲区中。 接下来存储控制器会通过地址引脚发送 CAS = 2 到 DRAM 芯片中。 DRAM芯片从内部行缓冲区中根据 CAS = 2 拷贝出第二列的 supercell 并通过数据引脚发送给存储控制器。 DRAM 芯片的 IO 单位为一个 supercell ,也就是一个字节(8 bit)。

CPU如何读写主存

CPU如何访问内存 CPU与内存之间的数据交互是通过总线(bus)完成的,而数据在总线上的传送是通过一系列的步骤完成的,这些步骤称为总线事务(bus transaction)。 其中数据从内存传送到CPU称之为读事务(read transaction),数据从CPU传送到内存称之为写事务(write transaction)。 总线上传输的信号包括:地址信号,数据信号,控制信号。其中控制总线上传输的控制信号可以同步事务,并能够标识出当前正在被执行的事务信息:

  • 当前这个事务是到内存的?还是到磁盘的?或者是到其他 IO 设备的?

  • 这个事务是读还是写?

  • 总线上传输的地址信号(物理内存地址),还是数据信号(数据)? IO Bridge 是计算机系统中负责连接和管理高速处理器与低速外部设备之间数据传输的硬件组件。在现代计算机架构中,IO Bridge 通常分为北桥(Northbridge)和南桥(Southbridge)两种. 总线上传输的地址均为物理内存地址。比如:在MESI缓存一致性协议中当CPUcore0修改字段a的值时,其他CPU核心会在总线上嗅探字段a的物理内存地址,如果嗅探到总线上出现字段a的物理内存地址,说明有人在修改字段a,这样其他CPU核心就会失效字段a所在的cache line。 系统总线是连接CPU与IO bridge的,存储总线是来连接IO bridge和主存的。 IO bridge负责将系统总线上的电子信号转换成存储总线上的电子信号。IO bridge也会将系统总线和存储总线连接到IO总线(磁盘等IO设备)上。这里我们看到IO bridge其实起的作用就是转换不同总线上的电子信号。

CPU 从内存读取数据过程

假设CPU 现在需要将物理内存地址为A的内容加载到寄存器中进行运算。 CPU只会访问虚拟内存,在操作总线之前,需要把虚拟内存地址转换为物理内存地址,总线上传输的都是物理内存地址。 CPU从内存读取数据过程 首先CPU芯片中的总线接口会在总线上发起读事务(read transaction)。该读事务分为以下步骤进行:

  1. CPU将物理内存地址A放到系统总线上。随后IObridge 将信号传递到存储总线上。

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

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

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

  5. CPU 芯片感受到系统总线上的数据信号,将数据从系统总线上读取出来并拷贝到寄存器中。 以上就是 CPU 读取内存数据到寄存器中的完整过程。 但是其中还涉及到一个重要的过程,这里我们还是需要摊开来介绍一下,那就是存储控制器如何通过物理内存地址 A 从主存中读取出对应的数据 X 的? 接下来我们结合前边介绍的内存结构以及从 DRAM 芯片读取数据的过程,来总体介绍下如何从主存中读取数据。

如何根据物理内存地址从主存中读取数据

当主存中的存储控制器感受到了存储总线上的地址信号时,会将内存地址从存储总线上读取出来。 随后会通过内存地址定位到具体的存储器模块。存储控制器会将物理内存地址转换为 DRAM 芯片中 supercell 在二维矩阵中的坐标地址(RAS,CAS)。并将这个坐标地址发送给对应的存储器模块。随后存储器模块会将 RAS 和 CAS 广播到存储器模块中的所有DRAM 芯片。依次通过 (RAS,CAS) 从 DRAM0 到 DRAM7 读取到相应的 supercell 。 我们知道一个 supercell 存储了一个字节( 8 bit ) 数据,这里我们从 DRAM0 到 DRAM7 依次读取到了 8个 supercell 也就是 8 个字节,然后将这 8 个字节返回给存储控制器,由存储控制器将数据放到存储总线上。 CPU总是以word size 为单位从内存中读取数据,在 64 位处理器中的 word size 为 8 个字节。64 位的内存每次只能吞吐 8 个字节。 CPU每次会向内存读写一个 cache line 大小的数据( 64 个字节),但是内存一次只能吞吐8个字节。 所以在物理内存地址对应的存储器模块中,DRAM0 芯片存储第一个低位字节( supercell ),DRAM1 芯片存储第二个字节,……依次类推 DRAM7 芯片存储最后一个高位字节。 存储器存储数据 由于存储器模块中这种由8个DRAM芯片组成的物理存储结构的限制,内存读取数据只能是按照物理内存地址,8 个字节 8 个字节地顺序读取数据。所以说内存一次读取和写入的单位是8个字节读取8字节 而且在程序员眼里连续的物理内存地址实际上在物理上是不连续的。因为这连续的8个字节其实是存储于不同的DRAM芯片上的。每个 DRAM 芯片存储一个字节(supercell) 内存与缓存的交互:虽然CPU希望以缓存行为单位(64字节)读取数据,但主内存的接口可能一次只能提供较小的数据块(例如8字节)。为了实现一次读取整个64字节的缓存行,内存控制器会连续多次从内存中读取8字节的数据块,直到将整个64字节的缓存行填满。 并行化与多通道内存:现代系统通常采用多通道内存技术(例如双通道、四通道),这样可以在单次访问内存时同时读取多个8字节的数据块,从而加快填充整个缓存行的速度。因此,虽然内存每次传输的数据块可能是8字节,但通过并行化和多通道设计,系统可以更快地读取整个缓存行。

CPU 向内存写入数据过程

现在假设CPU要将寄存器中的数据X写到物理内存地址A中。同样的道理,CPU芯片中的总线接口会向总线发起写事务(write transaction)。写事务步骤如下:

  1. CPU将要写入的物理内存地址A放入系统总线上。

  2. 通过IO bridge的信号转换,将物理内存地址A传递到存储总线上。

  3. 存储控制器感受到存储总线上的地址信号,将物理内存地址A从存储总线上读取出来,并等待数据的到达.

  4. CPU将寄存器中的数据拷贝到系统总线上,通过IO bridge的信号转换,将数据传递到存储总线上。

  5. 存储控制器感受到存储总线上的数据信号,将数据从存储总线上读取出来。

  6. 存储控制器通过内存地址A定位到具体的存储器模块,最后将数据写入存储器模块中的8个DRAM芯片中。

Linux物理内存管理

从CPU角度看物理内存模型

内核中如何组织管理这些物理内存页struct page的方式我们称之为做物理内存模型,不同的物理内存模型,应对的场景以及page_to_pfn与pfn_to_page的计算逻辑都是不一样的。

FLATMEM 平坦内存模型

先把物理内存想象成一片地址连续的存储空间,在这一大片地址连续的内存空间中,内核将这块内存空间分为一页一页的内存块 struct page 。 由于这块物理内存是连续的,物理地址也是连续的,划分出来的这一页一页的物理页必然也是连续的,并且每页的大小都是固定的,所以我们很容易想到用一个数组来组织这些连续的物理内存页 struct page 结构,其在数组中对应的下标即为 PFN 。这种内存模型就叫做平坦内存模型 FLATMEM 。 FLATMEM平坦内存模型 内核中使用了一个 mem_map 的全局数组用来组织所有划分出来的物理内存页。mem_map 全局数组的下标就是相应物理页对应的 PFN 。在平坦内存模型下 ,page_to_pfn 与 pfn_to_page 的计算逻辑就非常简单,本质就是基于 mem_map 数组进行偏移操作。 ARCH_PFN_OFFSET 是 PFN 的起始偏移量。 Linux 早期使用的就是这种内存模型,因为在 Linux 发展的早期所需要管理的物理内存通常不大(比如几十 MB),那时的 Linux 使用平坦内存模型 FLATMEM 来管理物理内存就足够高效了。 内核中的默认配置是使用 FLATMEM 平坦内存模型。

DISCONTIGMEM 非连续内存模型

FLATMEM 平坦内存模型只适合管理一整块连续的物理内存,而对于多块非连续的物理内存来说使用FLATMEM 平坦内存模型进行管理则会造成很大的内存空间浪费。 因为 FLATMEM 平坦内存模型是利用 mem_map 这样一个全局数组来组织这些被划分出来的物理页 page的,而对于物理内存存在大量不连续的内存地址区间这种情况时,这些不连续的内存地址区间就形成了内存空洞。 由于用于组织物理页的底层数据结构是 mem_map 数组,数组的特性又要求这些物理页是连续的,所以只能为这些内存地址空洞也分配 struct page 结构用来填充数组使其连续。 而每个 struct page 结构大部分情况下需要占用 40 字节(struct page 结构在不同场景下内存占用会有所不同),如果物理内存中存在的大块的地址空洞,那么为这些空洞而分配的 struct page 将会占用大量的内存空间,导致巨大的浪费。 struct页结构空洞 为了组织和管理这些不连续的物理内存,内核于是引入了 DISCONTIGMEM 非连续内存模型,用来消除这些不连续的内存地址空洞对 mem_map 的空间浪费。 在 DISCONTIGMEM 非连续内存模型中,内核将物理内存从宏观上划分成了一个一个的节点 node(微观上还是一页一页的物理页),每个 node 节点管理一块连续的物理内存。这样一来这些连续的物理内存页均被划归到了对应的 node 节点中管理,就避免了内存空洞造成的空间浪费。 ![DISCONTIGMEM 非连续内存模型](https://cdn.jsdelivr.net/gh/zysok2023/cloudImg/blogs/picture/DISCONTIGMEM 非连续内存模型.webp) 内核中使用 struct pglist_data 表示用于管理连续物理内存的 node 节点(内核假设 node 中的物理内存是连续的),既然每个 node 节点中的物理内存是连续的,于是在每个 node 节点中还是采用 FLATMEM 平坦内存模型的方式来组织管理物理内存页。每个 node 节点中包含一个 struct page *node_mem_map 数组,用来组织管理 node 中的连续物理内存页。 我们可以看出 DISCONTIGMEM 非连续内存模型其实就是FLATMEM 平坦内存模型的一种扩展,在面对大块不连续的物理内存管理时,通过将每段连续的物理内存区间划归到 node 节点中进行管理,避免了为内存地址空洞分配 struct page 结构,从而节省了内存资源的开销。 由于引入了 node 节点这个概念,所以在 DISCONTIGMEM 非连续内存模型下 page_to_pfn 与pfn_to_page 的计算逻辑就比 FLATMEM 内存模型下的计算逻辑多了一步定位 page 所在 node 的操作。

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

  • 通过 arch_pfn_to_nid 可以根据物理页的 PFN 定位到物理页所在 node。 当定位到物理页 struct page 所在 node 之后,剩下的逻辑就和 FLATMEM 内存模型一模一样了。

SPARSEMEM 稀疏内存模型

随着内存技术的发展,内核可以支持物理内存的热插拔了,这样一来物理内存的不连续就变为常态了,其实每个 node 中的物理内存也不一定都是连续的。 ![node 中的物理内存也不一定都是连续](https://cdn.jsdelivr.net/gh/zysok2023/cloudImg/blogs/picture/node 中的物理内存也不一定都是连续.webp) 而且每个 node 中都有一套完整的内存管理系统,如果 node 数目多的话,那这个开销就大了,于是就有了对连续物理内存更细粒度的管理需求,为了能够更灵活地管理粒度更小的连续物理内存,SPARSEMEM 稀疏内存模型就此登场了。 SPARSEMEM 稀疏内存模型的核心思想就是对粒度更小的连续内存块进行精细的管理,用于管理连续内存块的单元被称作 section 。物理页大小为 4k 的情况下, section 的大小为 128M ,物理页大小为 16k 的情况下, section 的大小为 512M。 由于 section 被用作管理小粒度的连续内存块,这些小的连续物理内存在 section 中也是通过数组的方式被组织管理,每个 struct mem_section 结构体中有一个 section_mem_map 指针用于指向 section 中管理连续内存的 page 数组。 SPARSEMEM 内存模型中的这些所有的 mem_section 会被存放在一个全局的数组中,并且每个mem_section 都可以在系统运行时改变 offline / online(下线 / 上线)状态,以便支持内存的热插拔(hotplug)功能。 ![SPARSEMEM 内存模型](https://cdn.jsdelivr.net/gh/zysok2023/cloudImg/blogs/picture/SPARSEMEM 内存模型.webp) 在 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 的索引。 从以上的内容介绍中,我们可以看出 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 ) 就得到了物理内存地址。 直接映射区中的物理页的虚拟地址会随着物理内存地址变动而变动, 因此这部分物理页是无法轻易迁移的,然而不可迁移的页会导致内存无法被拔除,因为无法妥善安置被拔出内存中已经为进程分配的物理页。那么内核是如何解决这个头疼的问题呢? 既然是这些不可迁移的物理页导致内存无法拔出,那么我们可以把内存分一下类,将内存按照物理页是否可迁移,划分为不可迁移页,可回收页,可迁移页。 然后在这些可能会被拔出的内存中只分配那些可迁移的内存页,这些信息会在内存初始化的时候被设置,这样一来那些不可迁移的页就不会包含在可能会拔出的内存中,当我们需要将这块内存热拔出时, 因为里边的内存页全部是可迁移的, 从而使内存可以被拔除。

从 CPU 角度看物理内存架构

一致性内存访问 UMA 架构

在 UMA 架构下,多核服务器中的多个 CPU 位于总线的一侧,所有的内存条组成一大片内存位于总线的另一侧,所有的 CPU 访问内存都要过总线,而且距离都是一样的,由于所有 CPU 对内存的访问距离都是一样的,所以在 UMA 架构下所有 CPU 访问内存的速度都是一样的。这种访问模式称为 SMP(Symmetric multiprocessing),即对称多处理器。 这里的一致性是指同一个 CPU 对所有内存的访问的速度是一样的。即一致性内存访问 UMA(Uniform Memory Access)。 但是随着多核技术的发展,服务器上的 CPU 个数会越来越多,而 UMA 架构下所有 CPU 都是需要通过总线来访问内存的,这样总线很快就会成为性能瓶颈,主要体现在以下两个方面:

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

  • 总线的长度也会因此而增加,进而增加访问延迟 UMA 架构的优点很明显就是结构简单,所有的 CPU 访问内存速度都是一致的,都必须经过总线。然而它的缺点就是随着处理器核数的增多,总线的带宽压力会越来越大。解决办法就只能扩宽总线,然而成本十分高昂,未来可能仍然面临带宽压力。 为了解决以上问题,提高 CPU 访问内存的性能和扩展性,于是引入了一种新的架构:非一致性内存访问NUMA(Non-uniform memory access)。

非一致性内存访问 NUMA 架构

在 NUMA 架构下,内存就不是一整片的了,而是被划分成了一个一个的内存节点 (NUMA 节点),每个 CPU 都有属于自己的本地内存节点,CPU 访问自己的本地内存不需要经过总线,因此访问速度是最快的。当 CPU 自己的本地内存不足时,CPU 就需要跨节点去访问其他内存节点,这种情况下 CPU 访问内存就会慢很多。 在 NUMA 架构下,任意一个 CPU 都可以访问全部的内存节点,访问自己的本地内存节点是最快的,但访问其他内存节点就会慢很多,这就导致了 CPU 访问内存的速度不一致,所以叫做非一致性内存访问架构。 numa CPU 和它的本地内存组成了 NUMA 节点,CPU 与 CPU 之间通过 QPI(Intel QuickPath Interconnect)点对点完成互联,在 CPU 的本地内存不足的情况下,CPU 需要通过 QPI 访问远程 NUMA节点上的内存控制器从而在远程内存节点上分配内存,这就导致了远程访问比本地访问多了额外的延迟开销(需要通过 QPI 遍历远程 NUMA 节点)。 在 NUMA 架构下,只有 DISCONTIGMEM 非连续内存模型和 SPARSEMEM 稀疏内存模型是可用的。而 UMA 架构下,前面介绍的三种内存模型都可以配置使用。

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 节点分配内存。

NUMA 的使用简介

查看 NUMA 相关信息

查看 NUMA 相关信息 numactl -H 命令可以给出我们想要的答案 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 节点的内存访问命中率:

  • numa_hit :内存分配在该节点中成功的次数。

  • numa_miss : 内存分配在该节点中失败的次数。

  • numa_foreign:表示其他 NUMA 节点本地内存分配失败,跨节点(numa_miss)来到本节点分配内存的次数。

  • interleave_hit : 在 MPOL_INTERLEAVE 策略下,在本地节点分配内存的次数。

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

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

绑定 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 节点的内存是最快的。

内核如何管理 NUMA 节点

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

内核如何统一组织 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 节点。 ![struct pglist_data 结构](https://cdn.jsdelivr.net/gh/zysok2023/cloudImg/blogs/picture/struct pglist_data 结构.webp) 全局数组 node_data[] 定义在文件/arch/arm64/include/asm/mmzone.h中: NODE_DATA(nid) 宏可以通过 NUMA 节点的 nodeId,找到对应的 struct pglist_data 结构。 node_data[] 数组大小 MAX_NUMNODES 定义在 /include/linux/numa.h文件中 UMA 架构下 NODES_SHIFT 为 0 ,所以内核中只用一个 NUMA 节点来管理所有物理内存。

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节点内的所有的物理内存页。 ![struct page](https://cdn.jsdelivr.net/gh/zysok2023/cloudImg/blogs/picture/struct page.webp) node_start_pfn 指向 NUMA 节点内第一个物理页的 PFN,系统中所有 NUMA 节点中的物理页都是依次编号的,每个物理页的 PFN 都是全局唯一的(不只是其所在 NUMA 节点内唯一) ![struct page list](https://cdn.jsdelivr.net/gh/zysok2023/cloudImg/blogs/picture/struct page list.webp) node_present_pages 用于统计 NUMA 节点内所有真正可用的物理页面数量(不包含内存空洞)。 由于 NUMA 节点内包含的物理内存并则是用于统计不总是连续的,可能会包含一些内存空洞,node_spanned_pages则是用于统计NUMA 节点内所有的内存页,包含不连续的物理内存地址(内存空洞)的页面数。 ![struct page list2](https://cdn.jsdelivr.net/gh/zysok2023/cloudImg/blogs/picture/struct page list2.webp)

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

内核对物理内存的管理都是以页为最小单位来管理的,每页默认 4K 大小,理想状况下任何种类的数据都可以存放在任何页框中,没有什么限制。比如:存放内核数据,用户数据,磁盘缓冲数据等。 但是实际的计算机体系结构受到硬件方面的制约,间接导致限制了页框的使用方式。 这些物理内存区域的划分定义在/include/linux/mmzone.h文件中 内核中定义的 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 内核通过迁移页面来规整内存,这样就可以避免内存碎片,从而得到一大片连续的物理内存,以满足内核对大块连续内存分配的请求。所以这就是内核需要根据物理页面是否能够迁移的特性,而划分出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_HIGHMEM ](https://cdn.jsdelivr.net/gh/zysok2023/cloudImg/blogs/picture/ZONE_HIGHMEM .webp) ZONE_NORMAL、ZONE_HIGHMEM 和 ZONE_MOVABLE 是可以出现在所有 NUMA 节点上的。 ![NUMA 节点](https://cdn.jsdelivr.net/gh/zysok2023/cloudImg/blogs/picture/NUMA 节点.webp) node_zones[MAX_NR_ZONES] 数组包含了 NUMA 节点中的所有物理内存区域,物理内存区域在内核中的数据结构是 struct zone node_zonelists[MAX_ZONELISTS] 是 struct zonelist 类型的数组,它包含了备用 NUMA 节点和这些备用节点中的物理内存区域。备用节点是按照访问距离的远近,依次排列在 node_zonelists 数组中,数组第一个备用节点是访问距离最近的,这样当本节点内存不足时,可以从备用 NUMA 节点中分配内存。 各个 NUMA 节点之间的内存分配情况我们可以通过前边介绍的 numastat 命令查看。

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 节点的状态 node_states

如果系统中的 NUMA 节点多于一个,内核会维护一个位图 node_states,用于维护各个 NUMA 节点的状态信息。如果系统中只有一个 NUMA 节点,则没有节点位图。 节点位图以及节点的状态掩码值定义在 /include/linux/nodemask.h 文件中: 节点的状态可通过以下掩码表示: N_POSSIBLE 表示 NUMA 节点在某个时刻可以变为 online 状态,N_ONLINE 表示 NUMA 节点当前的状态为 online 状态。 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节点。 比如:for_each_online_node用于迭代所有online的NUMA节点:

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

可以通过 cat /proc/zoneinfo | grep Node命令来查看 NUMA 节点中内存区域的分布情况: 服务器是 64 位,所以不包含 ZONE_HIGHMEM 区域。 通过 cat /proc/zoneinfo命令来查看系统中各个 NUMA 节点中的各个内存区域的内存使用情况: ![ZONE_NORMAL 区域](https://cdn.jsdelivr.net/gh/zysok2023/cloudImg/blogs/picture/ZONE_NORMAL 区域.webp) 内核中用于描述和管理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编译器关键字来实现最优的高速缓存行对齐方式。 为了使大家能够更好地理解内核如何使用 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;

truct zone 是会被内核频繁访问的一个结构体,在多核处理器中,多个 CPU 会并发访问 struct zone,为了防止并发访问,内核使用了一把 spinlock_t lock 自旋锁来防止并发错误以及不一致。 name 属性会根据该内存区域的类型不同保存内存区域的名称,比如:Normal ,DMA,HighMem 等。 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 node](https://cdn.jsdelivr.net/gh/zysok2023/cloudImg/blogs/picture/NUMA node.webp) 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 numa2 struct zone 结构中的 managed_pages 用于表示该内存区域内被伙伴系统所管理的物理页数量。 数组 free_area[MAX_ORDER] 是伙伴系统的核心数据结构。 vm_stat 维护了该内存区域物理内存的使用统计信息,cat /proc/zoneinfo命令的输出数据就来源于这个 vm_stat。

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

每个物理内存区域 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物理内存区域的预留内存比例。 那么每个内存区域如何根据各自的 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 得到的。

物理内存区域中的水位线

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

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

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

  3. 内存规整,将可迁移的物理页面进行迁移规整,消除内存碎片。从而获得更大的一片连续物理内存空间供进程分配。 内核将物理内存划分成一页一页的单位进行管理(每页 4K 大小)。内存回收的单位也是按页来的。在内核中,物理内存页有两种类型,针对这两种类型的物理内存页,内核会有不同的回收机制。 第一种就是文件页,所谓文件页就是其物理内存页中的数据来自于磁盘中的文件,当我们进行文件读取的时候,内核会根据局部性原理将读取的磁盘数据缓存在 page cache 中,page cache 里存放的就是文件页。当进程再次读取读文件页中的数据时,内核直接会从 page cache 中获取并拷贝给进程,省去了读取磁盘的开销。 对于文件页的回收通常会比较简单,因为文件页中的数据来自于磁盘,所以当回收文件页的时候直接回收就可以了,当进程再次读取文件页时,大不了再从磁盘中重新读取就是了。 但是当进程已经对文件页进行修改过但还没来得及同步回磁盘,此时文件页就是脏页,不能直接进行回收,需要先将脏页回写到磁盘中才能进行回收。 我们可以在进程中通过 fsync() 系统调用将指定文件的所有脏页同步回写到磁盘,同时内核也会根据一定的条件唤醒专门用于回写脏页的 pflush 内核线程。 而另外一种物理页类型是匿名页,所谓匿名页就是它背后并没有一个磁盘中的文件作为数据来源,匿名页中的数据都是通过进程运行过程中产生的,比如我们应用程序中动态分配的堆内存。 当内存资源紧张需要对不经常使用的那些匿名页进行回收时,因为匿名页的背后没有一个磁盘中的文件做依托,所以匿名页不能像文件页那样直接回收,无论匿名页是不是脏页,都需要先将匿名页中的数据先保存在磁盘空间中,然后在对匿名页进行回收。 并把释放出来的这部分内存分配给更需要的进程使用,当进程再次访问这块内存时,在重新把之前匿名页中的数据从磁盘空间中读取到内存就可以了,而这块磁盘空间可以是单独的一片磁盘分区(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 的积极程度越低。内核就越倾向于回收文件页。 那么到底什么时候内存才算是紧张的?紧张到什么程度才开始 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] 数组中
```c
struct zone {
      // 物理内存区域中的水位线
    unsigned long _watermark[NR_WMARK];
    // 优化内存碎片对内存分配的影响,可以动态改变内存区域的基准水位线。
    unsigned long watermark_boost;
} ____cacheline_internodealigned_in_smp;

水位线

  • 当该物理内存区域的剩余内存容量高于 _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] 以下的内存容量是预留给内核在紧急情况下使用的,这部分内存就是预留内存 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 分别是该内存区域内活跃和非活跃的文件页数量。

水位线的计算

事实上 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 的计算逻辑

min_free_kbytes 的计算逻辑定义在内核文件 /mm/page_alloc.c的 init_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 方法中:

static unsigned long  nr_free_zone_pages(int offset)

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 如果计算出的 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() 方法中计算内存区域的预留内存大小,防止被高位内存区域过度挤压占用。

setup_per_zone_wmarks 计算水位线

物理内存区域内的三条水位线:WMARK_MIN,WMARK_LOW,WMARK_HIGH 的最终计算逻辑是在__setup_per_zone_wmarks方法中完成的:

static void __setup_per_zone_wmarks(void)
{
  // 将 min_free_kbytes 转换为页
}

在 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 倍。 这段代码主要是通过内核参数 watermark_scale_factor 来调节水位线:WMARK_MIN,WMARK_LOW,WMARK_HIGH 之间的间距,那么为什么要调整水位线之间的间距大小呢?

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 能够有时间回收足够的内存,从而解决直接内存回收导致的性能抖动问题。

物理内存区域中的冷热页

根据摩尔定律:芯片中的晶体管数量每隔 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 的高速缓存,避免了进一步对内存的访问,极大提升了应用程序的性能。 在 NUMA 内存架构下,这些 NUMA 节点中的物理内存区域 zone 管理的这些物理内存页,哪些是在 CPU 的高速缓存中?哪些又不在 CPU 的高速缓存中呢?内核如何来管理这些加载进 CPU 高速缓存中的物理内存页呢? 所谓的热页就是已经加载进 CPU 高速缓存中的物理内存页,所谓的冷页就是还未加载进 CPU 高速缓存中的物理内存页,冷页是热页的后备选项。

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 数,因为 structper_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 结构中,每一种迁移类型都会对应一个冷热页链表。

内核如何描述物理内存页

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 指针用于指向链表中的下一
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,否则 fro
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 结构在不同场景下的使用方式: 第一种使用方式是内核直接分配使用一整页的物理内存,内核中的物理内存页有两种类型,分别用于不同的场景:

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

在内核中每个文件都会有一个属于自己的 page cache(页高速缓存),页高速缓存在内核中的结构体就是这个 struct address_space。它被文件的 inode 所持有。 如果当前物理内存页 struct page 是一个文件页的话,那么 mapping 指针的最低位会被设置为 0 ,指向该内存页关联文件的 struct address_space(页高速缓存),pgoff_t index 字段表示该内存页 page 在页高速缓存 page cache 中的 index 索引。内核会利用这个 index 字段从 page cache 中查找该物理内存页,同时该 pgoff_t index 字段也表示该内存页中的文件数据在文件内部的偏移 offset。偏移单位为 page size。 如果当前物理内存页 struct page 是一个匿名页的话,那么 mapping 指针的最低位会被设置为 1 , 指向该匿名页在进程虚拟内存空间中的匿名映射区域struct anon_vma 结构(每个匿名页对应唯一的anon_vma 结构),用于物理内存到虚拟内存的反向映射。

匿名页的反向映射

我们通常所说的内存映射是正向映射,即从虚拟内存到物理内存的映射。而反向映射则是从物理内存到虚拟内存的映射,用于当某个物理内存页需要进行回收或迁移时,此时需要去找到这个物理页被映射到了哪些进程的虚拟地址空间中,并断开它们之间的映射。 在没有反向映射的机制前,需要去遍历所有进程的虚拟地址空间中的映射页表,这个效率显然是很低下的。有了反向映射机制之后内核就可以直接找到该物理内存页到所有进程映射的虚拟地址空间 VMA ,并从 VMA 使用的进程页表中取消映射,