mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4
5631 字
15 分钟
物理内存管理
2024-07-03

一、引言:物理内存——内核一切资源管理的基石#

第 2 章:系统调用中,我们看到用户程序通过 brk()mmap() 等系统调用向内核申请内存。但你有没有想过:内核自己用什么来满足这些请求?当 malloc() 在用户态分配了一块内存,真正映射到物理 RAM 上的那一页帧是谁分配的?当内核需要为一个新的 task_struct 分配空间时,它又从哪里拿到物理页?

答案就在本章的主题——物理内存管理

物理内存管理是整个内存子系统的”地基”。虚拟内存、页缓存、共享内存——所有这些上层机制,最终都要落实到物理页帧的分配与回收。Linux 内核为此设计了一套精巧而高效的分层体系:从最底层的 struct page 页帧描述符,到 NUMA 节点与 Zone 区域的组织,再到伙伴系统(Buddy System)的 2^n 分配算法,以及 Slub 分配器对小对象的精细管理。当内存紧张时,kswapd 守护进程和 OOM Killer 依次登场,确保系统在极端情况下依然可控。

本章将自底向上,逐一剖析这些机制的设计与实现。

二、struct page:物理页帧的”身份证”#

1.1 每一页物理内存都需要元数据#

Linux 以页(Page)为物理内存管理的基本单位。在 x86_64 架构上,一页的大小为 4 KB(4096 字节)。内核为系统中的每一个物理页帧都维护一个 struct page 结构体,它记录了该页帧的状态、引用计数、所属区域等关键信息。

你可以把 struct page 想象成物理页帧的”身份证”——没有它,内核就不知道某一页内存是否空闲、是否被缓存占用、是否可回收。

// include/linux/mm_types.h — 简化版 struct page
struct page {
unsigned long flags; // 页状态标志位(PG_locked, PG_dirty, PG_lru 等)
union {
struct { // 页缓存使用
struct list_head lru;
struct address_space *mapping;
pgoff_t index;
unsigned long private;
};
struct { // Slab 分配使用
void *freelist; // Slub 空闲对象链表
union {
struct kmem_cache *slab_cache;
struct {
unsigned inuse:16;
unsigned objects:15;
unsigned frozen:1;
};
};
};
struct { // 伙伴系统使用
unsigned long _mapcount;
unsigned long _refcount;
};
};
} _struct_page_alignment;
Note

struct page 使用了大量 union(联合体),因为一个物理页帧在不同场景下扮演不同角色——它可能属于页缓存、可能被 Slab 分配器管理、也可能在伙伴系统中空闲。union 让不同用途共享同一块内存,将 struct page 的大小严格控制在 64 字节(x86_64),这对减少内存开销至关重要。

1.2 mem_map 数组#

所有 struct page 组成一个全局数组 mem_map(在 NUMA 系统中,每个节点有自己的 node_mem_map)。物理页帧的编号(pfn,Page Frame Number)就是该数组中的索引:

// 通过 pfn 获取对应的 struct page
#define pfn_to_page(pfn) (mem_map + (pfn))
// 通过 struct page 获取 pfn
#define page_to_pfn(page) ((unsigned long)((page) - mem_map))

这意味着:如果系统有 16 GB 物理内存,就有约 400 万个页帧,对应 400 万个 struct page,占用约 244 MB 内存。这是内核为管理物理内存必须付出的”税”。

Tip

在较新的内核中,struct page 的存储方式从连续数组演进为 SPARSEMEM_VMEMMAP 模式——将 mem_map 映射到一段连续的虚拟地址空间,使得即使物理内存存在”空洞”(某些地址范围没有 RAM),也能高效地通过 pfn 索引到对应的 struct page

1.3 页状态标志位#

struct pageflags 字段是一个位图,记录了该页帧的当前状态。常用的标志位包括:

标志位含义
PG_locked页已被锁定,正在 I/O 操作中
PG_dirty页内容已被修改(脏页)
PG_lru页在 LRU 链表上(可回收)
PG_slab页属于 Slab 分配器
PG_writeback页正在被写回磁盘
PG_swapcache页在交换缓存中
PG_compound页是复合页(Transparent Huge Page)的一部分

这些标志位是内核各子系统协作的”信号灯”——伙伴系统检查页是否空闲,页缓存检查页是否脏,kswapd 检查页是否可回收,都依赖这些标志位。

三、NUMA 节点与 Zone 区域:物理内存的组织结构#

2.1 NUMA 架构与 pg_data_t#

现代多路服务器普遍采用 NUMA(Non-Uniform Memory Access) 架构:每个 CPU 插槽(Socket)有自己的本地内存,访问本地内存的速度远快于访问远端内存。Linux 用 struct pglist_data(别名 pg_data_t)来描述一个 NUMA 节点:

