Linux内核内存管理:核心技术与优化策略

在 Linux 系统中,内存管理堪称内核的核心功能之一,其运作机制复杂且精妙。Linux 采用虚拟内存技术,为进程构建独立的地址空间,借内存管理单元(MMU)将虚拟地址精准映射至物理地址,既保障进程间内存隔离,又防止相互干扰。物理内存管理上,Linux 以分页机制为基,将内存切为固定大小页(常见 4KB) ,由伙伴系统算法主导分配与回收。通过合并、分割内存页,伙伴系统有效减少内存碎片,提升内存利用率。同时,slab 分配器专注于小内存块分配,进一步优化内存使用。

当物理内存捉襟见肘,页面替换算法粉墨登场。Linux 常用 “最近最少使用”(LRU)算法,依据程序访问局部性原理,淘汰长时间未访问页面,保障系统关键数据驻留内存;在优化策略方面,调整内核参数如vm.swappiness,可调控系统对交换空间的依赖程度;启用大页技术,能减少页表项数量,降低内存管理开销,大幅提升内存访问效率,尤其适用于对内存要求严苛的应用程序。

一、Linux内存管理概述

Linux 内存管理机制的设计遵循着一系列理念,这些理念是其高效运行和广泛适应性的基石,涵盖了效率、公平性、可扩展性等多个关键方面。

在效率层面,Linux 内存管理致力于实现快速的内存分配与释放。以伙伴系统(Buddy System)为例,这是 Linux 内核用于管理物理内存的一种重要机制。它将内存按照 2 的幂次方大小进行划分,形成不同大小的内存块。当一个进程请求内存时,伙伴系统能迅速找到合适大小的内存块并分配给它;当内存被释放时,伙伴系统又能高效地将相邻的空闲内存块合并成更大的内存块,从而减少内存碎片的产生。这种基于 2 的幂次方的分配策略,就像一个精密的齿轮系统,各个部分紧密配合,大大提高了内存分配和回收的效率。

再比如,在内存访问的优化上,Linux 采用了高速缓存机制。高速缓存(Cache)就像是一个靠近 CPU 的小型快速存储器,它存储了频繁访问的内存数据和指令。当 CPU 需要访问内存时,首先会在高速缓存中查找,如果找到所需数据,就可以直接读取,大大缩短了访问时间。这就好比我们在阅读一本厚厚的书籍时,如果将经常查阅的内容记录在一个便于翻阅的笔记本上,每次查找时就无需在整本书中逐页寻找,从而节省了大量时间。Linux 通过合理地组织高速缓存,使得内存访问的命中率大幅提高,进而提升了系统整体的运行效率。

公平性也是 Linux 内存管理设计中不可或缺的一环。在多任务环境下,每个进程都需要获得合理的内存资源,以保证它们能够正常运行。Linux 通过内存分配策略来确保公平性。例如,在进程启动时,系统会根据其需求为其分配一定的内存空间,并且在运行过程中,会根据进程的优先级和实际内存使用情况,动态地调整内存分配。这就如同在一场比赛中,每个选手都有公平的机会获得比赛资源,而裁判会根据选手的表现和需求,合理地分配比赛时间和场地等资源。

Linux 内存管理还注重对不同类型进程的公平对待。无论是前台交互进程,还是后台服务进程,都能在内存分配上得到合理的保障。前台交互进程通常对响应速度要求较高,因为它们直接与用户进行交互,如用户在使用图形界面的应用程序时,希望操作能够得到即时反馈。Linux 会优先为这类进程分配足够的内存,以确保它们能够快速响应用户的操作。而后台服务进程虽然对响应速度的要求相对较低,但它们在系统中承担着重要的服务任务,如网络服务、文件服务等。Linux 也会根据它们的实际需求,为其分配稳定的内存资源,保证这些服务的正常运行。

随着计算机技术的不断发展,系统的规模和应用场景日益复杂,可扩展性成为 Linux 内存管理设计必须考虑的因素。Linux 内存管理机制具备良好的可扩展性,能够适应不同硬件平台和应用需求的变化。在硬件平台方面,无论是小型嵌入式设备,还是大型服务器集群,Linux 都能有效地管理内存。对于嵌入式设备,由于其硬件资源有限,内存管理需要更加精细和高效,以充分利用有限的内存空间。Linux 通过精简内存管理算法和数据结构,减少内存开销,满足嵌入式设备的需求。而对于大型服务器集群,内存管理需要处理海量的内存资源和高并发的内存访问请求。Linux 采用分布式内存管理策略,将内存管理任务分布到各个节点上,提高系统的整体性能和可扩展性。

在面对不断涌现的新应用需求时,Linux 内存管理也能够灵活调整。例如,随着大数据和人工智能技术的发展,应用程序对内存的需求呈现出多样化和动态化的特点。一些大数据处理应用需要处理海量的数据,这就要求内存管理能够支持大规模内存的分配和高效利用。Linux 通过引入新的内存分配算法和技术,如大页内存(Huge Page)机制,满足这类应用对内存的特殊需求。大页内存可以提供更大的内存页,减少页表项的数量,从而降低内存管理的开销,提高大数据处理的效率。

二、物理内存与虚拟内存

2.1物理内存的管理

