Linux 内核内存管理深度实战:从伙伴系统到 Slab 分配器与 NUMA 优化

Linux 内核的内存管理子系统是整个系统性能的核心基石。从物理页面的分配到内核对象的缓存,从 NUMA 拓扑感知到 OOM 紧急回收,每一个机制都经过数十年的演进与打磨。本文将深入剖析这些核心机制,并结合生产环境的调优案例,帮助读者构建完整的知识体系。

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

1.1 物理内存的组织方式

Linux 内核将物理内存组织为 节点 (Node) → 区域 (Zone) → 页面 (Page) 的三层结构。在 64 位系统中,虽然不再需要 HIGHMem,但 ZONE 的概念仍然保留用于 DMA 兼容。

// 核心数据结构 (include/linux/mmzone.h)
struct pglist_data {
    struct zone node_zones[MAX_NR_ZONES];    // 节点的内存区域
    struct zonelist node_zonelists[MAX_ZONELISTS]; // 分配回退列表
    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;                              // NUMA 节点 ID
    // ...
};

1.2 ZONE 划分与用途

ZONE 类型 说明 x86_64 典型范围
ZONE_DMA 兼容老设备 ISA DMA 0-16MB
ZONE_DMA32 32位 DMA 设备 16MB-4GB
ZONE_NORMAL 直接映射到内核空间 16MB->896MB
ZONE_HIGHMEM 超出直接映射的区域 896MB+ (仅32位)
ZONE_MOVABLE 可移动页面(热插拔) 动态分配

在 64 位 x86_64 系统中,由于虚拟地址空间极其庞大(128TB 内核空间),ZONE_HIGHMEM 不再存在:

physical memory layout (x86_64):
┌─────────────────────────────────────────────────────────┐
│  ZONE_DMA   │  ZONE_DMA32   │     ZONE_NORMAL           │
│  0 - 16MB   │  16MB - 4GB   │  4GB - max                │
└─────────────────────────────────────────────────────────┘
全部线性映射到内核虚拟地址空间 (PAGE_OFFSET = 0xffff888000000000)

1.3 页面描述符 struct page

系统中的每个物理页面都有一个对应的 struct page 描述符,这是内核内存管理最基本的原子单元:

struct page {
    unsigned long flags;        // 页面状态标志 (PG_l PG_active, PG_dirty 等)

    union {
        struct address_space *mapping;  // 页面映射信息
        void *s_mem;                    // slab 第一个对象指针
    };

    pgoff_t index;              // 在映射中的偏移
    unsigned long private;      // 私有数据指针

    atomic_t _refcount;         // 引用计数
    atomic_t _mapcount;         // 映射计数

    // 链表节点:伙伴系统的 free_list / LRU 链表 / slab 链表
    struct {
        union {
            struct list_head lru;
            struct list_head slab_list;
        };
        struct { 
            unsigned long val; // union for buddy system
        };
    };
};

二、伙伴系统 (Buddy System) 深度剖析

2.1 算法原理

伙伴系统是内核物理页面分配的核心算法,由 Knowlton 于 1965 年提出。其核心思想是将可用内存分成不同 阶 (order) 的块组(order-N 块包含 2^N 个连续页面),分配时向上拆分,释放时与"伙伴"合并。

// mm/page_alloc.c 中的核心实现
struct free_area {
    struct free_list free_list[MIGRATETYPE_TYPES]; // 按迁移类型分类
    unsigned long nr_free;                         // 空闲页面总数
};

static inline struct page *__rmqueue(struct zone *zone, unsigned int order,
                                      int migratetype)
{
    struct page *page;

    // 从请求阶开始向上查找
    for (current_order = order; current_order < MAX_ORDER; ++current_order) {
        area = &(zone->free_area[current_order]);
        page = get_page_from_free_area(area, migratetype);
        if (!page)
            continue;

        // 找到了大块,需要拆分(剥洋葱)
        expand(zone, page, order, current_order, migratetype);
        set_pcppage_migratetype(page, migratetype);
        return page;
    }

    // 没有足够大的块,尝试直接回收/压缩
    return NULL;
}

2.2 伙伴关系的判定规则

两个页面块成为"伙伴"需要同时满足三个条件:

  1. 大小相同:都是 order-N 块
  2. 物理连续:地址首尾相接
  3. 对齐:第 N 个块的起始地址必须是 2^(N+PAGE_SIZE) 的整数倍
伙伴合并示意 (4页 = order-2 块):

  Page[0] Page[1] Page[2] Page[3] ── Order-2 Block A
  Page[4] Page[5] Page[6] Page[7] ── Order-2 Block B
  ↑
  Block A 的伙伴 = Block B (当 Page[0].number % 8 == 0)
  Block B 的伙伴 = Block A (当 Page[4].number % 8 == 4)

  伙伴关系的数学判定: 
  伙伴_pfn = pfn ^ (1 << order)
  即两个伙伴块只在第 order 位不同

2.3 迁移类型碎片控制 (Anti-Fragmentation)

伙伴系统长期运行后最大的问题是 外部碎片 ——空闲页面不连续,导致无法分配大块。内核引入了 页面迁移类型 分类来解决:

迁移类型 含义 回收特性
MIGRATE_UNMOVABLE 内核核心对象,无法迁移 几乎不回收
MIGRATE_MOVABLE 用户页面、可随意分配 容易回收
MIGRATE_RECLAIMABLE slab、kernel cache 可收缩
MIGRATE_ISOLATE 预留/热插拔 隔离
MIGRATE_CMA 连续内存分配器 设备专用