// include/linux/mmzone.h — 简化版 pg_data_t
typedef struct pglist_data {
struct zone node_zones[MAX_NR_ZONES]; // 该节点包含的 Zone 数组
struct zonelist node_zonelists[MAX_ZONELISTS]; // Zone 分配优先级列表
int nr_zones; // 该节点 Zone 的数量
unsigned long node_start_pfn; // 起始页帧号
unsigned long node_present_pages; // 实际存在的页数
unsigned long node_spanned_pages; // 跨越的页数(含空洞)
int node_id; // 节点编号
wait_queue_head_t kswapd_wait; // kswapd 等待队列
struct task_struct *kswapd; // kswapd 内核线程
} pg_data_t;

在 UMA(Uniform Memory Access)系统——也就是大多数个人电脑和虚拟机——上,整个系统只有一个 NUMA 节点,所有 CPU 对内存的访问延迟相同。内核通过将 UMA 模拟为单节点 NUMA 来统一处理。

# 查看系统的 NUMA 拓扑
numactl --hardware
# 输出示例(双路服务器):
# available: 2 nodes (0-1)
# node 0 size: 32768 MB
# node 1 size: 32768 MB

2.2 Zone:同一节点内的功能分区#

每个 NUMA 节点内的物理内存又被划分为若干 Zone,划分依据是物理地址范围用途限制

graph TB subgraph NUMA Node 0 direction TB ZONE_DMA["ZONE_DMA<br/>0 ~ 16 MB<br/>ISA 设备 DMA"] ZONE_DMA32["ZONE_DMA32<br/>0 ~ 4 GB<br/>32 位 DMA 设备"] ZONE_NORMAL["ZONE_NORMAL<br/>4 GB ~ 64 GB<br/>直接映射区"] ZONE_MOVABLE["ZONE_MOVABLE<br/>可迁移页<br/>内存热插拔/反碎片"] end subgraph NUMA Node 1 direction TB ZONE_DMA2["ZONE_DMA"] ZONE_DMA32_2["ZONE_DMA32"] ZONE_NORMAL2["ZONE_NORMAL"] ZONE_MOVABLE2["ZONE_MOVABLE"] end ZONE_DMA --> ZONE_DMA32 --> ZONE_NORMAL --> ZONE_MOVABLE style ZONE_DMA fill:#ff9999,stroke:#333 style ZONE_DMA32 fill:#ffcc99,stroke:#333 style ZONE_NORMAL fill:#99ff99,stroke:#333 style ZONE_MOVABLE fill:#9999ff,stroke:#333

各 Zone 的含义与存在原因:

Zone物理地址范围存在原因
ZONE_DMA0 ~ 16 MBISA 总线时代的 DMA 引擎只能访问低 16 MB 内存。现代系统极少需要,但为兼容性保留
ZONE_DMA320 ~ 4 GB32 位 DMA 设备(部分网卡、RAID 卡)只能访问 4 GB 以下内存
ZONE_NORMAL4 GB ~ 物理内存上限内核直接映射区,kmalloc() 默认从此分配。x86_64 上可覆盖全部物理内存
ZONE_HIGHMEM仅 32 位系统存在。内核空间只有 1 GB 虚拟地址,无法直接映射全部物理内存,超出部分为高端内存
ZONE_MOVABLE用于反碎片化和内存热插拔,其中的页可被迁移,不会产生不可移动的”钉子页”
Important

Zone 之间存在**备用分配(fallback)**机制:当 ZONE_NORMAL 空闲不足时,内核可以从 ZONE_DMA32 借用页帧——但反过来不行,DMA 设备需要的低地址内存不能被普通分配占用。这种”向下借用、不向上侵占”的策略保护了稀缺的 DMA 内存。

2.3 struct zone 的关键成员#

// include/linux/mmzone.h — 简化版 struct zone
struct zone {
unsigned long _watermark[NR_WMARK]; // 水位线:min / low / high
unsigned long managed_pages; // 被伙伴系统管理的页数
unsigned long present_pages; // 实际存在的页数
struct free_area free_area[MAX_ORDER]; // 伙伴系统空闲链表(11 个阶)
struct per_cpu_pageset __percpu *pageset; // Per-CPU 页缓存
spinlock_t lock; // Zone 级自旋锁
unsigned long flags; // Zone 状态标志
/* 内存回收相关 */
unsigned long dirty_balance_reserve;
int compact_considered;
unsigned long compact_defer_shift;
};

其中 free_area[] 是伙伴系统的核心数据结构,_watermark[] 是内存水位线,pageset 是 Per-CPU 缓存——这些将在后续章节逐一展开。

