目录
摘要
1 · 背景:一个"被多少人共享"的问题
2 · 主线:回收一个文件页的完整故事
3 · 细节一:拆映射为什么快
4 · 细节二:引用位是"种"出来的
5 · 性能评估:快在哪、慢在哪
6 · 另一条路:Linux 怎么做
7 · 面对面逐项对比
8 · 结论:成本的摆放艺术
附录 · Linux 试过又放弃的 PTE 级反向映射
附录 B · 把 PTE 链加回来?面向手机的推演
附录 C · 既然缺点这么多,为什么 XNU 还在用
摘要(先读这里)
用一个被 30 个进程共享的库代码页做例子,整个故事可以浓缩成七句话:
Linux 给每个物理页记一本精确的账 ——"你现在被几个页表项映射着"(_mapcount)。XNU 没有这本账,它只有一张反向映射表 :每个物理页挂一条记录,写着"我被映射到了哪些页表项上"。
所谓"mapcnt 大于 1 的页",在 XNU 里就是反向映射记录从"单条"变成"链表"的页。回收它只需要一次函数调用:pmap_disconnect() 顺着链表把 30 个进程里的映射一次性全部拆掉 。
这张表精确到页表项(PTE)本身 :每个映射直接记着页表项的地址。拆映射时不需要一层层走页表去"找",直接改就行。
判断"这页还热不热",XNU 完全不碰页表 ——引用和脏状态平时就汇总在物理页的两个软件标记位里,读一下就是 O(1)。
这两个标记位是靠"权限陷阱 "种出来的:老化时把页的访问标志清零(埋陷阱),之后谁访问谁缺页(踩陷阱),缺页处理把"被引用/被写脏"记回页上(收割)。
主要性能风险也在这套陷阱上:老化之后,30 个进程各踩一次缺页,而每次缺页处理又要恢复整页 30 个映射——最坏是 O(N²) 次 TLB 失效广播 。XNU 用"O(1) 预判先把热页放回去"和"可执行页专门保护"来兜底。
Linux 的取舍正好相反 :平时访问零打扰,回收时主动遍历所有映射去清点,精确但昂贵(内核注释自己承认"处理很慢")。新一代 MGLRU 的改进方向,恰恰是在向 XNU 这套"批量近似"靠拢。
1 · 背景:一个"被多少人共享"的问题
文件页(File Page)是把文件内容缓存进物理内存的那一页。它既挂在文件的缓存对象上,又被映射进一个或多个进程的地址空间。回收文件页时,内核必须先回答两个问题:这页还被谁用着(引用状态)?它被写脏了吗? ——回答完才能把所有进程里的映射干净地拆掉,把页拿走。
共享库、dyld 共享缓存里的代码页,是被几十上百个进程同时映射的典型——它们就是本文说的"mapcnt 大于 1 的页"。先看两个内核在这个问题上的第一处分歧:
Linux:每页一本精确的账
Linux 在每个物理页的 page 结构里放了一个整数 _mapcount,建立映射时加一、拆掉时减一。内核随时能问出"这页被几个人共享",很多回收策略直接建立在这个数字上。
XNU:没有计数,只有一张反向映射表
XNU 的 VM 层不记这个数。vm_page 结构里只有三个"曾经……过"式的布尔标记:pmapped(曾被映射)、wpmapped(曾被可写映射)、xpmapped(曾被可执行 映射)。它们只回答"有没有过",不回答"有几个"。
"有几个"的答案在 pmap 层。XNU 给每个物理页准备了一个反向映射入口(pv head),它的形态随映射数变化——这正是 XNU 处理多映射页的根基:
flowchart LR
NULL["没有映射<br/>入口为空"] -->|"第 1 个映射建立:<br/>页表项地址直接存在入口里,<br/>不消耗任何额外内存(:8403)"| PTEP["恰好 1 个映射<br/>绝大多数页停留在这个形态"]
PTEP -->|"第 2 个映射出现:<br/>分配一个节点,入口转成链表(:8427)"| PVEP["2 个及以上映射<br/>链表每个节点对应一个映射"]
PVEP -->|"映射继续增多:<br/>链表继续加节点"| PVEP
PTEP -->|"回收时一次拆光(:7401)"| NULL
PVEP -->|"回收时遍历链表,逐个拆除(:7401)"| NULL
图 A · 反向映射入口(pv head)的状态机:映射数决定形态,单映射零开销
注意这张表的粒度是页表项级 的——每个映射都精确到具体的 PTE。Linux 的文件页反向映射只能到"进程的一段地址区间(VMA)"这个粒度,找到候选区间后还得再走一遍页表才能定位到 PTE。这个差异会在后文反复出现。
源码依据 · xnu-7195.141.2:vm_fault.c:3180-3210(三个映射标记的设定)· pmap.c:987/4762(每物理页一个入口的 pv_head_table)· pmap.c:8347/8403/8427(pmap_enter_pv:单映射内联、第二映射转链表)
2 · 主线:回收一个文件页的完整故事
XNU 的 pageout 扫描线程(vm_pageout_scan)从 inactive 队列尾部取出候选页后,走的是一条四步流水线。对多映射页来说,每一步都有针对性设计:
第一步:问"这页还热吗"——不碰页表的 O(1) 判定
扫描线程读物理页上的两个软件标记位(引用、脏),就能知道这页自上次老化以来的使用情况,成本是一次内存读。如果页还热,放回 active 队列——但每轮扫描的"重激活"有次数上限,防止扫描线程被反复放回的页拖垮空转。
这一步还藏着 iOS 的一个专门保护:可执行映射的文件页 (代码页)只要总数低于下限(约为文件缓存的四分之一),一律当作热页放回。代码页一旦被回收,重新缺页的成本是执行路径上的延迟尖刺,XNU 明显不愿意冒这个险——而代码页恰恰是多映射页的最大群体。
第二步:拆掉全部映射——一次调用,原子完成
这是"mapcnt 大于 1 怎么回收"的直接答案。扫描线程调用 pmap_disconnect(),它顺着反向映射链把每个进程页表里的这个映射全部清空,并把期间收集到的"是否被写脏"作为返回值带回。拆完之后页处于 busy 状态,任何人都无法再建立新映射——所以带回的脏状态是原子时刻的真值 ,不存在"拆到一半又被写脏"的竞态。源码注释的原话是:"pmap_disconnect 原子地提供了真实状态"。
第三步:分流——干净页零成本释放,脏页进清洗队列
干净页直接从 VM 对象里摘除、挂上空闲队列,回收到此结束,全程没有磁盘 I/O (文件页的内容磁盘上本来就有)。脏页则挂到外部清洗队列(laundry),等待写回。
第四步:写回与复查——聚簇、原地清洗、双保险
独立的 pageout 线程把脏页交给 vnode pager,由它聚簇写回磁盘。聚簇顺带拉进来的相邻页采取"原地清洗" :只查一下状态,不拆映射——避免为了写回而误伤还在使用的映射。写回完成时还要复查一次:如果写回期间有进程把这页重新映射并写脏了,就重新标脏、放回 active 队列;确认干净才真正释放。
flowchart TD
V["vm_pageout_scan 从 inactive 队列取出候选页(:2833)"] --> P{"从未标记引用<br/>且曾被映射?(:3359)"}
P -->|"是"| G["读物理页上的聚合软件标记<br/>O(1),完全不碰页表(pmap.c:10082)"]
P -->|"否"| R
G --> R{"这页还热吗?<br/>被引用过,或是受保护的可执行文件页(:3383)"}
R -->|"热"| ACT["放回 active 队列<br/>重激活有上限,连续强回收也有上限(:3393 / :3395)"]
R -->|"冷"| TH{"是脏页且清洗队列已满?(:3474)"}
TH -->|"是"| SKIP["送回队尾,稍后再来"]
TH -->|"否"| D{"曾被映射?(:3532)"}
D -->|"是"| DC["pmap_disconnect:拆掉全部映射<br/>顺带拿到原子时刻的最终脏状态(:3570)"]
D -->|"否"| CL
DC --> CL{"干净页?(:3587)"}
CL -->|"是"| FREE["从 VM 对象摘除,挂上空闲队列<br/>回收完成,零磁盘 I/O(:3276)"]
CL -->|"脏"| LC["挂到外部清洗队列(:3652)"]
LC --> IO["pageout 线程逐页交给 vnode pager<br/>聚簇写回(:3881)"]
IO --> ADJ["聚簇拉入的相邻页原地清洗:<br/>只查状态,不拆映射(:6099-6108)"]
ADJ --> CK{"写回期间被重新映射并写脏了吗?(:7770)"]
CK -->|"是"| RD["重新标脏,放回 active 队列(:7777-7779)"]
CK -->|"否"| FR2["确认干净,真正释放(:7795-7798)"]
图 B · XNU 回收文件页的四步流水线(含每步的源码行号)
源码依据 · vm_pageout.c::3359-3368(O(1) 引用判定)· :3383-3384 / :4848(可执行页保护,下限≈文件缓存 1/4)· :3516-3531 注释("原子真值")· :3570(拆映射)· :3587 / :3276(干净页释放)· :3652 → :3881(脏页进 laundry、交给 pager)· :6099-6108(相邻页原地清洗)· :7769-7798(写回后复查)
3 · 细节一:拆映射为什么快——反向映射直达页表项
假设一个页被 30 个进程映射。XNU 拆掉这 30 个映射的全程是:一把锁、30 次直达的页表项改写、30 次广播、一次同步。逐步拆解:
拿锁。 每个物理页有自己的一把反向映射锁,互不干扰。
找映射。 链表每个节点里存的就是页表项的地址,取出来直接用——不需要像 Linux 那样先查区间树找到候选进程区间、再走页表逐级定位。pmap 和虚拟地址这些信息也不用在节点里存:每个页表页有一份影子元数据(记录这张表覆盖的虚拟地址基址、表内非空项计数和 wired 计数),需要时按页表项在表内的位置推算即可。
逐个清空。 把每个页表项写成"无效",同时递减页表页的引用计数(归零的页表页可以被回收)。
逐个失效 TLB。 TLB 是各核心自带的页表缓存——改了页表必须让所有核心的这份缓存失效,否则别的核心还会按旧映射继续访问。每拆一个映射,XNU 就发一条 arm64 的 TLB 失效指令;这类指令由硬件在核心间广播 ,不需要 x86 上那种逐核发 IPI 中断的开销。指令是异步发出的,全部拆完后只做一次同步等待,确认广播生效。
收尾。 30 个链表节点整批归还内存池,反向映射入口清空,把汇总的脏状态返回给调用方。
flowchart TD
S["pmap_disconnect(物理页号)<br/>pmap.c:10205"] --> L["拿到这个物理页专属的 pv 锁(:7377)"]
L --> T{"入口形态?(:7394)"}
T -->|"单映射"| ONE["直接取内联的页表项地址(:7395)"]
T -->|"多映射"| ITER["遍历链表(:7397)"]
ONE --> LOOP
ITER --> LOOP["对每一个映射执行(循环 :7401)"]
LOOP --> M1["从影子元数据还原 pmap 与虚拟地址(:7440-7441)"]
M1 --> M2["把这个页表项改写成无效<br/>地址直达,不走页表(:7516)"]
M2 --> M3["页表页引用计数减一(:7520-7526)<br/>文件页不涉及任何记账(:7604)"]
M3 --> M4["发一条 TLB 失效广播<br/>arm64 硬件广播,无需 IPI(:7647-7654)"]
M4 --> MORE{"还有下一个映射?"}
MORE -->|"有"| LOOP
MORE -->|"没有了"| FIN["一次同步等待,确认广播全部生效(:7701-7703)<br/>链表节点整批归还内存池(:7711)<br/>入口清空(:7684)"]
FIN --> RM["返回汇总的引用/脏状态"]
图 C · 拆除 30 个映射的完整过程:30 次页表项改写 + 30 次 TLB 广播 + 1 次同步
另一个容易忽略的细节:TLB 失效策略是自适应的。逐条广播是默认做法;一旦要失效的范围超过阈值,就升级成"整地址空间失效"或(在新 CPU 上)用"范围失效"指令一条搞定一大段——怎么便宜怎么来。
源码依据 · pmap.c::10205(pmap_disconnect)· :7330(主循环所在函数)· :7377(每页一把锁)· :7394-7399(单/多映射分派)· :7440-7441 / :1508-1530(影子元数据)· :7516(改写页表项)· :7647-7654(逐映射广播)· :7701-7703(单次同步)· :7711(节点整批归还)· :12334(自适应 TLB 失效策略)
4 · 细节二:引用位是"种"出来的——武装、踩坑、收割
前面反复出现"物理页上的聚合软件标记",它是整个机制的枢纽。这套机制可以概括为陷阱捕猎 :平时不巡逻(不扫页表),在关键位置埋好陷阱(清访问标志、改成只读),猎物踩到(缺页)才记录(置标记位)。
埋陷阱的三个时机
建立映射时(针对脏位)。 可写映射不直接装成可写,而是先以只读形式进页表,同时在物理页上挂一个 MODFAULT 标记。第一次写触发权限错误,错误处理把"这页被写脏了"记到标记位上,再把页表项改回可写。也就是说,arm64 上 XNU 的脏位完全由软件维护 ——写权限本身就是陷阱。
老化时(针对引用位)。 页从 active 队列降到 inactive 队列时,内核把这个页所有页表项里的 AF(硬件访问标志)清零。清零之后,硬件不会再替内核记录访问——下一次访问必然触发访问标志错误,这就是埋好的陷阱。
回收时(收割最终状态)。 pmap_disconnect 拆映射的瞬间,顺带把残余的访问/脏状态收进标记位,作为"原子真值"带回(见第 2 节第二步)。
踩坑与收割
陷阱埋好后,任何进程对这个页的访问都会触发一次缺页。这个缺页走的是一条极短的专用处理路径 (arm_fast_fault):确认这是软件埋的陷阱后,把这个页全部 页表项的访问标志恢复、逐个发 TLB 失效广播,并把"被引用"(读)或"被引用且被写脏"(写)记到物理页标记位上。从缺页到恢复,全程不进入通用的缺页处理流程。
flowchart TD
subgraph ENTER["① 建立映射 · pmap.c:8495"]
E1["页表项带着访问标志装入(:8751)<br/>物理页立即记为已被引用"]
E2["可写映射先装成只读 + 挂 MODFAULT 标记<br/>第一次写会触发权限错误(:8727)"]
end
subgraph AGE["② 老化:埋陷阱(active 降到 inactive)"]
A1["把这个页全部页表项的访问标志清零(:10554)<br/>并给物理页打上 REFFAULT 标记(:10645)"]
A2["刻意不做 TLB 失效——省成本<br/>最坏后果:漏记一次远端 TLB 里缓存的访问"]
A1 --> A2
end
subgraph USE["③ 踩坑:应用访问触发缺页"]
U1["任一进程访问这个页 → 访问标志错误"]
U2["专用快路径 arm_fast_fault 接手(:10840)"]
U3["恢复这个页的全部映射:<br/>读访问 → 恢复访问标志 + 记「被引用」(:10779-10784)<br/>写访问 → 恢复可写 + 记「被引用且写脏」(:10765-10778)"]
U1 --> U2 --> U3
end
subgraph RC["④ 回收:只读标记位"]
C1["vm_pageout 读物理页的两个标记位(:10082)<br/>或者 pmap_disconnect 拆映射时顺带收割(:3570)"]
end
ENTER --> AGE --> USE --> RC
图 D · 一个页的完整生命周期:陷阱在老化时埋下,状态在缺页时收割,回收时只需读一眼
第 ② 步"不做 TLB 失效"值得单独说:清访问标志理论上应该配一次 TLB 失效,否则别的核心还缓存着旧的页表项,访问会"漏网"而不触发陷阱。XNU 源码注释明说这是刻意取舍——最坏只是漏记一次引用,而"TLB 缓存不会存活太久",用一点精度换掉每次老化一条广播的成本,是划算的。
"……最坏情况是我们错过一次引用位的更新:某个远端处理器的 TLB 里还缓存着这个映射……这没什么问题,因为那次引用距今已经相当久远(TLB 缓存不会存活太久)。"
— vm_pageout.c:2800-2813 注释原文
源码依据 · pmap.c::8495/:8727/:8751(建立映射时的陷阱)· :10419/:10554-10558/:10645(老化清访问标志)· :10840/:10894(缺页快路径入口与判定)· :10704/:10765-10784(收割:恢复映射并置标记)· :10082-10087(O(1) 读标记)· vm_pageout.c::2814 与 :2800-2813 注释(老化不做 TLB 失效的取舍)
5 · 性能评估:快在哪、慢在哪
结论先行:扫描和判定极快,拆除和缺页集中付费 。六个具体问题,每个都配有源码可见的对冲设计:
快的一面
回收判定 O(1)。 扫描主循环判断"这页还热吗"只是一次内存读,这让 XNU 的扫描可以跑得非常便宜——Linux 内核注释自己承认,处理映射页时的引用检查"很慢"(见第 6 节)。
拆映射不用走页表。 反向映射直达页表项,对比 Linux 的"区间树 + 页表遍历"省掉整整一个层级的工作。
单映射页零开销。 绝大多数物理页只有一个映射,入口内联、无链表、无分配。
风险一:拆除成本随共享度线性增长
一个被 30 个进程映射的页,拆一次就是 30 次页表项改写、30 次 TLB 广播、外加一次同步等待。共享度越高,单页回收越贵。
arm64 广播 TLB 失效没有 IPI 开销;广播异步发出、只在最后同步一次;超过阈值自动升级为整地址空间或范围失效;单映射页(大多数)走内联路径。
风险二:热共享页的"缺页税",最坏 O(N²)
老化清访问标志之后,30 个进程各自都会踩一次缺页;而每次缺页处理又要恢复整页全部 30 个映射(逐个广播)。一个老化周期最坏累积 O(N²) 次 TLB 失效。这是整套设计里最尖锐的尾部风险。
O(1) 的引用判定会在拆映射之前就把"仍被引用"的热页放回 active 队列,根本走不到陷阱;可执行页保护再兜一层底。真正裸露在这个风险下的是"高共享度但刚好不在保护下"的页。
风险三:近似老化会漏记引用
老化清访问标志不刷 TLB,远端核心缓存的访问不会被记入引用位,可能把仍活跃的页误判为冷页而回收,引发缺页回流的小风暴。
漏记的时间窗受 TLB 项寿命限制(源码注释论证);误回收的页经缺页从磁盘回到内存,代价是延迟尖刺而非正确性问题。
风险四:单映射变多映射的瞬间要分配内存
第二个映射出现时需要从内存池分配链表节点;池子耗尽时,建立映射的路径要放下手里的锁、分配一整页内存切成节点、再从头重来。
节点按整页批量切分(16KB 能切上千个),配高低水位,实际很少耗尽;单映射转多映射在进程生命周期里只发生一次。
风险五:元数据内存更重
XNU 为每个物理页维护反向映射入口(8 字节),为每个多出来的映射维护一个 16 字节的链表节点;Linux 只需要在 page 结构里放一个 4 字节整数。两边在页表页上的开销相当,真正拉开的差距在多映射页的链表。
换来的正是"拆映射直达页表项"和"O(1) 状态查询"这两项效率。
风险六:没有精确计数,策略表达受限
VM 层说不出"这页被几个进程共享"这种话,只能基于聚合的"是否被引用过"和"是否可执行映射"做决策,粒度粗于 Linux 的"被多于一个页表项引用就立即激活"。
iOS 上多映射页的主体恰好是代码页,可执行页保护正好覆盖了最需要保护的群体——场景与近似互相成就。
6 · 另一条路:Linux 怎么做
Linux 的思路可以概括为一句话:平时完全不打扰,回收时主动上门清点。
传统 LRU 路径(5.10)
判定引用: page_referenced() 沿着文件的反向映射(挂在文件上的区间树)枚举所有可能映射这个页的进程地址区间,对每个候选走一遍页表 定位到页表项,读出并清除硬件的 young 位——每个页表项配一次 TLB 失效。精确计数器随做随减,减到零就提前收工。
决策: 发现多于一个页表项引用过它(说明不止一个进程在用),或者这是可执行文件页——立即激活;只有单次引用则再给一次机会。
拆映射: try_to_unmap() 同样沿区间树走,每拆一个页表项、计数减一,减到零提前停止;TLB 失效攒批处理。
脏页: 日常主要依赖后台回写线程提前清洗,回收路径遇到脏页大多先放回去等写完。
代价写在 Linux 自己的注释里:
"如果页是被映射的,处理就会很慢(page_referenced()),所以应该逐页释放 LRU 锁。"
— Linux 5.10 mm/vmscan.c:1832 注释原文
flowchart TD
L1["从 inactive LRU 隔离出候选页"] --> L2{"这页被映射着吗?"}
L2 -->|"没有"| L7
L2 -->|"有"| L3["page_check_references(:986)<br/>→ page_referenced(:850)"]
L3 --> L4["沿着文件的 VMA 区间树枚举候选<br/>粒度是「进程的一段地址区间」,不是页表项(:1899/:1920)"]
L4 --> L5["对每个候选:算出地址,正向走页表定位页表项<br/>读出并清除 young 位,每个页表项一次 TLB 失效(:788)<br/>精确计数随做随减,减到零提前收工(:810/:823)"]
L5 --> L6{"被多于一个页表项引用过?<br/>或是可执行文件页?(:1020/:1026)"}
L6 -->|"是"| LA["立即激活,放回 active"]
L6 -->|"否"| LT["try_to_unmap:<br/>沿区间树逐个拆除页表项(:1748 归零早停)<br/>TLB 失效攒批(:1299-1306)"]
LT --> L7{"还是脏页吗?"}
L7 -->|"是"| LW["交给 writepage / 后台回写线程"]
L7 -->|"否"| LF["从页缓存摘除,释放"]
图 E · Linux 传统 LRU 对被映射文件页的回收:判定即遍历,访问路径零额外缺页
MGLRU(6.1+):有意思的趋同
新一代 LRU 对上述流程做了两处关键改造,而两处都在向 XNU 的方向靠拢:
老化改为主动正向扫描。 不再等回收时沿反向映射去清点,而是直接做正向页表遍历,批量清访问位——把"清点"从回收路径挪了出来。
驱逐时的批量近似收割。 沿反向映射走的过程中如果发现某个页表项还是 young 的,就顺带扫描它旁边的 64 个页表项,批量清 young 且不做 TLB 失效 ——这与 XNU 老化的 NOFLUSH 近似完全同型。再用一个布隆过滤器把"哪些页表段值得扫"反馈给老化遍历器,形成正反向协同。
flowchart LR
M1["老化:正向页表遍历<br/>批量清访问标志"] --> M2["驱逐:沿反向映射发现 young 页表项<br/>→ lru_gen_look_around(:4589)"]
M2 --> M3["顺带扫描旁边 64 个页表项<br/>批量清 young,不做 TLB 失效<br/>(与 XNU 的 NOFLUSH 同型近似)"]
M3 --> M4["布隆过滤器反馈:<br/>告诉老化遍历器哪些页表段值得扫"]
M4 --> M1
图 F · MGLRU 的批量近似收割与正反向反馈环(驱逐本体仍走 try_to_unmap,:1876)
源码依据 · Linux 5.10:rmap.c:850/:767/:788/:810/:823(page_referenced 全链)· :1899/:1920(区间树枚举)· :1748(归零早停)· :1234(计数减一)· vmscan.c:986/:1020/:1026(决策)· :1299-1306(攒批)· :1832("很慢"注释)· Linux 6.1:vmscan.c:4589(look_around)、:4385(注释)、:1876(驱逐)
7 · 面对面逐项对比
维度 XNU(arm64) Linux(传统 LRU / MGLRU)
共享度信息 没有计数;靠反向映射表的形态隐式表达(单条 / 链表) 每页一个精确整数,随时可查可判断
反向映射粒度 精确到页表项,拆除时直达 到"进程地址区间"为止,找到候选还要走页表
引用 / 脏位获取 权限陷阱 + 缺页收割,状态汇总在物理页软件标记里 回收时主动遍历读硬件位(传统);正向页表批量收割(MGLRU)
回收判定成本 O(1) ,不碰页表随映射区间数线性增长,且每步含页表遍历
拆映射成本 随映射数线性,但不走页表;广播 TLB 失效无 IPI 随映射数线性,含页表遍历;归零早停 + 攒批失效
应用侧代价 老化后热页要踩缺页(最坏 O(N²) 广播);近似老化可能漏记 零额外缺页 ,访问路径完全无感
元数据内存 每页入口 + 每映射节点 + 每页表项影子元数据,较重 page 结构里一个整数,复用既有 VMA 树,较轻
策略精细度 只能按聚合状态和"是否可执行"决策 可按精确共享度决策("多于一个引用就激活")
8 · 结论:成本的摆放艺术
两个内核回答的是同一个问题——"一个被多个进程共享的页,我怎么知道它还被谁用着,又怎么把它干净地拿走? "——但把代价压在了完全不同的时刻:
时刻 XNU 付多少 Linux 付多少
建立映射 首映射免费;第二个映射起付一次节点分配 近乎免费(整数加减)
老化 清访问标志埋陷阱;不做 TLB 失效,便宜但可能漏记 传统路径不付;MGLRU 付正向页表扫描
回收判定 几乎免费 (读一个标记位)最贵的一环 :区间树 + 页表遍历 + 逐项失效
拆映射 线性成本,但直达页表项、广播廉价 线性成本,含页表遍历(有早停和攒批缓解)
应用访问 踩陷阱的时刻 :缺页 + 整页映射恢复(O(N²) 尾部风险)完全免费
XNU 的账本 :平时便宜、关键动作集中付费。判定 O(1)、拆除直达页表项、状态靠陷阱收集。这套取舍贴合 iOS 的环境——没有 swap,文件页随时可以重新缺页回来,误回收的代价可控;代码页天然大量共享,有专门保护兜底;arm64 有硬件广播 TLB;单机核数有限。付出的代价是共享热页的缺页税和近似老化的误判风险。
Linux 的账本 :平时零打扰、回收时精确清点。访问路径没有额外缺页,策略可以精细到页级,适配服务器上 cgroup 记账、精确限流的需求。代价是回收路径对映射页明显昂贵,需要一套又一套的补救机制(跳过映射页的模式、批量 TLB 失效、MGLRU 的布隆过滤器)。
最后是一个值得玩味的观察:两条路线正在互相走近。 MGLRU 引入的"批量清 young 不刷 TLB + 空间局部性扫描 + 正向页表遍历做老化",正是 XNU 已经运行多年的那套"近似 + 批量"方案。设计没有绝对的好坏,只有与场景的匹配——而当场景开始重叠,方案也就开始趋同。
附录 · Linux 试过又放弃的 PTE 级反向映射
一个自然的追问:既然直达页表项这么快,Linux 为什么不这么干?答案是——Linux 干过,在主线里跑了将近一年,然后亲手删掉了。 这段历史恰好是本文所有取舍的最佳注脚。
那段历史
2002 年 7 月,Rik van Riel 的"pte 链"反向映射进入开发版主线(2.5.27)。它的形状和今天 XNU 的 pv 入口状态机几乎逐点相同:每个物理页挂一条反向映射记录;只有一个映射时,页表项地址直接内联在页结构里(用页标志位区分"内联"还是"链表"两种形态);映射达到两个以上,才从专用内存池分配链表节点,每个节点大约一个缓存行,装着若干个页表项指针。拆除时顺着链表直达页表项,不用走页表——连"单映射零开销"这个优化都和 XNU 的内联设计如出一辙。
2003 年年中(2.5.70 前后),这套机制被整体删除,换成"基于对象"的反向映射(objrmap):文件页沿 page → 文件 → VMA 区间树找候选、再走页表定位;匿名页随后由 Andrea Arcangeli 的 anon_vma 补齐。删除时有三个直接原因:
常驻内存太贵。 链节点按"映射总数"增长,是常驻开销,还换不出去。2003 年的主流是 32 位小内存机器,实测要吃掉 MB 级的内存——放在今天不算什么,当时不可接受。
映射路径背上了分配和锁。 多映射页每次建立或撤销映射都要动链表和内存池;fork 密集的服务器负载把这一点放得很痛。
存在性论证压垮了它。 回收只需要回答"还有人引用这页吗、还有映射吗",并不需要知道"具体是哪几个页表项";而 VMA 级的定位数据本来就在——区间树为文件截断而存在,anon_vma 为 fork/COW 而存在。用回收时的一次页表遍历,换掉常驻的每映射内存,对 Linux 的负载构成来说这笔账算得过来。
代价是 objrmap 的回收路径从此更贵。Linux 随后用了二十年把它往下压:i_mmap 从全量链表换成优先搜索树(2004 年前后)、再换成区间树(3.16,2014 年)、无锁遍历、TLB 失效攒批,直到 MGLRU 干脆把老化改成正向页表扫描。
为什么同一个设计 XNU 留得住
当年的 Linux XNU(arm64)
引用/脏位在哪 硬件位,走页表就能读——不需要持久的反向指针 软件位:埋陷阱和收割都必须逐页表项改权限,pv 链同时服务撤销、埋陷阱、收割三条路径,存地址的成本被摊销
多映射负载 fork 密集的服务器、海量进程、巨大地址空间 iOS:多映射主体是共享缓存代码页,进程数有界
TLB 失效 32 位 x86,逐核发 IPI arm64 硬件广播,无 IPI
大页 当时没有(后来 THP 让"页表项粒度"进一步复杂化) 没有 THP,页表项粒度全场景一致
内存预算 百 MB 级,MB 级的常驻开销很扎眼 GB 级,且单映射页本来就需要 8 字节入口(聚合属性位也存在这里),内联后零额外成本
还有一层历史原因:pv_entry 本来就是 4.4BSD pmap 模块的经典设计,Mach/XNU 继承了这条血统,然后把整套回收机制——权限陷阱、聚合标记、原子撤销——全都建在它上面。在 XNU 里它不是"可选的加速结构",而是地基;在当年的 Linux 里它只是一个加速结构,所以砍得动。
两个回声
故事并没有以"Linux 拒绝页表元数据"告终。2021 年,Linux 给 mm 计数器引入了按页表页的元数据,把统计摊到页表页粒度;NUMA 均衡更是直接用"改权限埋陷阱、缺页时收割"来探测访问分布——和 XNU 的 REFFAULT 是同一个思想。设计会循环,只是每次都带着新的硬件和负载前提回来。
附录 B · 把 PTE 链加回来?一次面向手机的推演
一个自然的延伸问题:既然直达页表项这么快,现代手机又有硬件广播 TLB 和大内存——把附录 A 里删掉的 pte 链加回来 ,值不值?推演结论:技术上可行,但几乎肯定不值得——2003 年杀死它的两把刀(常驻内存税、映射路径的分配与锁),在手机负载下比当年更锋利,而它想买的收益,现代内核已经用零元数据的机制拿到了八成。
先说对它有利的一面:硬件确实变了
2003 年的障碍 今天 arm64 手机的状态
TLB 失效要逐核发 IPI 中断 硬件广播 TLBI,撤销变便宜了
NUMA 跨节点链表 手机没有 NUMA
内存以百 MB 计,MB 级元数据致命 8–16GB,元数据税不再立刻致命
单核双核,锁竞争不明显 多核下 rmap 遍历多个 VMA 的锁与缓存行竞争反而更痛
单看这张表,链式反向映射在今天比 2003 年更有资格活下来。但它撞上的负载也变了。
但负载变得更毒:zygote 数学
Android 的每个 app 进程都是 zygote 的 fork,而 zygote 预加载了几百 MB 框架代码(boot.art/boot.oat、bionic、各种 .so)。这些文件页被几乎所有 app 进程同时映射——正是本报告主角"高 mapcnt 文件页"的常态形态。以一台 12GB、约 150 个 app 进程、500MB 共享框架代码的典型设备粗算:
内存税。 链节点每个 64 字节装 7 个页表项指针。一个 4KB 页被 150 个进程映射,需要 22 个节点约 1.4KB 元数据——达到页自身大小的 35% ;300 个映射就是 67%。500MB 共享代码合计约 180MB 常驻元数据 (整机的 1.5%),还换不出去。16KB 页(Android 15 起开始支持)能把它除以 4,从"不可接受"救到"可疑",仅此而已。
fork 税——直接命中手机体验。 用户最敏感的指标是冷启动,而每次冷启动就是一次 zygote fork。fork 本来就要逐页表项拷贝页表(这部分逃不掉),pte 链会让每个被共享的框架页在每次 fork 时 额外做一次内存池分配加链表插入——十几万页 × 每次孵化,粗算是十几毫秒量级的额外开销,开机后连开多个 app 的启动高峰还要叠加内存池锁竞争。2003 年砍掉它的理由(fork 密集负载放大分配与锁的痛)在 zygote 模式下是加强版。
它真正能买到的只有一样东西
Linux 的 young/dirty 位在硬件里,走页表就能免费读——不存在 XNU 那套"权限陷阱收割"的需求 (那是 pv 链在 XNU 里摊销成本的第二、第三条路径)。所以链对 Linux 的价值只剩:try_to_unmap 时直达页表项,省掉"沿区间树找候选 + 逐级走页表"的 3–4 次内存访问。这是真收益(高 mapcnt 页回收的长尾延迟),但要清楚两点:省的只是 walk-down,每映射一条的 TLB 失效广播一分不少;而且高 mapcnt 页在回收流量里是长尾而非常态——绝大多数匿名页 mapcnt=1,文件缓存页 mapcnt 是 0 或 1。
而且现代内核已经用零元数据拿到了八成
MGLRU(5.18 合并,官方动机就包含 ChromeOS/Android 实测) :老化改为正向页表扫描 + look_around 批量清 young 不刷 TLB——正是 XNU"埋陷阱"的零元数据等价物。热共享页在走到 try_to_unmap 之前就被标记引用直接跳过,它削减的恰好是链最想优化的那条路径的流量 。
按页表页的 mm 计数器 (2021 年):主线已经表态,元数据的正确粒度是页表页,不是页表项。
folio 化 + 16KB 页 :遍历次数按 folio 计而不是按 4KB 页计,映射数与数据量的比值同步下降。
还有两颗 2003 年不存在的钉子:THP/大 folio 的 PMD 映射根本没有页表项,"页表项粒度"的链要么撒谎要么强制拆大页;而 maple tree、per-VMA 锁、无锁 rmap 遍历已经把 objrmap 当年的伸缩性痛点修掉了大半——链的相对优势又被削一刀。
flowchart LR
HW["硬件在帮它<br/>广播 TLBI · 无 NUMA · 大内存"] --> Q{"值得加回来吗?"}
LD["负载在杀它<br/>zygote 共享:元数据税约 1.5% 整机内存<br/>fork 税直接打在冷启动上"] --> Q
Q -->|"能买到的:只省 unmap 的页表遍历<br/>每映射一条的 TLB 广播一分不少"| B["收益:高 mapcnt 页回收的长尾延迟"]
Q -->|"已有零元数据等价物"| M["MGLRU 正向扫描 + look_around<br/>已拿到八成收益"]
B --> V["结论:不划算"]
M --> V
V --> Z["真正缺的那块(O(1) 每页状态汇总)<br/>正确形状是页表页粒度的聚合位图/计数器<br/>而不是每映射一条链(本报告的推测)"]
图 G · 加回 pte 链的权衡账本:硬件在让步,负载和工作量在反对
如果目标真是手机体验
冷启动、前台卡顿、后台查杀——现实路径是 MGLRU 成熟化、大 folio、16KB 页和 zram/lmkd 调优。XNU 证明这套链确实撑得起一部手机,但它靠的是整套套餐:pv 入口本来就承载聚合属性位、arm64 陷阱收割复用同一条链、没有 THP 的均匀粒度。Linux 单点把主菜买回来而不配那锅汤,性价比为负。MGLRU 才是 Linux 对"手机体验"的正式回答——而它选的,恰好是 XNU 设计里零元数据的那一半 。
附录 C · 既然缺点这么多,为什么 XNU 还在用
附录 B 列了一整页缺点,而 XNU 至今跑在这套机制上——这不是矛盾,是同一个答案的另一半 :那份缺点清单算的是"给 Linux 加装"的成本,而 XNU 面对的不是"要不要加装",是"要不要拆承重墙"。同一个东西,加装是消费,拆除是重写。
在 XNU 里,这条链同时干三份活
关键在架构决策的顺序:XNU 先决定了"引用/脏状态按页聚合、软件维护",pv 链随后成为必需品 ——要维护"这一页的状态"(O(1) 问答),就必须随时能找到"这一页的所有页表项"。软件脏位(可写映射先装成只读再挂 MODFAULT)更是把"第一次写"变成必须捕获的事件,进一步锁死了对逐页表项触达能力的需求。
用途 谁在用 为什么必须触达每一个页表项
撤销 pmap_disconnect 回收前拆光全部映射
埋陷阱 老化路径 把这页所有页表项的访问标志清零
收割 arm_fast_fault 缺页后恢复所有页表项并置聚合位
Linux 的 young/dirty 在硬件位里、走页表免费读,链只能服务"加速 unmap"一件事,所以是可选的奢侈品;XNU 的链服务三件事,还顺带撑起 vm_pageout 整个 O(1) 扫描设计。Linux 买链是为了一个花棚,XNU 拆链会塌三层楼。
flowchart TD
D["架构决策:引用/脏状态按页聚合、软件维护"] -->|"要维护每页状态<br/>就必须找到每页的全部页表项"| N["pv 链<br/>不是功能,是地基"]
N --> A["撤销<br/>pmap_disconnect"]
N --> B["埋陷阱<br/>老化清访问标志"]
N --> C["收割<br/>缺页恢复映射并置位"]
A --> O["撑起 vm_pageout 的 O(1) 扫描<br/>每字节元数据干三份活"]
B --> O
C --> O
图 H · 自洽的闭环:聚合位的架构决策把 pv 链变成了必需品,pv 链又把聚合位的设计变成现实
iOS 的负载恰好落在甜区——附录 B 的三笔税,iOS 逃掉了两笔半
附录 B 给 Android 算的税 iOS 的实际情况
fork 税 :zygote 孵化 = 十几万页链表插入,打在冷启动上收不到。 iOS 应用是 launchd 经 posix_spawn 拉起的全新进程,不是从共享大地址空间的父进程 fork 出来的;共享缓存靠惰性缺页进入,pv 节点一次只加一个,成本摊在应用生命周期里,没有孵化风暴
内存税 :4KB 页 × 300 映射 ≈ 页自身 67% 的元数据减税四分之三。 Apple 的 arm64 设备长期用 16KB 页,同样的映射数按数据量算直接除以 4
共享群体不可控 :各厂商框架代码开放增长受控。 多映射页的主体是 dyld 共享缓存,规模与构成都在 Apple 自己手里(诚实粗算仍是几十 MB、整机百分之一上下——与 Android 同量级,但买的是整套套餐)
最坏情况 :热共享页老化后的 O(N²) 广播可能被踩中被自家规则挡在门外。 可执行页的回收下限保护,恰好拒绝了最可能触发最坏情况的那些页
血统与沉没架构
pv_entry 是 4.4BSD pmap 模块的经典血统,Mach 的 VM 层从第一天就建在这套 pmap 服务接口上——回收、压缩器、权限撤销全都走它。拆掉它意味着重写整个系统里验证最充分的子系统,而 Apple 没有负载压力这么做:spawn 模型、16KB 页、受控共享、没有 swap、核数有限——iOS 的负载形态稳稳坐在设计的甜区里。对比 2003 年的 Linux:既有重写压力(fork 风暴的服务器负载),门口又正好站着更便宜的替代方案(objrmap)——这两个条件 Apple 一个都不占。
对称的收尾
"为什么 Linux 不该把链加回来"和"为什么 XNU 不把链拆掉"是同一个答案的两面:这条链不是一项功能,是一种承重结构。 它的成本和收益都取决于上面盖的是什么楼、地基是不是为它打的。XNU 为它打的地基(软件聚合位、权限陷阱、原子撤销语义)让每字节元数据干三份活;Linux 的地基(硬件位、objrmap、MGLRU)让每字节元数据只能干三分之一个活。同样的墙,在一个系统里承重,在另一个系统里占地方。
全部行号引用基于 xnu-7195.141.2(iOS 14.8)与 Linux 5.10 / 6.1 源码存档逐行验证,可循
xnu-source-analysis skill 的存档复核(锚点已录入 anchors-ios14.md 第十二批)。
Mermaid 已内嵌,无外部依赖 · 2026-09-30