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 至今行为一致。
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 |