# 查看 Zone 信息
cat /proc/zoneinfo
# 输出示例:
# Node 0, zone Normal
# pages free 234567
# min 8192
# low 12288
# high 16384
# ...
# Node 0, zone DMA32
# ...

四、伙伴系统:2^n 的分配艺术#

3.1 为什么需要伙伴系统?#

物理内存的分配面临一个经典难题——外碎片化(External Fragmentation):经过反复分配和释放后,空闲内存被切割成零散的小块,虽然总空闲量足够,却无法满足一个较大的连续分配请求。

伙伴系统(Buddy System)通过巧妙的 2^n 分配与合并策略,极大地缓解了外碎片化问题。它的核心思想是:

  1. 分配:将空闲块不断对半分裂,直到得到所需大小的块
  2. 释放:检查释放块的”伙伴”是否也空闲,若空闲则合并为更大的块,递归向上

3.2 伙伴系统的数据结构#

每个 Zone 维护一个 free_area[] 数组,共 MAX_ORDER(默认 11)个元素,分别对应阶(order)0 ~ 10:

  • order 0:1 页(4 KB)
  • order 1:2 页(8 KB)
  • order 2:4 页(16 KB)
  • order 10:1024 页(4 MB)
struct free_area {
struct list_head free_list[MIGRATE_TYPES]; // 按迁移类型分组的空闲链表
unsigned long nr_free; // 该阶空闲块的数量
};

每个 free_area 中的空闲块按**迁移类型(Migrate Type)**进一步分组:MIGRATE_UNMOVABLE(不可移动)、MIGRATE_MOVABLE(可移动)、MIGRATE_RECLAIMABLE(可回收)等。这种分组策略是反碎片化的关键——不可移动的”钉子页”不会与可移动页混在一起,避免碎片扩散。

3.3 分配过程详解#

假设系统需要分配 order=3(8 页 = 32 KB)的连续块,而 order=3 的空闲链表为空:

graph TD A["请求 order=3<br/>(8 页连续块)"] --> B{"order=3 空闲链表<br/>是否为空?"} B -->|非空| F["从链表取出一块<br/>分配完成"] B -->|为空| C{"order=4 空闲链表<br/>是否为空?"} C -->|非空| D["取出一块 16 页块<br/>分裂为两个 8 页伙伴"] D --> E["一个 8 页块分配给请求<br/>另一个挂入 order=3 空闲链表"] C -->|为空| G["继续向上查找 order=5...<br/>直到找到空闲块"] G --> H["递归分裂直到满足需求"] style A fill:#ffcc99,stroke:#333 style F fill:#99ff99,stroke:#333 style E fill:#99ff99,stroke:#333

伙伴的判定规则:两个大小为 2^order 的块互为伙伴,当且仅当它们的起始 pfn 满足:

buddy_pfn = pfn ^ (1 << order)

即 pfn 的第 order 位取反。这个位运算的精妙之处在于:合并时只需一次异或操作就能找到伙伴的位置。

3.4 释放与合并过程#

释放一个 order=n 的块时:

  1. 计算伙伴的 pfn:buddy_pfn = pfn ^ (1 << n)
  2. 检查伙伴是否在 order=n 的空闲链表上
  3. 若伙伴空闲且在同一个 Zone,将伙伴从空闲链表摘除,合并为 order=n+1 的块
  4. 递归向上尝试合并,直到伙伴不可合并或达到 MAX_ORDER
Tip

伙伴系统的合并效率极高——最坏情况下,一次释放操作最多触发 MAX_ORDER - 1 次合并尝试。而且,由于每次合并都是 2^n 对齐的,合并后的块天然满足伙伴系统的对齐要求,不会产生”无法合并的碎片”。

3.5 核心分配/释放 API#

mm/page_alloc.c
// 分配 2^order 个连续页帧,返回指向第一个 struct page 的指针
struct page *alloc_pages(gfp_t gfp_mask, unsigned int order);
// 释放通过 alloc_pages 分配的页帧
void __free_pages(struct page *page, unsigned int order);
// 分配单个页帧的便捷宏
#define alloc_page(gfp_mask) alloc_pages(gfp_mask, 0)
// 将 struct page �转换为虚拟地址
void *page_address(const struct page *page);
// 分配页帧并直接返回虚拟地址
unsigned long __get_free_pages(gfp_t gfp_mask, unsigned int order);

gfp_mask(Get Free Pages mask)是分配行为的关键控制参数,它告诉内核:从哪个 Zone 分配、是否可以阻塞、是否可以触发 I/O 等。常用的 GFP 标志:

