数字与字节:Linux 中的内存是如何工作的?

大家好!我是大聪明-PLUS

❯ 第一部分:身体记忆

在操作系统开发过程中,内存的使用始终是关注的重点。内存是计算机中存储程序和数据的组件,没有它,现代计算机将无法运行。内存中数据存储的关键单位是位(bit),它可以取两个值:0 或 1。内存由单元(cell)组成,每个单元都有自己的地址。单元可以包含不同数量的位,可寻址单元的数量取决于地址中的位数。

内存还包括随机存取存储器(RAM),可用于读写信息。RAM 分为静态 RAM(SRAM)和动态 RAM(DRAM),它们在信息存储方式上有所不同。SRAM 会一直保持信息直到断电,而 DRAM 使用晶体管和电容器,允许存储数据,但需要定期刷新。不同类型的 RAM 各有优缺点,选择取决于具体需求。

对于任何从事硬件和软件工作的人员来说,理解计算机内存的工作原理至关重要。了解内存的工作原理、内存的类型以及这些类型如何影响计算机的性能和功能非常重要。

但什么是物理内存?它在 Linux 中是如何工作的?什么是分段、内存泄漏和"页面"?

所有你想知道的关于企鹅内存的一切,但又不敢问------在这里阅读!

❯ 操作系统中的内存起什么作用?

内存是任何操作系统的基础。在开发操作系统时,内存的优化至关重要。即使是简单的系统启动,BIOS 也会读取引导加载程序内存的前 512 个字节,如果其中包含一个魔数,系统即可启动。

内存是计算机中存储程序和数据的组件。没有内存,现代计算机将不复存在。

内存中数据存储的基本单位是二进制数字,称为比特(bit)。有些人认为某些计算机同时使用十进制和二进制运算,但事实并非如此。它们使用的是二进制编码的十进制(BCD)码。存储一个十进制数字需要 4 位。这 4 位提供了 16 种组合,可以存储 10 个不同的值(从 0 到 9)。剩余的 6 个值未使用。在 BCD 码中,16 位足以存储从 0 到 9999 的数字,这意味着有 10,000 种组合可用。而如果使用相同的设备来存储二进制数,则只能存储 16 种组合。

内存地址

内存由若干部分组成,称为存储单元。每个存储单元可以存储一条信息。每个存储单元都有一个编号,称为地址。程序可以通过地址访问特定的存储单元。如果内存包含 N 个存储单元,它们的地址范围为 0 到 N-1。所有存储单元包含相同数量的比特。如果一个存储单元由 k 位组成,它可以包含 2^k 种不同的组合。

在采用二进制数系统(包括八进制和十六进制表示)的计算机中,内存地址也以二进制数表示,这令人惊讶。如果一个地址由 m 位组成,则可寻址的存储单元的最大数量为 2^m。地址的位数决定了可寻址存储单元的最大数量,而与存储单元的位数无关。

存储单元是内存的最小可寻址单位。近年来,几乎所有制造商都开始生产使用 8 位存储单元的计算机,这些单元被称为字节(有时也称为八位组)。字节被组合成字。在32位计算机中,每个字包含4个字节;而在64位计算机中,每个字包含8个字节。因此,32位计算机包含32位寄存器和用于操作32位字的指令,而64位计算机则包含64位寄存器和用于操作相应字的指令。

字中的字节可以从左到右编号,也可以从右到左编号。从最高位开始编号称为大端字节序,反之则称为小端字节序。

需要注意的是,在两种系统中,一个 32 位整数(例如 6)都由110字的最右边三位表示,其余 29 位均为零。如果字节从左到右编号,则这些位110位于字节 0(或 4、8、16 等)中。在两种系统中,包含该整数的字的地址均为 0。

内存和存储器

能够读写信息的存储器称为 RAM(随机存取存储器)。RAM 分为两种类型:静态 RAM 和动态 RAM。静态 RAM (SRAM) 使用 D 型触发器构建。只要供电,RAM 中的信息就会被保存。它的运行速度非常快,通常用作二级高速缓存。

而动态 RAM (DRAM) 则不使用触发器。DRAM 是一个单元阵列,每个单元包含一个晶体管和一个小电容器。电容器可以充电和放电,从而存储 0 和 1。由于电荷会消失,DRAM 中的每个比特每隔几毫秒就会刷新一次;否则,内存就会泄漏。由于外部逻辑必须处理刷新操作,DRAM 的接口比 SRAM 更复杂。然而,DRAM 更大的容量弥补了这一缺点。DRAM

有多种类型。目前仍在使用的最古老的类型是 FPM(快速页模式)。这种 RAM 是一种位矩阵。硬件表示行地址,然后是列地址。

FPM(流水线存储器)正逐渐被EDO(扩展输出存储器)所取代,EDO允许在前一次访问完成之前进行下一次访问。这种流水线模式虽然不能加快内存访问速度,但可以提高吞吐量,从而实现每秒更多的字数。

在内存芯片的运行时间不超过12纳秒的时代,FPM和EDO存储器仍然具有重要意义。随后,对更快内存芯片的需求出现,异步内存模式被SDRAM(同步动态随机存取存储器)所取代。同步动态随机存取存储器由主系统时钟发生器控制。这种器件是静态随机存取存储器和动态随机存取存储器的混合体。SDRAM的主要优势在于它消除了内存芯片对控制信号的依赖。消除这一问题加快了处理器和内存之间的通信速度。