物理内存是计算机系统中实际存在的内存,它由计算机硬件直接管理,通常由 DRAM 芯片组成,是计算机系统中最快的存储器 ,其大小通常是固定的,取决于计算机硬件的配置。在 Linux 内核中,物理内存被划分为一个个固定大小的页框(Page Frame),每个页框通常为 4KB(也有其他大小,如 2MB、1GB 的大页)。内核通过页框号(Page Frame Number,PFN)来管理这些页框,就像给图书馆的每一本书都编上了唯一的编号,方便查找和管理。

物理内存的作用至关重要,它是程序运行和数据存储的直接场所。当程序运行时,其代码和数据会被加载到物理内存中,CPU 直接从物理内存中读取指令和数据进行处理。比如,当我们启动一个文本编辑器时,编辑器的程序代码会被加载到物理内存的某个区域,我们在编辑器中输入的文本数据也会存储在物理内存中,这样 CPU 才能快速地对这些数据进行处理,实现文本的编辑、保存等操作。

2.2虚拟内存的奥秘

虚拟内存,简单来说,是一种内存管理技术,它为每个进程提供了一个独立的、连续的地址空间,让进程误以为自己拥有一块完整且足够大的内存空间 ,而无需关心实际物理内存的具体布局和大小限制。这就好比你拥有一个超大的虚拟仓库,你可以随意规划货物的摆放位置,而不用担心仓库空间不够。

虚拟内存的主要作用之一是实现内存地址转换。在 Linux 系统中,每个进程都有自己的虚拟地址空间,这个空间通过页表(Page Table)与物理内存进行映射。页表就像是一本地址翻译字典,负责将进程使用的虚拟地址翻译成实际的物理地址。

当程序运行时,它所访问的内存地址都是虚拟地址。例如,当程序需要读取某个变量的值时,它会给出一个虚拟地址。CPU 首先会根据这个虚拟地址中的页号(Page Number)在页表中查找对应的物理页框号(Page Frame Number)。如果页表中存在这个映射关系(即页表项有效),CPU 就可以通过物理页框号和虚拟地址中的页内偏移(Offset)计算出实际的物理地址,从而访问到物理内存中的数据。

但如果页表中没有找到对应的映射关系(即发生缺页异常,Page Fault),系统会认为这个虚拟页还没有被加载到物理内存中。此时,操作系统会介入,从磁盘的交换区(Swap Area)或者文件系统中找到对应的物理页,并将其加载到物理内存中,同时更新页表,建立虚拟地址与物理地址的映射关系。之后,程序就可以通过新建立的映射关系访问到数据了。

为了更直观地理解,我们可以把虚拟内存想象成一个图书馆的目录系统。每个进程就像是一个读者,拥有自己的目录(虚拟地址空间)。当读者想要查找某本书(访问数据)时,会先在自己的目录中找到对应的条目(虚拟地址),然后通过这个条目去书架(物理内存)上找到实际的书。如果书架上没有这本书(缺页异常),图书馆管理员(操作系统)就会从仓库(磁盘)中把书取出来放到书架上,并更新目录(页表),以便下次读者能更快地找到这本书。

例如:对于程序计数器位数为32位的处理器来说,他的地址发生器所能发出的地址数目为2^32=4G个,于是这个处理器所能访问的最大内存空间就是4G。在计算机技术中,这个值就叫做处理器的寻址空间或寻址能力。

照理说,为了充分利用处理器的寻址空间,就应按照处理器的最大寻址来为其分配系统的内存。如果处理器具有32位程序计数器,那么就应该按照下图的方式,为其配备4G的内存:

这样,处理器所发出的每一个地址都会有一个真实的物理存储单元与之对应;同时,每一个物理存储单元都有唯一的地址与之对应。这显然是一种最理想的情况。

但遗憾的是,实际上计算机所配置内存的实际空间常常小于处理器的寻址范围,这是就会因处理器的一部分寻址空间没有对应的物理存储单元,从而导致处理器寻址能力的浪费。例如:如下图的系统中,具有32位寻址能力的处理器只配置了256M的内存储器,这就会造成大量的浪费:

另外,还有一些处理器因外部地址线的根数小于处理器程序计数器的位数,而使地址总线的根数不满足处理器的寻址范围,从而处理器的其余寻址能力也就被浪费了。例如:Intel8086处理器的程序计数器位32位,而处理器芯片的外部地址总线只有20根,所以它所能配置的最大内存为1MB:

在实际的应用中,如果需要运行的应用程序比较小,所需内存容量小于计算机实际所配置的内存空间,自然不会出什么问题。但是,目前很多的应用程序都比较大,计算机实际所配置的内存空间无法满足。

实践和研究都证明:一个应用程序总是逐段被运行的,而且在一段时间内会稳定运行在某一段程序里。

这也就出现了一个方法:如下图所示,把要运行的那一段程序自辅存复制到内存中来运行,而其他暂时不运行的程序段就让它仍然留在辅存。

当需要执行另一端尚未在内存的程序段(如程序段2),如下图所示,就可以把内存中程序段1的副本复制回辅存,在内存腾出必要的空间后,再把辅存中的程序段2复制到内存空间来执行即可:

在计算机技术中,把内存中的程序段复制回辅存的做法叫做“换出”,而把辅存中程序段映射到内存的做法叫做“换入”。经过不断有目的的换入和换出,处理器就可以运行一个大于实际物理内存的应用程序了。或者说,处理器似乎是拥有了一个大于实际物理内存的内存空间。于是,这个存储空间叫做虚拟内存空间,而把真正的内存叫做实际物理内存,或简称为物理内存。

那么对于一台真实的计算机来说,它的虚拟内存空间又有多大呢?计算机虚拟内存空间的大小是由程序计数器的寻址能力来决定的。例如:在程序计数器的位数为32的处理器中,它的虚拟内存空间就为4GB。

可见,如果一个系统采用了虚拟内存技术,那么它就存在着两个内存空间:虚拟内存空间和物理内存空间。虚拟内存空间中的地址叫做“虚拟地址”;而实际物理内存空间中的地址叫做“实际物理地址”或“物理地址”。处理器运算器和应用程序设计人员看到的只是虚拟内存空间和虚拟地址,而处理器片外的地址总线看到的只是物理地址空间和物理地址。

由于存在两个内存地址,因此一个应用程序从编写到被执行,需要进行两次映射。第一次是映射到虚拟内存空间,第二次时映射到物理内存空间。在计算机系统中,第两次映射的工作是由硬件和软件共同来完成的。承担这个任务的硬件部分叫做存储管理单元MMU,软件部分就是操作系统的内存管理模块了。

在映射工作中,为了记录程序段占用物理内存的情况,操作系统的内存管理模块需要建立一个表格,该表格以虚拟地址为索引,记录了程序段所占用的物理内存的物理地址。这个虚拟地址/物理地址记录表便是存储管理单元MMU把虚拟地址转化为实际物理地址的依据,记录表与存储管理单元MMU的作用如下图所示:

综上所述,虚拟内存技术的实现,是建立在应用程序可以分成段,并且具有“在任何时候正在使用的信息总是所有存储信息的一小部分”的局部特性基础上的。它是通过用辅存空间模拟RAM来实现的一种使机器的作业地址空间大于实际内存的技术。

从处理器运算装置和程序设计人员的角度来看,它面对的是一个用MMU、映射记录表和物理内存封装起来的一个虚拟内存空间,这个存储空间的大小取决于处理器程序计数器的寻址空间。

可见,程序映射表是实现虚拟内存的技术关键,它可给系统带来如下特点:

系统中每一个程序各自都有一个大小与处理器寻址空间相等的虚拟内存空间;在一个具体时刻,处理器只能使用其中一个程序的映射记录表,因此它只看到多个程序虚存空间中的一个,这样就保证了各个程序的虚存空间时互不相扰、各自独立的;使用程序映射表可方便地实现物理内存的共享。

三、深度剖析核心技术

3.1分页:虚拟内存的基石

在 Linux 的世界里,内存分页机制就像是一位有条不紊的大管家,精心管理着系统的内存资源。简单来说,内存分页机制就是把物理内存和虚拟内存分割成固定大小的小块,这些小块被称作 “页” ,每个页的大小一般为 4KB 或者 8KB。就好比你有一个巨大的仓库(内存),为了更好地管理里面的货物(数据),你把仓库划分成了一个个大小相同的小隔间(页)。

(1)什么是分页机制

分页机制是 80x86 内存管理机制的第二部分。它在分段机制的基础上完成虚拟地址到物理地址的转换过程。分段机制把逻辑地址转换成线性地址,而分页机制则把线性地址转换成物理地址。分页机制可用于任何一种分段模型。处理器分页机制会把线性地址空间划分成页面,然后这些线性地址空间页面被映射到物理地址空间的页面上。分页机制的几种页面级保护措施,可和分段机制保护措施或用或替代分段机制的保护措施。

(2)分页机制如何启用

在我们进行程序开发的时候,一般情况下,是不需要管理内存的,也不需要操心内存够不够用,其实,这就是分页机制给我们带来的好处。它是实现虚拟存储的关键,位于线性地址与物理地址之间,在使用这种内存分页管理方法时,每个执行中的进程(任务)可以使用比实际内存容量大得多的连续地址空间。而且当系统内存实际上被分成很多凌乱的块时,它可以建立一个大而连续的内存空间的映象,好让程序不用操心和管理这些分散的内存块。

分页机制增强了分段机制的性能。页地址变换是建立在段变换基础之上的。因为,段管理机制对于Intel处理器来说是最基本的,任何时候都无法关闭。所以即使启用了页管理功能,分段机制依然是起作用的,段部件也依然工作。

分页只能在保护模式(CR0.PE = 1)下使用。在保护模式下,是否开启分页,由 CR0. PG 位(位 31)决定:

当 CR0.PG = 0 时,未开启分页,线性地址等同于物理地址;当 CR0.PG = 1 时,开启分页。(3)分页机制线性地址到物理地址转换过程

80x86使用 4K 字节固定大小的页面,每个页面均是 4KB,并且对其于 4K 地址边界处。这表示分页机制把 2^32字节(4GB)的线性地址空间划分成 2^20(1M = 1048576)个页面。分页机制通过把线性地址空间中的页面重新定位到物理地址空间中进行操作。由于 4K 大小的页面作为一个单元进行映射,并且对其于 4K 边界,因此线性地址的低 12 位可做为页内偏移地量直接作为物理地址的低 12 位。分页机制执行的重定向功能可以看作是把线性地址的高 20 位转换到对应物理地址的高 20 位。

