Linux Kernel Memory Management 深度实战:从 Slab 分配器到 NUMA 优化

引言

Linux 内核内存管理是操作系统中最复杂、最核心的子系统之一。对于从事底层系统开发的工程师而言,理解 Slab 分配器、伙伴系统(Buddy System)、内存回收机制和 NUMA 拓扑感知不仅是解决性能瓶颈的钥匙,更是构建高并发服务的基石。本文将从内核源码出发,深度剖析内存管理的核心机制,并结合 eBPF 观测工具和 cgroup 控制,展示在生产环境中如何对内存子系统进行性能调优。

一、伙伴系统:物理内存分配的基石

1.1 核心设计与 free_area 结构

伙伴系统是内核物理内存分配的基础,它将内存按阶(order)组织为 11 个链表(阶 0—10),分别管理 2^0 到 2^10 个连续物理页(4KB 页模式下对应 4KB—4MB)。关键数据结构如下:

// include/linux/mmzone.h
struct free_area {
    struct free_list free_list[MIGRATE_TYPES];
    unsigned long    nr_free;
};

struct zone {
    struct free_area free_area[MAX_ORDER];
    // ...
};

每个 zone(DMA/Normal/HighMem)内部维护一个 free_area 数组,每个 free_area 又按迁移类型(MIGRATE_UNMOVABLE、MIGRATE_MOVABLE、MIGRATE_RECLAIMABLE等)细分链表。这种设计的目的是减少内存碎片:不可移动的内核数据结构与可移动的用户空间页面分离,避免因页面无法迁移而导致的外部碎片。

1.2 分配流程与 __alloc_pages

所有物理页面分配最终汇聚到 __alloc_pages() 函数(以 6.8 内核为例,该函数已重构为 __alloc_pages_noprofile 的入口简化版):

// mm/page_alloc.c (简化)
struct page *alloc_pages(gfp_t gfp_mask, unsigned int order)
{
    return __alloc_pages(gfp_mask, order, numa_node_id());
}

当请求分配 2^order 个连续页面时,伙伴系统从 order 阶开始向上查找,若找到则进行拆分(split):将大块内存逐级折半,直到满足请求大小,另一半伙伴块放回低阶链表。释放时则执行合并(coalesce):检查相邻的同阶伙伴块是否空闲,若空闲则合并为更高阶块,递归直到最大阶。

1.3 关键调参数

  • /proc/buddyinfo:查看各 zone 各阶的可用块数量
  • /proc/pagetypeinfo:按迁移类型查看内存分布
  • zone_reclaim_mode:控制 zone 回收策略

生产环境中,若 pagetypeinfo 显示 MIGRATE_UNMOVABLE 在高阶(order≥3)有大量占用导致无法分配连续大块,通常意味着内核 slab 或驱动存在长期持有大对象的泄漏。

二、Slab 分配器:小对象分配的利器

2.1 为什么需要 Slab?

伙伴系统的最小分配单位是一个页面(4KB),若内核频繁分配几十字节的结构体(如 task_struct、dentry、inode),内部碎片率可高达 99%。Slab 分配器正是为此而生:在伙伴系统分配的页面内部,进一步细分为等长小对象。

2.2 Slub 分配器:当前默认实现

Linux 内核现以 Slub(简化版 Slab)为默认分配器,其设计理念是极简—通用。Slub 取消了对 CPU 本地缓存队列的严格维护,转而利用 per-CPU 的 page 快速路径。核心操作如下:

// mm/slub.c
static __always_inline void *slab_alloc(struct kmem_cache *s, gfp_t gfp)
{
    // 1. 从 per-CPU cpu_slab 的 freelist 直接取用(O(1))
    // 2. fastpath 失败时尝试从 partial page 补充
    // 3. 仍不满足则调用 new_slab() 从伙伴系统获取新页
    void *object = __slab_alloc(s, gfp, node, _RET_IP_, c);
    return object;
}

关键路径:per-CPU freelist → partial 链表 → 伙伴系统新页。这种分层设计保证了热路径上的分配近乎 O(1)。

2.3 Slab 调试技巧:检测 Use-After-Free

Slub 内置了强大的调试机制。开启 CONFIG_DEBUG_SLUB 和 slub_debug=U 后,任何通过 kfree 释放的对象会被填充 poison 值(0x6b),并在下次分配前校验——若检测到 poison 被覆盖,立即触发 kernel crash dump:

# 启用 slub debug(需重启或使用启动参数)
slub_debug=UFP