下一步发展是DDR(双倍数据速率)。这项技术允许在脉冲的上升沿和下降沿都输出数据。

但RAM并不是唯一的内存芯片类型。在许多情况下,即使断电也必须保留数据------这时就需要用到只读存储器(ROM)。ROM 不允许修改或擦除数据;数据是在制造过程中写入的。

❯ 内存管理

内存管理是 Linux 操作系统的一个重要子系统,它确保物理内存和虚拟内存资源的有效利用。在 Linux 中,内存管理主要涉及处理进程的内存请求、分配和释放内存块,以及确保内存的高效使用。Linux

内存管理的关键概念包括:

  • 虚拟内存------Linux 使用虚拟内存的概念,它营造出每个进程拥有独立私有内存空间的假象。虚拟内存允许系统使用比物理可用内存更多的内存来执行应用程序代码。这是通过将未使用的应用程序内存块刷新到磁盘来实现的。
  • 分页系统:物理内存和虚拟内存被划分成固定大小的块,称为页。分页系统能够实现高效的内存管理,并支持RAM和磁盘(交换空间)之间的数据交换。
  • 内存分配:进程在执行命令时需要内存。相应的内存管理器负责为进程分配合适的内存块。内存从可用的物理内存中分配。如有必要,通过将不活动的页面刷新到磁盘来释放物理内存。
  • 内核空间和用户空间------Linux 中的内存分为内核空间和用户空间。内核空间用于执行内核代码、内核扩展和大多数设备驱动程序。用户空间是所有用户应用程序访问的内存区域。
  • 缓存------Linux 使用多种缓存机制来提升系统性能。例如,页面缓存用于缓存从磁盘读取的文件,缓冲区缓存用于管理磁盘写入操作。
  • 内存超额分配:Linux 允许进程分配比实际可用内存更多的内存。这种机制称为内存超额分配。它允许更多进程同时运行,前提是它们不会耗尽所有分配的内存。默认情况下,`vm.overcommit_ratio` 为 50,这意味着进程不能消耗(或实际物理消耗)超过 50% 的内存。

在程序之间分配资源的任务落在了操作系统内核上------在本例中是 Linux。为了营造完全独立的假象,内核为每个程序分配了独立的虚拟地址空间以及与之交互的底层接口。这使得每个程序无需了解彼此的信息、内存大小以及其他不必要的步骤。进程虚拟空间中的地址被称为逻辑地址。

为了追踪物理内存和虚拟内存之间的映射关系,Linux 内核在其物理内存服务区(它直接访问的唯一区域)中使用了一套分层数据结构,以及名为内存管理单元 (MMU) 的专用硬件电路。

Linux采用分页内存,因为逐字节追踪内存过于复杂和繁琐。内核以 4 KB 大小的内存块(页)为单位进行操作。

但内核使用页并非因为追踪每个字节很困难,而是因为内存管理单元 (MMU) 最终使用页而不是单个字节将虚拟地址映射到实际地址。顺便一提,页面大小不一定非得是 4 KB------它至少取决于硬件的性能。

然而,在硬件层面,通常支持以 RAM"段"形式存在的额外抽象层,可以用来将程序分割成多个部分。与其他操作系统不同,Linux 很少使用这种方式------逻辑地址始终与线性地址(段内的地址)匹配。

但是,这里有两个需要注意的地方:

首先,分段是 IA-32(又名 x86)架构的一个独特特性,在 16 位和 32 位模式下可用,但在 64 位模式下被移除。其他架构由于向后兼容性,因此遗留问题较少。

其次,自 80386 处理器问世以来,分段几乎就没再使用过。具体来说,它从未在 32 位版本的 Windows 中使用过。

正如您所知,虚拟内存是存在的------它是操作系统为程序创建的空间。这包括 RAM(我们上面讨论过)和所有交换分区。分配给进程的内存可以是常驻内存,也可以是虚拟内存。下面我运行了该命令ps,它可以分析正在运行的进程。列表显示进程拥有常驻内存 (rss) 和虚拟内存 (vsz)。内存大小以 KB 为单位显示。

复制代码
$ ps` `-C` gnome-shell `-o` pid,user,rss,vsz,comm

    PID USER       RSS    VSZ COMMAND
    `941` alexeev  `147252` `4197004` gnome-shell`

例如,我使用的是 Gnome Shell v46.0。gnome-shell 占用的常驻内存约为 147 MB​​,而虚拟内存却已达到约 4197 MB。这内存占用范围也太大了!

  • 虚拟内存(VSZ)是已分配给进程的内存,但这并不意味着该进程已成功向该内存写入任何内容。
  • 驻留内存(RSS)是指进程已分配的内存,意味着它已将数据保存到虚拟内存中。驻留内存用于衡量进程消耗的物理内存量。

应用程序可能请求大量内存,但实际只使用其中的一小部分。因此,RSS 几乎总是小于 VZZ。

交换分区 (SWAP) 是硬盘上的一个分区,用于存储:

  • 很少使用的内存数据;
  • 当物理内存不足时,任何数据都将被丢弃。

如果 rss 中的任何数据被刷新到交换空间,rss 会被释放,但 vsz 不会被释放。这意味着进程存储在交换空间中的数据包含在该进程的虚拟内存中。