GFP 标志含义典型场景
GFP_KERNEL可阻塞、可触发回收、从 ZONE_NORMAL 分配内核一般分配
GFP_ATOMIC不可阻塞、不可触发回收中断上下文、自旋锁内
GFP_DMA从 ZONE_DMA 分配ISA 设备 DMA 缓冲区
GFP_DMA32从 ZONE_DMA32 分配32 位 DMA 设备
GFP_HIGHUSER从 ZONE_NORMAL 分配、可映射到用户空间用户态 mmap
# 查看伙伴系统各阶空闲块数量
cat /proc/buddyinfo
# 输出示例:
# Node 0, zone DMA 3 2 1 1 2 1 1 0 1 1 3
# Node 0, zone DMA32 127 85 42 21 12 5 3 2 1 0 0
# Node 0, zone Normal 512 256 128 64 32 16 8 4 2 1 0
# 每列对应 order 0~10 的空闲块数量

3.6 Per-CPU Pageset:减少锁竞争的利器#

伙伴系统的 free_area[] 由 Zone 级自旋锁保护。在多核系统上,频繁的分配/释放会导致严重的锁竞争。为此,内核为每个 CPU 在每个 Zone 上维护了一个 Per-CPU Pageset

// include/linux/mmzone.h — 简化版
struct per_cpu_pageset {
struct per_cpu_pages pcp;
};
struct per_cpu_pages {
int count; // 当前缓存页数
int high; // 高水位(超过则归还给伙伴系统)
int batch; // 一次批量分配/释放的页数
struct list_head lists[MIGRATE_TYPES]; // 按迁移类型分组的页链表
};

工作流程

  • 分配:优先从当前 CPU 的 pageset 中取页,无需加 Zone 锁
  • 释放:优先归还到当前 CPU 的 pageset,无需加 Zone 锁
  • 溢出处理:当 pageset 中的页数超过 high 水位时,批量归还 batch 个页给伙伴系统

这种”本地缓存 + 批量同步”的设计,将大部分分配/释放操作变成了无锁操作,极大提升了多核场景下的性能。

五、Slab 分配器:小对象的精细管理#

4.1 为什么需要 Slab?#

伙伴系统只能分配 2^n 个连续页帧,最小粒度是一页(4 KB)。但内核中有大量远小于 4 KB 的对象需要分配——task_struct 约 9 KB、dentry 约 192 字节、inode 约 600 字节……如果每个对象都分配一整页,内存浪费将不可接受。

Slab 分配器在伙伴系统之上构建了小对象分配的精细管理层:它从伙伴系统申请连续页帧,然后将其切割为固定大小的对象,通过空闲链表管理。

4.2 从 Slab 到 Slub 到 Slob:三兄弟的演进#

Linux 内核先后实现了三种 Slab 分配器:

分配器特点适用场景
Slab最早实现(1996 年,Sun 公司 Jeff Bonwick 设计)。每个 CPU 维护本地缓存(array_cache),每个 slab 有专用管理结构。功能完善但内存开销大已被 Slub 取代
Slub当前默认分配器(2.6.22 引入)。简化了管理结构,将空闲对象指针直接嵌入对象本身(freelist),减少了元数据开销。调试功能更强通用服务器、桌面
Slob极简实现,代码量仅约 600 行。使用简单的首次适配算法,适合嵌入式等内存极度受限的场景嵌入式系统
Note

三种分配器共享同一套 API(kmallockmem_cache_alloc 等),内核配置时选择其一即可。现代 Linux 默认使用 Slub。

4.3 Slub 分配器的核心概念#

Slub 的组织层次为:kmem_cache → slab → object

// mm/slub.c — kmem_cache 简化版
struct kmem_cache {
const char *name; // 缓存名称(如 "task_struct")
unsigned int size; // 对象大小(含对齐填充)
unsigned int object_size; // 对象原始大小
unsigned int offset; // freelist 指针在对象内的偏移
unsigned int objs_per_slab; // 每个 slab 中的对象数
unsigned int order; // slab 占用的页帧阶数
struct kmem_cache_cpu __percpu *cpu_slab; // Per-CPU slab 缓存
struct kmem_cache_node *node[MAX_NUMNODES]; // NUMA 节点 partial slab 链表
};

关键设计

  1. Per-CPU slab:每个 CPU 维护当前正在使用的 slab(cpu_slab),从中分配对象无需任何锁
  2. freelist 嵌入:空闲对象的第一个 sizeof(void*) 字节存储下一个空闲对象的指针,无需额外元数据
  3. partial 链表:每个 NUMA 节点维护一个部分空闲的 slab 链表,当 Per-CPU slab 耗尽时从中补充

分配流程

  1. 从当前 CPU 的 cpu_slab 获取 freelist
  2. 若 freelist 非空,取出第一个空闲对象,freelist 指向下一个
  3. 若 freelist 为空,从 partial 链表取一个 slab,或从伙伴系统分配新 slab

