ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Linux内核Buddy分配器详解:物理页分配机制与调试实战

Linux内核Buddy分配器详解:物理页分配机制与调试实战

1. 项目概述:Buddy系统分配器到底在解决什么问题

物理内存是计算机里最宝贵的资源之一,而操作系统内核每天要做的事情,就是把这些内存块高效地分配给各个进程、内核模块、设备驱动使用。如果你写过或者看过内核内存管理相关代码,一定绕不开一个名字:Buddy System(伙伴系统)。我这次要聊的"Buddy系统分配器之分配page",说白了就是Linux内核里一套物理页分配机制的核心实现。它负责回答一个看似简单、实则极其复杂的问题:当一个进程或内核子系统向系统申请若干物理页时,分配器怎么给得又快又准,同时在释放时又能把零散的内存重新拼成大块。

这个模块的适用场景非常广。嵌入式Linux移植、驱动开发、虚拟化内存管理、数据库性能调优,甚至你在排查一次应用报错"Out of Memory"时,底层追到最后都会看到Buddy分配器的工作痕迹。对内核开发者而言,理解分配page的整个流程,不只是搞懂几个函数调用,而是真正看懂物理内存从"空闲"到"被使用"的完整生命周期。对做系统调优的工程师而言,分配器的行为直接决定了内存碎片化程度、分配延迟和OOM触发的概率,这些指标最终都会反馈到业务稳定性和性能表现上。

我最初接触这块内容时,也一度被各种术语绕晕:order、migratetype、watermark、zone、free_area……听起来像天书。但真正把分配page的主路径从入口函数到链表操作逐行走了一遍之后,我发现Buddy分配器的设计其实非常优雅,核心思路也就是把一个复杂的资源管理问题,通过"按大小分桶 + 拆分与合并"这套组合拳给化解掉了。这篇文章就打算用我理解的方式,把这套机制掰开揉碎讲清楚,重点放在分配路径上,顺带把背后的设计逻辑、常见坑点和排障手段也一并分享出来。

2. 整体设计思路:为什么内核选了"伙伴"这套玩法

2.1 从碎片化说起:连续内存的稀缺性

先讲一个背景。物理内存分配有一个天然痛点:内存页是4KB大小的最小单位,但系统里不同的使用者对连续性的要求差异极大。普通进程的数据页,分散在哪儿都行;但DMA缓冲区、内核栈、页表等场景,必须拿到物理上连续的一段内存。如果分配器只会一个页一个页地发,那么时间一长,物理内存上就会布满"洞",大块连续内存被大量零星页切割得支离破碎。这种碎片化一旦严重,哪怕系统明明还有几百MB总空闲内存,也凑不出一块2MB或更大的连续区域,直接导致分配失败或者触发OOM。

Buddy分配器的出现就是为了对抗碎片化。它的核心策略是先尽量分配大小合适的内存块,避免把大块拆成小块;释放时则主动把相邻的小块合并回大块,让空闲内存始终保持"既有小块应急,也有大块兜底"的状态。这套策略翻译成数据结构,就是我在下文里要详述的free_area数组加链表体系。

2.2 核心数据结构一览

分配page这套机制贯穿了几个关键数据结构。首先是节点、内存域、页框这三层模型:内核把物理内存按访问速度和硬件拓扑划分为若干node(节点),每个node再细分为多个zone(内存域),比如ZONE_DMA、ZONE_NORMAL、ZONE_MOVABLE。Buddy分配器主要工作在zone这一层,每个zone都维护着自己的空闲页集合,避免跨zone的锁竞争和访存开销。

每个zone里最关键的就是struct free_area数组,它把空闲页按order(阶数)分成最多11个链表。order是一个以2为底的指数,order为0表示1个页,order为1表示2个连续页,order为10表示1024个连续页,也就是通常说的4MB大小块。每个free_area链表里挂的都是该order大小的空闲连续块。为什么用2的幂?因为拆分和合并时,只要判断"伙伴"是否存在,就可以在位图或链表层面快速完成,二进制天然适合这种操作。

除了free_area,分配路径上还会频繁碰到的数据结构包括:管理区的watermark数组(水印阈值)、pcp(per-cpu页表缓存)列表、page结构的flags字段,以及用于统计的vm_stat。理解了这些结构之间的关系,再去看分配page的代码就不会觉得是一团浆糊了。