通过将可移动对象分组,使得回收后能够形成大块连续内存。PAGE_ALLOC_COSTLY_ORDER (order-3, 32KB) 以上的大块分配请求会主动触发内存压缩 (compact)。

2.4 per-cpu 页面缓存 (PCP)

为减少锁竞争,每个 CPU 维护一个本地单页缓存队列:

struct zone {
    struct per_cpu_pages __percpu *per_cpu_pageset;  // PCP 缓存
    // ...
};

struct per_cpu_pages {
    int count;                // 本地缓存页面数
    int high;                 // 高于此值时归还伙伴系统
    int batch;                // 每次添加的批量
    struct list_head lists[MIGRATETYPE_TYPES];  // 按迁移类型分组的链表
};

PCP 的工作模式类似 L1 缓存:从伙伴系统批量填充到本地缓存,分配时优先从本地获取,释放时优先归还本地缓存。

2.5 分配路径全景图

alloc_pages(gfp_mask, order)
│
├─→ alloc_pages_current()
│   │
│   ├─→ prepare_alloc_pages()     // 校验参数,计算 nodemask
│   │
│   ├─→ get_page_from_pcp_list()  // 优先 PCP 快速路径
│   │
│   └─→ __alloc_pages_nodemask() // 慢速路径入口
│        │
│        ├─→ get_page_from_freelist()  // 直接分配
│        │   ├─→ node_reclaim()        // 尝试本地回收
│        │   └─→ __rmqueue()           // 从伙伴系统取块
│        │
│        ├─→ __alloc_pages_slowpath()  // 慢速分配
│        │   ├─→ gfp_to_alloc_flags()  // 转换 GFP 标志
│        │   ├─→ direct_reclaim()      // 直接页面回收
│        │   │   ├─→ shrink_node()
│        │   │   │   ├─→ shrink_lruvec()      // LRU 链表回收
│        │   │   │   └─→ shrink_slab()        // slab 缓存回收
│        │   │   └─→ compaction              // 内存压缩
│        │   ├─→ oom_kill_process()       // 最终 OOM
│        │   └─→ retry allocation loop
│        │
│        └─→ page = __alloc_pages()
│
└─→ 返回分配的页面或触发 OOM

三、Slab 分配器家族深度对比

3.1 Slab 的设计哲学

伙伴系统以 页 (4KB) 为粒度分配,但内核中大量对象的尺寸远小于一页(如 task_struct ~8KB 算大的,dentry ~200B,inode ~500B)。为减少内部碎片和 allocation/free 开销,引入了 Slab 分配器作为二级分配器。

Slab 的核心思想是 批量预分配 + CPU缓存:从伙伴系统获取整页,划分为多份等大对象,通过 free list 管理空闲对象指针。

3.2 SLAB (经典实现)

┌─────────────────────────────────────────────────────────────┐
│                    kmem_cache (对象缓存池)                    │
│  ┌───────────────────────────────────────────────────────┐  │
│  │ slabs_partial    slabs_full     slabs_free             │  │
│  │  (部分占用链表)   (全满链表)    (空闲链表)              │  │
│  └───────────────────────────────────────────────────────┘  │
│  objsize = 256B, num = 16 objects/page                      │
│  ┌──────────┐ ┌──────────┐ ┌──────────┐                    │
│  │ slab[0]  │ │ slab[1]  │ │ slab[2]  │ ...               │
│  │ 16 obj   │ │ 16 obj   │ │ 16 obj   │                     │
│  └──────────┘ └──────────┘ └──────────┘                    │
└─────────────────────────────────────────────────────────────┘

SLAB 每个 slab 维护:
  ├─ bufctl[]  : 空闲对象索引数组 (嵌入式)
  ├─ slab_list: 链入 cache 的 partial/full/free 链表
  ├─ inuse     : 当前已使用数量
  └─ free      : 首个空闲对象的索引(freelist head)

SLAB 的优点是实现清晰,但存在以下问题: - 每个 slab 需要额外的 bufctl 数组和着色偏移 - 链表管理复杂度高 - NUMA 支持不够灵活 - slab metadata 本身占用大量内存

3.3 SLUB (Unqueued Slab) — 当前默认

SLUB 是对 SLAB 的彻底简化,是现今 Linux 内核默认的 Slab 实现。核心改进:嵌入式 freelist + 去队列 + 原生 NUMA。

// SLUB 的核心对象管理 (mm/slub.c)
struct kmem_cache {
    struct kmem_cache_cpu __percpu *cpu_slab;  // CPU 本地 slab
    struct kmem_cache_node *node[MAX_NUMNODES]; // NUMA 节点级

    unsigned int offset;        // 空闲指针嵌入在对象内的偏移
    unsigned int object_size;   // 对象实际大小
    unsigned int size;          // 含元数据的总大小
    unsigned int order;         // 每个 slab 的页面阶
    void (*ctor)(void *);       // 对象构造函数
};

struct kmem_cache_cpu {
    void **freelist;   // 空闲对象指针链表 (头插法)
    struct page *page; // 当前使用的 slab
    struct page *partial; // 部分满 slab(仅配置了 CONFIG_SLUB_CPU_PARTIAL)
};

SLUB 的分配流程:

kmem_cache_alloc(cachep)
│
├─→ __kmem_cache_alloc()
│    │
│    └─→ slab_alloc_node()
│         │
│         ├─→ CPU freelist 不为空 → 直接 pop 一个对象 → 返回 (快速路径 ~5ns)
│         │
│         ├─→ CPU page 的 partial 补充 freelist
│         │
│         ├─→ CPU partial 不为空 → 切到新 page
│         │
│         ├─→ Node partial 不为空 → 交叉分配
│         │
│         └─→ new_slab() 从伙伴系统申请新页面
│
时间复杂度 (快速路径): O(1) ~ 5ns
时间复杂度 (慢速路径): 需要内存回收时 O(n) 但不常见

SLUB 的关键优化点:

  • 空闲指针嵌入:释放对象时直接将 freelist 指针写入对象内存,无需额外 metadata
  • CPU 本地优先:hot-path 完全无锁,每个 CPU 自有 freelist 和当前 page
  • 着色 (Colouring):通过在 cache line 级别偏移 slab 内的对象分布,减少 CPU cache 冲突
  • CPU partial 链表:减少跨节点分配的开销
  • 合并同类:创建新 cache 时会检查已有相似大小的 cache,通过 kmem_cache 合并减少碎片

3.4 SLOB — 极简实现

SLOB (Simple List of Blocks) 专为嵌入式系统设计,使用首次适应 (First-Fit) 算法管理页面碎片,代码量极小。但在较大内存碎片化系统上性能极差,已逐渐退出主线。

3.5 三大分配器性能对比

特性 SLAB SLUB SLOB
默认内核版本 2.6 早期 2.6.23 至今 嵌入式/特殊场景
代码复杂度 高 (~15K行) 中 (~10K行) 低 (~3K行)
分配延迟 ~20ns ~5ns ~50-200ns
内存开销 高(metadata多) 中 低
NUMA 支持 一般 优秀 无
调试功能 有限 SLUB_DEBUG 强 无
CPU 缓存亲和 良好 优秀 差

四、NUMA 内存分配策略

4.1 NUMA 拓扑基础

在现代多路服务器上,内存控制器分属不同 CPU Socket,形成非一致内存访问 (NUMA) 拓扑:

双路服务器 NUMA 拓扑:

  Node 0 (Socket 0)          Node 1 (Socket 1)
  ┌──────────────────┐       ┌──────────────────┐
  │ CPU 0-31        │       │ CPU 32-63        │
  │ Local Memory    │       │ Local Memory     │
  │    256GB        │       │    256GB         │
  └──────────────────┘       └──────────────────┘
        ↕                          ↕
  Local Access: 80ns       Local Access: 80ns
  Remote Access: 140ns     Remote Access: 140ns
  (通过 UPI/QPI 总线访问对方内存)

远程访问的延迟通常是本地的 1.5-2 倍,带宽也受限于互联总线。

4.2 Linux NUMA 分配策略

Linux 提供多种 NUMA 策略,通过 set_mempolicy() 或 mbind() 设置:

策略 说明 典型场景
MPOL_DEFAULT 节点本地优先,fallback 到 zonelist 大多数内核分配
MPOL_PREFERRED 首选某节点,本地无可用时回退 偏向但可容忍远程
MPOL_BIND 严格限定在指定节点集 实时/数据库
MPOL_INTERLEAVE 在节点间交叉分配 (round-robin) 超大内存数据库
// NUMA 调用的关键函数 (mm/mempolicy.c)
static struct page *alloc_pages_vma(gfp_t gfp, int order,
    struct vm_area_struct *vma, unsigned long addr, int node, bool hugepage)
{
    struct mempolicy *pol = get_vma_policy(vma, addr);
    // 根据 policy 决定从哪个 Node 分配
    switch (pol->mode) {
    case MPOL_INTERLEAVE:
        page = alloc_page_interleave(gfp, order, interleave_nid(pol));
        break;
    case MPOL_PREFERRED:
        page = __alloc_pages_nodemask(gfp, order, 
                preferred_node_zonelist(pol), nodemask);
        break;
    case MPOL_BIND:
        page = __alloc_pages(gfp, order, policy_nodemask(pol, &nodes));
        break;
    default:
        page = __alloc_pages_node(node, gfp, order);
    }
}

4.3 NUMA Balancing 自动均衡

Linux 4.7+ 引入了自动 NUMA 页面迁移,内核通过采样发现远程访问热点后,将页面迁移到访问它的 CPU 本地节点:

# 查看 NUMA 统计信息
numastat -c    # 每个进程的本地/远程比例
numactl --hardware   # 查看拓扑
numactl --interleave=all ./app   # 交叉分配模式

# 开启/关闭 NUMA Balancing
echo 1 > /proc/sys/kernel/numa_balancing      # 开启自动均衡
echo 0 > /proc/sys/kernel/numa_balancing      # 关闭

# 查看某进程的 NUMA 粒度和扫描设置
cat /proc/<pid>/numa_maps

NUMA Balancing 的工作流程:

页面扫描流程 (每隔 numa_scan_period):

  1. NUMA hinting fault: 
     CPU 访问远程节点页面 → 触发 minor fault → 记录该页面

  2. 页面迁移线程 (khugepaged 类似的内核线程):
     numa_migrate_page()
       ├─ 检查页面热度
       ├─ 迁移热页到本地节点
       └─ 冷页迁移到远程节点或保持原位

  3. 两层扫描:
     第一层: 两次访问间隔 < numa_scan_delay → 冷页
     第二层: 连续访问 → 标记热页并迁移

