前段时间在压测一个存储服务时,我发现一个奇怪的现象:明明内存总量还很充足,但每次接口申请一个8KB的连续块时,耗时总会偶发冲到几百微秒甚至毫秒级。查到最后,问题不在业务代码,而在内存管理这一层——更具体地说,是页面分配算法在长时间运行后出现了外部碎片,分配器不得不去更高阶的空闲列表中拆块,甚至触发内存规整。这让我意识到,很多应用开发同学对内存管理的理解停留在malloc/free,对内核里“物理页面究竟是怎么被分出去的”几乎没有概念。所以就有了这个系列,第一篇先把页面分配算法讲透。这篇文章会从物理页框的分配需求讲起,依次说清经典连续分配算法的取舍、现代内核普遍采用的伙伴系统、slab分配器,以及排查内存分配问题的实用手段。适合对操作系统有一定了解、但没深入看过内存管理源码的开发者,也适合正在啃操作系统的学生。读完你至少能回答三个问题:分配一页内存时内核做了什么、为什么伙伴系统能成为主流、线上内存分配变慢时该看哪里。1. 页面分配到底在解决什么问题1.1 从虚拟内存到物理页框的映射先理清一个基础事实。CPU访问的是虚拟地址,虚拟地址经过MMU查页表,最终才映射到物理地址。对进程来说,它的虚拟地址空间可以是连续的,但背后的物理页框完全可以不连续。你看到的堆空间是连成一片的地址,实际上这些地址可能散落在物理内存的不同位置,全靠页表项把它们串起来。物理内存本身是怎么组织的?内核会把整块物理内存按照固定大小切成一个个页框,最常见的是4KB一页。每个页框有唯一的物理页号,也就是pfn。页框的状态无非两种:空闲或已被占用。页面分配算法要回答的问题就是:当某个调用方申请一个或者多个物理页时,到底把哪些空闲页框给它,怎么记录这些页框已经被占用了,别人释放之后又怎么收回来。这个问题的本质,和停车场管理员分配车位很像。来的车有大有小,有的想停一个车位,有的想停连在一起的几个车位。管理员要快速找到一个合适的空闲车位或连续车位,录入系统,车走了之后再把车位标记为空。如果高峰期几十辆车同时进出,管理员还需要保证不会把同一个车位分给两个人。页面分配器干的就是这件事,只不过它的“车”是内核模块、驱动、用户进程缺页、DMA缓冲区这些申请者,“车位”是物理页框。1.2 分配器必须满足的三条底线内存分配器不是能分出内存就行,它必须同时满足几个看起来很基础、实际上每条都很难缠的要求。第一条是正确性。一个物理页框绝对不能被同时分配给两个调用方,释放之后也不能被某个仍然持有指针的人继续使用。这要求分配器有严格的页框状态管理,分配、释放、合并的路径上不能有竞态。在单核时代这不算难,锁住一个全局变量就行;但到了多核时代,多个CPU同时申请内存,如果每次都抢一把全局锁,性能立刻崩掉。所以现代分配器会在每个CPU上维护一小批本地页框缓存,减少全局锁竞争。第二条是效率。分配和释放路径必须在极短的时间内完成。内核里有些分配动作发生在中断处理、系统调用热路径、网络收包路径上,这些路径对延迟极其敏感。如果每次分配都遍历整个空闲链表找一块合适内存,复杂度可能到O(n),那系统就废了。理想情况是分配和释放都接近O(1),或者至多是O(logN)级别。第三条是碎片控制。碎片分两种:外部碎片是空闲块被拆得七零八落,每个块都不大,导致总量充足但连续大块申请失败;内部碎片是分配了比请求更大的块,多出来的部分浪费掉了。分配器要在“拆分粒度”和“浪费比例”之间做取舍。比如请求5页,你给他8页,内部碎片最多浪费3页;如果你严格找5页连续的块,又可能在剩余内存里留下很多1页、2页的“碎渣”,导致后续大块申请失败。这三条底线互相牵制。追求零外部碎片可能牺牲效率;追求效率又可能引入内部碎片。后面所有算法的具体设计,本质上都是在摆正这三个目标的优先级。2. 经典页面分配算法的思路与取舍2.1 连续分配的四种策略:首次适应、循环首次适应、最佳适应、最差适应早期操作系统处理进程内存分配时,内存被划分为若干个连续分区,空闲分区用链表或表项维护。当一个进程请求一块连续内存时,分配器需要从空闲分区中挑一块。经典教科书里总结了四种策略。首次适应(First Fit)从低地址开始,找到第一个足够大的空闲分区就分出去。它的优点是简单、快速,而且倾向于保留高地址的大空闲区,因为前面的小分区不断被切碎后,新来的小请求会优先填充这些碎片。缺点是低地址区域会被越切越碎,每次查找都要从头开始,搜索成本随着碎片增加而上升。循环首次适应(Next Fit)是首次适应的改进版,不是每次都从低地址开始,而是从上次分配结束的位置继续往下找,把搜索位置做成一个循环指针。这样做的好处是内存使用更均匀,不会总是把压力集中在前半段;坏处是会频繁去切碎后面的大空闲区,导致大块请求可能更难满足。最佳适应(Best Fit)遍历所有空闲分区,选出满足请求的最小空闲块。从“浪费最少”的角度看它是完美的,请求100KB,如果空闲区里有120KB,绝对不会给你300KB。但问题也很明显:每次分配都要扫描整个空闲链表,效率低;而且它留下的剩余块往往是几十KB甚至几KB的极小碎片,这些碎片基本无法被后续请求利用,外部碎片反而更严重。最差适应(Worst Fit)正好相反,它选最大的空闲块,希望切完之后剩下的部分仍然够大,能继续服务后续请求。听起来合理,但实测下来效果并不好。你每次都在大块上开口子,大块很快就被切碎,系统缺乏“压箱底”的大块资源。所以在通用场景下,最差适应并不常用。表格对比一下这四种策略的特点:算法选择策略外部碎片风险搜索成本典型场景首次适应第一个足够大的块低地址碎渣多低早期OS、嵌入式存储管理循环首次适应从上次位置继续找大块被切碎低对分配时间敏感的场景最佳适应最小的足够大块留下大量极小碎片高内存总量少、追求低浪费最差适应最大的空闲块大块被快速切碎高基本被淘汰的通用方案你可能会问,这和“页面分配”有什么关系?关系在于,这些策略解决的是“在多个不同大小的空闲块里挑一块”,而页面分配要解决的也是类似问题,只不过对象变成了等大的页框、连续块大小按2的幂次变化。四种经典策略的思路并没有完全消失,它们的思想以更高效的形式融入到了现代分配器中。2.2 为什么现代内核普遍选择伙伴系统经典连续分配策略有个通病:它们都基于一个动态变化的空闲块列表,当系统运行一段时间后,空闲块大小参差不齐,外部碎片不可避免。现代内核需要一种能在固定结构下快速分配、释放后又能自动合并的算法,伙伴系统因此成为主流。伙伴系统的核心思想是:把物理内存按照2的幂次大小切分成块,分别挂到不同阶(order)的空闲列表中。order等于0是一页,order等于1是两页连续内存,order等于2是四页,以此类推。一个阶为N的空闲块,一定由2的N次方个连续页组成。分配的时候,假设你申请2的N次方页,就先到order为N的列表中找。如果这个列表正好有空闲块,直接拿走;如果列表空了,就去order为N+1的列表中取一块,把它拆成两个order为N的块,一个给你使用,另一个挂回order为N的空闲列表。如果order为N+1也空了,就继续向更高阶借块,一层层拆下来。释放的过程正好反过来:当一块内存被释放时,分配器检查它旁边的“伙伴”是不是也空闲,如果伙伴空闲且大小相同,就把两块合并成一个大块,挂到高一阶的列表里,然后继续向上检查能不能进一步合并。为什么叫“伙伴”?因为两个块必须是同一个父块在拆半时产生的,它们大小相同、地址相邻。比如一个order为3的大块拆成两个order为2的块,这两半就是伙伴关系,且它们的地址满足一个非常巧妙的数学关系:只要用起始页号和块大小做一次异或运算,就能得到伙伴的起始页号。具体来说,如果块大小是2的order次方页,那么伙伴地址等于当前块起始地址异或上(1 << order)。这个特性让“判断能否合并”变成了O(1)的位运算,不需要扫描空闲链表。伙伴系统的优点是显而易见的。第一,分配和释放的时间复杂度极其稳定,多数情况下只涉及链表头和少量位运算;第二,外部碎片被限制在“order粒度”内,不会出现把内存切成无数个互不相干的小块的情况;第三,合并逻辑简单可靠,特别适合需要连续物理内存的场景,比如驱动DMA、CPU页表等。代价是内部碎片。由于只能分配2的幂次大小的页面,你申请3页,系统会给你4页,其中一页就浪费了,最坏情况下内部碎片接近50%。这个浪费在小对象场景下尤其明显:一个进程描述符可能只有几KB,如果每创建一个进程都要向伙伴系统申请一整页甚至两页,浪费会非常严重。于是内核在伙伴系统之上又套了一层slab分配器,补上了小对象分配这块短板。3. 实操核心:伙伴系统与slab分配器是怎么工作的3.1 伙伴系统的核心数据结构与分裂合并逻辑真实内核里的伙伴系统,不是纯教学版的数组模拟,而是围绕结构体数组、链表和页标志位组织起来的。看结构之前,我先带你建立一个全局图景。每个内存节点(Node)下有若干个内存域(Zone),每个Zone里有一个free_area数组。数组下标就是order,从0到MAX_ORDER-1,MAX_ORDER在主流内核里通常是11,也就是说order最高到10,单块最大能表示2的10次方页。按4KB一页计算,最大的空闲块是4MB。free_area[order]内部维护一个空闲块链表和一个nr_free计数,把所有order相同的空闲块串在一起。每个物理页框对应一个struct page对象。当这个页空闲并挂在伙伴系统中时,它的某些标志位会被设置成PageBuddy,同时它的private字段会记录它当前所在块的order。当这个页已经被分配出去时,这些字段就交给使用者重新解释。所以伙伴系统本质上是在操作一张页框状态表,而不是简单的内存字节数组。分配路径的入口通常是alloc_pages或alloc_page,最终会走到rmqueue函数。核心逻辑可以用下面这段简化伪代码概括:// 尝试从指定order及更高阶列表中分配2^order个页 static struct page *__rmqueue_smallest(struct zone *zone, unsigned int order) { for (unsigned int current_order = order; current_order < MAX_ORDER; current_order++) { struct free_area *area = &zone->free_area[current_order]; if (list_empty(&area->free_list)) continue; struct page *page = list_first_entry(&area->free_list, struct page, lru); list_del(&page->lru); area->nr_free--; // 如果借来的块比需要的更大,就把多余部分拆开放到低阶列表 expand(zone, page, order, current_order); return page; } return NULL; }expand函数做的事,就是把一个高阶块不断一分为二,只保留需要的部分,把不需要的兄弟块挂到对应的低阶空闲列表里。拆分的顺序在实现上很讲究:每次取块的前一半或后一半作为返回值,另一半放回空闲列表,然后只对返回的“上半部分”继续拆分。这样可以保证返回的块始终是连续的,而且剩余块始终挂在正确阶的链表上。释放路径的核心是__free_one_page函数,合并逻辑比拆块更有意思。释放一个order为N的块时,第一步是先计算它的伙伴地址:buddy_pfn = page_pfn ^ (1 << order)。然后检查这个伙伴页框是否满足三个条件:在同一Zone内、PageBuddy标志被设置、伙伴记录的order和当前order一致。满足条件就说明两个块确实是同一父块拆出来的,可以合并。合并后把伙伴从空闲链表中摘下来,把当前地址和伙伴地址中较小的那个作为合并后的块地址,order加一,继续向上检查能不能再合并。不要小看那个异或运算。这是伙伴系统最优雅的一个点。因为两个伙伴块的起始地址只差一个块大小的整数倍,且低order位完全一致,用异或算伙伴地址可以保证O(1)时间判定,不需要任何遍历。内核里大量这种用位运算封装复杂判断的设计,读源码时你会发现这正是难点所在:单独看每一行都很简单,但把它们组合起来,就构成了一个无锁级别的快速算法。3.2 slab分配器:解决小对象的碎片与性能问题伙伴系统只管“页”,但内核里大量对象远小于一页。文件对象、socket缓冲、进程描述符、锁对象、inode缓存,这些对象可能只有几十字节到几百字节,如果每个对象分配都直接走伙伴系统,一页4KB可能只用了其中几十字节,浪费极其严重,内碎片会高到不可接受。slab分配器的思路很直接:为同一类对象建一个缓存(cache),缓存里包含若干slab。每个slab本质上是从伙伴系统申请的一块连续物理内存,大小通常是一页或多页,然后被切成固定大小的对象槽。比如你创建一个名为task_struct的缓存,对象大小假设是2KB,那么一个4KB的slab就可以切成两个对象槽,缓存内部用free list把这些空闲槽串起来。分配对象时,slab分配器优先从partially-used的slab中拿一个空闲槽;如果没有,再去找完全空闲的slab;如果连空闲slab都没有,就去伙伴系统申请新的slab。释放对象时,把对象放回它所属slab的空闲链表中,如果整个slab的所有槽都被释放了,这个slab可能被整体还给伙伴系统。为了减少分配延迟,每个CPU还有一个本地对象缓存(per-cpu freelist),短期分配释放根本不需要碰全局缓存结构,这在多核系统上能省掉大量锁开销。slab还有一个非常关键但容易被忽略的设计:着色(cache coloring)。现代CPU的L1/L2缓存存在多路组相联问题,如果多个slab在物理内存中的起始地址有固定的偏移,就可能反复映射到同一个缓存集合,互相驱逐,造成严重的缓存冲突。着色就是让不同slab在分配时给对象起始地址加上不同的偏移量,尽量错开物理页面和CPU缓存集的对应关系。这个细节属于“性能调优级别”的知识,一般应用开发完全不需要关心,但如果你做驱动开发或者极致性能优化,理解它会有帮助。现代内核默认使用的是SLUB分配器,而不是最早的SLAB。SLUB简化了元数据管理,把更多信息直接塞进struct page里,减少了锁竞争,对NUMA和多核环境更友好。但在概念层面,SLUB仍然是“伙伴系统管理物理页,缓存层管理同尺寸对象”这个模型。你通过kmalloc申请内存时,内核其实是在一组按尺寸预建好的kmalloc通用缓存中挑一个合适的,比如kmalloc-32、kmalloc-128、kmalloc-1024,从这些缓存里拿对象。这层逻辑对普通开发者是透明的,但理解它能帮助你解释一个现象:为什么频繁申请和释放小内存的核心模块,整体内存开销看起来比预期大很多。3.3 完整实操:手写一个最小的伙伴系统分配器前面讲了那么多原理,不如直接写一个能编译运行的mini版伙伴系统。我用数组模拟空闲列表,方便你观察每一步拆分和合并的效果。假设总共有32页,order从0到4,最大块是32页连续内存。#include <stdio.h> #define MAX_ORDER 5 #define NPAGES (1 << MAX_ORDER) int free_count[MAX_ORDER]; int free_start[MAX_ORDER][NPAGES]; void add_free(int order, int page) { free_start[order][free_count[order]++] = page; } int pop_free(int order) { if (free_count[order] == 0) return -1; return free_start[order][--free_count[order]]; } int alloc_pages(int need_order) { int order, addr; for (order = need_order; order < MAX_ORDER; order++) { if (free_count[order] == 0) continue; addr = pop_free(order); // 把多余的块拆成低阶块,每次把高半块放回低一阶列表 while (order > need_order) { order--; add_free(order, addr + (1 << order)); } return addr; } return -1; } void free_pages(int addr, int order) { while (order < MAX_ORDER - 1) { int buddy = addr ^ (1 << order); // 伙伴地址 int found = 0; // 这里用线性扫描模拟真实内核的链表查找 for (int i = 0; i < free_count[order]; i++) { if (free_start[order][i] == buddy) { found = 1; free_start[order][i] = free_start[order][--free_count[order]]; break; } } if (!found) break; if (addr > buddy) addr = buddy; order++; } add_free(order, addr); } void dump(void) { for (int o = 0; o < MAX_ORDER; o++) { printf("order %d: count=%d, starts=", o, free_count[o]); for (int i = 0; i < free_count[o]; i++) printf("%d ", free_start[o][i]); printf("\n"); } } int main(void) { add_free(MAX_ORDER - 1, 0); // 初始状态:一个32页的大块 dump(); int a = alloc_pages(1); // 申请2页 printf("alloc order1 -> page %d\n", a); dump(); int b = alloc_pages(2); // 申请4页 printf("alloc order2 -> page %d\n", b); dump(); free_pages(a, 1); printf("after free a (order1):\n"); dump(); free_pages(b, 2); printf("after free b (order2):\n"); dump(); return 0; }运行一下,你能直观看到伙伴系统的完整工作过程。初始时order4空闲列表里有起始页0。第一次申请order1(2页),分配器从order4取块,依次拆成order3、order2、order1:order3里留下页8,order2里留下页4,order1里留下页2,返回页0。第二次申请order2,order2正好有页4,直接取走,没有触发拆分。释放页0(order1)时,伙伴页2在order1列表中,两块合并成order2块页0;再往上检查,伙伴页4已经被使用,停止合并。释放页4(order2)时,伙伴页0在order2列表中,合并成order3块页0;继续找order3的伙伴页8,正好也在,再合并成order4块页0。最后一切恢复原状。这个教学版实现为了直观使用了数组和线性扫描,真实内核里不会这么干。真实内核的free_area用链表,取块时间复杂度是O(1);判断伙伴是否空闲也不是扫描列表,而是直接检查对应struct page的PageBuddy标志和order记录。不过算法骨架是完全一致的,你理解了这个小demo,再去看内核源码里的__free_one_page和expand会顺畅很多。注意:这个demo里合并时只查找相同order的空闲块,天然保证了两块大小一致。真实内核里除了大小一致,还要确认两个块处于同一个zone且没有被隔离,否则可能合并不该合并的物理页面。这是小伙伴手写实现时最容易漏掉的细节。4. 常见问题与排查技巧实录4.1 内存碎片:最隐蔽的性能杀手很多线上内存问题,表面上是“内存不够”,实际是“内存碎得没法用”。最典型的场景:系统运行几天后,free内存总额还有几个GB,但驱动申请一块2MB连续物理内存时失败了。碎片问题之所以难搞,是因为有些页面是“动不了”的。比如内核页表、内核栈、某些驱动分配的DMA缓冲区,这些页面不允许被移动,物理地址必须固定。用户空间进程的普通匿名页和page cache页面则可以迁移——迁移就是把内容复制到新位置,更新页表映射,然后把旧页面释放。为了尽量让可移动页和不可移动页分开,伙伴系统在同一个zone里按“迁移类型”做分组。常见的有MIGRATE_UNMOVABLE、MIGRATE_RECLAIMABLE、MIGRATE_MOVABLE、MIGRATE_CMA等。相同迁移类型的页面尽量聚在一起,这样不可移动页集中在一个区域,剩下的区域可以被顺利规整出大块连续内存。你可以通过/proc/pagetypeinfo看到每个zone内各迁移类型的空闲块分布。当系统需要连续大块时,内核会执行内存规整(memory compaction)。它把可移动页从某段区域迁走,压缩出连续空闲块。这个过程不是无偿的,它需要扫描页面、拷贝内容、刷TLB,消耗不少CPU时间。所以你会看到,机器明明很闲,但某个瞬间申请大块内存的延迟突然飙升,很可能就是后台在默默做compaction。4.2 观察内存分配器状态的几个实用工具排查内存分配问题,第一件事就是看分配器的当前状态。下面这几个命令是我最常用的。cat /proc/buddyinfo这个文件直接输出每个Node、每个Zone在不同order下的空闲块数量。从左到右依次是order0、order1、order2……看到第2列(order2附近)长期为0、而后面几列还有不少块,说明小碎块已经消耗殆尽,内存在不断从大块拆成小块,外部碎片风险很高。cat /proc/pagetypeinfo | head -80这个能看到每个迁移类型在各order下的空闲块数量。如果MIGRATE_UNMOVABLE列在高阶区域占了大片,说明不可移动页太分散,规整难度大。cat /proc/slabinfo | head -20 slabtop -s c这两个命令看slab缓存的使用情况,可以定位某种内核对象占用过多。比如某个驱动的对象数量异常增长,往往能在这里一眼看出来。如果需要手动触发一次内存规整,Linux提供了个人为干预开关:echo 1 > /proc/sys/vm/compact_memory这个操作会立即对系统内存尝试一次规整,在严重碎片化的机器上,短时间内能看到大块空闲数量明显上升。但这不是治本方案,频繁手动规整说明系统配置或程序分配模式本身有问题。4.3 我在实际项目中踩过的几个坑第一个坑:在中断上下文或持锁路径上申请内存,使用了默认的GFP_KERNEL标志,结果触发睡眠或回收,导致延时抖动和死锁风险。中断上下文只能使用GFP_ATOMIC,它不会睡眠、不会触发回收,可以说是“兜里有什么给什么”,因此失败概率也更高。解决方式是尽量在进入中断路径之前提前分配好或者使用预分配的内存池。第二个坑:频繁申请释放高阶页面,导致2MB甚至4MB的大块被反复拆成小片,整个过程会不断向下传播碎片。最典型的场景是某些驱动为了一小块连续内存,每次申请order为8或9的内存,用完就释放。系统看起来内存充足,但高阶空闲列表总是空的。解决办法是缓存复用:把分配到的较大块留在池子里反复使用,而不是用一次就释放。第三个坑:合并伙伴时没有检查“伙伴的order和自己是否一致”。只看到伙伴是空闲的就合并,可能把两个大小不对等的块拼到一起,破坏伙伴结构,轻则碎块丢失,重则内存管理彻底混乱。真实内核之所以要在struct page里记录buddy order,就是为了避免这种错误。第四个坑:误以为“虚拟地址连续等于物理地址连续”。应用层甚至驱动层拿到一段连续虚拟地址,直接往物理DMA设备上提交,结果设备读写到错误物理内存。DMA设备需要的是物理连续内存,不是虚拟连续,尤其是老式设备没有IOMMU时,这个坑非常致命。申请DMA缓冲区一定要走专门的连续内存接口或CMA机制。第五个坑:内存充足但分配延迟很高。打开监控你可能会看到分配路径走了__alloc_pages_slowpath,意味着直接内存回收(direct reclaim)被触发。这种情况下分配器一边扫描LRU回收可回收页面,一边等待writeback完成,延迟可能到几十毫秒。解决方向是调整内存水位线、减少page cache压力、或者优化应用本身的分配频率。每个坑背后的教训都是同一个:不要只盯着“有没有内存可用”,还要关心“以什么成本、什么形态拿到这块内存”。页面分配算法只是框架,真正决定系统表现的,是碎片、并发、睡眠约束这些工程细节。5. 从“内存管理1”延伸到后续方向5.1 页面分配与页面置换的分工边界页面分配算法解决的是“从空闲内存里挑页”,但当系统内存不足时,光靠分配器是撑不住的。这时候就需要另一个系统介入:页面回收和页面置换。分配器在拿不到空闲页时,会先看水位线。水位线分high、low、min三档。当空闲页低于low时,内核的kswapd内核线程会被唤醒,异步回收一些页面,让水位恢复正常;当空闲页跌破min时,意味着内存严重短缺,分配路径被迫进行同步的直接回收,直接把某些页面回收掉来满足当前请求;如果回收都来不及,再往下就可能触发OOM killer。页面置换算法的任务,是在已分配的页面中选择“牺牲品”。最经典的策略是LRU,最近最少使用页面优先回收。为了实现LRU,内核把页面挂到LRU链表上,同时配合反向映射机制找到谁在引用这个页面。这部分内容牵扯到页表遍历、TLB控制、文件缓存回写,是一个比页面分配大得多的主题。理解分配和置换的分工,你才能读懂为什么内存压力大时系统会出现各种延迟尖峰:那些尖峰极有可能不是分配器慢,而是回收路径在忙。5.2 后续可以扩展的几大主题这次的“内存管理1”只是开了个头。顺着页面分配往下,值得展开的方向还有一大串。页面置换算法可以单独讲一篇:LRU的缺陷、Clock算法、二次机会算法、工作集模型,以及现代内核如何用多级LRU和阴影节点模拟访问频率。虚拟内存层次也值得深入:多级页表每一级到底怎么查、TLB miss怎么办、透明大页THP为什么有时候提升性能有时候反而导致问题。再看NUMA方向:现代服务器是多个内存节点,分配器默认是Node本地优先,本地内存不足才去远端借,这里涉及内存策略、自动迁移和Node回收问题的权衡。还有用户态分配器与内核的交互:glibc的ptmalloc、jemalloc、tcmalloc,它们和内核页面分配器之间隔着mmap和brk两座桥。很多人调的malloc参数,实际上影响的是用户态分配器的堆管理策略,和内核伙伴系统关系不大,但最终物理内存来源又离不开内核。把这两层放在一起看,你对线上内存问题的理解才算完整。内存cgroup的隔离、内存热插拔、持久内存等题目,也都是从“页面分配”这个根上长出来的分支。如果让我给后来的同学一个学习顺序建议,我会说:先把今天这个最小伙伴系统的实现跑熟,再去看内核源码里的expand和__free_one_page这两个函数,接着在带内存压力的机器上观察buddyinfo和pagetypeinfo,最后再碰slab。这个过程我自己走过一遍,最大的感受是:页面分配算法本身并不难,难的是你总要在“快”和“省”之间做取舍,还要时刻提防碎片和并发这两个看不见的敌人。第一篇就到这里,下一篇我们聊页面置换。
前段时间在压测一个存储服务时,我发现一个奇怪的现象:明明内存总量还很充足,但每次接口申请一个8KB的连续块时,耗时总会偶发冲到几百微秒甚至毫秒级。查到最后,问题不在业务代码,而在内存管理这一层——更具体地说,是页面分配算法在长时间运行后出现了外部碎片,分配器不得不去更高阶的空闲列表中拆块,甚至触发内存规整。这让我意识到,很多应用开发同学对内存管理的理解停留在malloc/free,对内核里“物理页面究竟是怎么被分出去的”几乎没有概念。所以就有了这个系列,第一篇先把页面分配算法讲透。这篇文章会从物理页框的分配需求讲起,依次说清经典连续分配算法的取舍、现代内核普遍采用的伙伴系统、slab分配器,以及排查内存分配问题的实用手段。适合对操作系统有一定了解、但没深入看过内存管理源码的开发者,也适合正在啃操作系统的学生。读完你至少能回答三个问题:分配一页内存时内核做了什么、为什么伙伴系统能成为主流、线上内存分配变慢时该看哪里。1. 页面分配到底在解决什么问题1.1 从虚拟内存到物理页框的映射先理清一个基础事实。CPU访问的是虚拟地址,虚拟地址经过MMU查页表,最终才映射到物理地址。对进程来说,它的虚拟地址空间可以是连续的,但背后的物理页框完全可以不连续。你看到的堆空间是连成一片的地址,实际上这些地址可能散落在物理内存的不同位置,全靠页表项把它们串起来。物理内存本身是怎么组织的?内核会把整块物理内存按照固定大小切成一个个页框,最常见的是4KB一页。每个页框有唯一的物理页号,也就是pfn。页框的状态无非两种:空闲或已被占用。页面分配算法要回答的问题就是:当某个调用方申请一个或者多个物理页时,到底把哪些空闲页框给它,怎么记录这些页框已经被占用了,别人释放之后又怎么收回来。这个问题的本质,和停车场管理员分配车位很像。来的车有大有小,有的想停一个车位,有的想停连在一起的几个车位。管理员要快速找到一个合适的空闲车位或连续车位,录入系统,车走了之后再把车位标记为空。如果高峰期几十辆车同时进出,管理员还需要保证不会把同一个车位分给两个人。页面分配器干的就是这件事,只不过它的“车”是内核模块、驱动、用户进程缺页、DMA缓冲区这些申请者,“车位”是物理页框。1.2 分配器必须满足的三条底线内存分配器不是能分出内存就行,它必须同时满足几个看起来很基础、实际上每条都很难缠的要求。第一条是正确性。一个物理页框绝对不能被同时分配给两个调用方,释放之后也不能被某个仍然持有指针的人继续使用。这要求分配器有严格的页框状态管理,分配、释放、合并的路径上不能有竞态。在单核时代这不算难,锁住一个全局变量就行;但到了多核时代,多个CPU同时申请内存,如果每次都抢一把全局锁,性能立刻崩掉。所以现代分配器会在每个CPU上维护一小批本地页框缓存,减少全局锁竞争。第二条是效率。分配和释放路径必须在极短的时间内完成。内核里有些分配动作发生在中断处理、系统调用热路径、网络收包路径上,这些路径对延迟极其敏感。如果每次分配都遍历整个空闲链表找一块合适内存,复杂度可能到O(n),那系统就废了。理想情况是分配和释放都接近O(1),或者至多是O(logN)级别。第三条是碎片控制。碎片分两种:外部碎片是空闲块被拆得七零八落,每个块都不大,导致总量充足但连续大块申请失败;内部碎片是分配了比请求更大的块,多出来的部分浪费掉了。分配器要在“拆分粒度”和“浪费比例”之间做取舍。比如请求5页,你给他8页,内部碎片最多浪费3页;如果你严格找5页连续的块,又可能在剩余内存里留下很多1页、2页的“碎渣”,导致后续大块申请失败。这三条底线互相牵制。追求零外部碎片可能牺牲效率;追求效率又可能引入内部碎片。后面所有算法的具体设计,本质上都是在摆正这三个目标的优先级。2. 经典页面分配算法的思路与取舍2.1 连续分配的四种策略:首次适应、循环首次适应、最佳适应、最差适应早期操作系统处理进程内存分配时,内存被划分为若干个连续分区,空闲分区用链表或表项维护。当一个进程请求一块连续内存时,分配器需要从空闲分区中挑一块。经典教科书里总结了四种策略。首次适应(First Fit)从低地址开始,找到第一个足够大的空闲分区就分出去。它的优点是简单、快速,而且倾向于保留高地址的大空闲区,因为前面的小分区不断被切碎后,新来的小请求会优先填充这些碎片。缺点是低地址区域会被越切越碎,每次查找都要从头开始,搜索成本随着碎片增加而上升。循环首次适应(Next Fit)是首次适应的改进版,不是每次都从低地址开始,而是从上次分配结束的位置继续往下找,把搜索位置做成一个循环指针。这样做的好处是内存使用更均匀,不会总是把压力集中在前半段;坏处是会频繁去切碎后面的大空闲区,导致大块请求可能更难满足。最佳适应(Best Fit)遍历所有空闲分区,选出满足请求的最小空闲块。从“浪费最少”的角度看它是完美的,请求100KB,如果空闲区里有120KB,绝对不会给你300KB。但问题也很明显:每次分配都要扫描整个空闲链表,效率低;而且它留下的剩余块往往是几十KB甚至几KB的极小碎片,这些碎片基本无法被后续请求利用,外部碎片反而更严重。最差适应(Worst Fit)正好相反,它选最大的空闲块,希望切完之后剩下的部分仍然够大,能继续服务后续请求。听起来合理,但实测下来效果并不好。你每次都在大块上开口子,大块很快就被切碎,系统缺乏“压箱底”的大块资源。所以在通用场景下,最差适应并不常用。表格对比一下这四种策略的特点:算法选择策略外部碎片风险搜索成本典型场景首次适应第一个足够大的块低地址碎渣多低早期OS、嵌入式存储管理循环首次适应从上次位置继续找大块被切碎低对分配时间敏感的场景最佳适应最小的足够大块留下大量极小碎片高内存总量少、追求低浪费最差适应最大的空闲块大块被快速切碎高基本被淘汰的通用方案你可能会问,这和“页面分配”有什么关系?关系在于,这些策略解决的是“在多个不同大小的空闲块里挑一块”,而页面分配要解决的也是类似问题,只不过对象变成了等大的页框、连续块大小按2的幂次变化。四种经典策略的思路并没有完全消失,它们的思想以更高效的形式融入到了现代分配器中。2.2 为什么现代内核普遍选择伙伴系统经典连续分配策略有个通病:它们都基于一个动态变化的空闲块列表,当系统运行一段时间后,空闲块大小参差不齐,外部碎片不可避免。现代内核需要一种能在固定结构下快速分配、释放后又能自动合并的算法,伙伴系统因此成为主流。伙伴系统的核心思想是:把物理内存按照2的幂次大小切分成块,分别挂到不同阶(order)的空闲列表中。order等于0是一页,order等于1是两页连续内存,order等于2是四页,以此类推。一个阶为N的空闲块,一定由2的N次方个连续页组成。分配的时候,假设你申请2的N次方页,就先到order为N的列表中找。如果这个列表正好有空闲块,直接拿走;如果列表空了,就去order为N+1的列表中取一块,把它拆成两个order为N的块,一个给你使用,另一个挂回order为N的空闲列表。如果order为N+1也空了,就继续向更高阶借块,一层层拆下来。释放的过程正好反过来:当一块内存被释放时,分配器检查它旁边的“伙伴”是不是也空闲,如果伙伴空闲且大小相同,就把两块合并成一个大块,挂到高一阶的列表里,然后继续向上检查能不能进一步合并。为什么叫“伙伴”?因为两个块必须是同一个父块在拆半时产生的,它们大小相同、地址相邻。比如一个order为3的大块拆成两个order为2的块,这两半就是伙伴关系,且它们的地址满足一个非常巧妙的数学关系:只要用起始页号和块大小做一次异或运算,就能得到伙伴的起始页号。具体来说,如果块大小是2的order次方页,那么伙伴地址等于当前块起始地址异或上(1 << order)。这个特性让“判断能否合并”变成了O(1)的位运算,不需要扫描空闲链表。伙伴系统的优点是显而易见的。第一,分配和释放的时间复杂度极其稳定,多数情况下只涉及链表头和少量位运算;第二,外部碎片被限制在“order粒度”内,不会出现把内存切成无数个互不相干的小块的情况;第三,合并逻辑简单可靠,特别适合需要连续物理内存的场景,比如驱动DMA、CPU页表等。代价是内部碎片。由于只能分配2的幂次大小的页面,你申请3页,系统会给你4页,其中一页就浪费了,最坏情况下内部碎片接近50%。这个浪费在小对象场景下尤其明显:一个进程描述符可能只有几KB,如果每创建一个进程都要向伙伴系统申请一整页甚至两页,浪费会非常严重。于是内核在伙伴系统之上又套了一层slab分配器,补上了小对象分配这块短板。3. 实操核心:伙伴系统与slab分配器是怎么工作的3.1 伙伴系统的核心数据结构与分裂合并逻辑真实内核里的伙伴系统,不是纯教学版的数组模拟,而是围绕结构体数组、链表和页标志位组织起来的。看结构之前,我先带你建立一个全局图景。每个内存节点(Node)下有若干个内存域(Zone),每个Zone里有一个free_area数组。数组下标就是order,从0到MAX_ORDER-1,MAX_ORDER在主流内核里通常是11,也就是说order最高到10,单块最大能表示2的10次方页。按4KB一页计算,最大的空闲块是4MB。free_area[order]内部维护一个空闲块链表和一个nr_free计数,把所有order相同的空闲块串在一起。每个物理页框对应一个struct page对象。当这个页空闲并挂在伙伴系统中时,它的某些标志位会被设置成PageBuddy,同时它的private字段会记录它当前所在块的order。当这个页已经被分配出去时,这些字段就交给使用者重新解释。所以伙伴系统本质上是在操作一张页框状态表,而不是简单的内存字节数组。分配路径的入口通常是alloc_pages或alloc_page,最终会走到rmqueue函数。核心逻辑可以用下面这段简化伪代码概括:// 尝试从指定order及更高阶列表中分配2^order个页 static struct page *__rmqueue_smallest(struct zone *zone, unsigned int order) { for (unsigned int current_order = order; current_order < MAX_ORDER; current_order++) { struct free_area *area = &zone->free_area[current_order]; if (list_empty(&area->free_list)) continue; struct page *page = list_first_entry(&area->free_list, struct page, lru); list_del(&page->lru); area->nr_free--; // 如果借来的块比需要的更大,就把多余部分拆开放到低阶列表 expand(zone, page, order, current_order); return page; } return NULL; }expand函数做的事,就是把一个高阶块不断一分为二,只保留需要的部分,把不需要的兄弟块挂到对应的低阶空闲列表里。拆分的顺序在实现上很讲究:每次取块的前一半或后一半作为返回值,另一半放回空闲列表,然后只对返回的“上半部分”继续拆分。这样可以保证返回的块始终是连续的,而且剩余块始终挂在正确阶的链表上。释放路径的核心是__free_one_page函数,合并逻辑比拆块更有意思。释放一个order为N的块时,第一步是先计算它的伙伴地址:buddy_pfn = page_pfn ^ (1 << order)。然后检查这个伙伴页框是否满足三个条件:在同一Zone内、PageBuddy标志被设置、伙伴记录的order和当前order一致。满足条件就说明两个块确实是同一父块拆出来的,可以合并。合并后把伙伴从空闲链表中摘下来,把当前地址和伙伴地址中较小的那个作为合并后的块地址,order加一,继续向上检查能不能再合并。不要小看那个异或运算。这是伙伴系统最优雅的一个点。因为两个伙伴块的起始地址只差一个块大小的整数倍,且低order位完全一致,用异或算伙伴地址可以保证O(1)时间判定,不需要任何遍历。内核里大量这种用位运算封装复杂判断的设计,读源码时你会发现这正是难点所在:单独看每一行都很简单,但把它们组合起来,就构成了一个无锁级别的快速算法。3.2 slab分配器:解决小对象的碎片与性能问题伙伴系统只管“页”,但内核里大量对象远小于一页。文件对象、socket缓冲、进程描述符、锁对象、inode缓存,这些对象可能只有几十字节到几百字节,如果每个对象分配都直接走伙伴系统,一页4KB可能只用了其中几十字节,浪费极其严重,内碎片会高到不可接受。slab分配器的思路很直接:为同一类对象建一个缓存(cache),缓存里包含若干slab。每个slab本质上是从伙伴系统申请的一块连续物理内存,大小通常是一页或多页,然后被切成固定大小的对象槽。比如你创建一个名为task_struct的缓存,对象大小假设是2KB,那么一个4KB的slab就可以切成两个对象槽,缓存内部用free list把这些空闲槽串起来。分配对象时,slab分配器优先从partially-used的slab中拿一个空闲槽;如果没有,再去找完全空闲的slab;如果连空闲slab都没有,就去伙伴系统申请新的slab。释放对象时,把对象放回它所属slab的空闲链表中,如果整个slab的所有槽都被释放了,这个slab可能被整体还给伙伴系统。为了减少分配延迟,每个CPU还有一个本地对象缓存(per-cpu freelist),短期分配释放根本不需要碰全局缓存结构,这在多核系统上能省掉大量锁开销。slab还有一个非常关键但容易被忽略的设计:着色(cache coloring)。现代CPU的L1/L2缓存存在多路组相联问题,如果多个slab在物理内存中的起始地址有固定的偏移,就可能反复映射到同一个缓存集合,互相驱逐,造成严重的缓存冲突。着色就是让不同slab在分配时给对象起始地址加上不同的偏移量,尽量错开物理页面和CPU缓存集的对应关系。这个细节属于“性能调优级别”的知识,一般应用开发完全不需要关心,但如果你做驱动开发或者极致性能优化,理解它会有帮助。现代内核默认使用的是SLUB分配器,而不是最早的SLAB。SLUB简化了元数据管理,把更多信息直接塞进struct page里,减少了锁竞争,对NUMA和多核环境更友好。但在概念层面,SLUB仍然是“伙伴系统管理物理页,缓存层管理同尺寸对象”这个模型。你通过kmalloc申请内存时,内核其实是在一组按尺寸预建好的kmalloc通用缓存中挑一个合适的,比如kmalloc-32、kmalloc-128、kmalloc-1024,从这些缓存里拿对象。这层逻辑对普通开发者是透明的,但理解它能帮助你解释一个现象:为什么频繁申请和释放小内存的核心模块,整体内存开销看起来比预期大很多。3.3 完整实操:手写一个最小的伙伴系统分配器前面讲了那么多原理,不如直接写一个能编译运行的mini版伙伴系统。我用数组模拟空闲列表,方便你观察每一步拆分和合并的效果。假设总共有32页,order从0到4,最大块是32页连续内存。#include <stdio.h> #define MAX_ORDER 5 #define NPAGES (1 << MAX_ORDER) int free_count[MAX_ORDER]; int free_start[MAX_ORDER][NPAGES]; void add_free(int order, int page) { free_start[order][free_count[order]++] = page; } int pop_free(int order) { if (free_count[order] == 0) return -1; return free_start[order][--free_count[order]]; } int alloc_pages(int need_order) { int order, addr; for (order = need_order; order < MAX_ORDER; order++) { if (free_count[order] == 0) continue; addr = pop_free(order); // 把多余的块拆成低阶块,每次把高半块放回低一阶列表 while (order > need_order) { order--; add_free(order, addr + (1 << order)); } return addr; } return -1; } void free_pages(int addr, int order) { while (order < MAX_ORDER - 1) { int buddy = addr ^ (1 << order); // 伙伴地址 int found = 0; // 这里用线性扫描模拟真实内核的链表查找 for (int i = 0; i < free_count[order]; i++) { if (free_start[order][i] == buddy) { found = 1; free_start[order][i] = free_start[order][--free_count[order]]; break; } } if (!found) break; if (addr > buddy) addr = buddy; order++; } add_free(order, addr); } void dump(void) { for (int o = 0; o < MAX_ORDER; o++) { printf("order %d: count=%d, starts=", o, free_count[o]); for (int i = 0; i < free_count[o]; i++) printf("%d ", free_start[o][i]); printf("\n"); } } int main(void) { add_free(MAX_ORDER - 1, 0); // 初始状态:一个32页的大块 dump(); int a = alloc_pages(1); // 申请2页 printf("alloc order1 -> page %d\n", a); dump(); int b = alloc_pages(2); // 申请4页 printf("alloc order2 -> page %d\n", b); dump(); free_pages(a, 1); printf("after free a (order1):\n"); dump(); free_pages(b, 2); printf("after free b (order2):\n"); dump(); return 0; }运行一下,你能直观看到伙伴系统的完整工作过程。初始时order4空闲列表里有起始页0。第一次申请order1(2页),分配器从order4取块,依次拆成order3、order2、order1:order3里留下页8,order2里留下页4,order1里留下页2,返回页0。第二次申请order2,order2正好有页4,直接取走,没有触发拆分。释放页0(order1)时,伙伴页2在order1列表中,两块合并成order2块页0;再往上检查,伙伴页4已经被使用,停止合并。释放页4(order2)时,伙伴页0在order2列表中,合并成order3块页0;继续找order3的伙伴页8,正好也在,再合并成order4块页0。最后一切恢复原状。这个教学版实现为了直观使用了数组和线性扫描,真实内核里不会这么干。真实内核的free_area用链表,取块时间复杂度是O(1);判断伙伴是否空闲也不是扫描列表,而是直接检查对应struct page的PageBuddy标志和order记录。不过算法骨架是完全一致的,你理解了这个小demo,再去看内核源码里的__free_one_page和expand会顺畅很多。注意:这个demo里合并时只查找相同order的空闲块,天然保证了两块大小一致。真实内核里除了大小一致,还要确认两个块处于同一个zone且没有被隔离,否则可能合并不该合并的物理页面。这是小伙伴手写实现时最容易漏掉的细节。4. 常见问题与排查技巧实录4.1 内存碎片:最隐蔽的性能杀手很多线上内存问题,表面上是“内存不够”,实际是“内存碎得没法用”。最典型的场景:系统运行几天后,free内存总额还有几个GB,但驱动申请一块2MB连续物理内存时失败了。碎片问题之所以难搞,是因为有些页面是“动不了”的。比如内核页表、内核栈、某些驱动分配的DMA缓冲区,这些页面不允许被移动,物理地址必须固定。用户空间进程的普通匿名页和page cache页面则可以迁移——迁移就是把内容复制到新位置,更新页表映射,然后把旧页面释放。为了尽量让可移动页和不可移动页分开,伙伴系统在同一个zone里按“迁移类型”做分组。常见的有MIGRATE_UNMOVABLE、MIGRATE_RECLAIMABLE、MIGRATE_MOVABLE、MIGRATE_CMA等。相同迁移类型的页面尽量聚在一起,这样不可移动页集中在一个区域,剩下的区域可以被顺利规整出大块连续内存。你可以通过/proc/pagetypeinfo看到每个zone内各迁移类型的空闲块分布。当系统需要连续大块时,内核会执行内存规整(memory compaction)。它把可移动页从某段区域迁走,压缩出连续空闲块。这个过程不是无偿的,它需要扫描页面、拷贝内容、刷TLB,消耗不少CPU时间。所以你会看到,机器明明很闲,但某个瞬间申请大块内存的延迟突然飙升,很可能就是后台在默默做compaction。4.2 观察内存分配器状态的几个实用工具排查内存分配问题,第一件事就是看分配器的当前状态。下面这几个命令是我最常用的。cat /proc/buddyinfo这个文件直接输出每个Node、每个Zone在不同order下的空闲块数量。从左到右依次是order0、order1、order2……看到第2列(order2附近)长期为0、而后面几列还有不少块,说明小碎块已经消耗殆尽,内存在不断从大块拆成小块,外部碎片风险很高。cat /proc/pagetypeinfo | head -80这个能看到每个迁移类型在各order下的空闲块数量。如果MIGRATE_UNMOVABLE列在高阶区域占了大片,说明不可移动页太分散,规整难度大。cat /proc/slabinfo | head -20 slabtop -s c这两个命令看slab缓存的使用情况,可以定位某种内核对象占用过多。比如某个驱动的对象数量异常增长,往往能在这里一眼看出来。如果需要手动触发一次内存规整,Linux提供了个人为干预开关:echo 1 > /proc/sys/vm/compact_memory这个操作会立即对系统内存尝试一次规整,在严重碎片化的机器上,短时间内能看到大块空闲数量明显上升。但这不是治本方案,频繁手动规整说明系统配置或程序分配模式本身有问题。4.3 我在实际项目中踩过的几个坑第一个坑:在中断上下文或持锁路径上申请内存,使用了默认的GFP_KERNEL标志,结果触发睡眠或回收,导致延时抖动和死锁风险。中断上下文只能使用GFP_ATOMIC,它不会睡眠、不会触发回收,可以说是“兜里有什么给什么”,因此失败概率也更高。解决方式是尽量在进入中断路径之前提前分配好或者使用预分配的内存池。第二个坑:频繁申请释放高阶页面,导致2MB甚至4MB的大块被反复拆成小片,整个过程会不断向下传播碎片。最典型的场景是某些驱动为了一小块连续内存,每次申请order为8或9的内存,用完就释放。系统看起来内存充足,但高阶空闲列表总是空的。解决办法是缓存复用:把分配到的较大块留在池子里反复使用,而不是用一次就释放。第三个坑:合并伙伴时没有检查“伙伴的order和自己是否一致”。只看到伙伴是空闲的就合并,可能把两个大小不对等的块拼到一起,破坏伙伴结构,轻则碎块丢失,重则内存管理彻底混乱。真实内核之所以要在struct page里记录buddy order,就是为了避免这种错误。第四个坑:误以为“虚拟地址连续等于物理地址连续”。应用层甚至驱动层拿到一段连续虚拟地址,直接往物理DMA设备上提交,结果设备读写到错误物理内存。DMA设备需要的是物理连续内存,不是虚拟连续,尤其是老式设备没有IOMMU时,这个坑非常致命。申请DMA缓冲区一定要走专门的连续内存接口或CMA机制。第五个坑:内存充足但分配延迟很高。打开监控你可能会看到分配路径走了__alloc_pages_slowpath,意味着直接内存回收(direct reclaim)被触发。这种情况下分配器一边扫描LRU回收可回收页面,一边等待writeback完成,延迟可能到几十毫秒。解决方向是调整内存水位线、减少page cache压力、或者优化应用本身的分配频率。每个坑背后的教训都是同一个:不要只盯着“有没有内存可用”,还要关心“以什么成本、什么形态拿到这块内存”。页面分配算法只是框架,真正决定系统表现的,是碎片、并发、睡眠约束这些工程细节。5. 从“内存管理1”延伸到后续方向5.1 页面分配与页面置换的分工边界页面分配算法解决的是“从空闲内存里挑页”,但当系统内存不足时,光靠分配器是撑不住的。这时候就需要另一个系统介入:页面回收和页面置换。分配器在拿不到空闲页时,会先看水位线。水位线分high、low、min三档。当空闲页低于low时,内核的kswapd内核线程会被唤醒,异步回收一些页面,让水位恢复正常;当空闲页跌破min时,意味着内存严重短缺,分配路径被迫进行同步的直接回收,直接把某些页面回收掉来满足当前请求;如果回收都来不及,再往下就可能触发OOM killer。页面置换算法的任务,是在已分配的页面中选择“牺牲品”。最经典的策略是LRU,最近最少使用页面优先回收。为了实现LRU,内核把页面挂到LRU链表上,同时配合反向映射机制找到谁在引用这个页面。这部分内容牵扯到页表遍历、TLB控制、文件缓存回写,是一个比页面分配大得多的主题。理解分配和置换的分工,你才能读懂为什么内存压力大时系统会出现各种延迟尖峰:那些尖峰极有可能不是分配器慢,而是回收路径在忙。5.2 后续可以扩展的几大主题这次的“内存管理1”只是开了个头。顺着页面分配往下,值得展开的方向还有一大串。页面置换算法可以单独讲一篇:LRU的缺陷、Clock算法、二次机会算法、工作集模型,以及现代内核如何用多级LRU和阴影节点模拟访问频率。虚拟内存层次也值得深入:多级页表每一级到底怎么查、TLB miss怎么办、透明大页THP为什么有时候提升性能有时候反而导致问题。再看NUMA方向:现代服务器是多个内存节点,分配器默认是Node本地优先,本地内存不足才去远端借,这里涉及内存策略、自动迁移和Node回收问题的权衡。还有用户态分配器与内核的交互:glibc的ptmalloc、jemalloc、tcmalloc,它们和内核页面分配器之间隔着mmap和brk两座桥。很多人调的malloc参数,实际上影响的是用户态分配器的堆管理策略,和内核伙伴系统关系不大,但最终物理内存来源又离不开内核。把这两层放在一起看,你对线上内存问题的理解才算完整。内存cgroup的隔离、内存热插拔、持久内存等题目,也都是从“页面分配”这个根上长出来的分支。如果让我给后来的同学一个学习顺序建议,我会说:先把今天这个最小伙伴系统的实现跑熟,再去看内核源码里的expand和__free_one_page这两个函数,接着在带内存压力的机器上观察buddyinfo和pagetypeinfo,最后再碰slab。这个过程我自己走过一遍,最大的感受是:页面分配算法本身并不难,难的是你总要在“快”和“省”之间做取舍,还要时刻提防碎片和并发这两个看不见的敌人。第一篇就到这里,下一篇我们聊页面置换。