Linux不仅可以使用交换分区,还可以使用交换文件。这意味着驻留内存中的数据可以刷新到位于硬盘上的特殊文件中。

交换文件和交换分区都与 RAM 的格式相同。这意味着 RAM 中的数据以页的形式存储,并以相同的页的形式刷新到交换空间。

可以使用 /proc//status 文件查看进程的内存使用情况的更多详细信息。

页面管理系统

簇(或页)是文件系统层面上可访问的数据块。读写操作都是以数据块为单位进行的。正如我之前提到的,默认大小为 4KB。

所有虚拟内存都由这些簇组成。但这些页的大小并不局限于 4KB;还有一种"大页",其大小可达 2MB 或 1GB。大页用于处理大型数据集,例如数据库(页表结构也会因此得到优化)。

页分为脏页和干净页。干净页未被修改,而脏页已被修改。例如,如果您加载一个库,则该页为干净页,因为它尚未被修改。但如果您加载一个文件,随后对其进行修改,则该页已变为脏页。它需要被保存,否则写入的信息将被擦除。

页面缓存(Page Cache)占用系统中最多的内存。所有对磁盘文件的操作(写入或读取)都通过页面缓存完成。在 Linux 系统中,写入速度总是比读取速度快(例如,使用 O_SYNC 时并非总是如此),因为写入操作会先写入页面缓存,然后再写入磁盘。读取操作时,内核会先在页面缓存中查找文件,如果找不到,则从磁盘读取文件。您可以使用 `free` 命令查看系统当前在页面缓存上占用了多少内存:

复制代码
$ free` `-h`

               total        used        free      shared  buff/cache   available
Mem:           `1`.5Gi       `1`.2Gi        34Mi       191Mi       548Mi       362Mi
Swap:             0B          0B          0B`

页面缓存显示在 buff/cache 列中。我们可以看到,页面缓存占用了 548Mi 的空间。但是,这不仅仅是页面缓存;它还包含缓冲区,缓冲区也与磁盘文件相关联。

您可以在 /proc/meminfo 文件中分别查看有关页面缓存和缓冲区的信息:

复制代码
$ grep` `"^Cach|^Buff"` `-E` /proc/meminfo

Buffers:            `3280` kB
Cached:           `518724` kB`

创建新文件时,该文件会被写入缓存,并且该文件的内存页会被标记为脏页。脏页会定期刷新到磁盘,如果脏页过多,也会被刷新。这可以通过 sysctl 参数进行控制(sudo nano /etc/sysctl.conf):

  • vm.dirty_expire_centisecs --- 将脏页刷新到磁盘的间隔,以百分之一秒为单位(100 = 1 秒);
  • vm.dirty_ratio --- 可分配给页面缓存的 RAM 百分比。
复制代码
$ sudo` sysctl vm.dirty_expire_centisecs

vm.dirty_expire_centisecs `=` `3000`

`$ sudo` sysctl vm.dirty_ratio

vm.dirty_ratio `=` `20

您可以从 /proc/meminfo 文件中获取脏页的数量。sync 命令会将脏页写入磁盘:

复制代码
$ grep` Dirty /proc/info

Dirty:               `864` kB`

同步命令会将数据写入磁盘:

复制代码
# sync`

`$ grep` Dirty /proc/info

Dirty: 			     `0` kB`

巨页

良好的记忆力至关重要。我们来简单了解一下 HugePages。这些页面具有以下特点:

  • 此类页面的大小为 2MB;
  • 该应用程序必须能够处理此类页面;
  • 这些页面永远不会被刷新到交换分区。

您可以使用sysctl参数为HugePages页面分配内存:

  • vm.nr_hugepages = <页数>(因此,如果您指定 1024,则会分配 1024*2MB=2048MB)。
  • vm.hugetlb_shm_group = gid - 只有该组的成员才能使用 HugePages。

编辑 /etc/sysctl.conf 文件后,需要重启系统并查看 /proc/meminfo 文件中的结果:

复制代码
$ egrep` `"HugePages_T|HugePages_F"` /proc/meminfo

HugePages_Total:    `1024`

