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 的直译。
08何时标记可回收:调用方驱动的触发器(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.1b 这个「快照」本身是什么设计?
它是 iOS 的场景快照(UIScene system snapshot sequence):app 退后台时,系统对场景的合成结果拍一组静态图,用来在进程冻结期间「顶替」活内容。WebKit 侧的完整编排可以逐行追出来 [SRC]:
// 每个 UIScene 注册 4 个观察者;后两个是 UIKit 私有通知: 298m_didEnterBackgroundObserver = [notificationCenter addObserverForName:UISceneDidEnterBackgroundNotification ... 306m_willEnterForegroundObserver = [notificationCenter addObserverForName:UISceneWillEnterForegroundNotification ... 314m_willBeginSnapshotSequenceObserver = [notificationCenter addObserverForName:_UISceneWillBeginSystemSnapshotSequence ... 321m_didCompleteSnapshotSequenceObserver = [notificationCenter addObserverForName:_UISceneDidCompleteSystemSnapshotSequence ...
110- (void)_willBeginSnapshotSequence { 120 page->setIsTakingSnapshotsForApplicationSuspension(true); // 序列开始:暂停部分行为 123- (void)_didCompleteSnapshotSequence { 134 page->setIsTakingSnapshotsForApplicationSuspension(false); 135 if ([self isBackground]) 136 page->applicationDidFinishSnapshottingAfterEnteringBackground(); // ←★ 经 IPC 进 Web 进程 → markLayersVolatile()
- 快照是什么:系统(SpringBoard/渲染服务器侧)对场景合成结果拍的静态图——多任务切换器卡片、回前台的缩放过渡动画、以及进程整个冻结期间「画面还停在你离开时的样子」,全靠它维持。它的成本远低于活内容(一张解码好的位图 vs. 整棵 layer 树的 IOSurface)。
- 为什么是 sequence(序列)而非单次:一次退后台系统可能按不同用途拍多张(不同尺寸/比例),所以协议是 begin/end 成对事件;WebKit 在 begin 时挂起部分行为(
setIsTakingSnapshotsForApplicationSuspension(true)),end 时才放行 volatilize。 - 照片替代本体的握手:快照期间活 IOSurface 必须保持 non-volatile——因为快照正是从这些活 surface 合成的;快照完成后,显示义务全部转移到静态照片上,本体随即获得可回收资格。purgeable 的标记时机被精确卡在这场交接仪式的完成时刻。
- 私有 API 的编排权:
_UISceneWillBeginSystemSnapshotSequence是 UIKit 私有通知——Apple 自家框架(WebKit)才有资格监听快照序列并编排 volatilize;第三方 app 只能感知sceneDidEnterBackground,拿不到「快照拍完了」这个信号(只能自己起定时器猜)。 - 超时兜底:若快照事件因故永不到来,
markLayersVolatile的重试链(初值 20ms 指数退避)在 2s 上限后放弃等待照样标记(WebPage.cpp:529-530, 4103-4104)——编排权再精密,也有不走运的路径。
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 状态转换:框架层触发(①–④)+ 内核层回收
8.5b 证据等级:哪些结论出自源码,哪些出自实验
本章的四个触发器(§8.1–8.4)全部是逐行源码验证的 [SRC];对闭源框架,我们只能给出运行时实验证据 [EXP] 或符号普查 [SYM],标注如下。
| 论断 | 证据等级 | 出处(原文可查) |
|---|---|---|
| 触发器①:退后台,快照完成后标记 | [SRC] | WebPageIOS.mm:3515-3518 —— 函数名即答案:applicationDidFinishSnapshottingAfterEnteringBackground() { markLayersVolatile(); } |
| 触发器②:进程挂起前标记 | [SRC] | WebProcess.cpp:1821-1844 —— 注释原文 “Ask the process to slim down before it suspends”;标记完成后才回调 WebProcessReadyToSuspend |
| 触发器③:layer unparent / 移出渲染树 | [SRC] | RemoteLayerBackingStoreCollection.mm:148-160(每次 flush 后扫描可达性)+ :280-292(不可达即尝试标记,源码注释自述“volatile timer 兜底”) |
| 触发器④:前台闲置(“离开屏幕”的精确判据) | [SRC] | Collection.h:118-119(1s / 200ms 常量)+ Collection.mm:250-277(timeSinceLastDisplay 比较与 m_reachableBackingStoreInLatestFlush 成员判据)——“离开屏幕”在代码中即这两个可计算条件 |
最终落点:IOSurfaceSetPurgeable() |
[SRC] | WebCore IOSurface.mm:753-763(含 previousState == Empty 回读检测) |
| ImageIO 解码 scratch 用完即 EMPTY | [EXP] | 运行时实验(§8.7,purge_probe):tag=70 缓冲每轮解码后实测状态 EMPTY、地址每轮更换——状态是实测,“哪个函数哪一行标记”是推断(ImageIO 闭源) |
| ImageIO 解码缓存常驻 VOLATILE | [EXP] | 运行时实验(§8.7):tag=3 purgeable zone 缓冲实测 VOLATILE;实现路径(malloc purgeable zone → libmalloc → vm_purgable_control)由 libmalloc 开源源码 L3247-3255 桥接 [SRC],但 ImageIO 侧调用点是闭源推断 |
| CoreAnimation / SkyLight / Metal / CoreML 等使用 purgeable | [SYM] | 符号普查(§8.6):dyld_info 确认这些框架导入 purgeable API——使用是事实,标记时机未验证,仅由框架职责推断(缓存治理) |
| 决策规则 “不可见 + 可廉价重建” | [归纳] | 从 [SRC] 代码判据(reachability、timeSinceLastDisplay、快照完成事件)归纳的抽象——不是源码原话 |
本站引用规范:[SRC] = 源码行号可证(xnu-7195.141.2 / WebKit main 快照,存档于 purgeable-report/src/);[EXP] = 运行时实验可复现(探针源码 purge_probe.c);[SYM] = 二进制符号普查(dyld_info -imports)。真机数据来自 iPhone 12 Pro / iOS 14.8 越狱设备。
8.5c 源码摘录:触发器③④的完整判据(含 WebKit 注释)
// 触发器③入口:每次 layer flush 结束后调用(updateUnreachableBackingStores,L148-160) 148bool RemoteLayerBackingStoreCollection::updateUnreachableBackingStores() 149{ 150 Vector<WeakPtr<RemoteLayerBackingStore>> newlyUnreachableBackingStore; 151 for (CheckedRef backingStore : m_liveBackingStore) { 152 if (!m_reachableBackingStoreInLatestFlush.contains(backingStore.get())) // ← 不在本轮渲染树里 = 不可达 153 newlyUnreachableBackingStore.append(backingStore.get()); 154 } 156 for (auto& backingStore : newlyUnreachableBackingStore) 157 backingStoreBecameUnreachable(protect(*backingStore)); // ← 立即去标 volatile 159 return !newlyUnreachableBackingStore.isEmpty(); 160} // … backingStoreBecameUnreachable L280-292 摘录见 §8.3,含注释: // “This will not succeed in marking all buffers as volatile, because the commit unparenting // the layer hasn't made it to the UI process yet. The volatility timer will finish marking // the remaining buffers later.” ← WebKit 自己解释了为什么需要定时器兜底 // 触发器④完整判据(markInProcessBackingStoreVolatile,L250-277) 250bool RemoteLayerBackingStoreCollection::markInProcessBackingStoreVolatile( 254 if (markingBehavior.contains(VolatilityMarkingBehavior::ConsiderTimeSinceLastDisplay)) { 255 auto timeSinceLastDisplay = now - backingStore.lastDisplayTime(); 256 if (timeSinceLastDisplay < volatileBackingStoreAgeThreshold) { // < 1s:还没资格 257 if (timeSinceLastDisplay >= volatileSecondaryBackingStoreAgeThreshold) 258 backingStore.setBufferVolatile(RemoteLayerBackingStore::BufferType::SecondaryBack); 260 return false; 261 } 262 } 266 if (!backingStore.setBufferVolatile(RemoteLayerBackingStore::BufferType::SecondaryBack)) 267 successfullyMadeBackingStoreVolatile = false; 269 if (!backingStore.setBufferVolatile(RemoteLayerBackingStore::BufferType::Back)) 270 successfullyMadeBackingStoreVolatile = false; 272 if (!m_reachableBackingStoreInLatestFlush.contains(backingStore) 273 || markingBehavior.contains(VolatilityMarkingBehavior::IgnoreReachability)) { 273 if (!backingStore.setBufferVolatile(RemoteLayerBackingStore::BufferType::Front)) // Front 仅不可达时 274 successfullyMadeBackingStoreVolatile = false; 275 }
这两段就是 §8.3/§8.4 结论的全部依据:§8.4 标题中的「1 秒」「200ms」即 volatileBackingStoreAgeThreshold = 1_s(.h L118)与 volatileSecondaryBackingStoreAgeThreshold = 200_ms(.h L119)的字面值;「不可达」即 m_reachableBackingStoreInLatestFlush 成员测试——没有任何「看图说话」的成分。
8.6 三扇门:用户态 API 的会师点
除了 IOSurfaceSetPurgeable(),用户态还有两条同样通往 vm_object_purgable_control() 的路。三条门对应三类内存形态:
| 门 | API | 内存形态 | 典型用户(二进制符号普查实测) |
|---|---|---|---|
| A · IOSurface | IOSurfaceSetPurgeable() |
IOSurface(经 memory entry) | WebKit、QuartzCore(CoreAnimation)、SkyLight(渲染服务器本体)、Metal、CoreImage、CoreML、CoreDisplay、IOGPU、CMPhoto、PencilKit、NeutrinoCore、RenderBox… |
| B · Mach VM | vm_allocate(VM_FLAGS_PURGABLE) + vm_purgable_control() |
匿名 VM 对象(无 IOSurface) | ImageIO、CoreFoundation、JavaScriptCore、MediaToolbox、CoreData、Network、CompositorServices、VFX、Symbolication… |
| C · malloc purgeable zone | malloc_default_purgeable_zone() + malloc_make_purgeable() |
大块 malloc 分配 | CoreGraphics、DataDetectorsCore、SpotlightIndex、AMPDesktopUI… |
普查方法:对 dyld 共享缓存逐框架执行 dyld_info -imports,筛选 purgeable 相关导入符号(macOS arm64e 实测,iOS 框架同源)。
门 C 与内核的对接点在 libmalloc(开源)里一目了然——malloc_make_purgeable() 就是「标 VOLATILE」的直译:
3247void 3248malloc_make_purgeable(void *ptr) 3249{ ... 3254 int state = VM_PURGABLE_VOLATILE; 3255 vm_purgable_control(mach_task_self(), (vm_address_t)ptr, VM_PURGABLE_SET_STATE, &state); 3256} ... 3269 int state = VM_PURGABLE_NONVOLATILE; 3270 vm_purgable_control(mach_task_self(), (vm_address_t)ptr, VM_PURGABLE_SET_STATE, &state); 3272 if (state == VM_PURGABLE_EMPTY) { // 旧状态 EMPTY ⇒ 数据已被内核丢弃
8.7 ImageIO 实证:解码缓存天生 purgeable
ImageIO 导入的是门 B(vm_purgable_control)而非 IOSurface——解码后的像素缓冲不需要跨进程共享,走匿名 VM 更轻。用 4000×3000 JPEG(解码缓冲理论值 45.8 MiB)在本机做运行时实验,进程内枚举全部区域并逐个探测 purgeable 状态(VM_PURGABLE_GET_STATE 仅对 purgeable 对象返回成功——普通对象返回 KERN_INVALID_ARGUMENT,恰与 §2 状态机 L5867 的行为一致):
| 阶段 | tag=70 IMAGEIO 缓冲(45.8MB) | tag=3 purgeable zone 缓冲(45.8MB) | 耗时 |
|---|---|---|---|
| draw#1(首次解码) | 出现,EMPTY | 出现,EMPTY | 100.0 ms |
| draw#2 | 换新地址,EMPTY | VOLATILE | 51.5 ms |
| draw#3(释放重建 CGImage 后) | 复用,EMPTY | VOLATILE | 2.3 ms |
这张表把 ImageIO 的 purgeable 策略拍在了明面上:
- 解码 scratch(tag=70):用完立即置 EMPTY——ImageIO 主动调用 §4.3 的同步丢弃路径,立刻归还 45.8MB 物理页但保留虚拟地址供下次复用(下一轮绘制时地址换了,说明每次解码重新分配)。
- 解码缓存(tag=3,malloc purgeable zone):常驻 VOLATILE——即门 C。«进缓存 = 声明可回收»,无需等内存压力,框架标记的瞬间就完成了。
- volatile ≠ 已回收:无内存压力时 VOLATILE 缓存照常命中——draw#3 只要 2.3ms(纯搬运),数据完好在物理页里。
- 活内存从不 volatile:绘制目标缓冲(tag=52)探测结果是非 purgeable(kr=4)。
- 对照组(
kCGImageSourceShouldCache=false):purgeable 缓冲照样出现、draw#3 同样 2.2ms——purgeable 是 ImageIO 解码路径的固有行为,缓存随CGImageSource存活,选项只影响保留策略。
外部交叉验证(另一个进程视角的 vmmap)连图例都写明白了:
MALLOC_LARGE 40a800000-40d5d0000 [ 45.8M 0K 0K 0K] rw-/rwx SM=PRV PURGE=E DefaultPurgeableMallocZone_0x1013c4000 PURGE=purgeable mode: V=volatile N=nonvolatile E=empty otherwise is unpurgeable
8.8 普查全景与真机数据
三个旁证把「谁在用 purgeable」钉死:
- VM tag 体系自带说明书(
mach/vm_statistics.h):69 =VM_MEMORY_WEBCORE_PURGEABLE_BUFFERS、70 =VM_MEMORY_IMAGEIO、88 =VM_MEMORY_IOSURFACE、103 =VM_MEMORY_COREUI_CACHED_IMAGE_DATA、87 =VM_MEMORY_SKYWALK(正对应 §7 的 NETWORK ledger tag)。内核的分配标签本身就记录了各子系统的 purgeable 用途。 - iOS 真机(iPhone 12 Pro / iOS 14.8,越狱采集):快照时刻
vm.page_purgeable_count = 2022页(≈31.6MB 驻留),而开机以来累计 Pages purged = 880,967 页(≈13.4GB)——purge 不是纸面机制,是这台 6GB 设备上每天真实发生数千次的事件;同一时刻vm.page_reusable_count = 3745,madvise 兄弟机制也在场。sysctl 阈值 warning/urgent/critical = 2/5/8 亦实机确认。 - 第三方对照:Chromium 的跨平台 discardable memory(chromium/chromium main,discardable_shared_memory.cc:417–432)在 POSIX 上选了
madvise(MADV_FREE_REUSABLE)、Android 上用 ashmem——第三方没有押注 Mach purgeable;而 Apple 自家图形栈(本节普查的 26 个框架)则全面押注 purgeable。两条路线的取舍与前文 §1 的机制对比(对象级状态机 vs 页级标记)一一对应。
vm_pageout_scan 缺页(或压力旁路/EMPTY/jetsam 前清场),且一次只收一个对象。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 至今行为一致。
09+真机实测:三类内存的回收速率
前文机制全部来自源码分析;本节在越狱 iPhone 12 Pro(iOS 14.8,xnu-7195.140.44——与源码基准同版本系列)上实测了 purgeable / 文件页 / 匿名页在压力下的回收行为,全部数字可由 src/reclaim_bench_dylib.c 复现 [EXP]。
实验方法
交叉编译 arm64 测试 dylib(-Wl,-no_fixup_chains 适配 iOS 14 dyld),注入系统签名的宿主进程执行;每类目标内存 48MB,配阶梯式匿名「压舱物」(384MB 起步,每秒 +96~128MB)把系统推过 vm_page_free_target=2000 的回收线;2~20ms 高频采样 mincore 驻留量与 vm 统计。
结果一:EMPTY 同步丢弃——3.5 毫秒丢光 48MB
D RESULT: SET_STATE(EMPTY) took 3.528ms, resident 48MB -> 0MB (run 1) D RESULT: SET_STATE(EMPTY) took 3.745ms, resident 48MB -> 0MB (run 2)
这就是 ImageIO 解码 scratch(§8.7)「用完即丢」的成本:≈13.6 GB/s 的等效归还速率,且立即生效——不需要等内核任何调度。
结果二:VOLATILE 的 lazy purge——慢得惊人
A (压舱 1152MB, free 稳定在 2000-3000): purge_start=NEVER // 60 秒零 purge E (压舱 1664MB + 高频触碰): force_purge_at=7289ms // →35MB F (一次性 512MB 赤字): purge_50pct_at=NEVER // 未触发
与直觉相反的结论:把系统压到 free 贴近 target 线,VOLATILE 对象几十秒都不会被自动 purge;要等压力再深一层(1664MB 级持续触碰)才在 ~7 秒后开始回收。这正是 token aging 的行为印证(§3):新标 VOLATILE 的对象 token 计数高,必须等 inactive 队列周转拉低计数才「成熟」。框架们抢着标 VOLATILE 的真实收益是 phys_footprint 记账立即下降(躲 jetsam),物理回收是懒的、滞后的。
结果三:匿名页压缩——中等速度、但有吞吐上限
C RESULT: anon_compress_start=4101~5419ms anon_50pct_done=5451~6761ms // 压缩启动后 ~1.3s 内压掉 96% (46MB/1.35s ≈ 34MB/s 观测值) 系统压缩器全貌: stored 185876 页 (2.8GB) / occupied 68711 页 // 压缩比 2.7:1
匿名页既不能丢也不能直接还,必须压缩搬迁——速率比 EMPTY 慢约 400 倍,但比 VOLATILE 的 lazy purge 可靠得多(压力一到就开始)。
结果四:文件页——最后被动的防线
B (mmap 干净文件页 48MB, 压舱 1792MB): file_evict_start=NEVER // resident 纹丝不动 B (压舱 3200MB): file_evict_start=NEVER // 仍未触发! free 压到 2700 (贴近 target 2000), 压缩器吞掉 2.8GB G (speculative 池): free <30000 页时 spec 从 1500+ 收到 ~30 // 只有投机读缓存被收
最反直觉的发现:3.2GB 压舱把压缩器撑到 2.8GB、free 逼到 target 线上,进程 mmap 的干净文件页仍然一页不丢。系统只收 speculative(投机预读)池。iOS 14.8 的实际回收优先级:
为什么 mmap 文件页如此顽固?干净页逐出虽无需写回,但 vm_pageout_scan 的扫描顺序(inactive 队列 + speculative 优先)让它们排在队尾;只要压缩器还有吞纳空间,系统不会碰它们。这解释了 macOS/iOS 上大文件 mmap 遍历后内存高企不下的日常观察。
汇总对比表:48MB vs 384MB(同设备同条件)
| 回收路径 | 触发方式 | 48MB 实测 | 384MB 实测 | 规律 |
|---|---|---|---|---|
| purgeable · EMPTY (同步丢弃) |
用户态主动SET_STATE(EMPTY) |
3.5 ms → 0MB | 30.1 ms → 0MB | 线性,≈12.8 GB/s 恒定——唯一不受规模惩罚的路径 |
| purgeable · VOLATILE (lazy purge) |
vm_pageout_scan 缺页驱动 (token 成熟才收) |
7.3 s 才触发 (深压 1664MB) 浅压 60s 零回收 |
110 s 零回收 (压舱 3.2GB 仍不收) |
规模越大越难回收——token 慢成熟 × 单对象粒度双重抑制;大块 VOLATILE 实际处于「几乎不会被自动回收」状态 |
| 匿名页 · 压缩器 | vm_compressor (mode 32 + 256MB swap) |
启动后 1.35 s 压掉 96% (≈34MB/s) |
29.7s 才启动;压掉 98MB 后饱和停滞 (286MB 稳态) |
有吞吐上限:触碰速率 ≈ 压缩速率时进入动态平衡;想真释放需停止触碰「让出时间」 |
| 文件页 · page cache (mmap 干净页) |
vm_pageout_scan 逐出 (最末位) |
3.2GB 压舱下纹丝不动 (只收 speculative) |
44.6 s 开始逐出,46MB(12%)后停滞 | 压缩器饱和后才轮到它;大块文件页只收「边缘」;活进程的 mmap 页近乎钉死 |
速率换算:EMPTY 48MB/3.5ms≈13.6GB/s、384MB/30ms≈12.8GB/s;匿名压缩观测值 34MB/s(受持续触碰对抗影响,为下界);VOLATILE lazy purge 在本实验压力范围内无法给出稳定速率(大对象 NEVER)。
实验 H:多 app 加压 × 查杀时机(检验「查杀前是否所有回收手段已用尽」)
在监控线程(250ms 采样全部 VM 指标 + JetsamEvent 文件轮询)常驻的情况下,用 uiopen 循环启动 8 个重度 app(美团/头条/高德/微信/抖音/B站/快手/小红书)× 3 波共 30+ 次启动,再叠加 2.5~5GB 压舱,逼到系统极限 [EXP]。
t=13.2s free=99 页(1.5MB!) spec=37(收光) purg=27892(满) file=53257 anon=151589 // 零查杀 t=13.2~28s purgeable 27892 → 2195 // 401MB @ 27MB/s 排水 t=28s free=45029 // purgeable 一己之力救场,无任何进程死亡 P 模式 4GB 独压: lvl=4 (critical) 持续 10+ 秒 → 仍零查杀, app 全活 多 app 纯加压(30+ 次启动): 永远到不了查杀(见下 freezer)
多 app 加压到不了查杀的三层吸收器(全部实测):
- 挂起+冻结:app 退后台即入挂起态;freezer 冻结 IDLE band 进程——
freeze_count=12、freeze_pageouts=24406 页(381MB)、freeze_jetsam_band=8证实冻结正是查杀的前置替代(本该查杀的 IDLE 进程先被冻结)。 - 压缩器:存量吞到 6.6GB(物理占用仅 1.5GB,全机压缩比 ~4.4:1)。
- purgeable 排水:27MB/s 实测速率,401MB 十几秒抽干。
真实查杀时刻的快照(检验用户假设)
| 查杀时刻指标 | 数值 | 判定 |
|---|---|---|
| purgeable | 538 页 ≈ 8MB | ✅ 收光——假设成立 |
| speculative 文件页 | 221 页 | ✅ 收光——假设成立 |
| 普通文件页(file-backed) | 53628 页 ≈ 838MB 仍驻留 | ❌ 假设不成立——不是查杀前置条件 |
| 匿名页 | 190846 页驻留;其中 173641 页≈2.7GB 已在压缩器 | ⚠️ 语义上不可「回收」——压缩/冻结就是它的回收 |
| free | 1645 页(低于 min=1500 线附近) | 触发查杀的直接条件 |
结论:「查杀前所有回收手段已用尽」只对 purgeable 和 speculative 成立。普通文件页有 838MB 仍驻留(干净页逐出是与查杀并行的选项,不是前置条件);匿名页永远不会被「回收」——它的归宿是压缩器(2.7GB 已在压缩器里)与 freezer(381MB 冻结换出),驻留的 2.98GB 正是查杀要解决的对象。实测完整优先级链:
代码级修正:「收干」是记账效应,不是硬保证
细读查杀路径源码(kern_memorystatus.c)后发现,上表的「✅ 成立」需要精确化——代码里不存在任何「先排干 purgeable/speculative 再查杀」的前置检查,三段证据 [SRC]:
- kill 路径里唯一的 purge 只清受害者自己:
memorystatus_kill_proc()(kern_memorystatus.c:5516)调用vm_purgeable_purge_task_owned(p->task)——只 purge 被杀进程自己的 volatile 对象,purge 后重查 footprint/压力,够用则免杀(「甚至可能因此免杀」的出处)。系统级 purgeable 状态根本不看。 - speculative「必然低」是算术约束,不是清空动作:查杀触发条件是
memorystatus_available_pages ≤ critical 阈值(:3630-3633),而 available =active + inactive + free + speculative(:868)。speculative 是这个和式的一个加项——available 低于阈值时 spec 数学上不可能大。实测 221 页非零正说明它从未被「清空」,只是被「算进去了」。 - 高水位 kill 完全绕开系统状态:hiwat kill 的触发是单进程 footprint 超自己的 memlimit,与系统页短缺无关——可以在系统 purgeable 还有 GB 时发生。
那为什么实践中(如 16:36 事件)purgeable 总是接近收干?两个软耦合:① purgeable 驻留页本身就躺在 active/inactive 队列里,推高 available、延迟 kill 触发——不排干就难以到达 kill 阈值,这是记账效应而非检查顺序;② 持续压力源先触发 pageout 的 force purge 链(warning→urgent→critical,vm_pageout.c:1842-1860),后触发 kill——同一压力的时间顺序。所以正确表述是:「查杀时 purgeable/speculative 基本收干」是记账约束 + 时序大概率的实践近似(实测 98% 排空、非零),不是内核的顺序保证。
附注:5GB 压舱下宿主死于 SIGBUS——KERN_PROTECTION_FAILURE 写入 Taurine 越狱层 pspawn_payload-stg2.dylib 的 COW 文本页。这是越狱注入载荷在极限压力下的怪癖死亡,非 iOS 原生查杀(无 JetsamEvent),读取数据时已剔除。压力等级实测为位标志:1=normal、2=warn、4=critical(与源码 kVMPressure* 一致)。
深读:为什么实验里看不到 purgeable 的「缓慢释放」?——token aging 的真实形态
实验 H 的曲线形态(长期平坦 → 压力来临时 27MB/s 快速排干)与「token aging 让 purgeable 缓慢释放」的直觉相反。回到 vm_purgeable.c / vm_pageout.c 源码,答案是一个三段串联的结构,aging 只是其中一段 [SRC]:
// ① aging 不是定时器——是 inactive 队列周转计数器 vm_purgeable_q_advance_all() // vm_purgeable.c:469 num_pages = 1; // L500: 每次调用只减 1 tokens[unripe].count -= 1; // L505 if (count == 0) available_for_purge++; // L511: 只登记资格, 一页不释放! // 调用点: vm_page_queues_remove() — 每有一页【离开】inactive 队列才触发 // (vm_resident.c:8628/8639) → 老化速率 = inactive 周转速率 // ② 门 A(需求门) 优先于一切 — pageout 睡着时连检查都不做 vm_pageout_scan() main loop: // vm_pageout.c:2961 if (free >= target) return_from_scan; // L2964: 直接回去睡觉 retval = vps_purge_object(); // L3012: 走到这里说明真的缺页 // ③ 释放 = 一次一个对象, 回循环顶重新评估 if (purge_one 成功) return NEXT_ITERATION; // vm_pageout.c:3020
三段各有各的速率,而「缓慢释放」一段都不存在:① 的速率跟随系统忙闲(闲时 inactive 队列几乎不周转,token 计数纹丝不动);② 是硬开关(free 不跌破 target,成熟的对象永远干等);③ 是突发(每对象一次 purge 全丢,全有全无)。逐条对齐实验观测:
| 实验观测 [EXP] | 机制解释 [SRC] |
|---|---|
| A/E:384MB VOLATILE 压舱下 60~110s 零回收 | 门 A 半开(free 贴着 target 波动)+ 我们的对象 token 未成熟——轻压力下 inactive 周转慢,计数减不動 |
| w4:free=99 页(紧急)却还扛了 15 秒才排水 | 门 A 大开但门 B 未开:系统各 app 的 VOLATILE token 尚未成熟。压缩器全速搬运压舱 → inactive 周转暴增 → token 快速成熟 → 27MB/s 逐对象排水。15 秒就是「成熟等待」的实测时长 |
| 双压舱:5ms 瞬时 purge | 前序实验已把 inactive 队列 churn 干净,new_pages 计数耗尽 → 新对象 token count≈0 → 瞬时成熟即被收(与压力等级走 force 旁路两种解释在数据上不可分辨) |
| 27MB/s 这个「速率」 | 不是释放吞吐上限,是「一次一个对象 × scan 迭代」的节奏(vm_pageout.c:3020 NEXT_ITERATION 语义) |
结论:「token aging → 缓慢释放」是对机制的误读。aging 的产物是「延迟的可回收资格」,不是「延迟的释放」。VOLATILE 的语义是「承诺可以丢」,不是「请求尽快丢」——门 A(free 检查)排在 purge 检查之前,保证系统永远不会「为了释放而释放」:无压力时提前丢弃 ripe 对象只有重建成本、没有任何收益。系统级 purgeable 计数的缓慢下降(如开机累计曲线)是统计叠加效果:各 app 退后台重标 VOLATILE/EMPTY + 偶发轻压力 purge 的宏观平均;单看一个对象,purge 永远是全有全无的瞬时事件。
系统级旁证(实验期间采集)
kern.vm_page_free_min=1500 / kern.vm_page_free_target=2000——§4 的 idle target 语义在真机证实;实验中 free 恰好稳定在 2000-3000 一线(pageout 在努力维持 target)。- 累计
Pages purged = 1,124,242(≈17GB)——purge 确实是这台设备的高频常态路径,不是纸面机制。 vm.compressor_mode=32+ 256MB 加密 swap;Pages reactivated=622,633说明被回收页的重取也频繁。- 排障备忘(可复现的坑):Mac codesign 的 adhoc 签名被 amfid 替身拒绝(CS_KILL 137 无 crash 报告);
ldid -S必须在最终路径签名;iOS 26 SDK 需-Wl,-no_fixup_chains;注入路径必须 /usr/lib;期间还发现并修复了 jailbreakd 的 fd 泄漏与kern.maxvnodes耗尽(12000→60000)。
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 |