五、页面回收与 Swap 机制

5.1 LRU 算法内核实现

内核维护两个 LRU 链表:Anonymous (匿名页) 和 File-backed (文件缓存页)。每种分活跃/非活跃两对:

enum lru_list {
    LRU_INACTIVE_ANON = 0,    // 不活跃匿名页 (最先回收)
    LRU_ACTIVE_ANON = 1,      // 活跃匿名页
    LRU_INACTIVE_FILE = 2,    // 不活跃文件缓存页
    LRU_ACTIVE_FILE = 3,      // 活跃文件缓存页
    LRU_ISOLATED_ANON = 4,    // 临时隔离
    LRU_ISOLATED_FILE = 5,    // 临时隔离
    NR_LRU_LISTS = 6
};

struct lruvec {
    struct list_head lists[NR_LRU_LISTS];
    unsigned long nr_pages[NR_LRU_LISTS];
    // ...
};

页面在不同链表间移动的规则:

首次分配/访问:
  ┌─ 页面进入 Inactive List (Cold)
  │
再访问 (硬件 PTE Access Bit 被置位):
  ├─ 页面提升至 Active List (Warm/Hot)
  │
长时间未访问 (老化):
  └─ 从 Active 降级到 Inactive

回收顺序:
  Inactive Anon → Inactive File → Active Anon → Active File

  (匿名页需要写入 swap,回收代价更高,但通常也更可能被回收)

PTE 扫描机制:
  1. shrink_page_list() 检查 PTE 的 Accessed 位
  2. Accessed=1 → 清除位,move_to_active (给页面第二次机会)
  3. Accessed=0 → 该页面已在 Inactive 足够久 → 可以回收

5.2 Swap 机制

当非活跃匿名页需要被回收且无法被丢弃时(因为它是私有的、脏的匿名数据),内核将其写入 swap 分区:

swap_entry_t 编码 (include/linux/swap.h):
  ┌──────────┬───────────────────────────────────┐
  │ type(6b) │            offset(57b)            │
  │ swap类型 │ 该swap设备上的slot偏移           │
  └──────────┴───────────────────────────────────┘

写入流程:
  page → add_to_swap() → get_swap_page() → writepage() → swap slot

O(1) swap 算法:
  传统的 map-based swap 在大量 swap slot 时消耗大量内存
  (1TB swap = 2M entries × 4B = 8MB map, 注意每个 swap slot 的搜索复杂度)

Linux 使用 swap cache + swap_info_struct:
  - swap cache: 仍保留在内存中的 page cache 节点
  - cluster: 将多个连续 slot 组成 cluster 分配,提高顺序 I/O

Swap 读取流程 (swapin):
  do_page_fault() → PTE 指向 swap entry →
  lookup_swap_cache() → 若在缓存直接返回 →
  否则 swapin_readahead() 直接读取 swap

5.3 直接回收与背景回收

回收触发方式:

1. 直接回收 (Direct Reclaim):
   当分配时发现水位低于 min → 同步触发的回收
   延迟大,调用者必须等待
   控制在 throttle_vm_writeout() 范围

2. kswapd (背景回收守护进程):
   每个 NUMA  Node 运行一个 kswapd<k>
   当 free_pages < high_wakeup 时被唤醒
   回收到 free_pages > high 后休眠
   配置: /proc/sys/vm/min_free_kbytes 决定水位

3. memcg 回收:
   当 cgroup 内存触及 limit 时触发
   由 memcg 内部的 kswapd 或同步回收处理

六、OOM Killer 深度解析

6.1 Out-Of-Memory 判定

在内核 5.x+ 中,out_of_memory() 在伙伴系统无法分配连续页并且直接回收也失败时被调用。核心判定条件:

// mm/oom_kill.c
bool out_of_memory(struct oom_control *oc)
{
    // 1. 检查是否仍有可分配空间 (从高到低尝试)
    // 2. 尝试 __alloc_pages_may_oom()  
    // 3. 检查 task 是否正在 oom 处理中

    if (can_oom_reap(oc)) {
        // 先尝试 OOM Reaper (5.0+)
        // 无需杀死进程,直接回收其匿名页 (仅 MADV_FREE 标记的)
        out_of_memory_reap(tsk);
    }

    // 真正的 OOM 杀手:
    select_bad_process(oc);   // 选择受害者
    oom_kill_process(oc);     // 杀死目标
}

6.2 OOM Score 计算算法

oom_badness() 的任务是给每个进程打分,分数最高的被杀死:

// 分数的五个计算维度:
unsigned long oom_badness(struct task_struct *p, unsigned long totalpages)
{
    long points;

    // 1. 基础分数: 该进程占用的物理内存 / 系统总内存 (归一化到 0-1000)
    points = get_mm_rss(p->mm) + get_mm_counter(p->mm, MM_SWAPENTS) + 
             mm_pgtables_bytes(p->mm) / PAGE_SIZE;
    points = points * 1000 / totalpages;

    // 2. oom_score_adj 调整 (用户可写)
    adj = (long)p->signal->oom_score_adj;
    if (adj == OOM_SCORE_ADJ_MIN) points = 0;  // 保护进程
    else points += adj;  // 范围 ±1000

    // 3. 特殊惩罚/保护:
    //    - init/systemd 不杀 (adj = -1000)
    //    - CAP_SYS_ADMIN 进程通常得到保护
    //    - 新进程 (< 1s) 较难选中

    return points > 0 ? points : 1;
}