HugePages_Free:     `1024

已分配 1024 页内存,且全部可用。但是,对于不支持 HugePages 的常规应用程序来说,这 2GB 内存将无法使用。因此,并非总是需要分配 HugePages。

交换部分

对于基于文件的内存,这很简单:如果其中的数据没有改变,就不需要进行任何特殊操作来清除它------只需擦除即可,而且始终可以从文件系统中恢复。

这种方法不适用于匿名内存:它没有关联的文件,因此为了防止数据永久丢失,需要将其存储在其他位置。可以使用所谓的"交换"分区或文件来实现这一点。虽然可行,但在实践中并非必要。如果禁用交换分区,匿名内存将无法被清除,从而使访问时间可预测。禁用交换分区

的一个缺点似乎是,例如,如果应用程序发生内存泄漏,则肯定会浪费物理内存(泄漏的内存无法被清除)。但最好从另一个角度来看待这个问题:它实际上有助于更早地检测和修复错误。

默认情况下,所有文件内存都是可抢占的,但 Linux 内核不仅允许按文件禁用抢占,还允许按页禁用。

这可以通过对 mmap 映射的虚拟内存区域使用 mlock 系统调用来实现。如果您不想深入了解系统调用,我建议使用 vmtouch 命令行实用程序,它也能实现相同的功能,但操作是在内核外部进行的。

以下是一些可能用到此功能的示例:

  • 该应用程序的可执行文件很大,包含许多分支,其中一些分支执行频率不高但却会定期执行。出于其他原因,这种情况也应该避免,但如果别无他法,为了避免在这些不常用的代码分支上产生不必要的等待,您可以阻止它们被抢占。
  • 数据库中的索引通常是一个物理文件,通过 mmap 访问,而 mlock 则用于最大限度地减少延迟和对已加载磁盘的 I/O 操作次数。
  • 该应用程序使用某种静态字典,例如,将 IP 地址子网映射到其所属国家/地区的字典。如果多个进程在同一台服务器上运行并访问此字典,这一点就显得尤为重要。

OOM 杀手

如果过度使用不可抢占内存,最终会导致内存已满且无法被抢占。然而,与其抢占内存,不如释放它。

这可以通过一种相当激进的方法来实现:本节名称所指的机制使用一种特定的算法来选择当前最适合牺牲的进程。当一个进程被终止时,它使用的内存将被释放,并重新分配给其他存活的进程。选择的主要标准是当前物理内存和其他资源的消耗量。此外,您还可以手动标记进程的价值,甚至完全排除某些进程。如果您完全禁用 OOM killer,系统一旦内存耗尽,就只能重启。

c组

默认情况下,所有用户进程都会平等地占用单个服务器上几乎所有的物理可用内存。这种行为通常难以接受。即使服务器相对来说只执行单一任务,例如仅使用 nginx 通过 HTTP 提供静态文件服务,也总会有一些服务进程(例如 syslog)或用户运行的临时命令。如果服务器上同时运行多个生产进程(例如,将 memcached 连接到 Web 服务器是一种常见的做法),那么在内存不足的情况下,防止它们相互"争用"内存就显得尤为重要。

为了隔离重要进程,现代内核提供了 cgroups 机制。它允许您将进程划分为逻辑组,并静态配置分配给每个组的物理内存量。每个组都拥有自己独立的内存子系统,包括独立的进程驱逐跟踪、OOM killer 以及其他功能。cgroups

机制的功能远不止监控内存消耗。它还可以用于分配计算资源、将组分配给处理器核心、限制 I/O 等等。这些组本身可以组织成一个层次结构,cgroups 是许多轻量级虚拟化系统和流行的 Docker 容器的基础。

NUMA

在多处理器系统中,并非所有内存都具有相同的价值。如果主板有 N 个处理器(例如 2 个或 4 个),那么通常所有内存插槽都会被物理划分为 N 组,每组内存都更靠近其对应的处理器------这种排列方式称为 NUMA。

这意味着每个处理器访问物理内存中 1/N 部分的速度(大约是剩余 (N-1)/N 部分的 1.5 倍)比访问剩余部分的速度快。Linux

内核可以自动检测到这一点,并且默认情况下会在调度处理器和为其分配内存时智能地考虑这一点。您可以使用 numactl 工具和一些可用的系统调用(特别是 get_mempolicy/set_mempolicy)来查看和调整此设置。

在 Linux 中使用内存

内存管理子系统是最重要的子系统之一。所有其他子系统都依赖于它的性能以及它管理内存的效率。

在分析内存子系统时,了解和理解不同类型的内存及其用途至关重要。下面,我们将讨论两种类型的内存:

  • 物理内存
  • 线性虚拟内存,它可以比你实际拥有的物理内存更大。

所有物理内存都被划分为页帧。页帧的大小取决于平台;对于 x86 平台,页帧通常为 4 KB,但最大可达 4 MB。每个物理帧都由一个基本数据结构 `struct page`(位于 `include/linux/mm_types.h` 中)描述。该结构用于跟踪页帧的状态:它是空闲的还是已分配的,它的所有者是谁,以及它存储的内容是什么:数据、代码等等。`struct page` 被组织成双字块,以执行原子性的双字操作。下面我们来描述 `struct page` 的一些重要字段:

atomic_t _refcount --- 页面结构的引用次数。根据 init_free_pfn_range() 函数(mm/init.c),如果 _refcount 为 0,则页面框架空闲;如果大于 0,则页面框架已被占用。

无符号长整型标志------包含描述页面框架状态的标志。所有标志都在文件(include/linux/page-flags.h)中进行了描述。

32 位 Linux 机器的物理内存分为 3 个部分------区域:

  • ZONE_DMA --- 物理内存的前 16 MB,
  • ZONE_NORMAL --- 占用地址范围为 16 MB 至 896 MB,
  • ZONE_HIGHMEM --- 包含大于 896 MB 的页面帧

32 位系统中物理内存的这种分区是由于线性内存只能寻址 4 GB,而进程必须在用户模式和内核模式下运行,例如执行系统调用。因此,进程的线性地址空间被划分为几个部分:3 GB 用于用户,1 GB 用于内核。在前 3 GB 中,地址 0xC0000000 以内,进程以普通用户模式运行;而地址 0xC0000000 以上的区域则用于超级用户模式。NORMAL 区和 DMA 区直接映射到第 4 GB 的线性地址空间。位于这些区域中的对象始终可以被访问,因为它们具有线性地址。然而,HIGHMEM 区包含内核无法轻易访问的帧。这是因为 HIGHMEM 区包含的帧在 32 位系统中根本不存在对应的线性地址。因此,页帧分配函数 alloc_page() 返回的指针(线性地址)并非指向第一个页帧本身,而是指向描述该页帧的第一个页描述符。所有页帧描述符都位于 NORMAL 区域,因此它们始终存在一个线性地址。NORMAL 地址的高 128 MB 用于映射线性地址空间中的高地址。映射高内存有多种技术:

  • 持续显示,
  • 临时展示,
  • 处理非连续内存区域。

Linux 是一款现代跨平台操作系统,此类系统必须能够高效地与多处理器系统协同工作。在多处理器系统中,计算机内存的实现方式有多种。第一种是统一内存访问 (UMA)。在这种方案中,访问所有物理内存所需的时间大致相等,因此访问不同地址对操作系统性能的影响完全没有区别。需要注意的是,并非所有计算系统都支持统一内存访问,因此 Linux 也支持非统一内存访问 (NUMA) 作为基本模型。在 NUMA 模型中,系统的物理内存被划分为多个节点。每个节点都由 pg_data_t 结构体(位于 include/linux/mmzone.h)描述。每个节点可以包含任何内存区域,因此 pg_data_t 结构体包含这些区域的描述符。所有节点的页帧描述符都存储在全局 zone_mem_map 数组中,该数组位于相应区域的描述符中。

复制代码
`                    pg_data_t
                         |
     ________________node_zones_______________
    /                    |                    \
