Linux内核内存管理深度:从伙伴系统到SLUB、缺页与回收机制

发布时间:2026/10/16 2:20:47
Linux内核内存管理深度:从伙伴系统到SLUB、缺页与回收机制
最近在配置几个内核实验环境时又把内存管理相关的代码翻出来过了一遍。这个系列写到这里已经是第六篇前几篇从物理页面的初始化、页表的建立一路聊到了伙伴系统的基础逻辑这篇继续往深处走聚焦在日常开发里真正会迎面撞上的那一部分小对象分配、高端分配路径、缺页处理以及内存紧张时的回收机制。如果你正准备阅读Linux内核源代码中内存管理相关的内容或者工作中遇到高于预期延迟、内存碎片、slab增长异常这类问题时想有个着手点这篇可以作为一条参照路线。内容不绕弯子直接落到代码路径和数据结构上配合一些我在实践中验证过的观测方法。1. 内存管理的全景图一次分配请求的旅程很多人一开始读内核内存管理代码容易被一堆分配接口搞懵kmalloc、vmalloc、alloc_pages、mmap到底什么场合该走哪条路为什么内核要维护这么多套分配机制。其实把这些接口放在一条完整的路径上看逻辑会清晰很多。1.1 为什么内核需要多级分配器伙伴系统是物理页面分配的地基但它解决的问题很单一以2的幂次为单位管理连续物理页面。问题在于内核中大量对象是几十字节到几百字节的小结构体比如task_struct、inode、dentry、各种cache对象。如果每次都向伙伴系统申请一整个页然后只用一个角落剩下的全部浪费那内存利用率和分配效率都会非常难看。这里就体现出多级分配器的价值。伙伴系统管“页块”SLUB分配器管“字节”。可以这样理解伙伴系统像建材批发市场一进货就是一卡车板材SLUB是木工房里的工具柜把板材裁成各种尺寸的抽屉需要哪块拿哪块。用户态malloc在底层需要物理内存时最终也会通过缺页异常向内核要页要到的页同样来自伙伴系统。所以阅读源码前先把三条路径刻在脑子里用户态虚拟地址通过缺页异常分配物理页走的是handle_mm_fault内核态小对象走kmalloc实际由SLUB分配器从预先创建的slab缓存中分配内核态大块、非连续内存走vmalloc分配物理页后在虚拟地址空间里重新映射成连续视图。核心里还有一个特别常用的接口是alloc_pageskmalloc在小块分配滑落时也会退化成它。理解了这些路径的归属读代码就不至于在各函数之间跳花了。1.2 伙伴系统核心逻辑order、拆分与合并伙伴系统这部分代码在mm/page_alloc.c核心数据结构是free_area数组每个数组项维护一个链表对应某个order的空闲页块。order等于0表示单页order等于1表示两页以此类推最大order由MAX_ORDER控制默认是11也就是一次最多分配2的11次方个连续物理页。分配过程我建议直接从alloc_pages跟起。它带着gfp_mask和order进入__alloc_pages_nodemask里面先尝试从指定的zone里取顺着order往上找找到一个有空闲块的最低的order然后把这块拆出来。举一个最典型的例子需要order等于2的页块但当前zone里order为2的空链表是空的order为3的链表里有一个块此时系统会把order为3的块拆成两个order为2的块一个给调用者另一个挂回order为2的空闲链表。如果order为3也没有继续向上直到耗尽整个zone。释放路径是分配路径的镜像。释放一块order等于n的物理页块时内核会检查它的伙伴块是否也空闲如果是就把两者合并成order加1的块继续向上检查。这个机制的关键点在于伙伴块地址的判定有严格的位运算逻辑必须保证两个块在物理地址上相邻且同属于更高一级块的一半否则不能合并。这也是伙伴系统名字的由来。真正工作以后你会发现这个机制在内存碎片化严重时会有它的软肋。连续大块被不断拆散后即使系统整体空闲内存还很多也可能分配不出一个order为10的大块。观测方法不复杂/proc/buddyinfo里每个zone各order的空闲块数量一目了然。2. SLUB分配器内核里的小物件仓库kmalloc背后的实现是SLUB分配器这是当前内核默认的通用缓存分配实现替代了早期的SLAB。在代码阅读优先级里我通常建议把mm/slub.c放在很靠前的位置因为它数据结构清晰而且和日常问题排查直接相关。2.1 SLUB的核心数据结构与设计取舍SLUB绕不开的几个数据结构struct kmem_cache是每个缓存实例的描述符里面包含了对象大小、对齐、标志位等struct kmem_cache_cpu是每个CPU本地的缓存管理结构分配快路径指向这里struct kmem_cache_node则是每个NUMA节点级别的管理结构承载了部分slab页面的链表。为什么要拆成CPU本地和NUMA节点两级因为分配对象是内核里极其高频的操作如果每次都去抢全局锁缓存线在CPU之间来回 bouncing性能会迅速崩掉。SLUB的解决思路是每个CPU维护一个freelist指针指向本地空闲对象链表的头大多数分配释放操作都只碰CPU本地数据既不需要拿锁也几乎不会触碰共享数据。struct kmem_cache_cpu里值得关注的字段包括freelist、page、partial。freelist是当前CPU正在分配的slab页里的空闲对象链表page指向这个正在使用的slab页partial指向该CPU已部分使用、但还有空位的slab页链表。当freelist耗尽时分配器先从CPU的partial链表中取一个还带空位的slab页再次耗尽则到NUMA节点级的partial链表中去取。如果节点级的也没有可用slab那就只能新建一个slab页面了这一步会产生一个向伙伴系统申请页面的动作。2.2 对象分配与释放的完整路径kmem_cache_alloc的代码路径值得一行一行看。快路径就是直接从cpu_slab-freelist取出第一个对象然后让cpu_slab-freelist指向下一个。这里有一步SLUB_NEED_NODE检查release 的时候还需要做__cmpxchg_double之类的操作更新freelist指针。源码不复杂但反复读几遍会有收获。释放路径对称但有个特别的地方释放对象时slab页本身的inuse计数会递减当这个计数归零、该页变成完全空闲时如果此时CPU本地partial链表太长这一页会被直接释放回伙伴系统否则会留在CPU的partial链表里以备后续快速分配。保留一部分空页是为了摊薄未来新建slab页的成本但保留过多又会造成内存浪费。内核通过cpu_partial参数来限制这个保留数量。kmem_cache_create是创建新缓存的入口传入参数包括名称、对象大小、align偏移、flags等。它会在内部计算每个slab页能放多少个对象并决定是否启用调试功能。一个细节是如果对象大小和对齐要求导致页面利用率很低SLUB可能选择在一个页面里放多个对象并且会刻意在对象之间填充“着色”偏移让不同对象的起始位置分布在cache line的不同偏移上降低CPU cache的冲突概率。写工具类代码时不用手动处理这些但理解它有助于解释为什么某些缓存内存占用比预期略大。2.3 借助 slub_debug 定位越界问题如果怀疑某个对象被缓冲区溢出踩了SLUB提供了一套调试手段。启动参数里加slub_debugP启用poisoning它会往空闲对象里填特定字节并在分配释放时检查这些字节有没有被改写。再配合slub_debugF开启红区检查页面的两端会加上区域标记越界写入会触发报告。实际经验里面对“过一段时间后某个模块就开始诡异崩溃”的问题这套方案比静态翻代码有效得多。报告里会打印出请求分配的大小、实际对象大小、分配的调用栈通常能直接看出是哪个函数越界写过对象。3. 直接映射区域与vmallocCPU眼中的内存视图内核地址空间不是所有区域都直观地对应物理地址。x86-64架构下内核线性映射区域从PAGE_OFFSET开始把物理内存的绝大部分直接映射到固定偏移的虚拟地址上。对这部分区域虚拟地址和物理地址只是一个简单加减法的关系虚拟地址减去PAGE_OFFSET加上物理内存起始地址就可以得到物理地址反之亦然。phys_to_virt和virt_to_phys两个接口干的就是这件事。3.1 直接映射区为什么存在直接映射区保证了内核能快速访问任意物理页这是内核自己的“主视图”。它和用户态页表完全无关每次进程切换时这部分映射保持不变。但你会发现直接映射区并非所有页面都设置了相同权限x86-64下内核会把这块区域设置为大页映射2MB或1GB页既能减少TLB压力又保持了访问效率。某些安全机制会进一步把内核的文本区设置为只读不可执行这部分在CONFIG_STRICT_KERNEL_RWX里控制。这里有个容易搞混的概念直接映射区的地址虽然可以算出物理地址但它不等于“分配出的连续内存”。一个kmalloc返回的虚拟地址落在直接映射区它的物理页确实连续但其他直接映射区地址也可能指向一段空闲区域不能拿来当作已分配内存使用。分配与映射是两回事。3.2 vmalloc如何实现虚拟地址连续vmalloc解决的痛点是当需要的物理页面数量很大时很难保证它们是物理连续的但某些场景确实需要一段连续的虚拟地址比如内核模块加载、某些驱动DMA缓冲区不是硬性要求物理连续的设备、或者调试信息缓冲区。vmalloc的做法是先分配一段虚拟地址空间然后逐个分配物理页并把它们映射到这段虚拟地址的对应位置上。mm/vmalloc.c里的核心管理结构是vmap_area所有已分配、已释放的区域用红黑树组织起来目的是快速查找可用缝隙。分配虚拟区间的入口是alloc_vmap_area它会从红黑树中找到满足大小和对齐要求的空隙返回对应起始地址。分配完虚拟区间后__vmalloc_area_node再为每个页面调用alloc_page并填写页表项。有一个用于防止越界的细节值得注意vmalloc区域和区域之间会留一个page guard原因是访问越界时不会悄悄落到另一个合法区域里而是触发页错误并快速暴露问题。这解释了为什么通过/proc/vmallocinfo看到的区域大小总是比请求大小多出一页左右。3.3 kmalloc与vmalloc怎么选做驱动或者内核模块开发时选择哪个分配接口不只是性能偏好问题更受物理连续性的硬约束影响。下表是我实用后的总结维度kmallocvmalloc物理连续性物理连续虚拟连续、物理不一定连续分配上限受伙伴系统连续块限制较大的order可能失败虚拟空间内都比较可行性能快路径极快几乎只在CPU本地操作需要逐页映射开销明显更高适用场景小对象、频繁分配、对延迟敏感的路径大缓冲区、模块加载、区分地址空间调试经验法则小对象定时优先kmalloc大块、低频、不要求物理连续时再考虑vmalloc。我调试过某驱动在高负载下频繁调用kvmalloc它内部实现了两者的自动选择小请求走kmalloc大请求转入vmalloc是一个比较实用的折中。4. 缺页异常处理器虚拟内存兑现的入口内存管理的用户视角其实都挂在缺页异常这一条线上。每个用户进程拥有自己的虚拟地址空间但大多数虚拟地址并没有对应物理页。当CPU访问一个没有对应物理页的虚拟地址时MMU触发缺页异常内核在异常处理路径中决定是补上这个页、还是杀掉进程。读懂了这一段才算把虚拟内存和物理内存的关系真正串起来。4.1 缺页异常的两条大分支缺页异常不都是一回事。按成因可以区分成几种典型情况匿名页缺页比如malloc之后首次写入的内存页表项是空的需要新建一个匿名物理页。文件映射缺页比如mmap映射一个文件首次对该区域读取会触发缺页内核从文件系统读取对应页。写保护触发的缺页最常见的是写时复制COWfork之后父子进程要写入共享页时触发。交换缺页页面被换出到交换设备后再次访问时需要从swap读回走do_swap_page。在x86-64上入口在arch/x86/mm/fault.c的do_page_fault它先做一系列内核地址空间检查再调用handle_mm_fault。ARM64下的路径命名略有差别但整体思路一致都是通过struct vm_fault这个结构把异常地址、页表项、vma等信息打包传给上层处理逻辑。4.2 handle_mm_fault的逐级流程handle_mm_fault先做的是找vma根据异常地址在进程的地址空间中查一个合法的虚拟内存区域。没有vma直接判为段错误找到了vma再检查权限位是否符合比如写了一个只读映射就是保护错误。接着需要分配页表项从pgd出发逐级检查p4d、pud、pmd、pte缺哪一层就补哪一层。这里体现了一个重要的懒分配思想——虚拟地址空间再大只为真正被访问的页建立页表并分配物理页。走到handle_pte_fault就有分支了。如果pte为空匿名映射走do_anonymous_page文件映射走do_fault。一个特别值得关注的细节是匿名映射的读操作会直接映射到一个全局的零页ZERO_PAGE不分配真实物理页直到进程真的对该页发生写操作才会通过COW机制分配一个新页并拷贝内容。这种机制让许多不会全部写入的缓冲区在初始化时几乎不消耗物理内存。如果pte非空但权限不对最常见的场景就是COW。fork时父进程的页表项被设置成只读子进程写入触发保护异常wp_page_copy此时会分配物理页、复制旧页内容、更新页表项为可写。这个路径是不是要复制页取决于该页的映射计数mapcount如果是单映射就可以直接修改权限不需要复制这也是fork性能优化的重要基础。4.3 缺页路径上的性能观察点缺页处理是用户态程序第一次触碰内存时的主要成本所以优化点都集中在这里。透明大页THP的核心逻辑是在缺页路径上尝试分配2MB大小的物理页用pmd级别的映射替代普通4KB页表的多次查找减少TLB miss。内核里对THP的使用策略有两种典型设置always表示对所有适用区域都尝试分配大页madvise则表示只有通过madvise(MADV_HUGEPAGE)明确建议的区域才使用。后者更稳因为它避开了那些根本不值得浪费内存的小区域代价是需要显式调用。我自己在服务端调优时观察到一个常见现象把/sys/kernel/mm/transparent_hugepage/enabled调成always后某些内存占用很大的服务首次内存访问时间确实下降了但也可能换来更严重的页面回收颠簸因为大页很难被整齐地回收。因此不要盲目开启要配合实际的缺页统计来评估。5. 内存回收机制与水位线判断当物理内存申请量持续大于释放量时内核不能无限吃内存cache和空闲页必须把一部分不用的页释放给伙伴系统。这部分回收代码藏在mm/vmscan.c是内存管理中逻辑最多的部分也是高负载服务出现性能抖动时要重点排查的地方。5.1 LRU链表与shrink_node的扫描流程回收的对象不是随机内存而是标记为可回收的页文件页、匿名页、shmem页等。内核用LRU链表来跟踪这些页更准确地说是分为active和inactive两个链表且匿名页和文件页各自维护一套。这种设计的本质是一种近似LRU页面先进入inactive链表被多次访问后再升到active链表回收时优先从inactive链表淘汰active链表里的页只有经过老化降级后才会进入可回收池。shrink_node是回收一个NUMA节点内存的核心函数。它会遍历该节点各zone的LRU链表以batch大小为粒度扫描并把扫描结果区分成“可回收页”和“不可回收页”。可回收页会根据页面类型分别处理文件页通过shrink_page_list写出或者直接回收匿名页则需要先swap out再释放。reclaim_state结构里会累计本次回收的页数最终决定是否继续回收。读了这段代码会有个感悟内存回收不是笼统的“把内存腾出来”而是对页的分类处置。文件页只要干净直接丢脏文件页要写回磁盘匿名页要写swap再丢。这解释了为什么有些情况下明明有大量可回收内存回收速度却慢得像蜗牛——瓶颈可能出在磁盘I/O而不是扫描本身。5.2 kswapd与直接回收两条触发路径触发内存回收有两条路径理解它们对排查卡顿很有帮助。一条是后台kswapd。每个NUMA节点都有一个kswapd内核线程它会持续检查各zone的可用内存是否低于watermark[low]。一旦低于就进入回收回收到什么程度收手呢目标是把水位线恢复到watermark[high]以上。这个机制保证大多数分配请求在快路径就能拿到内存不用自己等回收。另一条是直接回收direct reclaim。如果分配内存时现有空闲页已经不满足watermark[min]并且kswapd也来不及补充分配器会直接从当前进程进入回收路径做完回收之后重新尝试分配。直接回收发生在请求线程的上下文里很容易解释为什么某次请求响应突然变慢它可能不是在等I/O而是在等内存回收释放页面。OOM是在直接回收之后系统已经处于极端不足时的最后手段。__alloc_pages_may_oom会选择一个进程释放其占用的内存。真实环境中OOM通常发生在内存上限没设置好或泄漏严重的容器内遇到OOM不要急着骂内核先看是不是页面回收被某种长期持有的文件页卡住了。5.3 通过内核水位线参数理解“内存还有但申请失败”内核在mm/page_alloc.c里的__zone_watermark_ok负责计算水位线它并不是简单比较free_pages和某个阈值而是把free_pages减去reserve_pages再比较。reserve_pages的计算包括不可回收页、页表页以及/proc/sys/vm/min_free_kbytes等配置项对应的保留内存。这意味着即使/proc/meminfo里显示有几GB的available内存一个特定order的分配请求也可能因为该zone的保留不足而失败。计算水位线时涉及lowmem_reserve每个zone都有一个保留量保证高优先级分配不会把所有内存都抢走。调试时可以把/proc/zoneinfo打开看每个zone的watermark数值变化如果某个zone持续在low以下徘徊就说明该zone已经持续处于回收压力下。6. 实操记录内存压力场景下的行为观测源码阅读终究要落回实际系统上。我在模拟环境里做过一组内存压力实验专门观察回收行为过程记录下来有很多值得参考的细节。6.1 实验环境与压测方式实验环境称它为某测试虚拟机配置了4GB内存、2个vCPU跑的是标准内核swap设备是一个8GB的文件。压测工具用的stress但不建议在生产机器上直接跑我这次也只在一台隔离的虚拟机上执行。产生压力的方式很简单开4个进程并发执行stress --vm 2 --vm-bytes 1536M --vm-hang 0这类参数同时跑另一个程序持续分配并随机写内存。关键是启动第二路程序前先把第一路压力稳住让系统正式进入回收而不是马上OOM。6.2 关键观测点与现象解读实验过程中我同时打开了三个观测窗口watch -n 1 cat /proc/meminfo、vmstat 1、dmesg -w。最直观的现象是当内存逐渐逼近上限时/proc/meminfo里的MemFree会极速下降而Active(anon)和Inactive(anon)交替波动。Inactive(anon)突然下降的那一帧基本就是kswapd开始回收匿名页了。此时vmstat里的si和so列同时开始跳动表示swap换入换出开始活跃。还有个比较容易忽略的指标是pgscan_kswapd和pgsteal_*这些counter在/proc/vmstat里。kswapd扫描多、回收少说明内存里大量页面是刚被访问过的活性很高回收代价大如果发现kswapd频繁唤醒但每次只能回收一点点就要考虑是不是某块区域碎片化或存在大量不可回收页。配套参考dmesg里的水位线记录。如果看到kswapd: ... pages immediately这类信息说明回收已经走到比较吃力的阶段。此时用cat /proc/buddyinfo看一眼碎片分布非常有用如果order较高的空闲块全部消失而order为0的空闲块却堆了好几页问题很可能是连续内存需求在碎片化环境下难以满足。7. 常见问题排查与源码阅读顺序建议整理几个实际遇到和社区里经常被问的问题直接做成速查表方便以后遇到对应现象时快速定位方向。7.1 内存相关典型问题速查现象可能原因优先排查位置进程突然被OOM killcgroup内存上限过小、内存泄漏、回收失败dmesg里的OOM记录、/sys/fs/cgroup下的memory.events系统可用内存足够但大块分配失败zone碎片化、低order空闲块不足/proc/buddyinfo、/proc/zoneinfo内核对象增长不见回落某缓存对象未释放或obj被反复创建/proc/slabinfo按对象数量top排序vmalloc区域耗尽驱动反复申请大缓冲区且未释放/proc/vmallocinfo看未释放区域系统响应抖动但CPU不高direct reclaim或swap颠簸vmstat里的si/so、pgscan_directkmalloc申请失败分配order过高、内存碎片、中断上下文限制调用栈里的gfp_mask和order这里有一个排查内核内存泄漏的经验不要只看free命令的数值要配合/proc/slabinfo和/proc/meminfo里Slab字段的长期趋势。如果slab内存持续上升且无回落优先怀疑某个内核对象缓存泄漏。配合kmemleak工具来扫描通常能直接给出疑似泄漏对象的调用栈。7.2 源码阅读顺序与工具链建议给想要系统读内存管理源码的朋友一个顺序建议先读mm/page_alloc.c的伙伴系统按页分配核心路径然后读mm/slub.c的kmem_cache_alloc和kmem_cache_free接着读mm/vmalloc.c的__vmalloc_node_range再到mm/memory.c的handle_mm_fault最后读mm/vmscan.c的shrink_node和shrink_page_list。这个顺序从最底层到最上层符合内存从分配到缺页到回收的闭环。工具链方面我在读代码对照行为时习惯打开/proc/vmstat、/proc/buddyinfo、/proc/slabinfo、/proc/zoneinfo四个文件配合bpftrace观察内核函数调用。比如当怀疑直接回收影响延迟时可以用bpftrace统计__alloc_pages_direct_reclaim的调用次数与耗时直接确认抖动是否由这条路径引发。内核文档里的Documentation/admin-guide/sysctl/vm.rst是另一份重要的参考很多参数解释只有源码里看不出来。老实说内存管理是我觉得Linux内核里最值得花时间啃的一块它决定了系统在负载下到底能挺多久。每次读到vmscan.c里那些复杂的分支判断都会感叹它在性能和正确性之间做了那么多层权衡。如果你也正在读这部分代码建议不要只盯着函数看动手把你环境里的/proc那堆文件实时拉出来对照一旦看到代码里的判断和系统行为完全对上整个子系统的轮廓就会一下子清晰起来。