# 查看 kmem_cache 详细状态
cat /proc/slabinfo

# trace 特定 cache 的 alloc/free 对
echo 1 > /sys/kernel/debug/tracing/events/kmem/kmalloc/enable
echo 1 > /sys/kernel/debug/tracing/events/kmem/kfree/enable

在排查网络层的 sk_buff 泄漏时,我曾通过监控 kmalloc-2k cache 在 /proc/slabinfo 中的 active 对象数持续上升,快速定位到驱动中缺失的 kfree_skb() 调用点。

三、页面回收与 OOM Killer

3.1 LRU 链表与 kswapd

Linux 使用 LRU(Least Recently Used)算法管理页面回收。内核为每个 zone 维护两个 LRU 链表——active 和 inactive——页面根据访问频度在两者间迁移:

// mm/vmscan.c (核心逻辑简化)
enum lru_list {
    LRU_INACTIVE_ANON,
    LRU_ACTIVE_ANON,
    LRU_INACTIVE_FILE,
    LRU_ACTIVE_FILE,
    LRU_ISOLATED_ANON,
    LRU_ISOLATED_FILE,
    NR_LRU_LISTS
};

回收由 kswapd 后台守护进程触发,当特定 zone 的 free 页面低于 low 阈值时唤醒 kswapd,开始扫描 LRU 尾部将不活跃页面回写或丢弃。若 free 降至 min 阈值以下,分配者进入直接回收(direct reclaim)路径,这将导致严重的分配延迟。

3.2 OOM Killer 评分算法

当系统内存完全耗尽且所有回收手段均失败时,OOM Killer 会被触发。它以逐进程评分的方式选择牺牲者:

// mm/oom_kill.c
unsigned long oom_badness(struct task_struct *p, unsigned long totalpages)
{
    long points;
    // 基础分数 = (进程驻留内存 / 总内存) * 1000
    // 调整因子:
    //   * CAP_SYS_ADMIN / mlock 进程分数 *3%
    //   * oom_score_adj 直接偏移 (-1000 到 +1000)

    points = get_mm_rss(p->mm) + get_mm_counter(p->mm, MM_SWAPENTS);
    points = points * 1000 / totalpoints;

    points += p->signal->oom_score_adj;
    return max_t(unsigned long, points, 1);
}

实战技巧:

  • 通过设置 oom_score_adj=-1000 保护关键守护进程(如数据库):echo -1000 > /proc/<pid>/oom_score_adj
  • 用 dmesg | grep -i oom 查看 oom-kill 日志
  • 启用 vm.oom_kill_allocating_task=1 可直接杀死触发 oom 的进程,而非扫描选择

四、eBPF 追踪内存分配热路径

4.1 使用 memleak 脚本检测内存泄漏

BCC 工具集提供的 memleak 脚本利用 eBPF 的 kprobe 追踪 kmalloc/kfree 调用栈:

# 每 5 秒输出一次分配未释放的 Top 10 调用栈
memleak-bpfcc -p $(pidof myapp) 5

# 输出示例:
# Top 10 stacks with outstanding allocations:
#   addr = 0xffff888123456000 size = 256
#       kmem_cache_alloc_trace+0x164
#       alloc_rbio+0x45
#       raid_bio_alloc+0x32
#       md_do_sync+0x120
#   262144 bytes in 1024 allocations

此类追踪适合在测试环境长期运行以发现潜在的内存泄漏路径,而非线上高负载环境(eBPF探针可能引入额外开销)。

4.2 追踪直接回收频率

直接回收是影响延迟的关键因素。使用 bpftrace 可以实时监控:

# 统计每秒直接回收的次数
bpftrace -e '
kprobe:direct_reclaim_begin { @start[tid] = nsecs; }
kprobe:direct_reclaim_end /@start[tid]/ {
    @lat_us = hist((nsecs - @start[tid]) / 1000);
    @count = count();
    delete(@start[tid]);
}
'

若 p99 直接回收延迟超过 500us,或每秒触发次数大于 100,应立即检查内存压力并考虑增加物理内存或优化应用内存使用模式。

五、NUMA 感知内存分配策略

5.1 NUMA 拓扑对性能的影响

在 NUMA 架构中,CPU 访问本地节点的内存延迟通常为 80-100ns,而跨节点访问延迟可达 150-200ns(多芯片互联)。对于内存密集型应用,未优化的 NUMA 内存分配可导致 30% 以上的性能下降。

5.2 内核 NUMA 平衡与自动迁移