ZONE_DMA            ZONE_NORMAL          ZONE_HIGHMEM
    |                    |                     |
zone_mem_map        zone_mem_map           zone_mem_map`

这种内存管理方法的妙处在于,UMA 被简单地表示为一个单节点 NUMA,这使得所有地方都能使用相同的方法------可以说是完全通用。

在 64 位机器上,物理内存也被分为三个部分,但由于客观原因,真正的 64 位机器目前无法支持全部 2^64 字节的内存。例如,在 x86 架构中,内存最大支持 2^48 字节 = 256 TB,这无疑已经相当大了。由于实际物理内存远小于线性内存,因此 64 位系统目前不需要 HIGHMEM 区域;它为零,所有内存都分配在 DMA 和 NORMAL 区域之间。

现在我们了解了 Linux 如何描述其可用的物理内存,接下来需要研究内核如何处理内存。为此,理解 Linux 如何分配内存,或者换句话说,内存分配器的工作原理至关重要。

启动内存

内核可用的第一个内存分配器是 bootmem(mm/bootmem.c)。bootmem 分配器仅在内核启动时使用,用于在内存管理子系统可用之前初始分配物理内存。bootmem 使用首次适应算法进行操作,非常简单直接------它搜索第一个空闲的物理内存块(页)并将其分配。它使用位图来表示物理内存;如果值为 1,则表示该页已被占用;如果值为 0,则表示该页空闲。要分配小于一页的内存,它会记录上次此类分配的物理页号 (PFN),并且下一次分配的内存如果可能,将位于同一物理页上。分配器使用首次适应算法,因此它受内存碎片的影响不大,但由于使用了位图,因此速度非常慢。

复制代码
`/include/linux/bootmem.h
/*
 * node_bootmem_map is a map pointer - the bits represent all physical
 * memory pages (including holes) on the node.
 */