线性到物理地址转换功能,被扩展成允许一个线性地址被标注为无效的,而非要让其产生一个物理地址。以下两种情况一个页面可以被标注为无效的:

操作系统不支持的线性地址。对应的虚拟内存系统中的页面在磁盘上而非在物理内存中。

在第一中情况下,产生无效地址的程序必须被终止,在第二种情况下,该无效地址实际上是请求 操作系统虚拟内存管理器 把对应的页面从磁盘加载到物理内存中,以供程序访问。因为无效页面通常与虚拟存储系统相关,因此它们被称为不存在页面,由页表中称为存在的属性来确定。

当使用分页时,处理器会把线性地址空间划分成固定大小的页面(4KB),这些页面可以映射到物理内存中或磁盘存储空间中,当一个程序引用内存中的逻辑地址时,处理器会把该逻辑地址转换成一个线性地址,然后使用分页机制把该线性地址转换成对应的物理地址。

如果包含线性地址的页面不在当前物理内存中,处理器就会产生一个页错误异常。页错误异常处理程序就会让操作系统从磁盘中把相应页面加载到物理内存中(操作过程中可能会把物理内存中不同的页面写到磁盘上)。当页面加载到物理内存之后,从异常处理过程的返回操作会使异常的指令被重新执行。处理器把用于线性地址转换成物理地址和用于产生页错误的信息包含在存储与内存中的页目录与页表中。

(4)分页机制与分段机制的不同

分页与分段的最大的不同之处在于分页使用了固定长度的页面。段的长度通常与存放在其中的代码或数据结构有相同的长度。与段不同,页面有固定的长度。如果仅使用分段地址转换,那么存储在物理内存中的一个数据结构将包含其所有的部分。如果使用了分页,那么一个数据结构就可以一部分存储与物理内存中,而另一部分保存在磁盘中。

为了减少地址转换所要求的总线周期数量,最近访问的页目录和页表会被存放在处理器的一个叫做转换查找缓冲区(TLB)的缓冲器件中。TLB 可以满足大多数读页目录和页表的请求而无需使用总线周期。只有当 TLB 中不包含所要求的页表项是才会出现使用额外的总线周期从内存读取页表项。通常在一个页表项很长时间没有访问过时才会出现这种情况。

3.2交换空间:内存不足时的后盾

首先呢,提一个概念,交换空间(swap space),这个大家应该不陌生,在重装系统的时候,会让你选择磁盘分区,就比如说一个硬盘分几个部分去管理。其中就会分一部分磁盘空间用作交换,叫做swap space。其实就是一段临时存储空间,内存不够用的时候就用它了,虽然它也在磁盘中,但省去了很多的查找时间啊。当发生进程切换的时候,内存与交换空间就要发生数据交换一满足需求。所以啊,进程的切换消耗是很大的,这也说明了为什么自旋锁比信号量效率高的原因。

那么我们的程序里申请的内存的时候,linux内核其实只分配一个虚拟内存( 线性地址),并没有分配实际的物理内存。只有当程序真正使用这块内存时,才会分配物理内存。这就叫做延迟分配和请页机制。释放内存时,先释放线性区对应的物理内存,然后释放线性区;"请页机制"将物理内存的分配延后了,这样是充分利用了程序的局部性原来,节约内存空间,提高系统吞吐;就是说一个函数可能只在物理内存中呆了一会,用完了就被清除出去了,虽然在虚拟地址空间还在。(不过虚拟地址空间不是事实上的存储,所以只能说这个函数占据了一段虚拟地址空间,当你访问这段地址时,就会产生缺页处理,从交换区把对应的代码搬到物理内存上来)

Swap 交换机制是 Linux 虚拟内存管理的另一个重要组成部分。简单来说,Swap 是磁盘上的一块区域,当物理内存不足时,系统会将一部分暂时不用的内存页面(Page)交换到 Swap 空间中,腾出物理内存给更需要的进程使用 。当被交换出去的页面再次被访问时,系统会将其从 Swap 空间换回到物理内存中。

Swap 交换机制的工作原理涉及到内存回收和页面置换算法。当系统内存紧张时,内核会启动内存回收机制,扫描内存中的页面,选择一些不常用或最近最少使用的页面进行回收。如果这些页面是匿名页面(没有关联到文件的内存页面,如进程的堆和栈空间),就会被交换到 Swap 空间中;如果是文件映射页面(关联到文件的内存页面,如共享库、文件缓存等),则会根据情况进行处理,脏页面(被修改过的页面)会被写回文件,干净页面(未被修改过的页面)可以直接释放。

Swap 交换机制对系统性能有着重要的影响。当 Swap 使用频繁时,说明物理内存不足,系统需要频繁地在物理内存和 Swap 空间之间交换页面,这会导致磁盘 I/O 增加,系统性能下降 。因为磁盘的读写速度远远低于内存,过多的 Swap 操作会使系统变得迟缓。因此,在实际应用中,需要合理配置 Swap 空间的大小,并密切关注系统的内存使用情况,避免 Swap 过度使用。