Linux 内核通过 AutoNUMA Balancing 特性自动优化页面位置:

# 查看 NUMA 平衡状态(1=开启,0=关闭)
cat /proc/sys/kernel/numa_balancing

# 查看进程的 NUMA 内存分布
numastat -p $(pidof myapp)

# 示例输出:
# Node local_node     other_node
# 0      1.2GB        45MB
# 1      230MB        8MB

当 numastat 显示某进程在本地节点之外的内存占比超过 20%,应考虑: 1. 使用 numactl --membind=node 启动应用 2. 设置 vm.zone_reclaim_mode=1 让内核优先在本地节点回收 3. 配置 cgroup cpuset 将进程绑定到最优 NUMA 节点

5.3 手动 NUMA 策略示例

对数据库或 key-value 存储引擎,手动控制内存布局至关重要。以 PostgreSQL 为例:

# 将 PostgreSQL 绑定到 NUMA 节点 0
numactl --cpunodebind=0 --membind=0 postgres -D /data/pg

# 使用 mbind() 系统调用指定策略(C 语言示例)
unsigned long nodemask = 1UL << target_node;
syscall(SYS_mbind, addr, len, MPOL_BIND, &nodemask, 
        sizeof(nodemask) * 8, 0);

5.4 查看系统 NUMA 拓扑信息

# 查看 NUMA 节点拓扑
lstopo --of txt

# 节点间距离矩阵
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
# node 0 size: 196503 MB
# node 0 free: 82345 MB
# node 1 cpus: 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31
# node 1 size: 196608 MB
# node 1 free: 102893 MB
# node distances:
# node  0   1
#    0: 10  21
#    1: 21  10

其中距离值代表相对延迟(本地=10),21 表示跨节点延迟是本地的 2.1 倍。若跨节点比率大于 1.8,应用需要谨慎评估 NUMA 策略。

六、cgroup v2 内存控制实践

6.1 内存限制与保护

cgroup v2 对内存控制进行了重新设计——引入了更精细的 memory.min(保护下限)、memory.low(软限制)、memory.high(限流阈值)和 memory.max(硬上限)四档层次:

# 创建 cgroup 并设置 4GB 硬上限
mkdir /sys/fs/cgroup/app.slice
echo "4G" > /sys/fs/cgroup/app.slice/memory.max

# 设置 2GB 保护——即使系统内存紧张也不回收这些页面
echo "2G" > /sys/fs/cgroup/app.slice/memory.min

# 将应用 PID 加入 cgroup
echo $(pidof myapp) > /sys/fs/cgroup/app.slice/cgroup.procs

6.2 监控 OOM 事件

使用 cgroup v2 的 eventfd 机制可监听 OOM 事件而不需轮询:

// C 示例:监听 cgroup 内存事件
int cgroup_fd = open("/sys/fs/cgroup/app.slice", O_RDONLY);
int event_fd = eventfd(0, 0);
int memory_events_fd = openat(cgroup_fd, "memory.events", O_RDONLY);

// 将 eventfd、control fd 和监控项写入 cgroup.events 注册
struct cgroup_event_data event = {
    .eventfd = event_fd,
    .cgroup_fd = cgroup_fd,
    .memory_events_fd = memory_events_fd,
};

// 通过 select/poll 监听 event_fd 当 high/max OOM 触发时读取

6.3 实战:控制 Redis 内存峰值

生产环境中,若 Redis 使用大量内存可能导致系统 OOM。合理配置 cgroup:

# Redis cgroup 配置
redis_memory_max="32G"
redis_memory_min="30G"   # 保证常驻工作集
redis_memory_high="28G"  # 超过后提前限流

# 配合 maxmemory-policy=allkeys-lru 在限流前先驱逐冷数据

七、生产环境内存排查工具箱

7.1 vmstat 与 sar 实战

# 每秒采集一次,持续监控 swap 和 si/so(swap in/out)
vmstat 1

# 重点关注列:
# si: swap-in 速度(KB/s),非零说明内存压力大
# so: swap-out 速度
# bi/bo: 块设备读写流量
# us/sy/id: CPU 使用率

# 使用 sar 分析历史
sar -r 1 10     # 内存利用率采样
sar -B 1 10     # pgscank/pgscand 页面扫描统计
sar -W 1 10     # swap 切换统计

7.2 /proc/meminfo 深度解读