typedef struct bootmem_data {
    unsigned long       node_min_pfn;
    unsigned long       node_low_pfn;
    void                *node_bootmem_map;
    unsigned long       last_end_off;
    unsigned long       hint_idx;
    struct list_head    list;
} bootmem_data_t;`

启动和内存初始化完成后,内核即可使用其他内存分配器:

复制代码
`---------------
|   kmalloc   |
------------------------
|   kmemcache | vmalloc|
------------------------
|         buddy        |
------------------------`

伙伴

Buddy 分配器用于分配连续的页帧,而不是线性页。某些任务,例如 DMA,需要连续的物理页,因为 DMA 设备直接访问内存。这种方法的另一个原因是它避免了修改内核页表,从而加快了内存访问速度。连续页分配器的问题在于外部碎片化,因此 Linux 中的 Buddy 分配器采用了一种标准方法:将所有可用的页帧按 2 的幂次方分成若干列表:1、2、4、8、16、...、1024。1024 * 4096 = 4MB。块中第一个页帧的物理地址是组大小的倍数。算法如下:假设我们要分配 256 个页帧。分配器将检查列表 256;如果列表 512 中没有可用帧,它会查找列表 512。如果列表 512 中有可用帧,它会取出 256 帧,并将剩余的帧放入列表 256。如果列表 512 中也没有可用帧,它会检查列表 1024。如果列表 1024 中有可用帧,它会将 256 帧返回给请求者,并将剩余的 768 帧拆分为两个列表:列表 512 和列表 256。如果列表 1024 中也没有可用帧,它会发出错误信号。伙伴系统有一个全局对象,用于存储所有可用帧的描述符,每个处理器都有自己的本地可用帧列表。如果本地列表内存不足,它会从全局对象中获取帧;如果本地列表空闲,它会返回帧。每个区域都有自己的伙伴分配器。要使用伙伴分配器,必须使用 `alloc_page` 和 `__rmqueue()` 函数(位于mm/page_alloc.c中)进行分配,并使用 `__free_pages()` 函数进行释放。使用这些函数时,必须禁用中断并获取 zone->lock 自旋锁。

有个朋友的好处:
  • 更快的启动内存(不使用位图)。
  • 您可以连续选择多个页面框架。
伙伴的缺点:
  • 不可能分配小于一个页面框架的资源,它总是分配大于等于 1 个页面框架的资源。
  • 它只会分配队列中物理内存中的那些内存,这仍然会导致内存碎片化。

VMALLOC

使用连续的物理内存区域有其优势,例如内存访问速度快,但也存在一些缺点,例如外部碎片化。Linux 支持使用非连续的物理内存区域,这些区域可以通过线性空间中的连续区域进行访问。非连续物理内存区域映射到的线性空间区域的起始位置可以通过 `VMALLOC_START` 宏获取,结束位置则通过 `VMALLOC_END` 宏获取。每个非连续的内存区域都由一个结构体描述。

复制代码
struct` `vm_struct` {
    `struct` `vm_struct`    `*next`;      
    `void`                `*addr`;      
    `unsigned` `long`       `size`;       
    `unsigned` `long`       `flags`;      
    `struct` `page`         `**pages`;
    `unsigned` `int`        `nr_pages`;
    `phys_addr_t`         `phys_addr`;
    `const` `void`          `*caller`;
};`

页面分配由 `void *vmalloc(unsigned long size)` 函数( mm/vmalloc.c ) 执行。`size` 是请求的内存区域的大小。内存以页面大小的倍数分配,因此首先将 `size` 取整到页面大小的倍数。它分配连续的页面,但位于虚拟地址空间中。`vmalloc` 按页帧从伙伴内存中获取物理页面。可以使用 `vfree()` 释放内存。缺点是会产生碎片,但碎片存在于虚拟内存中,并且需要访问页表,这很耗时。因此,`vmalloc` 很少被调用。它用于模块、I/O 缓冲区、防火墙和高内存映射。

KMemCache

显然,由于内存资源浪费,buddy 和 vmalloc 都不适合处理任意长度的小块内存。因此,Linux 提供了另一种内存系统 kmemcache,它允许在页面帧内为小对象分配内存。但是,使用 kmemcache 需要格外小心,因为它可能会导致内部碎片。一般来说,kmemcache 涵盖三种不同的系统:SLAB/SLUB/SLOB。这些系统本质上相似,但也存在显著差异:

  • SLOB 是为嵌入式子系统设计的,这意味着它使用的内存最少,性能较差,并且存在内部碎片化问题。
  • SLAB 是在 Solaris 中引入的,最初它是唯一可用的,但随着系统规模的扩大,SLAB 在处理器数量较多的系统中开始表现不佳。
  • SLUB 是 SLAB 的进化版------更快、更高、更强。

我们先来介绍一下 SLAB 接口。SLAB 基于以下几个观察结果。首先,内核经常请求和返回大小相同的内存区域来访问各种结构。因此,为了提高速度,与其释放这些内存区域,不如将它们保留在缓存中,以便稍后重用,这样可以节省时间。其次,最好尽可能减少对伙伴对象的访问,因为每次访问都会污染硬件缓存。此外,如果对象被频繁访问,也可以创建大小不是 2 的倍数的对象,这可以进一步提高硬件缓存的性能。SLAB 将对象分组到缓存中。每个缓存存储相同类型(大小)的对象。缓存包含多个 Slab 列表:完全空闲对象、部分空闲对象和完全占用对象。缓存的粒度为 1 页、2 页、4 页和 8 页。

要使用 struct kmem_cache,您需要通过以下函数获取句柄:

复制代码
`struct kmem_cache *kmem_cache_create(size);`

size 是我们想要获得的固定大小。然后我们可以使用以下方式分配内存:

复制代码
`void kmem_cache_alloc(kc, flags);`

发布:

复制代码
`void kmem_cache_free(kc);`

您可以使用以下命令清除缓存:

复制代码
`kmem_cache_destroy()`

所有关于 SLAB 的信息都可以在 `/proc/slabinfo` 中找到。SLAB

也需要内存分配,描述 SLAB 的描述符可以位于:另一个 kmem_cache 位于 `off-slab` 中。SLAB 描述符可以位于 buddy 发出的页面的头部------即 `on-slab`。但是 buddy 发出的页面和结构体页面的大小与 SLAB 匹配------结构体页面可以从系统中检索并用于 SLAB。这就是 SLAB 的由来。SLAB 分配器的缺点是它分配的对象大小是固定的,尽管我们并不总是知道需要分配内存的对象的大小。

更高级别的分配器是 kmalloc/kfree(` include/linux/slab.h`)。它访问所需的 kmem_cache,通过静态函数 `kmalloc_index(size)` 获取。在静态函数中,如果在编译时已知大小,编译器会将函数调用替换为生成的索引:

复制代码
`static __always_inline int kmalloc_index(size_t size)
{
...
if (KMALLOC_MIN_SIZE <= 32 && size > 64 && size <= 96)
        return 1;
if (KMALLOC_MIN_SIZE <= 64 && size > 128 && size <= 192)
	return 2;
if (size <= 8)
	return 3;
...
}`
  • 0 = 零分配
  • 1 = 65... 96 字节
  • 2 = 129... 192 字节
  • n = (2\^{n-1}+1)... 2\^n //待办事项

缓存大小为 0/8/16/32/64/96/128/192/256 .../2\^{26}。96 和 192 是根据启发式方法计算出的常用值。

所有分配器都使用 gfp_flags 标志组(include/linux/gfp.h)------获取空闲页标志。这些标志最初出现在 buddy 中,然后逐渐被更高层级的内存分配器所采用。

旗帜的种类

分配位置:__GFP_DMA(获取空闲页)、__GFP_HIGHMEM、__GFP_DMA32。默认情况下,系统尝试在 ZONE_NORMAL 区域分配内存。

低内存行为是我们实际操作的上下文。如果没有可用内存,则需要查找内存,例如:

  • 在磁盘缓存中,必须使用互斥锁;
  • 核缓存 - 需要使用互斥锁;
  • 释放脏磁盘缓存需要获取互斥锁并访问文件系统和数据块;
  • 交换操作需要使用互斥锁并访问数据块;

杀死某人;示例:__GFP_ATOMIC --- 无法执行任何操作,程序将返回 NULL。__GFP_NOFS --- 缓存和缓冲区使用,以确保它们不会被递归调用。__GFP_NOIO。

其他所有情况 --- __GFP_ZERO --- 分配器分配的内存应填充为零。__GFP_TEMPORARY --- 我需要分配一个页面,持有一段时间,然后再将其释放。(路径)GFP_NORETRY GFP_NOFAIL。

用户内存管理

内核内存分配请求(alloc_pages() 和 kmalloc())会在内存容量允许的情况下立即执行。这是合理的,因为:

  • 内核是系统中优先级最高的组件,它的请求至关重要。
  • 内核信任自身,假定内核中不存在错误。

对于以用户模式运行的进程,情况则有所不同:

  • 进程的内存请求可以被延迟。
  • 用户代码可能包含错误,因此您需要做好处理这些错误的准备。当进程请求内存时,它获得的不是新的页面帧,而是访问新的线性地址的权限。

进程地址空间

进程地址空间是指进程可以访问的线性地址。内核可以通过添加或删除内存区域(vm_area_struct)来动态修改进程地址空间。

进程可以获取新的内存区域,例如,通过调用 malloc()、calloc()、mmap()、brk()、shmget() + shmat()、posix_memalign()、mmap() 等函数。所有这些调用都基于 void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset);

  • addr --- 分配内存的地址。
  • 旗帜:
  • NULL------内存分配位置无关紧要。addr 参数仅供参考。
  • MAP_FIXED - 精确到地址中指定的那个位置。
  • MAP_ANON(MAP_ANONYMOUS) - 更改不会在任何文件中显示。
  • MAP_FILE --- 从文件或设备映射。
  • 保护:
  • PROT_EXEC
  • 保护读取
  • 保护写入
  • 保护_无

内存描述符

有关进程地址空间的所有信息都存储在 task_struct 中 mm 字段指向的 mm_struct(内存描述符)中。

复制代码
`task_struct
_________
|   ...   |     mm_struct
---------    _________
|   mm  | -> |   ...   |
---------    ---------
|   ...   |    |  mmap |  
---------    ---------
             |  pgd  |  
             ---------`

mm_struct 和 vm_area_struct 结构的描述可以在 /include/linux/mm_types.h 中找到。

存储区域

复制代码
`struct vm_area_struct {
    /* The first cache line has the info for VMA tree walking. */

    unsigned long vm_start;     /* Our start address within vm_mm. */
    unsigned long vm_end;       /* The first byte after our end address
                       within vm_mm. */

    /* linked list of VM areas per task, sorted by address */
    struct vm_area_struct *vm_next, *vm_prev;
             ...................
             struct rb_node               vm_rb;
             ...........
    /* Function pointers to deal with this struct. */
    const struct vm_operations_struct *vm_ops;
}`

内存区域有两个字段:vm_start 和 vm_end,分别表示已分配区域的起始地址和结束地址之后的第一个比特。如果使用相同的参数调用 mmap() 函数,内核不会创建新的 VMA,而只是修改现有 VMA 的 vm_end 值。

所有内存区域都被组合成一个双向链表,并按地址升序排列。为了避免在分配、回收或查找拥有特定地址的 VMA 时遍历整个链表,所有 VMA 也被组合成一棵红黑树。

复制代码
`struct rb_node {
    unsigned long  __rb_parent_color;
    struct rb_node *rb_right;
    struct rb_node *rb_left;
} __attribute__((aligned(sizeof(long))));
    /* The alignment might seem pointless, but allegedly CRIS needs it */

