引言

Linux 内核内存管理是整个操作系统最复杂、最核心的子系统之一。从用户空间的 malloc() 到内核空间的 kmalloc()、vmalloc(),从物理页帧分配器 Slab 到伙伴系统 Buddy,从匿名页面到文件映射页面,每一个内存分配决策都直接影响系统性能与稳定性。本文以 Linux 6.x 内核为背景,从工程实战角度深入解析内存管理的完整技术栈。

一、物理内存模型与 zone 架构

1.1 从 UMA 到 NUMA:节点-区域-页三级模型

Linux 采用节点-区域-页(Node-Zone-Page)三级模型管理物理内存。在 include/mm.h 中定义了核心数据结构:

struct pglist_data {    // 节点描述符 (typedef struct pglist_data pg_data_t)
    struct zone node_zones[MAX_NR_ZONES];      // 本节点的内存区域数组
    struct zoneref node_zonelists[MAX_ZONELISTS]; // 分配fallback的节点列表
    int nr_zones;                              // 本节点区域数量
    struct page *node_mem_map;                 // 本节点页描述符数组
    unsigned long node_start_pfn;              // 起始页帧号
    unsigned long node_present_pages;          // 可用物理页数
    unsigned long node_spanned_pages;          // 总页数(含空洞)
    int node_id;                               // 节点ID
    // ... kswapd、compact 等字段
};

struct zone {          // 区域描述符
    unsigned long _watermark[NR_MARK];    // min/low/high 水位
    unsigned long nr_reserved_highatomic; // 保留原子页
    struct free_area free_area[MAX_ORDER]; // 伙伴系统空闲区
    unsigned long vm_stat[NR_VM_ZONE_ITEMS]; // 区域统计
    // ...
};

在 x86_64 架构下,典型的 zone 包括:

  • ZONE_DMA(0-16MB):用于 ISA 设备的 DMA 传输
  • ZONE_DMA32(16MB-4GB):用于 32 位 DMA 设备
  • ZONE_NORMAL(内核直接映射区):线性映射到内核地址空间
  • ZONE_MOVABLE(可迁移区):用于内存热插拔

1.2 NUMA 架构实战

在多路服务器上,NUMA 拓扑感知至关重要。假设一台双路 EPYC 服务器(2×64 核):

// 查看 NUMA 拓扑
$ numactl --hardware
available: 2 nodes (0,1)
node 0 cpus: 0-63,128-191
node 0 size: 263798 MB
node 1 cpus: 64-127,192-255
node 1 size: 263798 MB
node distances:
node 0   1
 0: 10 21
 1: 21 10

本地节点访问延迟 80ns,跨节点访问通过 Infinity Fabric 延迟 130ns。对于数据库这类内存密集型应用,策略性绑核与本地分配可提升 30%+ 吞吐量。

二、伙伴系统(Buddy System)

2.1 算法原理与实现

伙伴系统是物理页帧分配的核心算法,解决外部碎片问题。内核维护 11 个空闲链表(order 0~10),分别管理 2^0~2^10 个连续页(即 4KB~4MB 连续物理块):

// mm/page_alloc.c
struct free_area {
    struct free_list free_list[MIGRATE_TYPES];
    unsigned long nr_free;
};

struct free_list {
    struct list_head lru;    // 双向链表头(通过 page->lru 嵌入)
    unsigned long nr_pages;  // 该迁移类型空闲页数
};

分配 order-N 页面的流程:

  1. 检查 free_area[N] 是否有空闲块 → 有则直接取出
  2. 向上查找 order N+1, N+2... 直到 order 10
  3. 找到后逐层"拆分":取出一半返回,另一半插入低一级 free_area