例如,可以通过调整/proc/sys/vm/swappiness参数来控制系统对 Swap 的使用倾向,swappiness的值范围是 0 - 100,表示系统将内存页面交换到 Swap 空间的倾向程度,值越大表示越倾向于使用 Swap 。一般来说,对于内存充足的系统,可以将swappiness设置为较低的值,如 10 或 20,以减少不必要的 Swap 操作;对于内存紧张的系统,可以适当提高swappiness的值,但也要注意不要过高,以免严重影响性能。

3.3内存映射:高效 I/O 的秘诀

内存映射,英文名为 Memory - mapped I/O,从字面意思理解,就是将磁盘文件的数据映射到内存中。在 Linux 系统中,这一机制允许进程把一个文件或者设备的数据关联到内存地址空间,使得进程能够像访问内存一样对文件进行操作 。

举个简单的例子,假设有一个文本文件,通常我们读取它时,会使用read函数,数据从磁盘先读取到内核缓冲区,再拷贝到用户空间。而内存映射则直接在进程的虚拟地址空间中为这个文件创建一个映射区域,进程可以直接通过指针访问这个映射区域,就好像文件数据已经在内存中一样,大大简化了文件操作的流程 。

内存映射的工作原理涉及到虚拟内存、页表以及文件系统等多个方面的知识。当进程调用mmap函数进行内存映射时,大致会经历以下几个关键步骤 :

虚拟内存区域创建:系统首先在进程的虚拟地址空间中寻找一段满足要求的连续空闲虚拟地址,然后为这段虚拟地址分配一个vm_area_struct结构,这个结构用于描述虚拟内存区域的各种属性,如起始地址、结束地址、权限等,并将其插入到进程的虚拟地址区域链表或树中 。就好比在一片空地上,规划出一块特定大小和用途的区域,并做好标记。地址映射建立:通过待映射的文件指针,找到对应的文件描述符,进而链接到内核 “已打开文件集” 中该文件的文件结构体。再通过这个文件结构体,调用内核函数mmap,定位到文件磁盘物理地址,然后通过remap_pfn_range函数建立页表,实现文件物理地址和进程虚拟地址的一一映射关系 。这一步就像是在规划好的区域和实际的文件存储位置之间建立起一条通道,让数据能够顺利流通。不过,此时只是建立了地址映射,真正的数据还没有拷贝到内存中 。数据加载(缺页异常处理):当进程首次访问映射区域中的数据时,由于数据还未在物理内存中,会触发缺页异常。内核会捕获这个异常,然后在交换缓存空间(swap cache)中寻找需要访问的内存页,如果没有找到,则调用nopage函数把所缺的页从磁盘装入到主存中 。这个过程就像是当你需要使用某个物品,但它不在身边,你就需要去存放它的地方把它取回来。之后,进程就可以对这片主存进行正常的读或写操作,如果写操作改变了数据内容,系统会在一定时间后自动将脏页面回写脏页面到对应磁盘地址,完成写入到文件的过程 。当然,也可以调用msync函数来强制同步,让数据立即保存到文件里 。

mmap内存映射的实现过程,总的来说可以分为三个阶段:

①进程启动映射过程,并在虚拟地址空间中为映射创建虚拟映射区域进程在用户空间调用库函数mmap,原型:void *mmap(void *start, size_t length, int prot, int flags, int fd, off_t offset);在当前进程的虚拟地址空间中,寻找一段空闲的满足要求的连续的虚拟地址为此虚拟区分配一个vm_area_struct结构,接着对这个结构的各个域进行了初始化将新建的虚拟区结构(vm_area_struct)插入进程的虚拟地址区域链表或树中②调用内核空间的系统调用函数mmap(不同于用户空间函数),实现文件物理地址和进程虚拟地址的一一映射关系为映射分配了新的虚拟地址区域后,通过待映射的文件指针,在文件描述符表中找到对应的文件描述符,通过文件描述符,链接到内核“已打开文件集”中该文件的文件结构体(struct file),每个文件结构体维护着和这个已打开文件相关各项信息。通过该文件的文件结构体,链接到file_operations模块,调用内核函数mmap,其原型为:int mmap(struct file *filp, struct vm_area_struct *vma),不同于用户空间库函数。内核mmap函数通过虚拟文件系统inode模块定位到文件磁盘物理地址。通过remap_pfn_range函数建立页表,即实现了文件地址和虚拟地址区域的映射关系。此时,这片虚拟地址并没有任何数据关联到主存中。③进程发起对这片映射空间的访问,引发缺页异常,实现文件内容到物理内存(主存)的拷贝

注:前两个阶段仅在于创建虚拟区间并完成地址映射,但是并没有将任何文件数据的拷贝至主存。真正的文件读取是当进程发起读或写操作时。

进程的读或写操作访问虚拟地址空间这一段映射地址,通过查询页表,发现这一段地址并不在物理页面上。因为目前只建立了地址映射,真正的硬盘数据还没有拷贝到内存中,因此引发缺页异常。缺页异常进行一系列判断,确定无非法操作后,内核发起请求调页过程。调页过程先在交换缓存空间(swap cache)中寻找需要访问的内存页,如果没有则调用nopage函数把所缺的页从磁盘装入到主存中。之后进程即可对这片主存进行读或者写的操作,如果写操作改变了其内容,一定时间后系统会自动回写脏页面到对应磁盘地址,也即完成了写入到文件的过程。注:修改过的脏页面并不会立即更新回文件中,而是有一段时间的延迟,可以调用msync()来强制同步, 这样所写的内容就能立即保存到文件里了。