2.3 为啥拆分合并这招这么好使

我说个生活化的类比。设想一个仓库里存放了好多标准货箱,容量分别是1、2、4、8个单位,每种规格的箱子都放在对应的货架上。有人来申请3个单位的货品时,管理员不会去1号货架东拼西凑,而是直接从4单位货箱里拆出一份,把用不完的1个单位放回1货架;等货品归还后,管理员再检查旁边的箱子,如果同规格且相邻,就两两合并放回更大的货架。清点始终高效,空间也不容易被零碎占用。Buddy分配器的哲学就是这个。

它的优势是多方面的。第一,分配和释放的时间复杂度可控,不是去扫描全内存找合适空间,而是从对应order的链表直接取块。第二,合并逻辑判断准确可靠:两个order相同的空闲块只要物理地址连续且互为"伙伴",就能直接升级为大块,无需遍历验证。第三,配合迁移类型机制后,还能把可移动页和不可移动页分类管理,进一步延后碎片化恶化。这几点叠加起来,让Buddy分配器在绝大多数场景下都能维持稳定表现。

2.4 分配路径的两层结构:快速路径与慢速路径

从代码角度看,分配page并不是一个孤立函数,而是一套有回退机制的流程。快速路径会尝试在当前zone的free_area和per-cpu缓存中直接获取页面,这个路径没有复杂的回收或压缩操作,延迟极低。如果水位条件不满足,或者快速路径拿到不合适的迁移类型,分配器会滑入慢速路径:唤醒内核回收线程、触发直接内存回收、尝试内存规整,甚至调用OOM killer来腾出空间。

这两个路径的分界点,就是我在调试时最关注的逻辑之一。系统CPU使用率突增,往往不是业务计算导致的,而是慢路径里的直接回收或规整在消耗CPU。理解了这两个路径的差异和触发条件,排障时就能快速锁定方向。

3. 分配page之前的准备:order、迁移类型与水位线详解

3.1 order参数到底怎么选

调用Buddy分配器的入口函数很多,比如alloc_pages、__get_free_pages、alloc_page等,但它们最终都会携带一个关键参数——order。order为0最常见的用途是匿名页、文件缓存页这种单页分配;order大于0则意味着申请者需要连续多页,常见的场景包括网络收发环形缓冲区、SLUB分配器的页块、以及一些驱动直接申请的连续DMA内存。

这里有一个容易被忽略的细节:order不是随便选的,因为伙伴系统的块大小完全由order唯一确定,申请方必须按实际需求取最合适的order。比如需要3页,通常就申请order为2(4页),多出来的一页由伙伴系统内部处理。内核不会提供order为0.5这种选项,也没办法只拆出3页。这一点上,设计者是在用"一定的内部碎片浪费"换取分配算法的简洁和高效。

我在实际调试中就遇到过有人为了省内存,强行把所有分配都改成order=0的循环,结果性能反而下降。因为零散页会造成页表项增加、TLB命中率降低,最终得不偿失。该用连续页的场景,老老实实申请order,反而更划算。

3.2 迁移类型:给页打上"可迁移"标签

迁移类型(migratetype)是Buddy分配器对抗碎片化的另一个重要支柱。内核把内存页分成了若干类别,例如不可移动页、可回收页、可移动页、CMA页等,每种类型在zone内都有自己的free_area链表。这样做的好处是:不可移动的页集中在一处,可移动的页集中在另一处,内核做内存规整时,就可以优先迁移那些可移动页,腾出连续区域给不可移动的高阶分配需求,而不是把全zone的内存全部搬一遍。

选迁移类型的原则也值得展开。默认情况下,页面会被归入MIGRATE_MOVABLE或MIGRATE_RECLAIMABLE等类别,具体由页面的用途来决定。比如,进程的匿名页、用户态文件映射页大部分标记为可移动;内核自身的vmalloc区域、某些驱动分配的内存则标记为不可移动。分配器在free_area里找块时,会优先匹配同类迁移类型;如果找不到,允许通过pageblock的fallback机制"借用"其他类型的空闲块。这里要留意fallback的顺序,它直接影响了碎片化的演进方向:分配器总是优先借用相邻分类的块,尽量避免动到不可移动但大块集中的区域。

3.3 水位线:分配器的“红绿灯”