查看进程的 OOM 打分:

# 查看各进程的 oom_score 和 oom_score_adj
ps aux | awk '{print $2}' | xargs -I{} cat /proc/{}/oom_score /proc/{}/oom_score_adj

# 手动触发 OOM (仅用于测试容器)
echo f > /proc/sysrq-trigger
dmesg | grep -i "killed process"

6.3 OOM 对容器的影响

容器环境 (Docker/K8s) 下的 OOM 是日常运维中最常遇到的内存问题之一:

容器 OOM  两种场景:

1. Container OOM (cgroup 内存超限):
   - 触发的 killer 是 cgroup 内部的 oom killer
   - 由 memory.max (cgroup v2) 或 memory.limit_in_bytes (cgroup v1) 限定
   - dmesg 显示: "Memory cgroup out of memory: Killed process XXX"
   - K8s 中 Pod 变为 OOMKilled 状态

2. Host OOM (物理内存不足):
   - 宿主机的全局 OOM killer 触发
   - 在所有进程(含容器)中选择得分最高者
   - 生产环境极度致命,需通过保留 memory (kubelet --system-reserved) 防护

防护策略:

# 1. 在宿主机上保留内存给系统和 kubelet
kubelet --system-reserved=cpu=500m,memory=1Gi \
        --kube-reserved=cpu=200m,memory=512Mi \
        --eviction-hard="memory.available<500Mi"

# 2. 设置关键进程的 OOM 保护
echo -1000 > /proc/<pid>/oom_score_adj

# 3. 使用 memory.min (cgroup v2) 硬保护
echo 536870912 > /sys/fs/cgroup/my-cgroup/memory.min

# 4. 启用 earlyoom/systemd-oomd 提前介入 (避免系统卡死)
apt install earlyoom && systemctl enable --now earlyoom

七、高级内存管理特性

7.1 KSM (Kernel Samepage Merging)

KSM (Kernel Samepage Merging) 是内核的去重机制,通过扫描页面内容,将内容相同的页面合并为一份只读副本:

# 启用 KSM (常见于 KVM 虚拟化环境)
echo 1 > /sys/kernel/mm/ksm/run
echo 1000 > /sys/kernel/mm/ksm/pages_to_scan    # 每次扫描页数
echo 20 > /sys/kernel/mm/ksm/sleep_millisecs    # 扫描间隔

# KSM 节省的内存量
cat /sys/kernel/mm/ksm/pages_sharing   # 节省的页面数
cat /sys/kernel/mm/ksm/pages_shared    # 当前共享的页面数

KSM 的工作流程:

ksmd (KSM 守护线程):
  ├─ 扫描注册了 MADV_MERGEABLE 的 VMA
  ├─ 对每个候选页面:
  │   ├─ 计算 checksum/content hash
  │   ├─ 在 KSM 红黑树中查找相同内容
  │   ├─ 若找到 → 合并: PTE 指向稳定树中的只读页面
  │   │   └─ 原页面引用计数减一, 若为0 → 归还伙伴系统
  │   └─ 若未找到 → 插入红黑树
  │
  └─ 写时复制: 对共享页面的任何写入操作 → COW 拆分回独享页面

使用场景:
  - KVM 虚拟化: 合并不同 VM 之间的相同内核页面
  - 容器平台: 合并相同基础镜像的只读层
  - 代价: CPU 开销换内存

7.2 Transparent Huge Pages (THP)

THP 是内核自动将连续的小页面合并为大页面的机制,减少 TLB 压力:

# THP 模式配置
echo always > /sys/kernel/mm/transparent_hugepage/enabled   # 始终开启
echo madvise > /sys/kernel/mm/transparent_hugepage/enabled  # 按需 (默认)
echo never > /sys/kernel/mm/transparent_hugepage/enabled    # 关闭

# 碎片整理策略
echo defer > /sys/kernel/mm/transparent_hugepage/defrag
echo defer+madvise > /sys/kernel/mm/transparent_hugepage/defrag

# 特定应用通过 madvise 使用
madvise(ptr, length, MADV_HUGEPAGE);   // 建议合并
madvise(ptr, length, MADV_NOHUGEPAGE); // 不建议合并

THP 在数据库场景的注意事项:

数据库使用 THP 的双刃剑效应:

优点:
  ├─ TLB miss 减少 8 倍 (2MB vs 4KB)
  ├─ 减少 page table 内存占用
  ├─ 减少 page fault 次数
  └─ 顺序访问性能提升显著

缺点:
  ├─ 碎片化导致分配延迟毛刺
  ├─ compaction 消耗 CPU
  ├─ 内存浪费 (访问小块时使用整 2MB)
  └─ 小内存操作时反而降低性能

SRE 建议:
  - MySQL/Oracle: 关闭 THP,使用显式 hugepages
  - MongoDB: 视 workload 而定
  - Redis: 关闭 TH般无影响 (fork 时 COW 少)
  - PostgreSQL: 默认关闭

7.3 CMA (Contiguous Memory Allocator)

CMA 为大块连续物理内存需求预留的可移动区域,常用于 GPU/V4L2:

# Boot 参数配置
cma=256M@0-4G    # 在 0-4GB 区间预留 256MB CMA