四、内存管理机制的具体实现

4.1slab 分配器:小内存的管理者

slab分配器是Linux内核中用于管理小内存对象的一种高效内存分配机制,主要用于管理那些频繁分配和释放、大小相对固定的小内存对象,其设计目的是为了解决伙伴系统在分配小内存时存在的内存浪费和分配效率低的问题 。

slab 分配器的工作机制基于对象复用和缓存技术。它预先分配一组相同大小的内存块,将这些内存块组成一个缓存(Cache),每个缓存专门用于存储特定类型的对象。当内核需要分配一个小内存对象时,首先会在对应的缓存中查找空闲的内存块。如果缓存中有空闲块,就直接从缓存中分配,避免了通过伙伴系统进行内存分配的开销。当对象不再使用时,将其释放回缓存中,而不是立即归还给伙伴系统,以便后续再次使用。

例如,在 Linux 内核中,进程描述符(struct task_struct)是一种频繁使用的小内存对象。slab 分配器会为进程描述符创建一个专门的缓存,在系统启动时,预先分配一定数量的内存块,这些内存块的大小刚好适合存储进程描述符。当创建一个新进程时,内核直接从这个缓存中获取一个空闲的内存块,用于存储新进程的描述符。当进程结束时,对应的内存块被释放回缓存中,等待下一次使用。

①创建slab缓存区

该函数创建一个slab缓存(后备高速缓冲区),它是一个可以驻留任意数目全部同样大小的后备缓存。其原型如下:

复制
struct kmem_cache *kmem_cache_create(const char *name, size_t size, \ size_t align, unsigned long flags,\ void (*ctor)(void *, struct kmem_cache *, unsigned long),\ void (*dtor)(void *, struct kmem_cache *, unsigned ong)));1.2.3.4.

其中:

name:创建的缓存名;size:可容纳的缓存块个数;align:后备高速缓冲区中第一个内存块的偏移量(一般置为0);flags:控制如何进行分配的位掩码,包括 SLAB_NO_REAP(即使内存紧缺也不自动收缩这块缓存)、SLAB_HWCACHE_ALIGN ( 每 个 数 据 对 象 被 对 齐 到 一 个 缓 存 行 )、SLAB_CACHE_DMA(要求数据对象在 DMA 内存区分配)等);ctor:是可选的内存块对象构造函数(初始化函数);dtor:是可选的内存对象块析构函数(释放函数)。②分配slab缓存函数

一旦创建完后备高速缓冲区后,就可以调用kmem_cache_alloc()在缓存区分配一个内存块对象了,其原型如下:

复制
void *kmem_cache_alloc(struct kmem_cache *cachep, gfp_t flags);1.

cachep指向开始分配的后备高速缓存,flags与传给kmalloc函数的参数相同,一般为GFP_KERNEL。

③释放slab缓存

该函数释放一个内存块对象:

复制
void *kmem_cache_free(struct kmem_cache *cachep, void *objp);1.
④销毁slab缓存

与kmem_cache_create对应的是销毁函数,释放一个后备高速缓存:

复制
int kmem_cache_destroy(struct kmem_cache *cachep);1.

它必须等待所有已经分配的内存块对象被释放后才能释放后备高速缓存区。

⑤slab缓存使用举例

创建一个存放线程结构体(struct thread_info)的后备高速缓存,因为在linux中涉及频繁的线程创建与释放,如果使用__get_free_page()函数会造成内存的大量浪费,效率也不高。所以在linux内核的初始化阶段就创建了一个名为thread_info的后备高速缓存,代码如下:

复制
/* 创建slab缓存 */ static struct kmem_cache *thread_info_cache; thread_info_cache = kmem_cache_create("thread_info", sizeof(struct thread_info), \ SLAB_HWCACHE_ALIGN|SLAB_PANIC, NULL, NULL); /* 分配slab缓存 */ struct thread_info *ti; ti = kmem_cache_alloc(thread_info_cache, GFP_KERNEL); /* 使用slab缓存 */ ... /* 释放slab缓存 */ kmem_cache_free(thread_info_cache, ti); kmem_cache_destroy(thread_info_cache);1.2.3.4.5.6.7.8.9.10.11.12.13.14.

这种机制在管理小对象时具有明显的优势。一方面,它减少了内存碎片的产生。由于缓存中的内存块大小固定,且专门用于存储特定类型的对象,避免了因不同大小的内存需求而导致的内存碎片化问题。另一方面,通过对象复用,减少了内存分配和释放的次数,从而提高了内存分配的效率。因为从缓存中分配和释放内存块的操作比通过伙伴系统进行内存分配和释放要快得多,减少了系统调用和内存管理的开销,使得内核在处理大量小内存对象的分配和释放时能够更加高效地运行 。

4.2buddy 系统:大块内存的管家