水位线是Buddy分配器里一个地位极其关键的机制。每个zone的watermark数组里存着三个阈值:WMARK_MIN、WMARK_LOW、WMARK_HIGH。简单理解,空闲页数量高于HIGH时,内存充裕;处于LOW和HIGH之间,属于正常波动;低于LOW,内核后台的kswapd线程会被唤醒开始回收页;低于MIN,说明内存压力很大,分配路径可能直接进入慢速回收甚至OOM。

这三个值是动态计算的,基准上会参考min_free_kbytes这个内核参数,再按zone内存大小比例放大。实际项目里最常见的改动就是调高min_free_kbytes,为的是让系统更早进入回收状态、保留更多应急空闲页。但它不是越大越好,因为min_free_kbytes设太高会导致可用内存减少、缓存压低,业务吞吐反而下滑。这个参数的平衡,推荐结合监控数据来做,不要照抄网上的“推荐值”。

3.4 从gfp_mask开始的设计串联

真正进入内存分配之前,内核还需要拿到gfp_mask(分配掩码),它决定了这次分配的行为取向。掩码里携带了大量关键决策:是否允许睡眠、允许使用哪些zone、是否可以进行磁盘I/O回收、是否允许规整、是否属于高优先级分配等。我见过不少新手在写驱动时,分配GFP_KERNEL用得很顺,但到了原子上下文里还是用GFP_KERNEL,直接触发睡眠并导致内核栈报错。正确做法是原子上下文至少要改用GFP_ATOMIC。

理解了gfp_mask的作用,就能理解为什么分配page的路径上会有那么多分支判断:所有判断最终都是为了让“这次分配”在“当前上下文”下用最合理的方式拿到内存。

4. 核心分配流程:把page从伙伴系统里抠出来的每一步

4.1 入口:get_page_from_freelist的成功定律

虽然分配路径有快慢之分,但最终真正从zone里取页的操作,收敛在一个核心函数上:get_page_from_freelist。这个函数的核心逻辑并不复杂:遍历允许分配的zone列表,逐个检查水印条件,然后从zone里取页。它做的事有点像你去取款机取钱,先看哪台机器在服务范围内,再看机器余额够不够,最后才真正出钞。

在遍历zone的时候,分配器会结合gfp_mask里设置的zone限制。比如GFP_DMA标记只允许从ZONE_DMA里分配,普通GFP_KERNEL则首选ZONE_NORMAL。每到一个zone,先比较当前空闲页数与水印阈值,空闲不够就跳过或进入慢路径;够了就调用rmqueue执行实际取页。这里有一个很容易忽视的细节:即使zone的水印条件暂时满足,如果分配的是高阶order,还需要检查当前zone的碎片化指数,判断这块zone是否还有足够大的连续块。如果一个zone已经被碎片塞满,大块分配会直接失败,尽管它总空闲页并不少。

4.2 rmqueue与分配拆分的细节操作

rmqueue是分配页的另一个重头戏。它会根据order是否超过阈值,决定走per-cpu页表缓存还是直接操作free_area链表。order为0的分配大多走pcp路径,因为单页分配频率极高,如果每次都去拿zone的锁、动链表,锁竞争和cache miss会成为严重的性能瓶颈。per-cpu列表相当于给每个CPU准备了一个“小仓库”,先把一批单页批量搬进来,分配时直接本地拿,既快又稳。

而order大于等于1的高阶分配,则会走free_area路径。这个操作里最核心的是buddy拆分的实现。以申请order=2(4页)为例,系统先看当前order=2的链表是否还有空闲块。如果没有,就向上找order=3、order=4乃至更高的链表,找到后把大块逐级对半拆开:拆分出来的“剩余半块”挂回下一级链表,目标半块继续往下拆,直到拆出所需order的块。这个过程有一个专门的辅助函数expand,通过循环处理buddy的下半部分,效率非常高。反过来,释放页面合并时,系统会检查“伙伴”页是否空闲,是的话就两两合并并升级order,一路向上直到无法合并。

我看了很多讲解,但真正把expand函数一步步走下来才明白,原来拆分的顺序不是随便定的,而是特意把保留块放在低位地址、把剩余块放回空闲列表,这样会让后续的合并判断特别干净。内核代码里处处都是这种细节智慧。