# 查看 CMA 区域
dmesg | grep -i cma
cat /proc/iomem | grep -i CMA

八、内存控制器 (memcg) 与容器内存管理

8.1 cgroup v1 vs cgroup v2 对比

特性 cgroup v1 memory cgroup v2 memory
接口文件 memory.limit_in_bytes (独立) memory.max (统一)
软限制 memory.soft_limit_in_bytes memory.low (优先保护)
优先级 竞争式 (shares) 分层权重 (memory.weight)
内核无感知 内核内存不计入 可控制 (memory.kmem)
读写分开 hierarchical 统一层级管理
事件通知 cgroup.event_control memory.events (poll)

8.2 cgroup v2 的层级保护

内存分配层级 (cgroup v2):

  root (memory.max = 32G, memory.high = 29G, memory.low = 0)
  │
  ├─ database (memory.max = 16G, memory.low = 8G)
  │   ├─ mysql (memory.max = 12G)
  │   └─ redis (memory.max = 4G, memory.min = 2G)  ← 硬保护
  │
  ├─ webserver (memory.max = 12G, memory.low = 4G)
  │   ├─ nginx
  │   └─ php-fpm
  │
  └─ system (memory.max = 4G, memory.min = 1G)  ← 系统保护

优先级规则:
  1. 触及 max → 触发 cgroup 内部 OOM
  2. 触及 high → 主动回收 (不阻塞分配)
  3. 分配时保证 min 不被挤占
  4. 空闲内存按 weight 比例分配

8.3 K8s 内存资源模型

apiVersion: v1
kind: Pod
metadata:
  name: my-app
spec:
  containers:
  - name: app
    resources:
      requests:
        memory: "512Mi"   # 调度依据, 对应 memory.low
      limits:
        memory: "1Gi"     # 硬上限, 对应 memory.max (OOMKill)
K8s 资源映射:

  Pod → cgroup: /sys/fs/cgroup/kubepods/<QoS>/<pod-id>/<container-id>

  QoS Class:
    Guaranteed: requests == limits (全部一致)
    Burstable:  仅 limits 或 requests < limits
    BestEffort: 无 requests/limits

  Eviction (驱逐) 阈值:
    memory.available < memory-request → 驱逐 BestEffort Pod
    memory.available 极低 → 按 OOMScore 驱逐

  OOM (杀死) 条件:
    container memory > limit → OOMKilled (即 cgroup OOM)
    node memory 耗尽 → host OOM 选择性杀死

九、反向映射 (Reverse Mapping)

9.1 为什么需要反向映射

在回收匿名页或文件页面时,需要找到所有 PTE (页表项) 引用了这些页面的进程,然后将 PTE 指向 swap 地址或另一个物理页面。如果没有反向映射,就需要遍历整个系统的页表,这是不可接受的。

9.2 匿名页面的反向映射 (anon_vma)

// include/linux/rmap.h
struct anon_vma {
    struct anon_vma *root;        // 根节点 (fork/merge 时共享)
    atomic_t refcount;            // 引用计数
    unsigned degree;              // 链表中的子节点数

    struct rb_root_cached rb_root;  // VMA 红黑树 (fork形成的子树)
    struct rw_semaphore rwsem;
};

struct anon_vma_chain {
    struct vm_area_struct *vma;   // 指向的 VMA
    struct anon_vma *anon_vma;    // 所属的 anon_vma
    struct list_head same_vma;    // 链入 vma->anon_vma_chain
    struct rb_node rb;            // 链入 anon_vma 的红黑树
};

回收匿名页面时的页表遍历:

page_referenced_anon() 流程:
  page_lock_anon_vma()  
  ├─ 遍历 anon_vma 红黑树中的所有 VMA
  │   └─ 每个 VMA 可能属于不同进程 (fork 后共享)
  ├─ 对每个进程:
  │   └─ 计算该 PTE 对应的虚拟地址
  │   └─ 检查 PTE 的 Accessed 位
  │   └─ 若 Accessed → 说明近期被访问 → page 保持活跃
  │   └─ 若 clear → 可回收候选
  └─ 收集所有 PTE 信息给 shrink_page_list 决策

9.3 文件页面的反向映射 (interval tree)

文件页面的反向映射基于优先查找树 (Priority Interval Tree):

page_referenced_file() 流程:
  mapping->i_mmap (interval tree / rmap_item)
  ├─ 根据 page->index (文件偏移) 在树中搜索覆盖的 VMA
  ├─ 遍历命中的 VMA
  ├─ 检查 PTE Accessed 位
  └─ (与匿名类似但使用不同的数据结构)

十、生产环境实战调优

10.1 关键 sysctl 参数

# ============================================================
# 1. Swappiness (交换倾向)
# ============================================================
# vm.swappiness = 60 (默认)
# 
# 含义: 0-100, 越高越倾向于将匿名页 swapout
# 
# 推荐:
#   数据库 (MySQL/PG): 1-10 (避免 swap 引起延迟风暴)
#   高性能应用: 0 (尽量在内存中, 用 OOM 替代 swap)
#   桌面/通用: 60 (默认)
#   容器化环境: 0 或极低 (cgroup 限制下无意义)

vm.swappiness = 10   # 数据库服务器推荐