释放时的"合并"操作:检查伙伴块(相邻且同阶)是否空闲 → 若是则合并为更高阶块。伙伴地址计算:buddy_pfn = pfn ^ (1 << order>。

2.2 页面迁移类型(MIGRATE_TYPES)

为减少碎片化内核引入页面迁移分类:

  • MIGRATE_UNMOVABLE:内核数据结构、页表
  • MIGRATE_MOVABLE:用户页面、缓存页面
  • MIGRATE_RECLAIMABLE:slab 缓存、dcache(可回收但不可迁移)
  • MIGRATE_HIGHATOMIC:紧急预留原子页

通过 /proc/pagetypeinfo 可观察各迁移类型的页面分布情况,诊断内存碎片。

三、Slab 分配器:从 Slab 到 SLUB

3.1 Slab 分配器演进

Linux 内核经历三代对象级缓存分配器:

  • Slab(Linux 2.1):最初实现,包含 full/free/partial 三链表
  • SLOB:极简设计,适用于嵌入式(ROM 设备)
  • SLUB(默认,Linux 2.6.23+):简化设计,消除 Per-CPU 队列锁竞争

3.2 SLUB 核心数据结构

// mm/slub.c
struct kmem_cache {
    struct kmem_cache_cpu __percpu *cpu_slab;  // Per-CPU 快速路径
    struct kmem_cache_node *node[MAX_NUMNODES]; // Per-Node 慢速路径
    unsigned int size;          // 对象实际大小(含对齐)
    unsigned int object_size;   // 用户请求的原始大小
    unsigned long flags;        // SLAB flags(如 SLAB_ACCOUNT)
    unsigned int align;         // 对齐要求
    const char *name;           // 缓存名(见于 /proc/slabinfo)
    // ...
};

struct kmem_cache_cpu {
    void **freelist;            // 空闲对象链表头指针
    unsigned long tid;          // 全局事务ID(无锁同步)
    struct page *page;          // 当前 CPU page slab
    struct page *partial;       // CPU partial 链表
};

3.3 分配与释放流程

快速路径(无锁 Per-CPU):

// 简化版分配逻辑
static __always_inline void *slab_alloc(struct kmem_cache *s, gfp_t gfp) {
redo:
    // 1. 检查 freelist
    void *object = s->cpu_slab->freelist;
    if (unlikely(!object))
        goto new_slab;         // 慢速路径
    
    // 2. CAS 原子替换 freelist(无锁)
    void *next = get_freepointer(s, object);
    if (likely(this_cmpxchg_double_pointer(...))) {
        return object;
    }
    goto redo;
}

Per-CPU 设计消除了多核间的锁竞争,简单分配仅需 5-10 条 CPU 指令。

3.4 Slab 调试接口

// 查看所有 slab 缓存
$ cat /proc/slabinfo
kmalloc-1k         24512  24768  1024    8    8    1   512
kmalloc-512        18432  18432   512   16   16    1   256
dentry             32768  31232   192   21   21    1   128
inode_cache        16384  15984   632    6    6    1  1024

// 列:名称 活跃对象数 总对象数 对象大小 每slab页数 slab数 缓存数

// 跟踪 slab 分配/释放
echo 1 > /sys/kernel/debug/slab/kmalloc-512/alloc_traces
echo 1 > /sys/kernel/debug/slab/kmalloc-512/free_traces

四、虚拟内存与页表管理

4.1 虚拟地址空间布局

x86_64 Linux 采用 48 位虚拟地址(4 级页表),布局如下:

0x0000 0000 0000 0000 ─┬─ 用户空间(128TB)
                        │    ├── [0]        保留(NULL指针保护)
                        │    ├── [.text]    代码段
                        │    ├── [.data/.bss] 数据段
                        │    ├── [heap]     brk 堆区(向高地址增长)
                        │    ├── [mmap]     共享库 / 匿名映射区
                        │    └── [stack]    栈区(向低地址增长,8MB默认)
0x0000 7FFF FFFF FFFF ─┤
                        ├─ 未映射区(64TB 空洞)
0xFFFF 8000 0000 0000 ─┤
                        ├─ 内核空间(128TB)
                        │    ├── [PAGE_OFFSET]  直接物理映射区
                        │    ├── [VMALLOC]      vmalloc 非连续区
                        │    ├── [MODULES]      内核模块区
                        │    └── [FIXMAP]       固定映射区
0xFFFF FFFF FFFF FFFF ─┘

4.2 四级/五级页表

Linux 6.x x86_64 支持 4 级页表(PML4→PDPT→PD→PT),Linux 6.5+ 引入 5 级(PML5,57 位地址,128PB 虚拟空间):

// 4级页表虚拟地址分解(48位)
┌──────┬──────┬──────┬──────┬──────────┐
│ PML4 │ PDPT │  PD  │  PT  │  OFFSET  │
│ 9bit │ 9bit │ 9bit │ 9bit │  12bit   │
└──────┴──────┴──────┴──────┴──────────┘
  39-47   30-38   21-29   12-20    0-11

// 5级页表虚拟地址分解(57位)
┌─────┬──────┬──────┬──────┬──────┬──────────┐
│PML5 │ PML4 │ PDPT │  PD  │  PT  │  OFFSET  │
│9bit │ 9bit │ 9bit │ 9bit │ 9bit │  12bit   │
└─────┴──────┴──────┴──────┴──────┴──────────┘
 48-56  39-47  30-38  21-29  12-20   0-11

4 级页表需 4 次内存访问完成地址转换,配合 TLB 缓存常用映射;5 级页表默认需要 5 次访问。内核通过 __pgtable_l5_enabled() 运行时检测。

4.3 页表加速:TLB 与 PCID

  • TLB:缓存最近使用的虚拟→现代映射,分指令 TLB 和数据 TLB,L1 通常 64 项,L2 可达 1536 项
  • PCID(Process Context IDentifier):避免进程切换时 TLB 全刷,Linux 4.14+ 默认开启 INVPCID 支持
  • HugePage(大页):2MB/1GB 大页减少 TLB miss,数据库场景提升 15-25%
// 配置透明大页
echo always > /sys/kernel/mm/transparent_hugepage/enabled

// 查看 TLB miss 统计
perf stat -e dTLB-load-misses,dTLB-store-misses ./your_database

五、vmalloc 与地址映射

5.1 vmalloc 非连续内存映射

当需要分配较大缓冲区但物理连续性不必要时,使用 vmalloc():

// mm/vmalloc.c
void *vmalloc(unsigned long size) {
    return __vmalloc_node_flags(size, NUMA_NO_NODE, GFP_KERNEL);
}

static void *__vmalloc_area_node(struct vm_struct *area, gfp_t gfp, int node) {
    // 1. 计算需要多少页
    unsigned long nr_pages = get_vm_area_size(area) >> PAGE_SHIFT;
    
    // 2. 分配虚拟内存区域(vm_area)
    area = __get_vm_area(size, VM_ALLOC, VMALLOC_START, VMALLOC_END);
    
    // 3. 逐页分配物理页面,建立页表映射
    for (i = 0; i < nr xss=removed>

5.2 vmap / vunmap

vmap() 将一组已存在的物理页映射到连续虚拟地址空间,适用于需要操作不连续物理页面的场景:

// 将分散的物理页映射到连续虚拟地址
struct page *pages[10];
for (i = 0; i < 10 xss=removed xss=removed>

六、内存回收与 kswapd

6.1 页面回收算法

Linux 采用 LRU(Least Recently Used)双链表算法回收页面,分为:

  • active list:活跃页面链表(最近被访问过)
  • inactive list:非活跃页面链表(Candidate for eviction)

页面首次加入 inactive,第二次访问时晋升到 active。当 active 链表尾部页面长时间未再访问,被降级到 inactive 尾部,最终被回收。

6.2 页面类型与回收代价

页面类型来源回收代价
Anonymous Pagemalloc/mmap匿名映射高(需写入swap)
File-backed Pagemmap文件、Page Cache低(丢弃或写回)
Slab Page内核对象缓存可回收(dentry、inode_cache)
HugePage大页分配不可部分回收

6.3 kswapd 守护进程

每个 NUMA 节点都有一个 kswapd 内核线程,周期性检查 vm_zone->_watermark:

// mm/vmscan.c
static int kswapd(void *p) {
    pg_data_t *pgdat = (pg_data_t*)p;
    
    while (!kthread_should_stop()) {
        // 1. 平衡区域水位(异步轻量扫描)
        balance_pgdat(pgdat, order, highest_zoneidx);
        
        // 2. 检查是否需要回收
        if (pgdat_balanced(pgdat, order, highest_zoneidx)) {
            wait_event_interruptible(pgdat->kswapd_wait,
                kswapd_should_run(pgdat));
            continue;
        }
        
        // 3. 执行直接回收或唤醒等待者
        pgdat_balanced(pgdat, order, highest_zoneidx);
    }
}

6.4 直接回收(Direct Reclaim)

当水位低于 min 且异步回收来不及,分配进程本身被卷入回收流程——这会导致严重的延迟。生产系统中常见 "kswapd0" 或分配进程进入 D 状态(Uninterruptible sleep):

// 诊断直接回收延迟
$ cat /proc/vmstat | grep allocstall
allocstall_dma 0
allocstall_dma32 12
allocstall_normal 89    ← 直接回收发生次数
allocstall_movable 156

七、OOM Killer

7.1 OOM 触发条件

当系统内存耗尽且所有回收手段均无法获取可用页面时,触发 OOM(Out of Memory):

  1. 所有 zone 低于 min 水位
  2. kswapd 无法回收足够页面
  3. 直接回收仍无法满足分配请求
  4. 调用 out_of_memory() 选择牺牲进程

7.2 oom_badness 评分算法

内核根据 oom_badness() 函数计算每个进程的"坏分值",分数越高越容易被 kill:

// mm/oom_kill.c
unsigned long oom_badness(struct task_struct *p, unsigned long totalpages) {
    // 1. 计算内存占用(RSS + swap + page_table + socket buffer)
    points = get_mm_rss(mm) + get_mm_counter(mm, MM_SWAPENTS)
           + mm_pages(mm);  // page tables pages
    
    // 2. 调整系数:CPU 时间开根号(长时间运行进程权重略降)
    adj = (long)p->signal->oom_score_adj * totalpages / 1000;
    points += adj;
    
    // 3. OOM_DISABLE 进程不参与评选
    if (p->signal->oom_score_adj == OOM_SCORE_ADJ_MIN)
        return 0;
    
    return points;
}

7.3 保护关键进程

// 保护进程不被 OOM kill(-1000 表示永久豁免)
echo -1000 > /proc/[pid]/oom_score_adj

// 降低某进程的优先级(更容易被牺牲)
echo 500 > /proc/[pid]/oom_score_adj

// systemd 方式:
[Service]
OOMPolicy=continue
OOMScoreAdjust=-500

八、内存 cgroup(memcg)

8.1 核心概念

内存 cgroup 实现容器级别的内存隔离与限制,关键参数:

  • memory.limit_in_bytes:硬限制(超过立即触发 OOM)
  • memory.soft_limit_in_bytes:软限制(全局内存充裕时允许超额)
  • memory.swappiness:单独控制 swap 倾向(0=禁用 swap)
  • memory.kmem.limit_in_bytes:内核内存限制(控制 slab、network buffer)

8.2 容器内存调优实战

// Docker 限制 1GB 内存
docker run --memory=1g --memory-swap=2g myapp

// Kubernetes 配置
resources:
  requests:
    memory: "512Mi"
  limits:
    memory: "1Gi"

// 诊断容器 OOM
dmesg | grep -i oom
# [189345.234] Memory cgroup out of memory: Killed process 12345 (java) total-vm:2097152kB, anon-rss:1048576kB

九、性能调优与生产实践

9.1 swappiness 调优

内核参数 vm.swappiness(默认 60)控制 swap 倾向:

  • 数据库推荐 1-10:优先回收 file cache,避免匿名页面被换出
  • 桌面系统 60:默认值,平衡 swap 与 cache
  • HPC/实时系统 0:禁用 swap(除非使用 zram)
# 数据库服务器优化
vm.swappiness = 1
vm.dirty_ratio = 15
vm.dirty_background_ratio = 5
vm.dirty_expire_centisecs = 500
vm.dirty_writeback_centisecs = 100

9.2 碎片整理(Memory Compaction)

针对大型块的连续物理内存碎片问题,Linux 提供两种机制:

  • 主动整理(/proc/sys/vm/compact_memory):重启时合并可迁移页
  • Proactive Compaction(Linux 5.9+):kcompactd 线程周期性触发
  • THP Compact Defrag:尝试为透明大页分配聚合碎片
// 查看碎片指数
$ cat /sys/kernel/debug/extfrag/extfrag_index

// 手动触发全局整理
echo 1 > /proc/sys/vm/compact_memory

// /proc/buddyinfo 观察连续内存块
cat /proc/buddyinfo
Node 0, zone  Normal    116    201   34   23    8    2    1    1    0    0    0

9.3 Zswap / Zram 压缩交换

对于内存受限场景,压缩 swap 比直接 swap 到磁盘更高效:

// Zswap:在内存中压缩缓存,必要时才写磁盘
echo 1 > /sys/module/zswap/enabled
echo zstd > /sys/module/zswap/compressor

// Zram:内存中的压缩块设备(推荐嵌入式)
modprobe zram num_devices=1
echo zstd > /sys/block/zram0/comp_algorithm
echo 2G > /sys/block/zram0/disksize
mkswap /dev/zram0
swapon /dev/zram0

9.4 内存热插拔

Linux 支持物理内存的在线添加/移除:

// 添加内存
echo online > /sys/devices/system/memory/memory42/state

// 离线内存
echo offline > /sys/devices/system/memory/memory42/state

// 迁移页面关键逻辑
// 1. 隔离页面(block page allocation on this block)
// 2. 迁移页面到目标节点
// 3. 移除该 block 的 struct page array

十、调试与诊断工具

10.1 vm_stat 与 /proc/meminfo

$ cat /proc/meminfo
MemTotal:       65765244 kB
MemFree:        12453232 kB
MemAvailable:   48291508 kB
Buffers:         1234567 kB
Cached:         34567890 kB
SwapTotal:       8388608 kB
SwapFree:        8123456 kB
HugePages_Total:     0
Hugepagesize:       2048 kB
DirectMap4k:    12345678 kB
DirectMap2M:    52428800 kB
Slab:            34567890 kB
SReclaimable:    2345678 kB
SUnreclaim:      1111111 kB

10.2 perf 内存分析

// 跟踪 kmalloc/kfree 调用栈
perf record -e kmem:kmalloc -e kmem:kfree -g -- your_app
perf report --sort=comm,dso,symbol

// 分析 TLB miss
perf stat -e dTLB-load-misses,iTLB-load-misses,page-faults ./app

// 使用 BPF 分析页面分配延迟
bpftrace -e 'kprobe:__alloc_pages_nodemask { @start[tid] = nsecs; }
             kretprobe:__alloc_pages_nodemask /@start[tid]/ {
               @lat_us = hist((nsecs - @start[tid]) / 1000);
               delete(@start[tid]);
             }'

10.3 Page Owner 追踪

// 编译时开启 CONFIG_PAGE_OWNER
echo 1 > /sys/kernel/debug/page_owner/on

// 查看每个页面的分配调用栈
cat /sys/kernel/debug/page_owner/sorted_pages

// 使用工具分析
./page_owner_sort page_owner.txt

十一、内核内存操作 API 总结

API用途GFP标志最大限制
kmalloc()小物件,物理连续GFP_KERNEL/GFP_ATOMIC4MB(架构相关)
kzalloc()kmalloc + 清零同上同上
kmem_cache_alloc()自定义slab缓存可指定节点2MB per object
vmalloc()大缓冲区,虚拟连续GFP_KERNEL几乎无限制
__get_free_page()单页分配各种一页
alloc_pages()多页,指定order可指定迁移类型1024页(4MB)
get_zeroed_page()分配已清零页同上一页

十二、总结

Linux 内核内存管理系统是一个经过 30+ 年演进的精密系统。理解它的关键在于:

  1. 物理层:伙伴系统管理物理页,MIGRATE_TYPES 减少碎片
  2. 缓存层:SLUB 为频繁分配的小对象提供 O(1) 性能
  3. 虚拟层:四级/五级页表映射虚拟→现代,TLB/PCID 加速访问
  4. 回收层:LRU 双链表 + kswapd 守护进程,水位线驱动回收
  5. 容器层:memcg 实现隔离与限制,保障多租户环境稳定性

生产系统中,内存调优的核心是观察 /proc/vmstat、perf TLB miss 指标、以及 dmesg 中的 OOM 日志,逐步调整 swappiness、dirty ratio、hugepage 配置。配合 eBPF 的 memleak 工具,可以长期追踪内存泄漏问题,构建健康的系统内存管理实践。

本文基于 Linux 6.6.x 内核源码分析,主要参考 mm/page_alloc.c、mm/slub.c、mm/vmscan.c、mm/vmalloc.c 等核心文件。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
0.352454s