释放流程

  1. 将对象放回当前 CPU slab 的 freelist 头部
  2. 若 slab 变为全空,根据策略决定是否归还给伙伴系统

4.4 kmalloc:通用对象分配器#

内核不仅需要为特定类型(如 task_struct)分配对象,还需要分配任意大小的内存块。kmalloc() 就是为此设计的通用分配器:

mm/slub.c
void *kmalloc(size_t size, gfp_t flags);
void kfree(const void *objp);

kmalloc 内部维护了一组预定义的 kmem_cache,覆盖了从 8 字节到 8 KB 的各种大小(对齐到 2 的幂次):

kmalloc-8 kmalloc-16 kmalloc-32 kmalloc-64
kmalloc-96 kmalloc-128 kmalloc-192 kmalloc-256
kmalloc-512 kmalloc-1k kmalloc-2k kmalloc-4k kmalloc-8k

当你调用 kmalloc(100, GFP_KERNEL) 时,内核会选择 kmalloc-128 缓存(大于等于 100 的最小可用大小),从中分配一个 128 字节的对象。

Warning

kmalloc 分配的内存在物理上是连续的,但大小受限于伙伴系统的最大阶(order-10,即 4 MB)。对于更大的分配,需要使用 vmalloc(见下文)。

4.5 专用缓存 vs 通用缓存#

内核为高频分配的对象创建了专用 kmem_cache

// 创建专用缓存
struct kmem_cache *kmem_cache_create(const char *name, unsigned int size,
unsigned int align, slab_flags_t flags,
void (*ctor)(void *));
// 从专用缓存分配
void *kmem_cache_alloc(struct kmem_cache *s, gfp_t flags);
// 释放到专用缓存
void kmem_cache_free(struct kmem_cache *s, void *objp);
// 销毁专用缓存
void kmem_cache_destroy(struct kmem_cache *s);

专用缓存的优势:

  • 精确大小:对象大小与实际需求完全匹配,无内部碎片
  • 构造/析构:可指定构造函数,每次分配新 slab 时自动初始化对象
  • 统计追踪:每个缓存有独立的统计信息,便于性能分析
# 查看 Slab 缓存信息
cat /proc/slabinfo
# 或使用更友好的 slabtop 工具
slabtop -o
# 输出示例:
# OBJS ACTIVE USE OBJ SIZE SLABS OBJ/SLAB CACHE SIZE NAME
# 12345 10000 80% 0.19K 617 20 9872K dentry
# 8765 7000 79% 0.62K 876 10 14016K inode_cache
# 5432 4000 73% 9.22K 5432 1 86912K task_struct

六、kmalloc vs vmalloc:两种分配策略的抉择#

5.1 物理连续 vs 虚拟连续#

特性kmallocvmalloc
物理内存连续不连续
虚拟内存连续连续
最大大小约 4 MB(order-10)理论上可达 VMALLOC_SPACE 大小
分配速度快(Per-CPU 缓存)慢(需建立页表映射)
TLB 效率高(物理连续,TLB 命中率高)低(物理不连续,TLB 失效率高)
适用场景DMA 缓冲区、高性能数据结构大块内存、不要求物理连续
GFP 标志可用任意 GFP 标志仅限可睡眠上下文

5.2 vmalloc 的实现原理#

vmalloc() 在内核的 vmalloc 虚拟地址空间中分配一段连续的虚拟地址,然后从伙伴系统逐页分配物理页帧,建立页表映射将两者关联:

mm/vmalloc.c
void *vmalloc(unsigned long size);
void vfree(const void *addr);
graph LR subgraph "vmalloc 地址空间" V1["虚拟页 1"] --> V2["虚拟页 2"] --> V3["虚拟页 3"] end subgraph "物理内存(不连续)" P1["物理页 @0x8A7000"] P2["物理页 @0x3F2000"] P3["物理页 @0xC15000"] end V1 -.->|页表映射| P1 V2 -.->|页表映射| P2 V3 -.->|页表映射| P3 style V1 fill:#ffcc99,stroke:#333 style V2 fill:#ffcc99,stroke:#333 style V3 fill:#ffcc99,stroke:#333 style P1 fill:#99ff99,stroke:#333 style P2 fill:#99ff99,stroke:#333 style P3 fill:#99ff99,stroke:#333

vmalloc 的代价

  1. 页表建立开销:需要为每个页创建 PTE(Page Table Entry),修改页表时还需刷新 TLB
  2. TLB 抖动:虚拟连续但物理不连续,访问时 TLB 失效率远高于 kmalloc
  3. 不可用于 DMA:DMA 引擎需要物理连续的缓冲区
Important

内核社区有一个不成文的规则:能用 kmalloc 就不用 vmalloc。只有在确实需要大块内存且无法保证物理连续时,才使用 vmalloc。例如,内核模块的代码段加载、大型的哈希表等。