struct rb_root {
    struct rb_node *rb_node;
};`

mm_struct -> pgd 是指向每个进程全局页面目录的指针。在 x86 架构中,进程切换时,mm_struct -> pgd 会被放入 cr3 中。修改 cr3 会导致 TLB 刷新。然而,两个 task_struct 可以拥有相同的 mm,例如两个线程,在这种情况下,cr3 不会被修改,从而显著加快内存访问速度。

除了线程之外,内核线程也不会进行 cr3 切换。它们根本不需要内存区域,因为它们总是访问 TASK_SIZE = PAGE_OFFSET = 0xffff880000000000 (x86_64) 以上的固定线性地址。因此,内核线程在 task_struct 中的 mm 是不需要的;它为 NULL。但是,task_struct 确实有一个 active_mm,它等于被抢占进程的 active_mm。

VMA 中另一个有趣的字段是 vm_ops,它决定了可以对特定内存区域执行的操作。

使用记忆区域

功能描述:

  • do_mmap() (/mm/mmap.c) -- 分配新的内存区域
  • do_munmap() (/mm/mmap.c) -- 返回一个内存区域
  • find_vma()(/mm/mmap.c) -- 搜索距离给定地址最近的区域
  • find_vma_intersection() (/include/linux/mm.h) -- 查找包含地址的区域。
  • get_unmapped_area() (/mm/mmap.c) --- 查找未绘制区域
  • insert_vm_struct() (/mm/mmap.c) -- 将区域添加到描述符列表中

分配线性地址区间

分配的线性地址可以关联到文件(FILE),也可以不关联(ANON)。请求内存的进程可以共享这些地址(MAP_SHARED),也可以独占这些地址(MAP_PRIVATE)。

递延分配

如上所述,用户进程的内存请求可以延迟到实际需要内存时才执行。这是通过处理缺页异常来实现的,缺页异常表示页面缺失。

在 x86 架构中,每个页表项都按 4096 (2^12) 对齐,因此前 12 位包含页面相对信息,例如:

  • 0 位 - P(存在)标志
  • 1 位 - 读/写 (R/W) 标志
  • 2 位 - 用户/管理员标志

因此,如果 P 位设置为零,则访问此内存区域时会产生异常。当产生异常时,导致异常的地址将存储在 cr2 寄存器中。由此产生的算法可以用图表表示。

如果访问发生在堆栈 VMA(使用 MAP_GROWDOWN 标志创建的区域)附近,则该区域将被扩展。

❯ 提升内存性能

内存不足是一个常见问题。系统开始变慢------窗口卡顿,性能变得迟缓。这是为什么呢?因为Linux内核调度器在获得内存访问权限之前,无法执行正在运行的程序的操作请求。它也无法执行下一个操作,从而导致磁盘读取请求排队,而处理队列的速度较慢,系统开始运行缓慢。

此时,如果您运行htop命令,负载平均值(LA)指标很可能很高。

一些网站建议将vm.swappines参数从60设置为10。但实际上,这样做并不总是能提高性能。该参数用于控制内核使用内存分页的积极程度。值越高,分页的积极程度越高;值越低,分页次数越少。值为0表示内核在空闲页数和文件支持页数低于该区域的最大值之前不会开始分页。更详细地说,这个介于 0 到 100 之间的值决定了系统优先使用匿名内存还是页面缓存的程度。较高的值可以提高文件系统性能,同时减少将活动进程从物理内存中驱逐出去的积极性。较低的值可以防止进程因内存不足而过载,从而降低 I/O 性能。这会提高应用程序数据的优先级,但会牺牲 I/O 缓存。

您还可以启用 zram,这是一个内置的 Linux 内核模块,它通过增加 CPU 负载来压缩 RAM。

它通过阻止页面交换到磁盘来提高性能,在需要使用硬盘的交换文件之前,使用 RAM 中的压缩块设备。

要启用 zram,您需要加载内核模块:

复制代码
$ modprobe` zram `num_devices=2

同时编辑 /etc/default/grub:

复制代码
GRUB_CMDLINE_LINUX_DEFAULT="... zram.num_devices=2 ..."

num_devices 指定要创建的压缩块设备的数量。为了获得最佳的 CPU 利用率,应该根据核心数创建这些设备。

之后,您可以随意使用这些设备------甚至可以创建交换分区:

复制代码
echo` `'1024M'` > /sys/block/zram0/disksize
	`echo` `'1024M'` > /sys/block/zram1/disksize

	mkswap /dev/zram0
	mkswap /dev/zram1

	swapon /dev/zram0 `-p` `10`
	swapon /dev/zram1 `-p` `10

该模块的工作方式类似于 tmpfs------它代表内核占用一块内存。该块设备处理 discard/trim 命令的方式与 SSD 类似。

❯ 结论

Linux 的内存机制非常有趣。了解内存的工作原理对 Linux 开发大有裨益。

下一部分,我将介绍 Linux 中的文件系统和磁盘驱动器!同时,欢迎留下您的评论和点赞!

相关推荐
皓月盈江1 天前
个人计算机Linux Debian桌面操作系统上网安全防护措施
linux·ubuntu·网络安全·debian·桌面操作系统·上网安全防护措施
zl_dfq1 天前
Linux 之 【文件】(动静态库的制作与使用、ar、ldconfig)
linux
久绊A1 天前
磁盘故障处理
linux·运维·服务器
JANG10241 天前
【Linux】进程通信
linux·运维·chrome
viqjeee1 天前
RK3288设备树介绍和配置
linux·设备树
不脱发的程序猿1 天前
使用Python高效对比多个相似的CAN DBC数据
python·单片机·嵌入式硬件·嵌入式
末日汐1 天前
Linux进程信号
linux·运维·服务器
无垠的广袤1 天前
【工业树莓派 CM0 NANO 单板计算机】YOLO26 部署方案
linux·python·opencv·yolo·树莓派·目标识别
皮蛋sol周1 天前
嵌入式学习数据结构(二)双向链表 内核链表
linux·数据结构·学习·嵌入式·arm·双向链表
Sleepy MargulisItG1 天前
【Linux网络编程】网络层协议:IP
linux·网络·tcp/ip