buddy 系统是 Linux 内核中用于管理物理内存的一种重要机制,主要负责大块连续内存的分配和回收,其目标是高效地分配和回收连续的物理内存页,减少外部碎片的产生,确保系统能够满足对大块连续内存的需求 。

Linux 便是采用这著名的伙伴系统算法来解决外部碎片的问题。把所有的空闲页框分组为 11 块链表,每一块链表分别包含大小为1,2,4,8,16,32,64,128,256,512 和 1024 个连续的页框。对1024 个页框的最大请求对应着 4MB 大小的连续RAM 块。每一块的第一个页框的物理地址是该块大小的整数倍。例如,大小为 16个页框的块,其起始地址是 16 * 2^12 (2^12 = 4096,这是一个常规页的大小)的倍数。

下面通过一个简单的例子来说明该算法的工作原理:

假设要请求一个256(129~256)个页框的块。算法先在256个页框的链表中检查是否有一个空闲块。如果没有这样的块,算法会查找下一个更大的页块,也就是,在512个页框的链表中找一个空闲块。如果存在这样的块,内核就把512的页框分成两等分,一般用作满足需求,另一半则插入到256个页框的链表中。

如果在512个页框的块链表中也没找到空闲块,就继续找更大的块——1024个页框的块。如果这样的块存在,内核就把1024个页框块的256个页框用作请求,然后剩余的768个页框中拿512个插入到512个页框的链表中,再把最后的256个插入到256个页框的链表中。如果1024个页框的链表还是空的,算法就放弃并发出错误信号。

①相关数据结构
复制
#define MAX_ORDER 11 struct zone { …… struct free_area free_area[MAX_ORDER]; …… } struct free_area { struct list_head free_list; unsigned long nr_free;//该组类别块空闲的个数 };1.2.3.4.5.6.7.8.9.10.11.

Zone结构体中的free_area数组的第k个元素,它保存了所有连续大小为2^k的空闲块,具体是通过将连续页的第一个页插入到free_list中实现的,连续页的第一个页的页描述符的private字段表明改部分连续页属于哪一个order链表。

②伙伴算法系统初始化

Linux内核启动时,伙伴算法还不可用,linux是通过bootmem来管理内存,在mem_init中会把bootmem位图中空闲的内存块插入到伙伴算法系统的free_list中。

调用流程如下:

mem_init----->__free_all_bootmem()—>free_all_bootmem()>free_all_bootmem_core(NODE_DATA(0))–>free_all_bootmem_core(pgdat)

复制
//利用free_page 将页面分给伙伴管理器 free_all_bootmem return(free_all_bootmem_core(NODE_DATA(0))); //#define NODE_DATA(nid) (&contig_page_data) bootmem_data_t *bdata = pgdat->bdata; page = virt_to_page(phys_to_virt(bdata->node_boot_start)); idx = bdata->node_low_pfn - (bdata->node_boot_start >> PAGE_SHIFT); map = bdata->node_bootmem_map; for (i = 0; i < idx; ) unsigned long v = ~map[i / BITS_PER_LONG]; //如果32个页都是空闲的 if (gofast && v == ~0UL) count += BITS_PER_LONG; __ClearPageReserved(page); order = ffs(BITS_PER_LONG) - 1; //设置32个页的引用计数为1 set_page_refs(page, order) //一次性释放32个页到空闲链表 __free_pages(page, order); __free_pages_ok(page, order); list_add(&page->lru, &list); //page_zone定义如下return zone_table[page->flags >> NODEZONE_SHIFT]; //接收一个页描述符的地址作为它的参数,它读取页描述符的flags字段的高位,并通过zone_table数组来确定相应管理区描述符的地址,最终将页框回收到对应的管理区中 free_pages_bulk(page_zone(page), 1, &list, order); i += BITS_PER_LONG; page += BITS_PER_LONG; //这32个页中,只有部分是空闲的 else if (v) for (m = 1; m && i < idx; m<<=1, page++, i++) if (v & m) count++; __ClearPageReserved(page); set_page_refs(page, 0); //释放单个页 __free_page(page); else i+=BITS_PER_LONG; page += BITS_PER_LONG; //释放内存分配位图本身 page = virt_to_page(bdata->node_bootmem_map); for (i = 0; i < ((bdata->node_low_pfn-(bdata->node_boot_start >> PAGE_SHIFT))/8 + PAGE_SIZE-1)/PAGE_SIZE; i++,page++) __ClearPageReserved(page); set_page_count(page, 1); __free_page(page);1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.20.21.22.23.24.25.26.27.28.29.30.31.32.33.34.35.36.37.38.39.40.41.42.43.44.45.46.47.
③伙伴算法系统分配空间
复制
page = __rmqueue(zone, order); //从所请求的order开始,扫描每个可用块链表进行循环搜索。 for (current_order = order; current_order < MAX_ORDER; ++current_order) area = zone->free_area + current_order; if (list_empty(&area->free_list)) continue; page = list_entry(area->free_list.next, struct page, lru); //首先在空闲块链表中删除第一个页框描述符。 list_del(&page->lru); //清楚第一个页框描述符的private字段,该字段表示连续页框属于哪一个大小的链表 rmv_page_order(page); area->nr_free--; zone->free_pages -= 1UL << order; //如果是从更大的order链表中申请的,则剩下的要重新插入到链表中 return expand(zone, page, order, current_order, area); unsigned long size = 1 << high; while (high > low) area--; high--; size >>= 1; //该部分连续页面插入到对应的free_list中 list_add(&page[size].lru, &area->free_list); area->nr_free++; //设置该部分连续页面的order set_page_order(&page[size], high); page->private = order; __SetPagePrivate(page); __set_bit(PG_private, &(page)->flags) return page;1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.20.21.22.23.24.25.26.27.28.29.
④伙伴算法系统回收空间
复制
free_pages_bulk //linux内核将空间分为三个区,分别是ZONE_DMA、ZONE_NORMAL、ZONR_HIGH,zone_mem_map字段就是指向该区域第一个页描述符 struct page *base = zone->zone_mem_map; while (!list_empty(list) && count--) page = list_entry(list->prev, struct page, lru); list_del(&page->lru); __free_pages_bulk int order_size = 1 << order; //该段空间的第一个页的下标 page_idx = page - base; zone->free_pages += order_size; //最多循环10 - order次。每次都将一个块和它的伙伴进行合并。 while (order < MAX_ORDER-1) //寻找伙伴,如果page_idx=128,order=4,则buddy_idx=144 buddy_idx = (page_idx ^ (1 << order)); buddy = base + buddy_idx; /** * 判断伙伴块是否是大小为order的空闲页框的第一个页。 * 首先,伙伴的第一个页必须是空闲的(_count == -1) * 同时,必须属于动态内存(PG_reserved被清0,PG_reserved为1表示留给内核或者没有使用) * 最后,其private字段必须是order */ if (!page_is_buddy(buddy, order)) break; list_del(&buddy->lru); area = zone->free_area + order; //原先所在的区域空闲页减少 area->nr_free--; rmv_page_order(buddy); __ClearPagePrivate(page); page->private = 0; page_idx &= buddy_idx; order++; /** * 伙伴不能与当前块合并。 * 将块插入适当的链表,并以块大小的order更新第一个页框的private字段。 */ coalesced = base + page_idx; set_page_order(coalesced, order); list_add(&coalesced->lru, &zone->free_area[order].free_list); zone->free_area[order].nr_free++;1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.20.21.22.23.24.25.26.27.28.29.30.31.32.33.34.35.36.37.38.39.40.41.42.

