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 内核内存管理是一个跨越硬件拓扑、内核算法和应用行为的复杂体系。工程师在面对内存性能问题时,应遵循"观测先行—分层定位—针对性调优"的方法论:
- 使用
vmstat、sar、numastat快速定位压力源 - 通过
/proc/meminfo、/proc/smaps_rollup、slabinfo 逐层深入分析 - 利用 eBPF(bpftrace、BCC)和 DAMON 追踪延迟和访问模式
- 结合 cgroup v2 内存控制和 NUMA 绑定进行配置固化
- 针对具体应用特性(GC 策略、文件缓存模式)做最后一公里的精细调整
掌握这些机制,才能在内存密集型应用的稳定性保障中游刃有余。

发表评论 取消回复