4.3 释放路径里隐藏的“反向分配”

虽然这篇文章的主题是分配page,但不讲释放,就没法理解合并策略的意义。释放路径的入口是free_unref_page或__free_page,最终落到__free_one_page这个函数里。它做的事正好是分配拆分的逆操作:拿到释放页后,先看它的伙伴页是否也是空闲且order相同,如果是,就两页合并,order加一,继续检查更高阶伙伴,直到没有可合并的伙伴为止。

这里有个关键限制:伙伴页的判断不是看物理地址差多少,而是通过buddy_index之类的位运算,直接算出伙伴页的物理地址,再去free_area链表里查。这个算法非常聪明,它不需要扫描所有空闲块,每次合并操作都是常数级的判断。我在调试中曾试着打印伙伴页地址,手工验证过几个case,确实跟理论完全一致。如果你也想验证,最直接的手段就是打开页表的debug信息,或者用Ftrace跟踪释放路径的调用序列。

4.4 从分配器到真实页:page的flags与引用计数

从free_area链表里取下struct page之后,分配器还需要完成最后的初始化工作:把page的引用计数置为1,清除一些旧的标志位,设置新的映射信息。这些操作一半是为了安全,防止刚分配的页还带着上一个使用者的数据或状态;一半是为了记账,让系统的内存统计保持正确。

我早期排查内存泄漏时,经常忽略page的_refcount字段。有些驱动在申请页之后没有正确维护引用计数,导致页面永远无法释放回伙伴系统,最终表现为某个模块的内存持续增长。Buddy分配器能给管理员提供的调试入口之一,就是page结构的flags和_refcount字段,配合/proc/kpageflags这类接口,可以逐渐定位到泄漏页面到底被谁引用着。

5. 内存水印不足时的自救:慢路径、回收与规整

5.1 水印跌破之后发生了什么

在快速路径里,如果所有zone的空闲页都低于水印,系统并不会直接宣告分配失败,而是会进入__alloc_pages_slowpath这条慢路径。这个路径设计得很像一个急救流程:先尝试异步回收,不行再来同步回收;回收后如果水位还是不够,就尝试内存规整;如果连规整都凑不出足够的大块,并且这次分配不可失败,才最终触发OOM。

慢路径有几个值得注意的细节。比如,它会先把分配标志位里的__GFP_DIRECT_RECLAIM去掉再重试,避免回收过程陷入死循环;它还会调用wake_all_kswapd来唤醒各个node的回收线程。很多系统管理员发现CPU使用率毛刺,配合sar看到kswapd在跑,通常就是因为某个瞬间内存压力变大、watermark被突破了。这种毛刺一般不用过度紧张,但要是频繁出现,就要排查是不是内存泄漏或者page cache设置过大。

5.2 kswapd是怎么被调度的

kswapd是内核里最重要的内存回收守护线程之一,每个node有一个,它号称“后台清道夫”。它的触发条件是zone的空闲页面数量低于WMARK_LOW,而不是低于MINID。之所以留出LOW和MIN之间的缓冲区间,是为了让kswapd还有时间慢慢回收,避免分配线程因为等内存而直接卡死。一旦kswapd把zone空闲页补回WMARK_HIGH以上,它就会休眠,等待下一次内存压力信号。

这里有个调优经验:当你在做低延迟业务时,可以适当地把min_free_kbytes调大一些,让kswapd提前开始工作,避免业务线程在分配高峰亲自下场回收。但注意别弄过头,因为min_free_kbytes调大等于强制系统保留更多空闲页,会挤占page cache的空间,磁盘缓存命中率可能下降。我见过某系统因为这个参数调大后,读写性能反而下降了10%左右,最后又调了回来。

5.3 内存规整:让碎片重新连成片

当内存碎片化严重、需要高阶连续块但找不到时,分配器会尝试直接内存规整。规整的核心思路是把zone内可移动的页尽量搬到一起,把腾出来的连续区域排布成空闲大块。你可以把它理解成磁盘碎片整理:先把零散文件集中挪到一侧,空出另一侧连续磁盘区域。