解读 meminfo 中容易被误解的字段:

  • MemAvailable:真正可供启动新程序使用的内存(含可回收页缓存),比 MemFree 大得多
  • Slab + SReclaimable:可回收的内核缓存,MemAvailable 已计入
  • Shmem:包含 tmpfs 和共享内存,是不可回收的活跃内存
  • Mapped:通过 mmap 映射的文件页面,可能低于 Cached

7.3 smaps 分析进程内存明细

/proc/<pid>/smaps_rollup 提供进程的聚合内存视图:

# 查看进程的 PSS(Proportional Set Size,按比例分配的共享内存)
cat /proc/$(pidof java)/smaps_rollup | head -30
# Rss:            8388608 kB   # 驻留内存
# Pss:            4194304 kB   # 去重共享后的内存
# Shared_Clean:   2097152 kB   # 共享且干净的页面
# Swap:            1048576 kB   # 被 swap 出的内存

PSS 是衡量进程真实内存占用的金标准——Android 的 procrank 和许多内存泄漏检测工具都基于此。

八、性能调优案例:解决直接回收导致的尾延迟

8.1 问题现象

某 Go 语言编写的高并发服务在流量突增时,p999 延迟从 5ms 飙升到 200ms,CPU 使用率平均仅 40%。

8.2 诊断过程

# 1. vmstat 显示每秒 so 值 50MB+,说明页面被大量换出
vmstat 1

# 2. sar -B 显示 pgscand(直接页面扫描)频率异常
sar -B 1
# 12:00:01    pgscank/s   pgscank/s
# 12:00:08     15234      15234   # 大量 kswapd/directed reclaim!

# 3. 检查 NUMA 配置
numastat -p $(pidof goapp)
# Node 0:  200MB  (本地 10%)
# Node 1:  1800MB (远程 90%)  

# 4. 根本原因:Go GC 采用 madvise(MADV_DONTNEED) 快速归还内存,
#    但归还的页面被重新分配到远端 NUMA 节点,触发大量远程访问

8.3 修复方案

# 方案一:绑定进程到 NUMA 节点
numactl --cpunodebind=1 --membind=1 ./goapp

# 方案二:关闭内核 NUMA 平衡,让应用自行控制
echo 0 > /proc/sys/kernel/numa_balancing

# 方案三:调整 GC 策略减少归还频率
# 环境变量
export GOGC=200   # 将 GC 触发阈值从 100% 提升到 200%

8.4 效果对比

指标 调优前 调优后
p99 延迟 45ms 6ms
p999 延迟 210ms 12ms
每秒远程访问 850K 12K
so (swap out) 52 MB/s 0

九、前沿趋势:内存管理的未来

9.1 Folio 与 Large Folio

Linux 6.0+ 引入 Folio 抽象,将 compound page(高阶复合页)统一为 folio 类型,消除了传统 compound page 在 tail page 处理中的复杂性。Large Folio(如 64KB 在 4KB 页模式下)已在 XFS 文件系统和 HugeTLB 中落地,可减少 TLB miss 并提升大文件操作吞吐量。

9.2 DAMON:数据访问监控框架

DAMON(Data Access MONitor)提供内核级的页面访问频率监控,无需硬件 PMU 开销,配合其自带的 DAMON-based Operation Schemes(DOS),可自动实现内存冷热分离、swap 优先级排序和大页升级:

# 启动 DAMON 监控目标进程
damo start $(pidof myapp)
# 查看访问直方图,识别热/冷页面
damo report heats --heatmap hot

9.3 MGLRU:多代 LRU 算法

在 6.1 内核中合并的 Multi-Gen LRU 算法打破了传统 active/inactive 二分法引入多代管理,回收决策更精准,在低内存场景下可将后台 IO 干扰降低 43%,已逐渐成为服务器场景的标准配置。

十、总结

Linux 内核内存管理是一个跨越硬件拓扑、内核算法和应用行为的复杂体系。工程师在面对内存性能问题时,应遵循"观测先行—分层定位—针对性调优"的方法论:

  1. 使用 vmstat、sar、numastat 快速定位压力源
  2. 通过 /proc/meminfo、/proc/smaps_rollup、slabinfo 逐层深入分析
  3. 利用 eBPF(bpftrace、BCC)和 DAMON 追踪延迟和访问模式
  4. 结合 cgroup v2 内存控制和 NUMA 绑定进行配置固化
  5. 针对具体应用特性(GC 策略、文件缓存模式)做最后一公里的精细调整

掌握这些机制,才能在内存密集型应用的稳定性保障中游刃有余。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部
0.368566s