5.3 其他分配函数#

函数说明
kcalloc(n, size, flags)分配 n 个 size 大小的元素,内存清零
kzalloc(size, flags)kmalloc + 清零
kvmalloc(size, flags)优先 kmalloc,失败则回退到 vmalloc
dm_alloc_coherent()DMA 一致性内存分配

七、内存水位线与 kswapd:内存回收的守护者#

6.1 三条水位线#

每个 Zone 维护三条水位线,控制内存回收的触发时机:

include/linux/mmzone.h
enum zone_watermarks {
WMARK_MIN, // 最低水位线:内存极度紧张,分配者必须同步回收
WMARK_LOW, // 低水位线:唤醒 kswapd 开始后台回收
WMARK_HIGH, // 高水位线:kswapd 回收至此停止
NR_WMARK
};

水位线的含义与行为:

水位线含义触发行为
min紧急保留线。低于此线时,分配者必须调用 __alloc_pages_slowpath 同步回收alloc_pages 进入慢速路径,直接回收内存
low预警线。空闲内存低于此线时唤醒 kswapd异步唤醒 kswapd 内核线程
high安全线。kswapd 回收内存直到空闲量达到此线kswapd 完成回收,重新休眠

水位线的计算公式(由 setup_per_zone_wmarks() 初始化):

min = managed_pages * (min_free_kbytes / 1024) // 可通过 /proc/sys/vm/min_free_kbytes 调整
low = min * 5 / 4
high = min * 3 / 2
# 查看当前 min_free_kbytes 设置
cat /proc/sys/vm/min_free_kbytes
# 典型值:67584(约 66 MB)
# 查看 Zone 水位线
cat /proc/zoneinfo | grep -E "Node|zone|min|low|high|free"
Tip

在生产环境中,适当调大 min_free_kbytes 可以为内存突增留出更多缓冲,减少直接回收(direct reclaim)的发生,但代价是减少了可用内存。对于延迟敏感型应用(如数据库),建议设置为物理内存的 1%~5%。

6.2 kswapd 内核线程#

kswapd 是每个 NUMA 节点一个的内核线程,负责在后台回收内存,使空闲量恢复到 high 水位线以上。它的工作流程:

  1. 休眠等待:平时在 kswapd_wait 等待队列上休眠
  2. 被唤醒:当某 Zone 的空闲内存低于 low 水位线时被唤醒
  3. 回收循环:依次尝试以下回收手段:
    • LRU 回收:从活跃 LRU 链表尾部扫描,将最近最少使用的页移到非活跃链表,最终回收
    • 脏页写回:将脏页写回磁盘后回收
    • Slab 回收:收缩可回收的 Slab 缓存(如 dentry cache、inode cache)
  4. 完成条件:所有 Zone 的空闲内存均达到 high 水位线,或已扫描足够多的页
  5. 重新休眠

6.3 直接回收(Direct Reclaim)#

当分配路径发现空闲内存低于 min 水位线时,分配者自身必须同步执行回收——这就是直接回收。它会阻塞当前进程,执行与 kswapd 类似的回收操作,但代价是分配延迟大幅增加。

直接回收是系统内存紧张的信号。如果你在 perf/proc/vmstat 中观察到大量 pgscan_direct 计数,说明系统需要更多物理内存或调整水位线参数。

# 查看内存回收统计
cat /proc/vmstat | grep -E "pgscan|pgsteal|kswapd"
# pgscan_kswapd_dma 0
# pgscan_kswapd_normal 12345
# pgscan_direct_normal 0 # 直接回收次数,理想值为 0
# kswapd_inodesteal 678

八、内存规整与 OOM Killer:极端情况下的最后防线#

7.1 内存规整(Compaction)#

伙伴系统解决了外碎片化问题,但长时间运行后仍可能出现:空闲页总量充足,却无法满足高阶(order ≥ 3)的连续分配。这是因为可移动页和不可移动页交错分布,导致空闲页无法合并。

内存规整(Memory Compaction) 通过迁移可移动页来”挤”出大块连续内存:

  1. 从 Zone 的起始端扫描寻找空闲页
  2. 从 Zone 的末尾端扫描寻找可移动页
  3. 将可移动页迁移到空闲页的位置
  4. 释放原位置,形成更大的连续空闲块
# 查看规整统计
cat /proc/vmstat | grep compact
# compact_migrate_scanned 12345
# compact_free_scanned 67890
# compact_isolated 4567
# compact_stall 12 # 触发规整的次数
# compact_fail 0 # 规整失败的次数
# compact_success 12 # 规整成功的次数

7.2 OOM Killer:最后的审判者#