# ============================================================
# 2. Dirty 写回策略
# ============================================================
# vm.dirty_ratio = 20       脏页占总内存百分比上限 (%)
# vm.dirty_background_ratio = 10  # 后台 pdflush 启动阈值 (%)
# vm.dirty_expire_centisecs = 3000 # 脏页过期时间 (30秒)
# vm.dirty_writeback_centisecs = 500 # pdflush 检查间隔 (5秒)
#
# 更高性能场景 (SSD/filesystem 有电池保护):
vm.dirty_ratio = 40
vm.dirty_background_ratio = 10
vm.dirty_expire_centisecs = 6000       # 延长刷新间隔

# ============================================================
# 3. 过度分配策略
# ============================================================
# vm.overcommit_memory = 0  (默认, 启发式)
#   0: 内核检查是否有足够内存粗略估算
#   1: 总是允许 (用于交换空间充足的场景)
#   2: 严格 (CommitLimit = swap + RAM * overcommit_ratio%)
#
# 关键场景:
#   Redis: 必须设为 1 (因 fork + COW)
#   数据库: 0 或 2
#   通用: 0

vm.overcommit_memory = 1   # Redis 场景

# ============================================================
# 4. 最小保留内存
# ============================================================
# vm.min_free_kbytes = 自动计算
# 
# 保证系统在任何时候都有足够的紧急分配内存
# 8GB 内存通常设置 262144 (256MB) 比较安全

vm.min_free_kbytes = 262144

# ============================================================
# 5. HugePages 配置
# ============================================================
# 查看当前 HugePages 状态
cat /proc/meminfo | grep -i huge
# HugePages_Total:    1024
# HugePages_Free:     1024
# HugePages_Rsvd:        0
# Hugepagesize:       2048 kB

# 静态 HugePages (数据库常用)
echo 1024 > /proc/sys/vm/nr_hugepages
/etc/sysctl.conf:
vm.nr_hugepages = 1024
vm.hugetlb_shm_group = 500    # oracle/mysql 组

# 透明大页 (数据库推荐关闭)
echo never > /sys/kernel/mm/transparent_hugepage/enabled

10.2 NUMA 拓扑感知的绑定实践

# ============================================================
# 1. 查看 NUMA 硬件拓扑
# ============================================================
numactl --hardware
# available: 2 nodes (0-1)
# node 0 cpus: 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47
# node 0 size: 131035 MB
# node 0 free: 117234 MB
# node 1 cpus: 16 17 18 19 20 21 22 23 24 25 26 27 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71
# node 1 size: 131072 MB
# node 1 free: 119831 MB
# node distances:
# node   0   1
#   0:  10  20      ← 本地访问代价 10, 远程代价 20
#   1:  20  10

# ============================================================
# 2. MySQL 绑定 NUMA 实战
# ============================================================
# 方法 A: numactl 启动 (简单有效)
numactl --cpunodebind=0 --membind=0 /usr/sbin/mysqld

# 方法 B: 在 my.cnf 中使用 (推荐)
# innodb_numa_interleave = ON
# 交叉分配, 避免热点节点

# 方法 C: mysqladmin 查看 numactl 状态
# 确认 binding:
cat /proc/$(pidof mysqld)/numa_maps | grep -c " N0="

# ============================================================
# 3. 查看进程 NUMA 内存分布
# ============================================================
# 每个进程的本地/远程内存分布
numastat -p $(pidof mysqld)
# Per-node process memory usage (in MBs) for PID 12345 (mysqld)
#                            Node 0          Node 1           Total
#                 --------------- --------------- ---------------
# Huge                         0.00            0.00            0.00
# Heap                       512.34          487.65          999.99
# Stack                        0.05            0.03            0.08
# Private                   1024.50          256.30         1280.80
# ----------------  --------------- --------------- ---------------
# Total                    1536.89          743.98         2280.87

# 如果 Node 0 比例 > 80% 且系统处于高负载 → 可能有 NUMA 不平衡
# 检测方法: perf stat -e node-loads,node-load-misses -p <pid>

10.3 内存泄漏检测与诊断

# ============================================================
# 1. 查看系统整体内存分配
# ============================================================
free -h
#               total        used        free      shared  buff/cache   available
# Mem:           32Gi       8.2Gi       1.5Gi       256Mi       22Gi        23Gi
# Swap:         8.0Gi       128Mi       7.8Gi
#
# 关键指标: available (不是 free), 包含可回收缓存

# ============================================================
# 2. 查看进程详细内存映射
# ============================================================
# pmap 完整映射
pmap -x $(pidof nginx) | tail -1
# Address           Kbytes     RSS   Dirty Mode  Mapping
# total kB        12584968 12584968 12584968

# 更详细的 RSS/PSS/USS
cat /proc/$(pidof nginx)/smaps_rollup
# Rss:            204800 kB
# Pss:            189234 kB  ← 按比例分摊的共享内存
# Shared_Clean:    45056 kB  
# Shared_Dirty:     8192 kB
# Private_Clean:  102400 kB
# Private_Dirty:  49152 kB  ← 进程独占脏页 (泄漏黄金指标)
# Referenced:     196608 kB
# Anonymous:      153600 kB  ← 若持续增长,可能泄漏
# Swap:                0 kB

