iOS 是怎么回收"被多个进程共享的文件页"的

XNU(iOS 内核)源码逐行分析 × Linux 对照 —— 机制、性能风险与设计取舍
xnu-7195.141.2 = iOS 14.8 vm_pageout.c / arm pmap.c Linux 5.10 / 6.1 全部行号可复核

摘要(先读这里)

用一个被 30 个进程共享的库代码页做例子,整个故事可以浓缩成七句话:

  1. Linux 给每个物理页记一本精确的账——"你现在被几个页表项映射着"(_mapcount)。XNU 没有这本账,它只有一张反向映射表:每个物理页挂一条记录,写着"我被映射到了哪些页表项上"。
  2. 所谓"mapcnt 大于 1 的页",在 XNU 里就是反向映射记录从"单条"变成"链表"的页。回收它只需要一次函数调用:pmap_disconnect() 顺着链表把 30 个进程里的映射一次性全部拆掉。
  3. 这张表精确到页表项(PTE)本身:每个映射直接记着页表项的地址。拆映射时不需要一层层走页表去"找",直接改就行。
  4. 判断"这页还热不热",XNU 完全不碰页表——引用和脏状态平时就汇总在物理页的两个软件标记位里,读一下就是 O(1)。
  5. 这两个标记位是靠"权限陷阱"种出来的:老化时把页的访问标志清零(埋陷阱),之后谁访问谁缺页(踩陷阱),缺页处理把"被引用/被写脏"记回页上(收割)。
  6. 主要性能风险也在这套陷阱上:老化之后,30 个进程各踩一次缺页,而每次缺页处理又要恢复整页 30 个映射——最坏是 O(N²) 次 TLB 失效广播。XNU 用"O(1) 预判先把热页放回去"和"可执行页专门保护"来兜底。
  7. 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 次广播、一次同步。逐步拆解:

  1. 拿锁。每个物理页有自己的一把反向映射锁,互不干扰。
  2. 找映射。链表每个节点里存的就是页表项的地址,取出来直接用——不需要像 Linux 那样先查区间树找到候选进程区间、再走页表逐级定位。pmap 和虚拟地址这些信息也不用在节点里存:每个页表页有一份影子元数据(记录这张表覆盖的虚拟地址基址、表内非空项计数和 wired 计数),需要时按页表项在表内的位置推算即可。
  3. 逐个清空。把每个页表项写成"无效",同时递减页表页的引用计数(归零的页表页可以被回收)。
  4. 逐个失效 TLB。TLB 是各核心自带的页表缓存——改了页表必须让所有核心的这份缓存失效,否则别的核心还会按旧映射继续访问。每拆一个映射,XNU 就发一条 arm64 的 TLB 失效指令;这类指令由硬件在核心间广播,不需要 x86 上那种逐核发 IPI 中断的开销。指令是异步发出的,全部拆完后只做一次同步等待,确认广播生效。
  5. 收尾。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 · 细节二:引用位是"种"出来的——武装、踩坑、收割

前面反复出现"物理页上的聚合软件标记",它是整个机制的枢纽。这套机制可以概括为陷阱捕猎:平时不巡逻(不扫页表),在关键位置埋好陷阱(清访问标志、改成只读),猎物踩到(缺页)才记录(置标记位)。

埋陷阱的三个时机

踩坑与收割

陷阱埋好后,任何进程对这个页的访问都会触发一次缺页。这个缺页走的是一条极短的专用处理路径(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 · 性能评估:快在哪、慢在哪

结论先行:扫描和判定极快,拆除和缺页集中付费。六个具体问题,每个都配有源码可见的对冲设计:

快的一面

风险一:拆除成本随共享度线性增长
一个被 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)

  1. 判定引用:page_referenced() 沿着文件的反向映射(挂在文件上的区间树)枚举所有可能映射这个页的进程地址区间,对每个候选走一遍页表定位到页表项,读出并清除硬件的 young 位——每个页表项配一次 TLB 失效。精确计数器随做随减,减到零就提前收工。
  2. 决策:发现多于一个页表项引用过它(说明不止一个进程在用),或者这是可执行文件页——立即激活;只有单次引用则再给一次机会。
  3. 拆映射:try_to_unmap() 同样沿区间树走,每拆一个页表项、计数减一,减到零提前停止;TLB 失效攒批处理。
  4. 脏页:日常主要依赖后台回写线程提前清洗,回收路径遇到脏页大多先放回去等写完。