当所有回收手段(kswapd、直接回收、规整)都无法释放足够内存时,系统面临内存耗尽的危机。此时 OOM Killer(Out-Of-Memory Killer) 登场——它选择一个进程杀死,释放其占用的内存,拯救整个系统。

OOM Killer 的选择策略基于 oom_score(OOM 坏蛋分数),分数越高越容易被杀:

// mm/oom_kill.c — oom_badness() 简化逻辑
long oom_badness(struct task_struct *p, unsigned long totalpages) {
long points;
points = get_mm_rss(p->mm) + // RSS(驻留集大小)
get_mm_counter(p->mm, MM_SWAPENTS) + // 交换区使用量
mm_pgtables_bytes(p->mm) / PAGE_SIZE; // 页表占用
// 根据进程的 oom_score_adj 进行调整(-1000 ~ 1000)
// oom_score_adj = -1000 表示"永不杀死"
// oom_score_adj = 1000 表示"优先杀死"
return points;
}

关键参数

参数路径说明
oom_score/proc/<pid>/oom_score内核计算的 OOM 分数(只读)
oom_score_adj/proc/<pid>/oom_score_adj用户调整的权重(-1000 ~ 1000)
oom_kill_disable/proc/sys/vm/oom_kill_disable是否禁用 OOM Killer(0=启用,1=禁用)
# 查看进程的 OOM 分数
cat /proc/$$/oom_score
# 调整 OOM 权重(保护关键进程)
echo -1000 > /proc/<pid>/oom_score_adj # 永不杀死
echo 1000 > /proc/<pid>/oom_score_adj # 优先杀死
# 查看最近的 OOM 事件
dmesg | grep -i "out of memory"
# 或查看内核日志
journalctl -k | grep -i "oom-kill"
Warning

禁用 OOM Killer(oom_kill_disable=1)是危险的——当内存耗尽时,系统将无法杀死任何进程,可能导致整个系统挂起。除非你完全清楚自己在做什么,否则不要禁用它。

7.3 完整的内存分配路径#

将所有机制串联起来,一次内存分配的完整路径如下:

graph TD A["alloc_pages(gfp_mask, order)"] --> B{"Per-CPU pageset<br/>有可用页?"} B -->|是| C["从 Per-CPU 缓存分配<br/>(无锁,快速路径)"] B -->|否| D{"Zone 空闲页 ≥ low 水位线?"} D -->|是| E["从伙伴系统分配<br/>(需加 Zone 锁)"] D -->|否| F{"唤醒 kswapd<br/>后台回收"} F --> G{"Zone 空闲页 ≥ min 水位线?"} G -->|是| H["从伙伴系统分配<br/>(慢速路径)"] G -->|否| I["直接回收<br/>(同步阻塞)"] I --> J{"回收后<br/>能分配吗?"} J -->|是| K["分配成功"] J -->|否| L["尝试内存规整"] L --> M{"规整后<br/>能分配吗?"} M -->|是| K M -->|否| N["OOM Killer<br/>选择进程杀死"] N --> O["重新尝试分配"] style C fill:#99ff99,stroke:#333 style E fill:#99ff99,stroke:#333 style K fill:#99ff99,stroke:#333 style I fill:#ffcc99,stroke:#333 style N fill:#ff9999,stroke:#333

九、动手实践:观察物理内存管理的运行状态#

实践 1:查看 Zone 布局与水位线#

# 查看 Zone 详细信息(水位线、空闲页数、伙伴系统状态)
cat /proc/zoneinfo
# 重点关注以下字段:
# - pages free: 当前空闲页数
# - min / low / high: 三条水位线
# - managed: 伙伴系统管理的页数
# - nr_free_pages: 空闲页数

实践 2:观察伙伴系统空闲块分布#

# 查看各阶空闲块数量
cat /proc/buddyinfo
# 解读示例:
# Node 0, zone Normal 512 256 128 64 32 16 8 4 2 1 0
# order: 0 1 2 3 4 5 6 7 8 9 10
# 含义:order=0 有 512 个 1 页空闲块,order=1 有 256 个 2 页空闲块...
# 如果高阶(order ≥ 5)空闲块很少,说明碎片化较严重

实践 3:分析 Slab 缓存使用情况#

# 查看 Slab 缓存汇总
cat /proc/slabinfo
# 使用 slabtop 实时监控(按大小排序)
slabtop -o -s c # 按缓存大小排序
# 关注占用最大的缓存:
# - dentry: 目录项缓存
# - inode_cache: inode 缓存
# - task_struct: 进程描述符
# - kmalloc-*: 通用分配器缓存
# 手动回收可回收的 Slab 缓存
echo 2 > /proc/sys/vm/drop_caches # 释放 dentry 和 inode 缓存
echo 3 > /proc/sys/vm/drop_caches # 释放页缓存 + dentry + inode
# 注意:生产环境慎用!仅用于调试