规整过程中,内核会遍历zone内的pageblock,扫描可迁移页并逐一迁移。这一步比较耗时,而且迁移本身需要复制页表、处理TLB失效,所以分配器会优先尝试轻量的异步规整,只有在万不得已时才做同步规整。我实测过一个场景:一个内存压力很大的容器里,反复申请order=4以上的连续内存,触发同步规整后分配延迟从几十微秒暴涨到几毫秒,业务RT直接拉垮。这时候最有效的办法不是去优化规整本身,而是从上层减少高阶连续内存的申请频率,比如改用分散的缓冲区方案。

5.4 OOM killer:最后的裁决者

如果回收和规整全都救不了场,系统会启动OOM killer去杀掉一些“占用内存大户”的进程,腾出空间。这个机制虽然听起来暴力,但它保证了内核不会因为内存耗尽而全线崩溃。OOM的触发条件也跟水位线紧密相关:分配请求无法获得满足,同时zone的空闲页低于某个阈值且回收无法恢复。

OOM killer选中的目标通常基于一个叫作oom_score的评分,它会结合进程的内存占用、运行时间、优先级等因素综合排序。这部分我曾写过一个小工具,通过读取/proc/*/oom_score来预判哪些进程可能成为牺牲者。不过调优时我建议优先堵住内存泄漏的源头,而不是反复期待OOM killer帮你“准确执法”,因为它的选择标准并不总是符合业务预期。

6. 跨界联动:Buddy分配器与PCP、CMA及外部模块

6.1 PCP缓存:为什么order=0的分配这么快

前面提过per-cpu页表缓存,这里展开讲。PCP的设计初衷很简单:order=0的单页分配占到了分配总量的绝大多数,如果每个CPU都频繁去抢zone的全局锁,就算泡在cache里也会产生巨大的cacheline乒乓效应。于是Buddy分配器就直接给每个CPU分配了一个本地页“储备库”——PCP。

PCP里按迁移类型再细分了链表,批量从伙伴系统搬过来一批单页,本地分配时基本无锁。当PCP空了,再调用rmqueue_pcplist从伙伴系统一次性补充一批;当PCP超出高水位,也会主动释放部分页回伙伴系统。这个过程对上层使用者完全透明,但你通过perf工具统计锁竞争时,能明显看到引入了PCP后zone锁的争用大幅度下降。在很多性能敏感的内核模块里,利用PCP的分配路径是一个刻意优化的方向。

6.2 CMA区域:给驱动准备的“弹性连续内存”

另一个跟Buddy分配器关系密切的概念是CMA(Contiguous Memory Allocator)。CMA的设计思路比较有意思:它预留的内存区域平时可以被可移动页正常使用,但当驱动需要大块连续内存时,它会把区域内已有的可移动页迁走,腾出连续空间给驱动用。这个机制让“连续内存的利用率”和“大块内存的可用性”这对矛盾得到了一定程度的调和。

在Buddy分配器的实现里,CMA区域同样以迁移类型MIGRATE_CMA的形式出现在free_area链表中。分配时,普通页面请求通常不会去触碰CMA区域,只有带特殊标志的分配请求才会优先选用CMA区域。正因为这种隔离设计,CMA区域的快速回收和大块分配才成为可能。我在调嵌入式多媒体系统时,就经常使用CMA给ISP或GPU预留连续缓冲区,体验下来,只要合理配置CMA大小和迁移类型,基本不会影响常规内存的分配。

6.3 与SLUB/SLAB的协作

Buddy分配器并不是内核内存管理的终点。以内核对象分配为例,SLUB分配器会从Buddy系统申请一些order较大的内存块,然后切分成若干个小对象供内核频繁分配释放使用。换句话说,Buddy系统处理的是“物理页”一级的粗粒度管理,SLUB处理的是“对象”一级的细粒度管理。层层分工,各有侧重。

其实这也就解释了,为什么有时候你在slabtop里看到某个内核缓存占用很大,但free命令里系统内存依然够用。因为SLUB手里囤了一批Buddy给它的大块页,这些页对Buddy来说已经被分配出去了,但对SLUB来说可能只是闲置对象。排查内存占用时,不能光看Buddy的free分布,还要结合slab、page cache、进程内存等一起分析。

7. 实测与调试:如何观察伙伴系统的分配行为

7.1 用buddyinfo和zoneinfo快速摸底

接到一个问题现场时,我通常会第一时间看几个proc文件。第一个是/proc/buddyinfo,它按node和zone分别展示每个order链表的空闲块个数。通过它,你一眼就能看出系统的物理内存碎片化到了什么程度:如果order=0、1的空闲块有很多,但order=4以上的块几乎没有,同时系统总空闲内存又不低,那么大概率是碎片化在作祟。

第二个是/proc/zoneinfo,它提供了每个zone的水印水位、free页数、各种统计计数等详细信息。我会尤其关注nr_free_pages、pageset、vm_stat中的pgalloc_success、pgalloc_dma等字段。如果看到pgalloc_high或pgalloc_normal统计持续增长但free却不断下降,说明分配发生在哪个zone、哪类路径上,能够迅速把问题范围缩小。

7.2 用tracepoint跟到分配现场

如果proc文件不够用,下一步就是利用内核的tracepoint来做动态追踪。比如,mm_page_alloc这个tracepoint会输出分配调用的order、gfp_flags和返回的page地址,是分析分配路径最直接的“监听器”。mm_page_free则对应释放路径。配合ftrace的function_graph,你甚至可以看到一个分配请求具体经过了哪几个函数,从而判断它走的是快速路径还是慢速路径。

我分享一个实际排查案例:某平台出现周期性的分配延迟尖刺。通过ftrace抓取mm_page_alloc事件后,发现大部分延迟尖刺都伴随着order=3以上的高阶分配,并且这些分配大多都在慢路径的compact过程中完成。顺着线索查到,是一个网络驱动在收包时频繁申请高阶连续页,调整驱动改用预分配缓冲池之后,问题消失。整个过程,如果你不懂Buddy分配路径,只会以为网络驱动本身有bug,白折腾好久。

7.3 开启内核debug特性定位泄漏与越界

内核本身也提供了一批给内存调试用的选项:CONFIG_DEBUG_PAGEALLOC、CONFIG_PAGE_POISONING、CONFIG_PAGE_OWNER等。开启编译后,释放的内存页会被填入特定字节,分配时如果发现内容被改动,基本就可以断定有越界或UAF。page_owner则能记录每个页面的分配调用栈,配合工具可以定位到是哪个调用路径“占着茅坑不拉屎”。

不过要说句实在话:这些debug选项在线上环境开启的成本不低,它们会显著增加内存和CPU开销。通常做法是在测试环境或者灰度环境开启,收集足够信息后再关闭。我自己的习惯是遇到疑难杂症才开page_owner,平时保持默认不开,以免影响性能。

7.4 常见问题速查表

现象最可能原因排查建议
order=0分配正常,高阶分配总失败碎片化严重 / 水印设置过低检查buddyinfo与zoneinfo,考虑开启compact或调高min_free_kbytes
分配延迟出现周期性毛刺慢路径回收或规整被频繁触发抓mm_page_alloc tracepoint,定位高阶分配来源并优化
内存缓慢耗尽,无进程占用page cache或slab/驱动泄漏结合slabtop、/proc/meminfo、page_owner逐层定位
内核报"BUG: soft lockup"慢路径里长时间遍历回收或规整检查内核日志中的回收线程栈,重点看compact和shrink路径
OOM killer频繁触发但free还有剩余水位设置不合理 / 存在不可回收的大块内存计算实际水位,结合zoneinfo确认是否触顶

8. 收尾:我对Buddy分配器分配page的个人体会

写到这里,整套分配page的核心链路基本过了一遍。从我个人的实践经验来说,理解Buddy分配器最好的方式不是背代码,而是把“内存为什么分zone、为什么要按order组织、为什么要分迁移类型、为什么要设水位线”这四个为什么彻底想通。因为这四个问题背后,对应着物理硬件差异、碎片化对抗、隔离性需求和内存压力预警,每一个都是内核设计者实打实的工程权衡。

另一个让我很受益的实操习惯是:排内存问题永远先看数据,再谈理论。先用buddyinfo和zoneinfo把现象量化,再用tracepoint把路径验出来,最后才根据路径去翻源码。这套方法论帮我在不少诡异问题上省下过大量时间,强烈推荐你也试试。Buddy分配器里的很多细节,比如expand函数为什么这样做、水印阈值怎么算、迁移类型fallback为什么排成那个顺序,看起来平平无奇,实际都是被无数线上问题打磨过的。真正读懂它们的时候,你会对整个内核内存管理的工程美学产生一种真切的尊敬。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进