代价写在 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 的方向靠拢:

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 补齐。删除时有三个直接原因:

  1. 常驻内存太贵。链节点按"映射总数"增长,是常驻开销,还换不出去。2003 年的主流是 32 位小内存机器,实测要吃掉 MB 级的内存——放在今天不算什么,当时不可接受。
  2. 映射路径背上了分配和锁。多映射页每次建立或撤销映射都要动链表和内存池;fork 密集的服务器负载把这一点放得很痛。
  3. 存在性论证压垮了它。回收只需要回答"还有人引用这页吗、还有映射吗",并不需要知道"具体是哪几个页表项";而 VMA 级的定位数据本来就在——区间树为文件截断而存在,anon_vma 为 fork/COW 而存在。用回收时的一次页表遍历,换掉常驻的每映射内存,对 Linux 的负载构成来说这笔账算得过来。

代价是 objrmap 的回收路径从此更贵。Linux 随后用了二十年把它往下压:i_mmap 从全量链表换成优先搜索树(2004 年前后)、再换成区间树(3.16,2014 年)、无锁遍历、TLB 失效攒批,直到 MGLRU 干脆把老化改成正向页表扫描。

为什么同一个设计 XNU 留得住

当年的 LinuxXNU(arm64)
引用/脏位在哪硬件位,走页表就能读——不需要持久的反向指针软件位:埋陷阱和收割都必须逐页表项改权限,pv 链同时服务撤销、埋陷阱、收割三条路径,存地址的成本被摊销
多映射负载fork 密集的服务器、海量进程、巨大地址空间iOS:多映射主体是共享缓存代码页,进程数有界
TLB 失效32 位 x86,逐核发 IPIarm64 硬件广播,无 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 共享框架代码的典型设备粗算:

它真正能买到的只有一样东西

Linux 的 young/dirty 位在硬件里,走页表就能免费读——不存在 XNU 那套"权限陷阱收割"的需求(那是 pv 链在 XNU 里摊销成本的第二、第三条路径)。所以链对 Linux 的价值只剩:try_to_unmap 时直达页表项,省掉"沿区间树找候选 + 逐级走页表"的 3–4 次内存访问。这是真收益(高 mapcnt 页回收的长尾延迟),但要清楚两点:省的只是 walk-down,每映射一条的 TLB 失效广播一分不少;而且高 mapcnt 页在回收流量里是长尾而非常态——绝大多数匿名页 mapcnt=1,文件缓存页 mapcnt 是 0 或 1。

而且现代内核已经用零元数据拿到了八成

还有两颗 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)让每字节元数据只能干三分之一个活。同样的墙,在一个系统里承重,在另一个系统里占地方。

附录 D · Linux 的 rmap 还能怎么优化

既然 Linux 拿不到 XNU"存好的指针",回收文件页的反向遍历还有多少优化空间?答案是:一整条零元数据的优化斜坡——不存指针,就摊销遍历、批量摊平、聚合跳过、粒度升级。先拆准成本,再看每一级怎么压。

先把账拆开:钱花在哪

传统 LRU 路径下,每回收一个被映射的文件 folio 要走两次反向遍历:判定遍历(folio_referenced:区间树找候选 VMA → 走页表 → 逐 PTE 读清 young 加 TLB 失效)和拆除遍历(try_to_unmap:同样的区间树加页表走,逐 PTE 写无效加批量 TLB)。所以成本 = 遍历次数 × 每次步数 × 每步成本。注意前提:未映射的文件缓存页(回收流量的大多数)根本不走 rmap——所有优化都瞄准"被映射的那部分长尾"。

方向一:少走——把判定挪出回收路径(已上线,收益最大)

MGLRU(5.18)把"清点谁年轻"从回收时刻挪到老化时刻:老化做正向页表扫描、按世代号升级;回收时看世代号即可,不再需要完整的判定遍历。驱逐路径保留的检查由 look_around(6.1)摊平:发现一个 young PTE 就顺带扫旁边 64 个,NOFLUSH 不刷 TLB,布隆过滤器把"哪些页表段值得再扫"反馈给老化——正是图 F 的内容,XNU"埋陷阱"的零元数据等价物,已在生产里跑了。

