Linux 内核内存管理深度实战:页分配器、SLUB 分配器与 OOM Killer 全链路剖析

内存管理是 Linux 内核最核心的子系统之一,它直接影响着系统的吞吐量、延迟和稳定性。从底层的 buddy system 页分配器,到上层的 SLUB slab 分配器,再到紧急情况下的 OOM Killer,整个内存管理栈涉及众多精巧的设计和复杂的生产级调优参数。本文将从内核源码、性能调优和故障排查三个维度,对 Linux 内存管理进行全方位的深度实战解析。


一、Buddy System 页分配器:物理内存的基石

1.1 核心思想与 free_area 结构

Linux 内核采用 Buddy(伙伴)系统来管理物理内存页。其核心思想是将内存划分为不同阶(order)的连续页面块,每个阶包含 2^order 个连续物理页。当需要分配内存时,从对应阶的空闲链表中取出一个块;如果该阶没有空闲块,则从更高的阶"分裂"出一个块,一半用于分配,另一半降阶放入低阶链表。

struct free_area 是 buddy system 的核心数据结构,在 include/linux/mmzone.h 中定义:

struct free_area {
    struct list_head free_list[MIGRATE_TYPES];
    unsigned long     nr_free;
};

每个内存区域(zone)内部按照 migrate type 进一步划分,这是 Linux 为解决碎片化问题引入的页面迁移机制。不同的 migrate type 将页面分为可移动、可回收、不可移动等类别,使得在内存碎片整理(compaction)时能够高效地迁移页面。

1.2 分配路径:从 fast path 到 slow path

页面分配遵循"先快后慢"的两级策略:

  • Fast path(快速路径):使用 per-CPU 的页面缓存(page allocator fastpath),从 pcp->count 充足时直接返回单个页面,无需加锁或访问 zone->lock。这一步可以分配 order-0 的页面。
  • Slow path(慢速路径):当 fast path 失败时,进入 __alloc_pages_slowpath(),可能触发直接回收(direct reclaim)、内存规整(compaction),甚至调用 OOM Killer。

对于高阶分配(order > 0),例如需要连续 4 个页面的 scatter-gather DMA,slow path 还需处理失败的回退策略:尝试降低分配阶、启用 reclaim、触发 compaction。

1.3 页面迁移类型与碎片避免

Linux 定义了以下核心迁移类型:

enum migratetype {
    MIGRATE_UNMOVABLE,   // 内核数据结构(不可移动)
    MIGRATE_MOVABLE,     // 用户页面(可移动)
    MIGRATE_RECLAIMABLE, // 可回收(文件缓存等)
    MIGRATE_PCPTYPES,    // per-cpu pageset 专用
    MIGRATE_HIGHATOMIC,  // 原子分配保留池
    MIGRATE_ISOLATE,     // 隔离用(不可分配)
    MIGRATE_TYPES
};

这种分类使得在 __alloc_pages_direct_compact() 阶段,可以高效地将可移动页面迁移到一起,腾出连续的物理空间。


二、SLUB 分配器:内核对象的高效分配

2.1 SLAB vs SLUB vs SLOB:演进之路

SLAB 分配器自 Linux 2.3 引入以来经历了三代演进:

  • SLAB:经典实现,每个 slab 包含多个对象,使用复杂的着色(coloring)机制减少缓存冲突。但管理结构开销大,适用于早期系统。
  • SLUB(Unqueued SLAB):自 2.6.23 成为默认分配器。去掉了 per-CPU 队列的复杂锁机制,每个 slab 只有一个 freelist,大大简化了实现并提升了 NUMA 扩展性。
  • SLOB:极简实现,适用于嵌入式系统(如早期的 Gumstix),使用简单的 first-fit 算法,适合内存小于 16MB 的设备。

2.2 SLUB 核心数据结构

SLUB 的设计核心在于将管理结构 inline 到页面中:

// include/linux/slub_def.h
struct kmem_cache {
    struct kmem_cache_cpu *cpu_slab;    // per-cpu 缓存指针
    unsigned long         flags;        // 对象对齐等标志
    unsigned long         min_partial;  // 最小 partial slab 保留数
    unsigned int          size;         // 对象大小(含元数据)
    unsigned int          object_size;  // 实际对象大小
    struct kmem_cache_node *node[MAX_NUMNODES]; // NUMA 节点
    