# ============================================================
# 3. slabtop 实时 slab 监控
# ============================================================
slabtop -o | head -20
#  OBJS      ACTIVE   USE  OBJ SIZE  SLABS OBJ/SLAB  CACHE SIZE  NAME
# 2281644  2281644  100%   0.03KB   17552      128      561472K  dentry
#  472614   472614  100%   0.19KB    2251       210     350080K  inode_cache
#  148752   148752  100%   0.76KB    2975        50     238000K  ext4_inode_cache
#   66560    66560  100%   1.00KB    1040        64      66560K  kmalloc-1k
#
# 按"d"排序可看到 LRU相关缓存; 异常增长说明可能泄漏

# ============================================================
# 4. valgrind massif 分析
# ============================================================
valgrind --tool=massif --pages-as-heap=yes ./my_app
ms_print massif.out.12345
# 展示内存随时间分配的完整趋势

# ============================================================
# 5. BPF/eBPF 追踪内存分配
# ============================================================
# 使用 bcc 工具 trace SLUB 分配
funclatency -u kmem_cache_alloc   # 分配延迟分布
stackcount kmem_cache_alloc        # 分配调用栈分布
memleak -p $(pidof my_app)         # 泄漏检测 (跟踪未匹配的 alloc/free)

# ============================================================
# 6. page-types 页面状态分析
# ============================================================
page-types -p $(pidof my_app) -l  # 每类页面计数
page-types -p $(pidof my_app) -r -a 0x700000000  # 详细信息

十一、性能监控与排障全流程

11.1 内存问题排查决策树

收到告警: 系统内存不足 / OOM / 应用变慢

1. 系统级别快速诊断:
   ├─ free -h → 看 available, 不是 free
   ├─ dmesg | grep -i "out of memory" → 是否有 OOM
   ├─ vmstat 1 → 观察 si/so (swap in/out)
   └─ sar -r 1 → 历史内存使用趋势

2. 进程级别分析:
   ├─ top -o %MEM → 占用内存最大的进程
   ├─ cat /proc/<pid>/status | grep -E "VmRSS|VmSwap" 
   └─ cat /proc/<pid>/smaps_rollup → PSS/USS/Anonymous

3. 内核级别排查:
   ├─ slabtop → 内核对象缓存异常
   ├─ cat /proc/meminfo | grep -E "Huge|Slab|Active|Inactive"
   ├─ cat /proc/zoneinfo → 每个 zone 的空闲页面分布
   └─ numastat -p <pid> → NUMA 本地率

4. OOM 分析:
   ├─ dmesg | grep -A 30 "Out of memory"
   │   ├─ 查看 oom_score 排名
   │   └─ 查看被 kill 进程的 Total-vm/rss/anonymous
   └─ journalctl -k | grep -i "oom-killer"

5. 高级手段:
   ├─ bpftrace -e 'kprobe:out_of_memory { printf("OOM triggered by %s\n", comm); }'
   ├─ perf record -e page-faults -p <pid> -- sleep 30
   └─ /sys/fs/cgroup/memory/<pod>/memory.peak (cgroup v2 历史峰值)

11.2 生产环境黄金指标

# 建议采集的 Prometheus 指标 (node_exporter + process_exporter):

# 系统内存
node_memory_MemAvailable_bytes        # 最核心指标
node_memory_MemTotal_bytes
node_memory_SwapTotal_bytes
node_memory_SwapFree_bytes

# 页面回收速率
node_vmstat_pgscan_kswapd            # kswapd 扫描页面数 (增长 → 内存压力)
node_vmpgste_pgsteal_kswapd          # kswapd 回收成功数
node_vmstat_pgscan_direct            # 直接回收扫描 (增长 → 严重内存压力)
node_vmpgste_pgsteal_direct          # 直接回收成功数
node_vmstat_oom_kill                 # OOM 计数

# NUMA
node_memory_numa_Active              # 各 node 活跃内存

# Slab
node_memory_Slab_bytes
node_memory_SReclaimable_bytes       # 可回收 slab
node_memory_SUnreclaim_bytes         # 不可回收 slab

# HugePages
node_memory_HugePages_Total
node_memory_HugePages_Free

# 告警规则:
# 1. MemAvailable < 10% 总内存 → 警告
# 2. pgscan_direct/min > 100 → 内存压力严重
# 3. oom_kill counter 增加 → 紧急处理
# 4. slab 持续增长且 SUnreclaim → 可能内核泄漏

十二、总结

Linux 内核内存管理是一个历经数十年演进的精密系统。理解这些核心机制不仅有助于日常运维排障,更能指导应用架构和系统调优:

  • 伙伴系统 提供物理页面的高效分配与回收,通过迁移类型分类缓解外部碎片
  • SLUB 分配器 以极简设计实现极低的分配延迟,是内核对象的二级缓存核心
  • NUMA 感知 在多路服务器上对性能影响可达 30%+,务必根据 workload 选择合适策略
  • OOM Killer 作为最后手段,需通过 oom_score_adj 和 cgroup 双重保护关键服务
  • KSM/THP 分别在虚拟化和数据库场景有截然不同的配置决策
  • 反向映射 是页面回收效率的基石,理解它有助于分析 COW 和共享内存的性能影响
  • memcg 是容器内存隔离的基础,requests/limits 映射到 cgroup 的 low/max

在生产实践中,建议采用 监控先行 → 参数调优 → 瓶颈分析 → 架构优化 的系统化方法,避免盲目修改 sysctl。


本文基于 Linux 6.x 内核源码分析,重点关注 x86_64 平台的实现细节,生产环境请以实际内核版本为准。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部
0.442702s