方向二:走得更少——大 folio 与 16KB 页(正在铺开)

rmap 遍历按 folio 计费,而页表遍历天然覆盖 folio 的全部子页。页缓存大 folio(近两三年逐步铺开的读预取大页化)把"每 4KB 一次遍历"摊成"每 2MB 一次"——步数直接除以 512,区间树和页表走的部分几乎白赚。Android 15 起的 16KB 页再降 PTE 密度四倍。这两个都不碰 rmap 一行代码,却是单笔最大的摊销收益。

方向三:单步更便宜(已在主线)

方向四:反转循环嵌套——正向批量拆除(方向推演,非现有补丁)

现在的结构是"外层 folio、内层 VMA",一个 folio 拆一次就走一遍表。但内存压力是一场风暴:一批 victim 往往共享同一批页表页。反转成"外层页表页、内层 victim 批量拆除",一次页表走拆掉一串 folio。这不是幻想:exit_mmap 与 munmap 的正向清扫(6.9 前后的 zap_present_folio_ptes 批量机制)已经证明了这条路的单页成本有多低。这正是"XNU 直达页表项"的零元数据版本——XNU 用存好的指针直达,Linux 用正向扫描摊销走表。

方向五:改粒度——进程级回收(生态实践)

整进程冷掉时,页粒度 rmap 是最贵的方式:kill 一个进程走 exit_mmap 正向整表清扫,单页成本最低。Android 的 lmkd 加 PSI(按压力信号杀进程)、iOS 的 jetsam,本质都是把回收粒度从页升级到进程,页粒度 rmap 只服务"部分冷"的场景。这是对 rmap 成本最强的生态级规避。

方向六:元数据的正确形状(推测,呼应附录 B/C)

真想要 XNU"问页不问表"的 O(1) 精神,正确形状是页表页粒度的 young 聚合:每张页表页维护一个"还有没有 young 项"的计数器或位图——老化与驱逐可以整表跳过,判定退化为一次内存读。代价是页表项装入与清 young 路径上几次计数器维护(有界、便宜),换来整表级的 O(1) 剪枝。Rik van Riel 2021 年的按页表页 mm 计数器是同一粒度上的先例,说明主线接受这种形状的元数据——只是还没有人把它做成 young 摘要。这是设计空间,不是现有方案。

flowchart TD C["回收一个被映射文件 folio 的成本<br/>= 遍历次数 × 每次步数 × 每步成本"] --> L1["少走<br/>方向一:MGLRU 正向老化<br/>(已上线)"] C --> L2["走得更少<br/>方向二:大 folio + 16KB 页<br/>(铺开中)"] C --> L3["单步更便宜<br/>方向三:早停 / 批量 / exec 特判<br/>(已在主线)"] C --> L4["改粒度与元数据<br/>方向四:正向批量拆除(推演)<br/>方向五:进程级回收(生态)<br/>方向六:页表页 young 聚合(推测)"] L1 --> Z["XNU 用每页 8 字节元数据买 O(1)<br/>Linux 用摊销与剪枝买 O(1)<br/>——元数据免费,聪明花在什么时候根本不用走"] L2 --> Z L3 --> Z L4 --> Z
图 I · 零元数据的优化斜坡:六条路都压在同一笔账的不同因子上

收口:一张账本

方向状态对应 XNU 的哪一手
一:正向老化 + look_around已上线(5.18 / 6.1)埋陷阱 + NOFLUSH 近似
二:大 folio + 16KB 页铺开中(无对应物,纯 Linux 侧红利)
三:早停 / 批量 / exec 特判已在主线广播 TLBI、可执行页保护
四:正向批量拆除方向推演"直达页表项"的摊销版
五:进程级回收生态实践jetsam(粒度对齐)
六:页表页 young 聚合推测O(1) 状态汇总,换粒度实现
源码依据(沿用 §6 存档锚点) · Linux 5.10:rmap.c:1748(归零早停)· :1899/:1920-1921(区间树枚举与可执行特判)· vmscan.c:986/:1020/:1026(决策与激活)· :1299-1306(TLB 攒批)· Linux 6.1:vmscan.c:4589(look_around)· :1876(驱逐)· :4385(注释)