    const char            *name;        // 缓存名称(/proc/slabinfo 可见)
    struct list_head      list;         // 全局缓存列表
    
    unsigned int          offset;       // freelist 指针偏移
    unsigned int          allocflags;
    struct kobject        kobj;         // sysfs 接口
};

struct kmem_cache_cpu {
    void **freelist;        // 空闲对象链表头
    unsigned long tid;      // 全局事务 ID(防乱序)
    struct page *page;      // 当前使用的 slab 页面
    struct page *partial;   // per-cpu partial 列表
};

SLUB 分配路径中,首先尝试从 cpu_slab->freelist 直接取对象(fast path);如果失败,从 partial 列表获取;如果还不足,向 buddy system 申请新的 slab 页面。

2.3 kmem_cache_create 实战:自定义缓存

在编写内核模块或高性能驱动时,频繁分配同类型的结构体(如网卡驱动中的 sk_buff 自定义扩展)建议创建专用缓存:

// 模块初始化时创建缓存
static struct kmem_cache *my_obj_cache;

static int __init my_init(void)
{
    my_obj_cache = kmem_cache_create(
        "my_custom_objs",           // 缓存名称
        sizeof(struct my_object),   // 对象大小
        0,                          // 对齐(0 表示自然对齐)
        SLAB_HWCACHE_ALIGN,         // 硬件缓存对齐标志
        NULL                        // 构造函数(可为 NULL)
    );
    if (!my_obj_cache)
        return -ENOMEM;
    return 0;
}

// 使用缓存分配/释放
struct my_object *obj = kmem_cache_alloc(my_obj_cache, GFP_KERNEL);
// ... 使用 obj ...
kmem_cache_free(my_obj_cache, obj);

static void __exit my_exit(void)
{
    kmem_cache_destroy(my_obj_cache);
}

2.4 SLUB 调试:使用 slub_debug 排查 UAF

SLUB 内置了强大的调试选项 slub_debug,可以检测 Use-After-Free(UAF)和缓冲区溢出:

// 启动参数添加
slub_debug=FZP

// F - Freepointer check(释放后毒化 freelist 指针)
// Z - Red zoning(对象两端插入不可访问区域)
// P - Poison(释放时用 0x5a 填充对象)

在运行时动态跟踪某个缓存:

# 开启特定缓存的调试
echo 1 > /sys/kernel/slab/kmalloc-24/red_zone
echo 1 > /sys/kernel/slab/kmalloc-24/poison

# 查看分配情况
cat /proc/slabinfo | grep kmalloc
slabtop -o

三、vmalloc:大内存虚拟连续分配

3.1 vmalloc 的适用场景

当需要分配大量虚拟连续但物理可以不连续的内存时(如内核模块加载、io_uring 的 SQ/CQ 环、大页表映射等),使用 vmalloc()。与 kmalloc() 不同,vmalloc 只保证虚拟地址连续,物理地址可能离散。

3.2 vmalloc 的性能陷阱

由于 vmalloc 需要修改页表映射(建立新的 PTE 条目),每次分配/释放都需要 TLB shootdown,这在高 NUMA 系统上代价极高。典型场景:

// 每次 vmalloc 都可能触发 IPI TLB 无效化
void *buf = vmalloc(1 << 20);  // 1MB
// 使用 buf...
vfree(buf);  // 触发 TLB shootdown

优化建议:对于 io_uring 这类高频操作,优先使用 MAP_POPULATE 的 mmap 或预注册缓冲区(registered buffers),避免每次 I/O 都走 vmalloc。


四、OOM Killer:内存耗尽的最后防线

4.1 触发机制与 oom_badness 评分

当系统内存耗尽且所有回收手段失败时,内核调用 out_of_memory() 选择进程终止。选择算法基于 oom_badness() 函数,评分公式为:

// mm/oom_kill.c
unsigned long oom_badness(struct task_struct *p, unsigned long totalpages)
{
    long points;
    
    // 跳过正在_exit 的进程、内核线程、以及标记为 oom_score_adj 为 -1000 的进程
    if (!p->mm || p->flags & PF_KTHREAD || is_global_init(p))
        return 0;
    if (oom_unkillable_task(p))
        return 0;
    
    // 基础分数 = 进程占用内存页数 / 总页数 * 1000
    points = get_mm_rss(p->mm) + get_mm_counter(p->mm, MM_SWAPENTS) +
             mm_pgtables_bytes(p->mm) / PAGE_SIZE;
    
    // 乘上 oom_score_adj(范围 -1000 ~ 1000)
    points = points * 1000 / (unsigned long long)totalpoints;
    adj = (long)p->signal->oom_score_adj;
    if (adj == OOM_SCORE_ADJ_MIN)
        return 0;
    
    points += adj;
    return points > 0 ? points : 1;
}

4.2 保护关键服务:oom_score_adj

在生产环境中,为了避免关键进程被 OOM Killer 选中,可以通过设置 /proc/[pid]/oom_score_adj 来调整进程的 OOM 评分。值为 -1000 表示永远不会被选中:

# 保护数据库服务(假设 PID 为 1234)
echo -800 > /proc/1234/oom_score_adj

# 对于 systemd 管理的进程,使用 OOMPolicy
# /etc/systemd/system/mysql.service.d/oom.conf
[Service]
OOMPolicy=stop
OOMScoreAdjust=-800

4.3 容器环境的 OOM:cgroup v2 memory.max

在容器化场景下,Docker/Kubernetes 使用 cgroup v2 的 memory.max 限制内存。当容器内存超过阈值时:

  • cgroup v1:触发容器内 OOM Killer(选择容器内评分最高的进程)
  • cgroup v2:更精确的 memory.peak 监控和 memory.high 软限触发先发回收
# 容器的 cgroup 内存统计
cat /sys/fs/cgroup/memory/docker/<container>/memory.stat

# 关键字段:
#anon - 匿名页(堆内存)
#file - 文件缓存
#kernel_stack - 内核栈内存
#pagetables - 页表内存
#slab_unreclaimable - 不可回收 slab

4.4 OOM 日志解读实战

典型的 OOM Killer 日志如下:

[123456.789] Out of memory: Killed process 12345 (java)
    total-vm:8192000kB, anon-rss:3892000kB, file-rss:0kB,
    shmem-rss:0kB, UID:1000, oom_score_adj:0

关键信息解读:

  • total-vm:进程虚拟地址空间总量
  • anon-rss:匿名页驻留集大小(堆+栈)
  • file-rss:文件映射驻留集
  • oom_score_adj:OOM 评分调整值

五、生产环境调优实战

5.1 透明大页(THP)的取舍

Transparent Huge Pages 使用 2MB 大页代替 4KB 小页,减少 TLB Miss,提升内存密集型应用性能。但可能造成内存碎片化和延迟抖动:

# 查看当前 THP 策略
cat /sys/kernel/mm/transparent_hugepage/enabled
# [always] madvise never  表示默认开启

# 对于数据库场景(MySQL/PostgreSQL),建议关闭 THP
echo never > /sys/kernel/mm/transparent_hugepage/enabled
echo never > /sys/kernel/mm/transparent_hugepage/defrag

# 对于大内存 Java 应用,使用 madvise(仅对标记 MADV_HUGEPAGE 的区域启用)
echo madvise > /sys/kernel/mm/transparent_hugepage/enabled

5.2 swappiness 与页面回收策略

vm.swappiness 控制系统在内存压力下倾向于回收匿名页(swap)还是文件页(page cache):

# 默认值 60(平衡倾向)
# 0 = 禁用 swap(除非内存极低)
# 100 = 积极 swap

# 对于 SSD 数据库服务器,降低 swappiness 优先回收 page cache
echo 10 > /proc/sys/vm/swappiness

# 对于计算密集型(无 IO 压力),设为 0
echo 0 > /proc/sys/vm/swappiness

5.3 内存超售与 overcommit 策略

Linux 默认启用内存 overcommit(vm.overcommit_memory = 0),允许分配超过物理内存+交换空间的总和,依赖 OOM Killer 在内存耗尽时收拾残局:

# 策略 0(默认):启发式 overcommit
# 策略 1:总是 overcommit(适合科学计算,内存从不用满的场景)
# 策略 2:不允许超过 overcommit_ratio(严格模式)

# 生产环境推荐策略 2(配合合理的 overcommit_ratio)
echo 2 > /proc/sys/vm/overcommit_memory
echo 80 > /proc/sys/vm/overcommit_ratio  # 允许分配物理内存的 80%

5.4 NUMA 感知内存分配

在 NUMA 架构服务器上,跨节点访问内存的延迟可达本地节点的 2-3 倍。内核通过 zonelist 控制分配顺序:

# 查看 NUMA 拓扑
numactl --hardware

# 查看进程内存在各 NUMA 节点的分布
numastat -p <PID>

# 将进程绑定到 NUMA 节点 0,优先使用本地内存
numactl --cpunodebind=0 --membind=0 ./my_app

# 使用 mbind() 系统调用在代码中精细控制
set_mempolicy(MPOL_BIND, &nodemask, MAXNODE, addr, len);

六、故障排查工具箱

6.1 排查内存泄漏:slabtop 与 /proc/meminfo

# 实时监控 slab 使用情况(按大小排序)
slabtop -s c

# 查看 /proc/meminfo 关键指标
cat /proc/meminfo | grep -E "MemTotal|MemFree|MemAvailable|Buffers|Cached|Slab|PageTables|VmallocTotal"

# 使用 pahole 查看内核结构体大小
pahole task_struct

6.2 页面错误分析:perf probe

# 跟踪 minor/major page fault 频率
perf stat -e page-faults,minor-faults,major-faults -p <PID> sleep 10

# 跟踪 page_fault 内核函数的调用栈
perf probe --add handle_mm_fault
perf record -e probe:handle_mm_fault -a sleep 30
perf script

6.3 直接回收延迟:使用 bpftrace/eBPF

#!/usr/bin/env bpftrace

// 跟踪 direct reclaim 的开始和结束,计算延迟
kprobe:__alloc_pages_direct_compact {
    printf("compact: pid=%d comm=%s gfp=%x order=%d\n",
           pid, curtask->comm, arg3, arg2);
}

kprobe:direct_reclaim_begin {
    @start[tid] = nsecs;
}

kprobe:direct_reclaim_end /@start[tid]/ {
    $duration = nsecs - @start[tid];
    printf("reclaim: pid=%d comm=%s duration=%d ms reclaimed=%lu pages\n",
           pid, curtask->comm, $duration / 1000000, arg1);
    delete(@start[tid]);
}

6.4 compaction 效果评估

# 查看 compaction 统计
cat /proc/vmstat | grep -E "compact|compact_
# compact_migrate_scanned - 扫描的可迁移页数
# compact_free_scanned   - 扫描的空闲页数
# compact_isolate        - 隔离的页面数
# compact_fail           - 碎片整理失败次数
# compact_success        - 碎片整理成功次数

# 手动触发 compaction
echo 1 > /proc/sys/vm/compact_memory

七、内存管理的未来演进

Linux 内存管理正朝着几个方向持续演进:

  • CMT(Coupled Memory Tiering):自动在 DRAM/CXL/PMEM 之间迁移热/冷页面
  • MGLRU(Multi-Gen LRU):替代传统 active/inactive 双链表,实现更精确的回收算法
  • MSEAL(Memory Sealing):system call 层面提供 VMA 不可变映射支持
  • Rust for Linux 内存安全:用 Rust 重写部分内存管理代码路径,减少 UAF、溢出等安全问题
  • Folio 大页抽象:统一 compound page 和 head page 的表示,简化 HugeTLB 管理

总结

Linux 内核的内存管理是一个庞大而精密的系统:底层 buddy system 管理物理页、SLUB slab 分配器优化对象分配、OOM Killer 做为最后防线、THP 和大页提升 TLB 命中率、NUMA 策略优化跨节点性能。深入理解这些机制,才能在生产环境中做出正确的调优决策,避免"内存足够但系统卡顿"的陷阱。

对于后端工程师来说,重点掌握 /proc/meminfo 解读、slabtop 监控、OOM 评分调优、THP 策略选择四项基础技能,就能覆盖绝大多数内存相关故障场景。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿
网站二维码

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部