实践 4:全面了解内存使用状况#

# 查看系统内存概览
cat /proc/meminfo
# 关键字段解读:
# MemTotal: 总物理内存
# MemFree: 空闲内存(未使用)
# MemAvailable: 可用内存(含可回收的缓存)
# Buffers: 块设备缓冲区
# Cached: 页缓存大小
# Slab: Slab 分配器占用的内存
# SReclaimable: 可回收的 Slab(dentry/inode 缓存)
# SUnreclaim: 不可回收的 Slab
# Mapped: 已映射到用户空间的页
# AnonPages: 匿名页(进程堆栈、malloc 分配)
# KernelStack: 内核栈占用
# 更友好的工具
free -h
vmstat 1 5 # 每秒刷新,共 5 次

实践 5:触发与观察内存回收#

# 查看 kswapd 活动统计
cat /proc/vmstat | grep -E "kswapd|pgscan|pgsteal|compact"
# 关键指标:
# kswapd_steal: kswapd 回收的页数
# pgscan_kswapd_*: kswapd 扫描的页数
# pgscan_direct_*: 直接回收扫描的页数(越少越好)
# compact_stall: 触发规整的次数
# compact_fail: 规整失败的次数
# 调整水位线参数
cat /proc/sys/vm/min_free_kbytes
echo 131072 > /proc/sys/vm/min_free_kbytes # 设置为 128 MB
# 调整 Swappiness(倾向使用 swap 还是回收页缓存)
cat /proc/sys/vm/swappiness
# 值范围 0~100,默认 60
# 0 = 尽量不用 swap,优先回收页缓存
# 100 = 积极使用 swap

实践 6:模拟 OOM 场景#

# 查看当前进程的 OOM 分数
cat /proc/$$/oom_score
cat /proc/$$/oom_score_adj
# 保护关键进程(如 SSH 守护进程)
echo -1000 > /proc/$(pgrep sshd)/oom_score_adj
# 使用 stress-ng 模拟内存压力(需安装)
stress-ng --vm 4 --vm-bytes 80% --timeout 30s
# 观察 OOM 事件
dmesg -w | grep -i "oom"

总结:物理内存管理的全景图#

本章从最底层的 struct page 出发,逐层向上构建了 Linux 物理内存管理的完整图景:

  1. struct page 是物理页帧的元数据,mem_map 数组通过 pfn 索引到每一个页帧
  2. NUMA 节点(pg_data_t) 描述多路系统的内存拓扑,Zone 按物理地址和用途分区
  3. 伙伴系统 通过 2^n 分配与合并算法,高效管理连续页帧分配,缓解外碎片化
  4. Per-CPU Pageset 为伙伴系统提供无锁缓存,减少多核锁竞争
  5. Slub 分配器 在伙伴系统之上管理小对象,kmalloc 提供通用分配,kmem_cache 提供专用分配
  6. kmalloc 保证物理连续、速度快;vmalloc 仅保证虚拟连续、适合大块分配
  7. 三条水位线(min/low/high) 控制 kswapd 的唤醒与停止,直接回收 在紧急时同步执行
  8. 内存规整 通过迁移可移动页来消除碎片,OOM Killer 在内存耗尽时选择进程杀死

参考资料#

内核源码#

文件内容
mm/page_alloc.c伙伴系统核心实现(alloc_pages、__free_pages、水位线计算)
mm/slub.cSlub 分配器实现(kmem_cache、kmalloc)
mm/vmalloc.cvmalloc 分配器实现
include/linux/mmzone.hZone、pg_data_t、水位线、Per-CPU pageset 数据结构定义
include/linux/mm_types.hstruct page 定义
mm/oom_kill.cOOM Killer 实现(oom_badness、out_of_memory)
mm/compaction.c内存规整实现
mm/vmscan.ckswapd 与页面回收实现
mm/slab.hSlab 分配器公共头文件

经典书籍#

  • 《深入理解 Linux 内核》(Daniel P. Bovet 等)— 第 8 章”内存管理”对伙伴系统和 Slab 有详细描述
  • 《Linux 内核设计与实现》(Robert Love)— 第 12 章”内存管理”提供了清晰的概述
  • 《Understanding the Linux Virtual Memory Manager》(Mel Gorman)— 内存管理子系统的权威参考,作者本人就是 Linux 内存管理核心开发者
  • 《奔跑吧 Linux 内核》(笨叔)— 结合最新内核版本,有大量图解和代码分析

在线资源#

支持与分享

如果这篇文章对你有帮助,欢迎支持作者或分享给更多人

物理内存管理
https://blog.souloss.com/posts/linux-internals/physical-memory-management/
作者
Souloss
发布于
2024-07-03
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时