4.3THP:大内存页面的优化器

THP(Transparent Huge Page,透明大页)是 Linux 内核中的一项内存管理优化技术,它主要用于处理大内存页面的分配和管理,旨在提高内存访问效率,特别是对于那些需要频繁访问大量内存的应用程序 。

THP 的原理是将多个连续的物理内存页合并成一个大的内存页,通常大小为 2MB 或 1GB,而不是使用传统的 4KB 小页。这样做的好处是减少了页表项的数量,因为一个大页只需要一个页表项来映射,而多个小页则需要多个页表项。减少页表项不仅节省了内存空间,还提高了地址转换的速度,因为在进行虚拟地址到物理地址的转换时,查找一个页表项比查找多个页表项要快。此外,大页还能提高 TLB(Translation Lookaside Buffer,转换后备缓冲器)的命中率,TLB 是一种高速缓存,用于存储最近访问的页表项。由于大页减少了页表项的数量,使得 TLB 能够缓存更多的有效映射,从而减少了 TLB 缺失的次数,进一步提高了内存访问的速度 。

例如,对于一个需要频繁访问大量内存的数据库应用程序,使用 THP 可以显著提高其性能。假设该数据库应用程序需要访问 1GB 的内存,如果使用传统的 4KB 小页,需要 262144 个页表项来映射;而使用 2MB 的大页,只需要 512 个页表项。这样,不仅减少了页表项占用的内存空间,还加快了地址转换的速度,提高了数据库的读写性能 。

THP 适用于那些具有良好内存访问局部性的应用程序,即应用程序在一段时间内主要访问内存的某个局部区域。对于这类应用程序,使用大页可以充分发挥 THP 的优势,提高内存访问效率。然而,对于内存访问比较分散的应用程序,THP 可能并不适用,因为大页的分配可能会导致内存碎片问题,反而降低系统性能 。

复制
// mm/khugepaged.c static void collapse_huge_page(struct mm_struct *mm, unsigned long address) { // 1. 检查是否可合并 if (!khugepaged_scan_pmd(mm, vma, addr)) return; // 2. 分配2MB大页 huge_page = alloc_pages(HPAGE_PMD_ORDER, ...); // 3. 复制小页内容 copy_page_to_huge_page(huge_page, pages, address); // 4. 替换页表项 set_pmd_at(mm, address, pmd, pmd_mkhuge(pfn_pmd(pfn, prot))); }1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.

大页的三大陷阱:

内存浪费:小内存应用被迫使用2MB大页

复制
# 查看大页内存碎片 $ grep Huge /proc/meminfo AnonHugePages: 102400 kB HugePages_Free: 512 # 空闲大页1.2.3.4.

延迟波动:khugepaged合并操作引入不可预测延迟

复制
// 合并操作可能阻塞 down_write(&mm->mmap_sem); // 获取写锁1.2.

OOM风险:大页不可交换,加剧内存压力

THE END