01三层堆叠:机制、框架、服务
「purgeable」这个词在 iOS 栈里出现在三个不同的层。分清这三层,是回答「何时可回收」的前提:
| 层 | 代码 | 职责 |
|---|---|---|
| VM 机制SRC | osfmk/vm/vm_purgeable.c + vm_purgeable_internal.h |
唯一的实现者:对象状态机(NONVOLATILE / VOLATILE / EMPTY)、token 队列与 aging、victim 选择、物理页收割。本文主角。 |
| IOKit 框架SRC | iokit/Kernel/IOMemoryDescriptor.cpp |
翻译转发层,不实现丢弃:把 IOKit 的四态(kIOMemoryPurgeableVolatile 等)直译为 VM_PURGABLE_* 位,对每个 memory entry 调用内核入口。 |
| IOSurface 服务闭源 kext | IOSurface.kext(不随 xnu 开源) | purgeable 最重的使用者:像素数据可重渲染,丢了能重建,天然适合 volatile。内核侧通过上面的 IOKit 层接入;用户侧公开 API 是 IOSurfaceSetPurgeable()。 |
madvise(MADV_FREE_REUSABLE) 走的是另一套机制——「reusable 页」(vm_page::vmp_reusable / vm_object::all_reusable,经 mach_vm_behavior_set → vm_map_behavior_set),是页级标记,由 pageout 扫描到该页时直接丢弃;而 purgeable 是对象级状态机 + token 队列。二者都提供「无 I/O 丢弃」语义,但实现、粒度、公平性机制完全不同。IOSurface 走的是后者。02对象状态机:四个状态与修饰位
每个 purgeable 对象(vm_object)有一个 purgable 字段,取值定义在 osfmk/mach/vm_purgable.h(注意该头文件刻意拼错为 purgable,见文件头注释):
图 1 · purgeable 对象状态机(vm_object_purgable_control(),vm_object.c:5846–6170)
状态之外,state 参数还携带修饰位(同样定义于 vm_purgable.h),它们直接决定「何时可回收」:
| 修饰位 | 值 | 对回收时机的影响 |
|---|---|---|
VM_PURGABLE_BEHAVIOR_FIFO / _LIFO | bit 6 | 选择挂入 FIFO 还是 LIFO token 队列。头文件注释给出用法:«Objects that will be needed in the future (forward cached) should be queued LIFO; objects that have been used and are cached for reuse (backward cached) should be queued FIFO»(vm_purgable.h:26–29)。 |
VM_PURGABLE_ORDERING_OBSOLETE | bit 5 | 废弃对象:永不进入 aging(token count 恒为 0,«all obsolete items are ripe immediately»,vm_purgeable.c:266–269),并且排在所有回收路径的最前面。 |
VM_PURGABLE_NO_AGING | bit 16 | 跳过 aging:置 purgeable_when_ripe = FALSE,挂上队列即刻可回收(vm_object.c:6013–6018)。 |
VM_VOLATILE_GROUP | bits 8–10 | 8 个 volatile 分组(0–7)。«Every user is entitled to group 7 (highest)»;只有想让某些对象「确定先死」时才放进低组(vm_purgable.h:30–33)。 |
状态机的核心是 vm_object_purgable_control(),VOLATILE 分支的关键片段:
6009if (old_state == VM_PURGABLE_NONVOLATILE || 6010 old_state == VM_PURGABLE_EMPTY) { 6013 if ((*state & VM_PURGABLE_NO_AGING_MASK) == VM_PURGABLE_NO_AGING) { 6015 object->purgeable_when_ripe = FALSE; // 跳过 aging:即刻可回收 6016 } else { 6017 object->purgeable_when_ripe = TRUE; // 默认:要等 token 成熟 6018 } 6020 if (object->purgeable_when_ripe) { 6024 vm_page_lock_queues(); 6026 result = vm_purgeable_token_add(queue); // 挂 token,进入 aging 6027 if (result != KERN_SUCCESS) { ... } 6031 vm_page_unlock_queues(); 6032 } 6048 object->purgable = new_state; ... 6096vm_purgeable_object_add(object, queue, (*state & VM_VOLATILE_GROUP_MASK) >> VM_VOLATILE_GROUP_SHIFT); 6097if (old_state == VM_PURGABLE_NONVOLATILE) { 6098 vm_purgeable_accounting(object, VM_PURGABLE_NONVOLATILE); // ← 此处移出 phys_footprint
三个值得注意的转换规则:
- EMPTY 是单向的:«what's been emptied must stay empty»(vm_object.c:5901–5910)——被清空的对象不能回到 VOLATILE,只能回 NONVOLATILE(然后重新填内容)。
- VOLATILE→NONVOLATILE(复用)删除的是「最老」的 token(
vm_purgeable_token_delete_last,vm_object.c:5957–5959)——对象活了下来,等价于它的 token 被消费掉。 - EMPTY 设置是同步回收:
(void) vm_object_purge(object, 0)直接在调用线程里收割页面(vm_object.c:6159)。
03Token 状态机:一台以「页周转」为刻度的 aging 时钟
这是整个机制最精巧、也最少被理解的部分:内核不用时间戳、不用定时器,而是用「物理 inactive 队列的翻转速」来度量 volatile 对象的年龄。
3.1 数据结构
59struct token { 60 token_cnt_t count; // 剩余「老化额度」:还要再周转多少页才成熟 61 token_idx_t prev; 62 token_idx_t next; 63}; 74int available_for_purge = 0; // 成熟(ripe) token 的页数配额;pageout 据此决定能否 purge 54struct purgeable_q { 55 token_idx_t token_q_head; // 队首 token 56 token_idx_t token_q_tail; // 队尾 token 57 token_idx_t token_q_unripe; // 第一个未成熟 token(其后的都未成熟) 58 int32_t new_pages; // 尚未被任何 token 认领的「新页」数 59 queue_head_t objq[NUM_VOLATILE_GROUPS]; // 8 个组的 volatile 对象链
3.2 关键不变量:token 队列 = inactive 队列的「虚拟影子」
调试内核里的断言直接写出了这台时钟的设计意图:
141/* obsolete queue doesn't maintain token counts */ 142if (queue->type != PURGEABLE_Q_TYPE_OBSOLETE) { 143 our_inactive_count = page_cnt + queue->new_pages + token_new_pagecount; 145 assert((uint32_t) our_inactive_count == vm_page_inactive_count - vm_page_cleaned_count); 146}
即:所有 token 的 count 之和 + new_pages + token_new_pagecount,恒等于当前 inactive(非 cleaned)队列中的物理页数。这意味着 token 队列是 inactive 队列的一面「虚拟影子」。
3.3 时钟如何走
- 对象变 volatile 时(
vm_purgeable_token_add):新 token 的count = queue->new_pages——即那一刻 inactive 队列里排在你前面的页数(vm_purgeable.c:263–270)。语义是「排在您前面的、比您更冷的页,还有 N 页」。 - 每有一页进入 inactive 队列:
token_new_pagecount++(vm_resident.c:8788,vm_page_enqueue_inactive();以及 UPL 提交路径 vm_pageout.c:7951)。 - 每有一页离开 inactive 队列(被回收 / 压缩 / 重新激活出队):
vm_purgeable_q_advance_all()被调用,把最早的未成熟 token 的 count 减 1(vm_resident.c:8628 / 8639 → vm_purgeable.c:469–542)。 - count 归零 → token 成熟(ripe):
available_for_purge++,KERNEL_DEBUG 记TOKEN_RIPEN事件(vm_purgeable.c:509–518)。
487/* 488 * Decrement token counters. A token counter can be zero, this means the 489 * object is ripe to be purged. It is not purged immediately, because that 490 * could cause several objects to be purged even if purging one would satisfy 491 * the memory needs. Instead, the pageout thread purges one after the other 492 * by calling vm_purgeable_object_purge_one and then rechecking the memory 493 * balance. 495 * No need to advance obsolete queue - all items are ripe there, always 497 */ 498for (i = PURGEABLE_Q_TYPE_FIFO; i < PURGEABLE_Q_TYPE_MAX; i++) { 499 purgeable_q_t queue = &purgeable_queues[i]; 500 uint32_t num_pages = 1; // 本函数每次只「走 1 格」 503 while (queue->token_q_unripe) { 504 if (tokens[queue->token_q_unripe].count && num_pages) { 505 tokens[queue->token_q_unripe].count -= 1; // 老化 1 页 506 num_pages -= 1; 507 } 509 if (tokens[queue->token_q_unripe].count == 0) { 510 queue->token_q_unripe = tokens[queue->token_q_unripe].next; 511 available_for_purge++; // ← 成熟!获得被回收资格 512 KERNEL_DEBUG_CONSTANT(... TOKEN_RIPEN ...); 519 continue; 521 } 522 if (num_pages == 0) break; 526 } 534 if (!queue->token_q_unripe) queue->new_pages -= num_pages; 541}
04实际回收的六个时机
token 成熟只意味着「有资格」,物理回收还需要一个「行动者」。XNU 中共有六条路径能让 volatile 对象的页真正离开物理内存:
4.1 惰性主路径:vm_pageout_scan 每轮循环开头
页回收守护线程 vm_pageout_scan() 的主循环中,在做任何其他事情之前(在确认空闲页已达标、准备 return 之后)有一个 purge 钩子:
2959if (vm_page_free_count + local_freed >= vm_page_free_target) { ... 2994 return; // 空闲页达标 → 扫描直接结束(根本不会 purge) 2997} 2999/* 3000 * Before anything, we check if we have any ripe volatile 3001 * objects around. If so, try to purge the first object. 3002 * If the purge fails, fall through to reclaim a page instead. 3003 * If the purge succeeds, go back to the top and reevalute 3004 * the new memory situation. 3005 */ 3006retval = vps_purge_object(); 3008if (retval == VM_PAGEOUT_SCAN_NEXT_ITERATION) { 3018 continue; // purge 成功 → 回循环顶部重新评估,可能就够了 3019}
注意两个先后关系:
- 先判达标,后 purge(L2959 → L3006):只有空闲页 低于
vm_page_free_target、扫描器真正在干活时,才会走到 purge 钩子。没有内存需求 = 永不 purge,即使 token 全部成熟。 - 一次一个,purge 完重新评估:这正是
vm_purgeable_q_advance_all()注释(L487–493)解释的设计——避免为了回收一页的缺口而连锅端掉多个对象。
4.2 强制路径:内存压力等级 → force_purge
vps_purge_object() 内部还有一个压力旁路:force_purge 取自全局压力等级,无视 token 是否成熟:
1847pressure_level = memorystatus_vm_pressure_level; 1849if (pressure_level > kVMPressureNormal) { 1850 if (pressure_level >= kVMPressureCritical) { 1851 force_purge = vm_pageout_state.memorystatus_purge_on_critical; // = 8 1852 } else if (pressure_level >= kVMPressureUrgent) { 1853 force_purge = vm_pageout_state.memorystatus_purge_on_urgent; // = 5 1854 } else if (pressure_level >= kVMPressureWarning) { 1855 force_purge = vm_pageout_state.memorystatus_purge_on_warning; // = 2 1856 } 1858} 1860if (available_for_purge || force_purge) { 1864 if (vm_purgeable_object_purge_one(force_purge, C_DONT_BLOCK)) { 1869 return VM_PAGEOUT_SCAN_NEXT_ITERATION; 1870 } 1873}
force_purge 的数值是一个 volatile group 阈值:在 vm_purgeable_object_purge_one() 中,未成熟 token 的对象若 group < force_purge 仍会被强制回收(vm_purgeable.c:940–962)。默认配置下:
| 压力等级 | force_purge | 含义 |
|---|---|---|
kVMPressureWarning | 2 | group 0–1 的 volatile 对象可被无视 aging 强制回收 |
kVMPressureUrgent | 5 | group 0–4 可被强制回收 |
kVMPressureCritical | 8 | 全部 8 个组都可被强制回收 |
kIOMemoryPurgeableVolatileGroup0–7、BehaviorFifo/Lifo、OrderingObsolete,IOMemoryDescriptor.h:155–167,可经 IOMemoryDescriptor::setPurgeable 的 newState 一并传入——内核 purgeableControlBits() 会把这些高位直译给 VM),但 IOSurface 的公开枚举只有四个基础状态(kIOSurfacePurgeableKeepCurrent/NonVolatile/Volatile/Empty,对应 IOKit 值 1/2/3/4——注意 IOKit 的 Volatile = 3 而非 1),无法携带组位,经直译后 group = 0(vm_object.c:6096)。因此在 warning 级压力下,IOSurface 的 volatile 内存就足以被强制回收——比多数人直觉的「critical 才动手」早得多。4.3 EMPTY:应用主动、同步、立即
设置 VM_PURGABLE_EMPTY(用户态即 kIOSurfacePurgeableEmpty)不走任何队列或 token:vm_object_purgable_control() 的 EMPTY 分支直接在调用线程里执行 vm_object_purge()(vm_object.c:6159)。这是唯一由应用主动触发的同步回收路径。
4.4 常规扫描遇到 volatile 页:交给压缩器
即使整个对象还没被 purge,它的单个脏页也可能在常规 pageout 扫描中被逐出。iOS 上压缩器永远在场,此时内核选择「压缩」而非「等整对象 purge」:
3305if (object->copy == VM_OBJECT_NULL) { 3306 /* 3307 * No one else can have any interest in this page. 3308 * If this is an empty purgable object, the page can be 3309 * reclaimed even if dirty. 3310 * If the page belongs to a volatile purgable object, we 3311 * reactivate it if the compressor isn't active. 3312 */ 3313 if (object->purgable == VM_PURGABLE_EMPTY) { 3319 if (m->vmp_dirty || m->vmp_precious) { 3320 vm_page_purged_count++; // EMPTY 对象:脏页直接丢,省掉清洗开销 3321 } 3322 goto reclaim_page; 3323 } 3325 if (VM_CONFIG_COMPRESSOR_IS_ACTIVE) { 3326 /* 3327 * With the VM compressor, the cost of reclaiming a page is 3328 * much lower (no I/O), so if we find a "volatile" page, it's 3329 * better to let it get compressed rather than letting it 3330 * occupy a full page until it gets purged. 3331 * So no need to check for "volatile" here. 3332 */ 3333 }
因此 volatile ≠ 未压缩:volatile 对象的脏页可以先进压缩器;将来 vm_object_purge() 会把压缩器里的对应页一并收割(vm_compressor_pager_reap_pages(),vm_object.c:5719–5728)。purgeable 的「丢弃」覆盖 resident 与 compressed 两种形态。
4.5 jetsam 杀进程之前:先 purge,可能免杀
jetsam 选中受害者后、真正 kill 之前,会先把该进程自己的全部 volatile 对象(三条队列、全部组、无论成熟与否)purge 掉,再重新评估是否还需要杀:
5512if (cause != kMemorystatusKilledVnodes && cause != kMemorystatusKilledZoneMapExhaustion) { 5515 networking_memstatus_callout(p, cause); 5516 num_pages_purged = vm_purgeable_purge_task_owned(p->task); // 先收 volatile 5517 num_pages_reclaimed += num_pages_purged; ... 5534 if (num_pages_reclaimed) { 5538 if (cause == kMemorystatusKilledHiwat) { 5540 success = (footprint_in_bytes <= memlimit_in_bytes); // 高水位:不超限即成功 5541 } else { 5542 success = (memorystatus_avail_pages_below_pressure() == FALSE); 5544 } 5553 if (success) { 5554 memorystatus_purge_before_jetsam_success++; 5563 os_log_with_startup_serial(..., "memorystatus: reclaimed %llu pages (%llu purged, ...) from pid %d ... and avoided %s\n", ...); 5570 *killed = FALSE; // ← 免杀! 5571 return TRUE; 5572 }
4.6 PURGE_ALL:一把梭
mach_vm_purgable_control(map, addr, VM_PURGABLE_PURGE_ALL, …)(用户态可达的 mach 陷阱)会触发 vm_purgeable_object_purge_all():清空全部队列、全部组、无论成熟与否(vm_map.c:18566–18569 → vm_purgeable.c:815–879)。它也是唯一能从用户态主动「清场」的入口。
图 2 · 三层时机 + 扫描循环决策图(所有行号对应 xnu-7195.141.2)
05受害者选择算法:谁先被 purge
当决定 purge 一个对象时(vm_purgeable_object_purge_one(),vm_purgeable.c:894–1030),遍历顺序是:
- 队列优先级:
OBSOLETE → FIFO → LIFO(L915)。obsolete 对象永远最先(且永远 ripe)。 - 组优先级:每条队列内按 volatile group 0 → 7(L940)。低组 = 客户端主动声明「我先死」。
- owner 重要性:同一组内有多个候选时,比较 owner 进程的 jetsam 优先级——iOS 上即
proc_get_memstat_priority()(vm_purgeable.c:730–746):后台/低优先级应用的 volatile 内存先被收。这就是 purgeable 与 jetsam band 体系的耦合点。 - 扫描上限:每轮最多考察
PURGEABLE_LOOP_MAX = 64个对象后选当前最优(L720–722),防止在超长队列上空转。
730object_task_importance = 0; 737owner = object->vo_owner; 738if (owner != NULL && owner != VM_OBJECT_OWNER_DISOWNED) { 739#if !XNU_TARGET_OS_OSX 740#if CONFIG_JETSAM 741 object_task_importance = proc_get_memstat_priority( // ← iOS:jetsam band 742 (struct proc *)get_bsdtask_info(owner), TRUE); 743#endif 744#else 745 object_task_importance = task_importance_estimate(owner); // macOS 走 importance 746#endif 747} 748if (object_task_importance < best_object_task_importance) { 749 if (vm_object_lock_try(object)) { 755 best_object = object; // 越不重要越先死
此外还有一个 FIFO/LIFO 协同细节:当 FIFO 队列组内有对象但对应 token 未成熟、而 LIFO(或相反)队列有成熟 token 时,会发生 token 迁移(vm_purgeable_token_choose_and_delete_ripe(),L588–673),保证删除的 token 与被 purge 的对象所属队列账目平衡。
06记账:volatile 的瞬间,phys_footprint 立即下降
purgeable 与 iOS 内存限制(jetsam memlimit / 高水位)的关系,全部藏在 vm_purgeable_accounting() 里。状态切换时,页在 owner 的四本账(volatile / nonvolatile / volatile_compressed / nonvolatile_compressed)之间转移:
1572} else if (old_state == VM_PURGABLE_NONVOLATILE) { 1574 ledger_debit(owner->ledger, ledger_idx_nonvolatile, 1575 ptoa_64(resident_page_count - wired_page_count)); 1581 if (do_footprint) { 1583 ledger_debit(owner->ledger, task_ledgers.phys_footprint, // ← 立刻移出 footprint 1585 ptoa_64(resident_page_count + compressed_page_count 1586 - wired_page_count)); 1588 } 1591 ledger_credit(owner->ledger, ledger_idx_volatile, ...);
- 标记 volatile = 立即「减负」:一个字节的物理页都还没回收,
phys_footprint就已经扣掉了。jetsam 的 memlimit 检查从此「看不见」这些页——这就是大 volatile 缓存不直接引发杀身之祸的原因。 - 例外(no_footprint):Skywalk(网络栈)的 purgeable 内存用
VM_LEDGER_TAG_NETWORK且不计 footprint(IOMemoryDescriptor.cpp:558–566)。 - 历史包袱:iOS 11 之前构建的 app(
task_legacy_footprint)的 purgeable 对象 owner 被换成kernel_task——「当年漏记账,继续漏」的兼容(vm_user.c:2687–2696)。 - 复用时反向操作:VOLATILE→NONVOLATILE 把页重新计入 footprint;若期间被清空,内容以零填充缺页的方式「重放」。
07IOSurface 的完整调用链
把前六章串起来。IOSurface 的像素存储在内核里是 IOBufferMemoryDescriptor 的「pageable + purgeable」形态——这是 IOSurface 唯一可 purge 的形态;wired 形态(如相机管线的物理连续缓冲)从语义上就与 purgeable 互斥。
7.1 分配链:surface 创建时
└─ IOSurface.kext(闭源)→ IOBufferMemoryDescriptor::initWithOptions iokit/Kernel/IOBufferMemoryDescriptor.cpp:276–281
└─ kIOMemoryPageable | kIOMemoryPurgeable → kIOMemoryBufferPageable | kIOMemoryBufferPurgeable
└─ IOMemoryDescriptor::memoryReferenceCreate → mach_make_memory_entry_internal IOMemoryDescriptor.cpp:610
└─ prot |= MAP_MEM_PURGABLE | MAP_MEM_PURGABLE_KERNEL_ONLY | MAP_MEM_NAMED_CREATE | VM_PROT_WRITE IOMemoryDescriptor.cpp:558–560
└─ VM 创建全新 vm_object:purgable = VM_PURGABLE_NONVOLATILE,owner = current_task osfmk/vm/vm_user.c:2666–2700
MAP_MEM_PURGABLE_KERNEL_ONLY 的意义:该 entry 置 purgeable_only_by_kernel = TRUE(vm_user.c:2684–2686)。用户态直接拿 mach_memory_entry_purgable_control(VM_PURGABLE_SET_STATE) 去改状态会被拒绝(KERN_PROTECTION_FAILURE,vm_object.c:5883–5886)——状态控制权保留给内核侧(即 IOKit 的 setPurgeable 链,它使用 VM_PURGABLE_SET_STATE_FROM_KERNEL,见 IOMemoryDescriptor.cpp:264–268)。这保证了 entry 的波动性只能经 IOSurface 的正规路径修改。7.2 控制链:设置 volatile 时
└─ IOSurface.kext → IOMemoryDescriptor::setPurgeable iokit/Kernel/IOMemoryDescriptor.cpp:3554
└─ IOGeneralMemoryDescriptor::setPurgeable :3490
└─ memoryReferenceSetPurgeable(对 reference 的每一个 memory entry):1505–1548
└─ purgeableControlBits():kIOMemoryPurgeable{NonVolatile,Volatile,Empty,KeepCurrent} → VM_PURGABLE_* 直译(:244–272,SET_STATE_FROM_KERNEL)
└─ memory_entry_purgeable_control_internal osfmk/vm/vm_user.c:3456
└─ vm_object_purgable_control() osfmk/vm/vm_object.c:5846 —— 进入第 2 章的状态机
后续的一切——token 挂队列、footprint 扣账、inactive 时钟走字、vm_pageout_scan 的一次一个 purge——全部发生在第 2–4 章描述的 VM 层。IOSurface 和 IOKit 层没有任何自己的丢弃逻辑;应用取回时 oldState == kIOSurfacePurgeableEmpty 就是 vm_object_purgable_control() 返回的旧状态 VM_PURGABLE_EMPTY 的直译。
08IOSurface 何时变 volatile:调用方驱动的触发器(WebKit 案例)
前一章的调用链回答了「怎么变」,本章回答「谁在什么时候发起」。先给出一个容易被忽略的结论:
IOSurfaceSetPurgeable() → IOKit setPurgeable() → vm_object_purgable_control() 这条显式调用链才会改变。xnu 中不存在任何「应用挂起 → 自动置 volatile」的内核路径——对全部内核源码 grep VM_PURGABLE_SET_STATE 的结果:状态写入点只有用户态 trap(mach_vm_purgable_control / memory entry 接口)、IOKit 的 SET_STATE_FROM_KERNEL 入口,以及 vm_map_copyin 里跨拷贝传播既有状态的调用(vm_map.c:11818–11823,不产生新转换)。因此「什么时候变可回收」的第一环在调用方——图形栈框架与应用自己。调用方里最有代表性、且完全开源可查的是 WebKit(RemoteLayerTree 渲染架构,iOS 上所有 WKWebView 页面内容的宿主)。以下行号取自 WebKit main(2025-08 快照,文件版权头显示该机制自 2013/2014 年起持续存在)。它的 layer 内容全部存放于 IOSurface,purgeable 状态管理集中在四类触发器上:
| 触发器 | 入口 | 行为 |
|---|---|---|
| ① 退后台(快照完成后) | UI 进程 IPC:applicationDidFinishSnapshottingAfterEnteringBackground() |
WebPage::markLayersVolatile()(WebPageIOS.mm:3513–3515)——把该页全部 layer 的 surface 标 volatile |
| ② 进程挂起前 | WebProcess::prepareToSuspend()(WebProcess.cpp:1841) |
markAllLayersVolatile() → 逐页 markLayersVolatile(),带指数退避重试与超时放弃 |
| ③ layer 变不可达 | 提交时发现 layer 被 unparent:backingStoreBecameUnreachable()(Collection.mm:284) |
立即标记能标的部分,剩余由 volatility timer 稍后补标 |
| ④ 前台闲置 | 200ms 重复定时器 volatilityTimerFired()(Collection.mm:380–390,由 didFlushLayers L143 调度) |
back buffer 距上次绘制 > 1s、secondary back > 200ms 即标 volatile——不需要退后台 |
8.1 触发器①:退后台——「快照先行,volatilize 后至」
3499void WebPage::applicationDidEnterBackground(bool isSuspendedUnderLock) 3500{ 3501 [[NSNotificationCenter defaultCenter] postNotificationName:WebUIApplicationDidEnterBackgroundNotification 3502 object:nil userInfo:@{@"isSuspendedUnderLock": @(isSuspendedUnderLock)}]; 3503 m_isSuspendedUnderLock = isSuspendedUnderLock; // ← 锁屏触发的退后台 3504 if (!m_backgroundTextExtractionEnabled) 3505 freezeLayerTree(LayerTreeFreezeReason::BackgroundApplication); // 先冻结渲染 ... 3510} 3512void WebPage::applicationDidFinishSnapshottingAfterEnteringBackground() 3513{ 3514 markLayersVolatile(); // ←★ 系统快照完成后,才敢标 volatile 3515} 3517void WebPage::applicationWillEnterForeground(bool isSuspendedUnderLock) 3518{ 3522 m_isSuspendedUnderLock = false; 3523 cancelMarkLayersVolatile(); // ← 回前台:取消尚未完成的标记 3525 unfreezeLayerTree(LayerTreeFreezeReason::BackgroundApplication);
applicationDidFinishSnapshottingAfterEnteringBackground,触发 volatilize。这是「purgeable 时机由系统精心编排」的最佳例证——同一台机器上,快照与回收在抢同一批像素。8.2 触发器②:进程挂起前——重试、锁屏语义与超时
1801WEBPROCESS_RELEASE_LOG_FORWARDABLE(ProcessSuspension, WebProcessPrepareToSuspend, ...); 1805SetForScope allowExitScope(m_allowExitOnMemoryPressure, false); 1806m_processIsSuspended = true; ... 1824// Ask the process to slim down before it suspends, in case it suspends for a very long time. 1829 releaseMemory([] { }); // 挂起前先瘦身后 volatilize 1833freezeAllLayerTrees(); ... 1841markAllLayersVolatile([this, ...]() mutable { // ←★ 挂起前最后一搏 1843 WEBPROCESS_RELEASE_LOG_FORWARDABLE(ProcessSuspension, WebProcessReadyToSuspend); 1844 completionHandler(); 1845}); // —— WebPage.cpp:4066-4113 的重试语义 —— 4077markLayersVolatileOrRetry(m_isSuspendedUnderLock 4078 ? MarkLayersVolatileDontRetryReason::SuspendedUnderLock // 锁屏:尽力而为,不再重试 4079 : MarkLayersVolatileDontRetryReason::None); 4062m_layerVolatilityTimerInterval *= 2; // 指数退避重试 4100case MarkLayersVolatileDontRetryReason::SuspendedUnderLock: 4101 WEBPAGE_RELEASE_LOG(Layers, "markLayersVolatile: Did what we could to mark IOSurfaces as purgeable after locking the screen"); 4103case MarkLayersVolatileDontRetryReason::TimedOut: 4104 WEBPAGE_RELEASE_LOG(Layers, "markLayersVolatile: Failed to mark layers as volatile within %gms", ...);
为什么可能失败/需要重试?因为 surface 可能正被渲染服务器(render server)持有或正被 GPU 进程写入——此时 IOSurfaceSetPurgeable 无法完成,WebKit 以 2ⁿ 退避定时器反复重试,直到全部成功或超时放弃。锁屏场景(进程即将被冻结,没时间重试)直接「尽力而为」。
8.3 触发器③:layer 不可达——unparent 即回收资格
284void RemoteLayerBackingStoreCollection::backingStoreBecameUnreachable(RemoteLayerBackingStore& backingStore) 285{ ... 288 // This will not succeed in marking all buffers as volatile, because the commit unparenting 289 // the layer hasn't made it to the UI process yet. The volatility timer will finish marking 290 // the remaining buffers later. 291 markBackingStoreVolatileAfterReachabilityChange(backingStore); 292}
layer 从树上摘下(哪怕 app 还在前台)→ 其 backing store 立即尝试标 volatile;由于 unparent 的提交尚未到达 UI 进程(渲染服务器还可能显示着它),先标记能标的,剩下的交给 200ms 定时器接力。注意这里揭示的协作事实:渲染服务器持有的 surface 不能被单方面 volatilize。
8.4 触发器④:前台闲置——「1 秒不重绘的 back buffer 就没必要保持 non-volatile」
45const Seconds volatilityTimerInterval = 200_ms; // 重复定时器周期 118static constexpr auto volatileBackingStoreAgeThreshold = 1_s; // back buffer 年龄阈值 119static constexpr auto volatileSecondaryBackingStoreAgeThreshold = 200_ms; 250bool ...::markInProcessBackingStoreVolatile(..., MonotonicTime now) 255 if (markingBehavior.contains(...ConsiderTimeSinceLastDisplay)) { 256 auto timeSinceLastDisplay = now - backingStore.lastDisplayTime(); 257 if (timeSinceLastDisplay < volatileBackingStoreAgeThreshold) { 258 if (timeSinceLastDisplay >= volatileSecondaryBackingStoreAgeThreshold) 259 backingStore.setBufferVolatile(BufferType::SecondaryBack); // 先收最冷的一块 261 return FALSE; 262 } 263 } 266 ...setBufferVolatile(BufferType::SecondaryBack); // >1s:三块缓冲逐一标 volatile 269 ...setBufferVolatile(BufferType::Back); 273 if (!m_reachableBackingStoreInLatestFlush.contains(backingStore) || ...IgnoreReachability) 274 ...setBufferVolatile(BufferType::Front); // front buffer:仅当不可达时
三缓冲(front / back / secondary back)逐级 volatilize:secondary back 200ms、back 1s、front 仅当 layer 不可达。这套年龄阈值与内核的 token aging(§3)互相独立、互相叠加——框架层先按「重绘时间」收窄候选,内核再按「inactive 周转」决定物理回收。
8.5 落到 IOSurface API 的最后一跳 + 反向路径
// Source/WebKit/Shared/RemoteLayerTree/RemoteLayerWithInProcessRenderingBackingStore.mm:175 175bool ...::setBufferVolatile(RefPtr<WebCore::ImageBuffer>& buffer, bool forcePurge) 176{ 177 if (!buffer || buffer->volatilityState() == VolatilityState::Volatile) 178 return TRUE; 180 if (forcePurge) { 181 buffer->setVolatileAndPurgeForTesting(); // forcePurge → EMPTY(直接清空) 182 return TRUE; 183 } 184 buffer->releaseGraphicsContext(); 185 return buffer->setVolatile(); // → WebCore::IOSurface::setVolatile 186} // Source/WebCore/platform/graphics/cocoa/IOSurface.mm:753 —— 真正调 IOSurface API 753SetNonVolatileResult IOSurface::setVolatile(bool isVolatile) 754{ 756 IOReturn ret = IOSurfaceSetPurgeable(m_surface.get(), 757 isVolatile ? kIOSurfacePurgeableVolatile : kIOSurfacePurgeableNonVolatile, &previousState); 759 if (previousState == kIOSurfacePurgeableEmpty) // ← 旧状态 EMPTY = 内容已丢 760 return SetNonVolatileResult::Empty;
反向路径(变回 non-volatile)同样显式:layer 将被重新显示时(backingStoreWillBeDisplayed)调用 setBufferNonVolatile;若返回 Empty,WebKit 触发整块重绘。渲染服务器侧还有防御检查——收到 volatile 的 surface 会直接报错:
541 if (surface->isVolatile()) 542 RELEASE_LOG_ERROR(RemoteLayerTree, "Received volatile IOSurface");
图 3 · App 生命周期与 purgeable 状态转换:框架层触发(①–④)+ 内核层回收
09跨版本稳定性:iOS 12 → 18 机制未变
对 vm_purgeable.c 做三版本同口径对比(iOS 12 = xnu-4903.270.47,iOS 14.8 = xnu-7195.141.2,iOS 18 = xnu-11215.1.10):
| 要素 | iOS 12 | iOS 14.8 | iOS 18 |
|---|---|---|---|
| token 状态机函数集(token_add / q_advance_all / purge_one / accounting…) | ✅ 齐全 | ✅ 齐全 | ✅ 齐全(函数名逐一对应) |
| pageout 挂钩形态 | 逻辑内联在 vm_pageout_scan(vm_pageout.c:2020–2060) | 重构为 vps_purge_object()(:1836–1876),调用点 :3006 | 同 14.8(:1998 / 调用点 :3171) |
| 「Before anything, we check if we have any ripe volatile objects…」注释 | ✅ 逐字存在 | ✅ | ✅ |
| force_purge 压力阈值(warning=2 / urgent=5 / critical=8) | ✅ | ✅(:4822–4824) | ✅ |
受害者按 owner 的 proc_get_memstat_priority() 选择 | ✅(:739) | ✅(:741) | ✅(:726) |
| 14.8 → 18 的 diff | 仅机械性差异:头文件改名(vm_page.h → vm_page_internal.h 等)、token 数组改用 kmem_realloc_guard 带守卫分配。算法零变化。 | ||
结论:本文描述的机制与时机在 iOS 12 至 18 上语义一致,行号引用以 iOS 14.8 为基准,跨版本锚点已标注。§8 的 WebKit 案例取自 main 分支(2025-08 快照);该 volatility 管理机制存在于 RemoteLayerTree 渲染架构的整个历史(源文件版权头 2013/2014 年起),从 iOS 8/9 时代的 WKWebView 至今行为一致。
10常见误区
| 误区 | 源码事实 |
|---|---|
| 「标记 volatile 后,内存马上被系统回收」 | 记账马上变(footprint −N),物理页不马上动。没有内存压力时,vm_pageout_scan 达标即返回(vm_pageout.c:2959–2994),连 purge 钩子都到不了。 |
| 「volatile 内存不占物理内存」 | 占。token 成熟前页原样驻留;成熟后也只在 free < target 时被逐对象收割。闲置设备上 volatile 缓存命中率接近 100%。 |
| 「volatile 页不会被压缩」 | 会。压缩器在场时,扫描到 volatile 脏页直接送去压缩——「比等整对象 purge 更划算」(vm_pageout.c:3325–3332);purge 时再从压缩器一并收割。 |
| 「madvise(MADV_FREE_REUSABLE) 就是 purgeable」 | 另一套机制:页级 vmp_reusable 标记(vm_map_behavior_set 路径),无 token、无对象状态机。IOSurface 走的是 memory entry + vm_object_purgable_control。 |
| 「被清空的内容回来时还在」 | EMPTY 的页回访是零填充缺页(vm_object.c:5799–5801 注释)。应用必须检查 oldState == EMPTY 并重建内容。 |
| 「purgeable 和 wired 可以叠加」 | 语义互斥:wired = 「不许丢」。IOSurface 只有 pageable 形态可 purge(IOBufferMemoryDescriptor.cpp:276–281);vm_object_purge() 也只收割非 wired 部分。 |
11对开发者的实践要点
- 把 volatile 当「免费但可能丢」的内存用:记账瞬间免费(不占 footprint、不受 memlimit 惩罚),物理页在低压力下几乎不丢——这是 iOS 上做大缓存的最优姿势,前提是内容可重建。
- 取回时必须检查状态:
IOSurfaceSetPurgeable(…, kIOSurfacePurgeableNonVolatile, &old)后若old == kIOSurfacePurgeableEmpty,内容已丢,需重新渲染。这个三态返回正是内核*state = old_state(vm_object.c:6165)的直译。 - 低 volatile group = 更早被牺牲:默认 group 0 在 warning 压力即可被强制回收;想多活一会儿可显式带 group 位(不过
IOSurfaceSetPurgeable枚举不暴露组位——底层mach_vm_purgable_control/ memory entry 接口才暴露)。 - EMPTY 是主动的、同步的、立即的:确定不再需要时直接置 EMPTY,立即还页给系统(还省掉压缩器里的副本)。
- 前后台切换不会自动 volatile 化:这是应用策略问题。图像缓存类内容在
didEnterBackground时标记 volatile 是常见实践,能显著降低后台 jetsam 概率(jetsam 前 kernel 还会再给一次 purge 免杀机会,kern_memorystatus.c:5516–5571)。 - 观测手段:kdebug 有整套事件码(
TOKEN_ADD/DELETE/RIPEN、OBJECT_PURGE*,vm_purgeable_internal.h:132–140);全局计数vm_pageout_pages_purged、vm_page_purged_count;每个 task 的 purgeable 统计可经task_info(TASK_PURGABLE_INFO)读取。
12源码引用总表
基准版本 xnu-7195.141.2(iOS 14.8),全部行号已在原始文件核对;跨版本锚点单独注明。源文件均来自 apple-oss-distributions/xnu 公开仓库。
| 论断 | 文件 | 行号 |
|---|---|---|
| token 结构 / available_for_purge 定义 | osfmk/vm/vm_purgeable.c | 59–78 |
| token 计数=inactive 队列页数的不变量 | osfmk/vm/vm_purgeable.c | 141–146 |
| token_add:count=new_pages、unripe/成熟处理 | osfmk/vm/vm_purgeable.c | 154–308(263–289 关键) |
| q_advance_all:每页流出减一、成熟即 available_for_purge++ | osfmk/vm/vm_purgeable.c | 468–542 |
| 受害者选择(jetsam 优先级 / LOOP_MAX=64) | osfmk/vm/vm_purgeable.c | 677–812(720、741) |
| purge_one:队列/组遍历、force_purge 旁路、一次一个 | osfmk/vm/vm_purgeable.c | 894–1030(915、940–962) |
| obsolete 永远 ripe | osfmk/vm/vm_purgeable.c | 264–269 |
| purge_task_owned(jetsam 前清场) | osfmk/vm/vm_purgeable.c | 1274–1404 |
| accounting:volatile 瞬间移出 phys_footprint | osfmk/vm/vm_purgeable.c | 1504–1605(1581–1588) |
| purgeable_q / token_q_unripe / NUM_VOLATILE_GROUPS=8 | osfmk/vm/vm_purgeable_internal.h | 42–74 |
| kdebug 事件码 TOKEN_* / OBJECT_PURGE* | osfmk/vm/vm_purgeable_internal.h | 132–140 |
| 状态机主体(四态转换、NO_AGING、EMPTY 单向、token 增删) | osfmk/vm/vm_object.c | 5846–6170 |
| vm_object_purge:先置 EMPTY 再收割;连压缩页一起收 | osfmk/vm/vm_object.c | 5643–5767(5719–5728) |
| API 语义长注释(「eligible for being reclaimed …」) | osfmk/vm/vm_object.c | 5770–5841 |
| vm_pageout_scan 主循环 purge 钩子(先达标后 purge、一次一个) | osfmk/vm/vm_pageout.c | 2959–3019 |
| vps_purge_object:压力等级 → force_purge | osfmk/vm/vm_pageout.c | 1836–1876 |
| force_purge 默认阈值 2/5/8 | osfmk/vm/vm_pageout.c | 4822–4824 |
| EMPTY 脏页直接回收;volatile 页交压缩器 | osfmk/vm/vm_pageout.c | 3296–3349 |
| token_new_pagecount +=(UPL 提交进 inactive) | osfmk/vm/vm_pageout.c | 7951 |
| 时钟两端:页入 inactive 计数 / 页出 inactive 走字 | osfmk/vm/vm_resident.c | 8628、8639、8788 |
| mach_vm_purgable_control 陷阱(拒绝 FROM_KERNEL) | osfmk/vm/vm_user.c | 2114–2131 |
| memory_entry_purgeable_control_internal(entry 整体覆盖校验) | osfmk/vm/vm_user.c | 3441–3515 |
| MAP_MEM_PURGABLE 建对象(NONVOLATILE 起步 / KERNEL_ONLY / legacy footprint) | osfmk/vm/vm_user.c | 2666–2700 |
| VM_PURGABLE_PURGE_ALL 全清 | osfmk/vm/vm_map.c | 18539–18569 |
| jetsam 前 purge + 免杀判定 | bsd/kern/kern_memorystatus.c | 5492–5575(5516、5570) |
| purgeableControlBits:IOKit 四态 ↔ VM 直译(FROM_KERNEL) | iokit/Kernel/IOMemoryDescriptor.cpp | 244–272 |
| memoryReferenceSetPurgeable:逐 entry 控制 | iokit/Kernel/IOMemoryDescriptor.cpp | 1505–1548 |
| IOBMD purgeable → MAP_MEM_PURGABLE|KERNEL_ONLY(含 Skywalk 例外) | iokit/Kernel/IOMemoryDescriptor.cpp | 536–566 |
| IMD/IOGMD setPurgeable 双路径 | iokit/Kernel/IOMemoryDescriptor.cpp | 3490、3554 |
| IOBMD pageable+purgeable 分岔(wired 不可 purge) | iokit/Kernel/IOBufferMemoryDescriptor.cpp | 276–281 |
| FIFO/LIFO/组/obsolete 语义注释 | osfmk/mach/vm_purgable.h | 26–36、58–92、118–151 |
| 跨版本:iOS 12 内联 purge 逻辑 / iOS 18 vps_purge_object | vm_pageout.c | 12:2020–2060 · 18:1998、3171 |
| 跨版本:iOS 12/18 按 jetsam 优先级选受害者 | vm_purgeable.c | 12:739 · 18:726 |
| WebKit(main,2025-08 快照)— 谁在什么时候发起 volatilize | ||
| 退后台快照完成后 → markLayersVolatile;回前台 → cancel | WebProcess/WebPage/ios/WebPageIOS.mm | 3499–3525(3514、3523) |
| 进程挂起前 → markAllLayersVolatile(slim down → freeze → volatilize) | WebProcess/WebProcess.cpp | 1795–1846(1824–1829、1841) |
| markLayersVolatile 重试语义(指数退避 / 锁屏尽力而为 / 超时日志) | WebProcess/WebPage/WebPage.cpp | 4042–4113(4062、4077–4079、4100–4104) |
| DrawingArea → Collection 入口 | WebProcess/WebPage/RemoteLayerTree/RemoteLayerTreeDrawingArea.mm | 493–496 |
| layer 不可达即标 volatile(含「commit 未达 UI 进程」注释) | Shared/RemoteLayerTree/RemoteLayerBackingStoreCollection.mm | 284–292 |
| 年龄门控(secondary back 200ms / back 1s / front 仅不可达) | RemoteLayerBackingStoreCollection.mm/.h | .mm:250–277 · .h:118–119 |
| 200ms volatility timer 与调度点 | RemoteLayerBackingStoreCollection.mm | 45、143、380–390 |
| setBufferVolatile(forcePurge→EMPTY)→ ImageBuffer::setVolatile | Shared/RemoteLayerTree/RemoteLayerWithInProcessRenderingBackingStore.mm | 175–186、200–213 |
| setVolatile → IOSurfaceSetPurgeable(API 最后一跳);Empty 旧态检测 | WebCore/platform/graphics/cocoa/IOSurface.mm | 753–761(735–751 查询态) |
| 「Received volatile IOSurface」防御检查 | Shared/RemoteLayerTree/RemoteLayerBackingStore.mm | 541–542、688–689 |
| IOKit purgeable 修饰位(Group0–7/FIFO/LIFO/Obsolete/FaultOnAccess) | iokit/Kernel/IOMemoryDescriptor.h | 148–167 |