本文在真实越狱 iPhone 12 Pro(A14/iOS 14.8)上实测了 4 个内存分配器(libmalloc/jemalloc/mimalloc/scudo)、5 种 page fault 类型(minor/COW/guard/reactivation/major)、5 种压缩算法(LZ4/LZFSE/LZMA/zlib/WKdm),共 14 项 benchmark。
核心发现:① libmalloc 慢 4-6× 但 iOS 流畅——分配器速度是二阶项,内存保命策略(jetsam 协同)才是关键;② iOS 有 256MB 加密 swap(推翻"无 swap"说法),但 7 层回收层次使 swap 几乎不用;③ jetsam 策略动态——"先压缩扛 / 先等再渐进杀",不是 LMK 式"压力来了就杀";④ 稳定胜过快——libmalloc p99/p50=1.02× vs jemalloc 4KB=2.98×;⑤ 内核选 WKdm 而非 LZ4 因为"可预测"而非"快"。
| 指标 | libmalloc | jemalloc | mimalloc | scudo | WKdm(内核) | LZ4 |
|---|---|---|---|---|---|---|
| 快路径延迟 | 88 ns | ★21 ns | 28 ns | 60 ns | — | — |
| 6线程吞吐 | 44M ops/s | ★183M | 135M | 63M | — | — |
| 跨线程争用 | 106 ns | ★17 ns | 28 ns | 30 ns | — | — |
| retained 内存 | ★17 MB | 81 MB | 194 MB | 206 MB | — | — |
| 大块延迟(1MB) | 2193 ns | ★423 ns | 407 ns | 8298 ns | — | — |
| page fault (minor) | ~3000 ns/fault(16K 页)= 143× jemalloc malloc | |||||
| COW fault | ~5000 ns/fault(16K 页),比 minor fault 慢 1.7×(多了页面复制+双 PTE 更新) | |||||
| guard page fault | ~3570 ns/fault(PROT_NONE→SIGSEGV→siglongjmp),比 minor fault 贵 570ns(信号投递链) | |||||
| reactivation fault | ~11000 ns/fault(从 compressor 解压 WKdm 回收页),5GB 压力测试 43.9% 页面落入此区间 | |||||
| major fault (swap-in) | 100μs-3.9ms(从加密 swap 文件读取+解密),仅 0.5% 页面触发——iOS 有 256MB 加密 swap! | |||||
| compressor_mode | 32(通用压缩器路径)| Freezer 仅执行 2 次(369K 考虑→2 冻结)| thrashing=0 从未发生 | |||||
| 压缩吞吐(TYPICAL) | — | — | — | 1594 MiB/s | 805 MiB/s | |
| 解压吞吐(TYPICAL) | — | — | — | 1978 MiB/s | ★15989 MiB/s | |
| 6核并发解压 | WKdm 8000 MiB/s(×4 单核)— 内核真实聚合解压能力 | |||||
| jetsam 级联 | 8 进程→先压缩扛(compressor翻倍)→全杀 | 16 进程→先等2.5s→渐进杀(先2后余)→动态策略 | |||||
★ = 该维度最优 · 所有数据在真实 iPhone 12 Pro (A14/iOS 14.8) 上实测 · scudo quarantine 默认 OFF
每一层延迟差约一个数量级:88ns → 1,960ns → 3,000ns → 11,000ns → 4,000,000ns → kill。iOS 的策略是尽量留在高层(便宜),避免落到 L5(慢)和 L6(致命)。
本文是一组围绕"移动端内存管理"的源码级分析,全部基于在真实越狱 iPhone 上的实测 + 官方开源仓库逐行核对。覆盖原始需求的两部分:① pagefault 延迟实测(§5)② 内存压缩/解压性能(§6)+ 分配器对比(§1)。核心方法:
| 项 | 值 | 验证方法 |
|---|---|---|
| 设备 | iPhone 12 Pro (iPhone13,3) A14 Bionic, 6GB RAM | sysctl hw.model=D53pAP hw.physicalcpu=6 |
| OS | iOS 14.8 (Darwin 20.6.0, XNU 7195.141.2) | uname -a |
| 越狱 | Taurine (Sileo/electra) | SSH root@127.0.0.1:2222 |
| 页大小 | 16384 bytes (16K) | sysctl hw.pagesize |
| 编译器 | Xcode clang arm64 -O2 | xcrun -sdk iphoneos clang |
| 分配器隔离 | 每后端独立 fork() 子进程 | 避免后端间 RSS 污染 |
| RSS 测量 | mach_task_basic_info.resident_size | Mach 调用,非 /proc |
| 默认 zone 验证 | MallocNanoZone=0 = 默认(89.5 vs 88.4ns) | 排除 nano zone |
| scudo 稳定性 | 4 轮独立运行,CV < 2.2% | 中位数取值 |
| quarantine 验证 | quarantine_size_kb=0 vs 默认 = 相同 | 证实上游默认 OFF |
8 个工作负载覆盖:快路径延迟(pair_32b/4kb/1mb)、驻留(hold_20k)、大块(large_1mb)、realloc 增长、线程争用(threaded_4t)、跨线程对象流(shared_churn_6t)。配置依据对齐移动端真实默认:
| 后端 | 配置 | 依据 |
|---|---|---|
| iOS libmalloc | 直接调 libc malloc() | 设备默认区 = DefaultMallocZone 杂志区(非 nano),fork 子进程不降速已验证,代表 App 真实路径。验证方法(三重互证):① MallocNanoZone=0 结果与默认一致(88.4 vs 89.5ns),MallocNanoZone=1 反而更慢(101.6ns)→ 默认非 nano;② nano_probe 工具直接打印 default_zone="DefaultMallocZone"(非 NanoMallocZone);③ fork 子进程不降速(88.9→89.2ns),杂志区 fork 安全 |
| jemalloc 5.3 | 上游默认 = Android Bionic 基线 | dirty/muzzy_decay_ms=10000(10s)、narenas=ncpu、--with-lg-page=14(16K 页) |
| jemalloc0 | 调 decay=0 + arena.<all>.purge | 探索"立即 decommit"调参 |
| mimalloc 2.1.7 | 上游默认 | purge_delay=10ms;mimalloc 无移动端 Bionic 出货默认 |
| mimalloc0 | 调 purge_delay=0 + mi_collect(true) | 与 jemalloc0 对称的调参实验 |
| scudo standalone | LLVM 16.x 上游默认(Darwin 移植) | Android 11+ 默认分配器;无上游 darwin.cpp,自写 Darwin 平台层(os_unfair_lock 替 futex、arc4random 替 getrandom、mach_task_basic_info 替 /proc);DarwinConfig(PrimaryRegionSizeLog=24,因 iOS 拒绝 180GB 虚拟预留);sc_ 前缀 |
W6: shared_churn_6t — 跨线程对象流(设计最巧妙的工作负载)
/* 每个线程 malloc + atomic_exchange 到共享 slot pool + free 旧对象。
* 对象在线程间流动:线程 A (核 X) 分配的块被线程 B (核 Y) 释放。
* jemalloc 走 cross-tcache/remote-free 路径,libmalloc 的 per-CPU 杂志
* 释放到当前 CPU——锁的 cache line 不跨核弹跳。*/
static void* sc(void*x) {
struct scarg*a=x; backend_t*b=a->b;
double t0=now_us(); long c=0; uint32_t s=a->seed;
while(now_us()-t0 < a->dur*1e6){
for(int i=0;i<1000;i++){
s^=s<<13; s^=s>>17; s^=s<<5; /* xorshift 线程本地 RNG */
int idx = (int)(s % (uint32_t)a->nslots);
void *np = b->malloc(a->sz);
void *old = atomic_exchange(&a->slots[idx], np); /* 原子交换 */
if(old) b->free(old); /* 释放另一个线程可能分配的块 */
c++;
}
}
__atomic_fetch_add(a->ops, c, __ATOMIC_RELAXED);
}
为什么 shared_churn 是关键测试:它暴露了 per-thread cache (jemalloc/mimalloc) vs per-CPU magazine (libmalloc) 在跨线程对象流场景下的根本差异。atomic_exchange 是对所有后端的公平固定税。6 线程 × 64B × 0.5s。
/* 通过 sysctl 提取内核压缩器的完整计数器 */
c->input_bytes = sysctl_u64("vm.compressor_input_bytes");
c->compressed_bytes= sysctl_u64("vm.compressor_compressed_bytes");
c->bytes_used = sysctl_u64("vm.compressor_bytes_used");
c->wk_comp = sysctl_u64("vm.wk_compressions");
c->lz4_comp = sysctl_u64("vm.lz4_compressions");
c->lz4_threshold = sysctl_u64("vm.lz4_threshold"); /* 12288: 12KB 阈值 */
/* mode 32 下 wk_comp/lz4_comp 均为 0 — 统一计数路径 */
| 后端 | peakRSS | retained(释放后) | 32B 速度 | 速度代价 |
|---|---|---|---|---|
| libmalloc | 168 MB | 17 MB | 88 ns | — |
| jemalloc(默认) | 178 MB | 81 MB | 21 ns | — |
| jemalloc0(decay=0) | 178 MB | 6 MB(最低) | 21 ns | 零损失 |
| mimalloc | 194 MB | 194 MB | 28 ns | — |
| mimalloc0 | 194 MB | 194 MB(不变) | 28 ns | 大块 churn 暴跌 100-1900× |
| scudo | 209 MB | 206 MB | 60 ns | 大块 pair_1mb 8298 ns(3.8× 慢于 libmalloc) |
原假设:跨线程对象流动(A 分配的块 B 释放)会触发同核争用+跨核 cache 弹跳,libmalloc 的 per-CPU 杂志锁局部性优势应显现、缩小与 jemalloc 的 gap。workload:6 线程共享 2048 槽池,每线程 malloc(64)+atomic_exchange(slots[i],new)+free(old)。
| 工作负载 | libmalloc ns/op | jemalloc ns/op | mimalloc ns/op | scudo ns/op | libmalloc 慢倍数 |
|---|---|---|---|---|---|
| pair_32b(单线程) | 87.8 | 20.9 | 28.1 | 60.4 | 4.2× vs jemalloc |
| threaded_4t(4线程争用) | 21.6 | 5.1 | 7.2 | 15.5 | 4.2× vs jemalloc |
| shared_churn_6t(跨线程流) | 105.8 | 16.7 | 28.4 | 30.4 | 6.3× vs jemalloc |
每线程速率:libmalloc 1.58M ops/t(跌 7.2×)、jemalloc 10.0M ops/t(跌 4.8×)——libmalloc 跌得更狠,假设被证伪。
根因(源码级):① libmalloc 每操作付两把杂志锁(malloc 的 SZONE_MAGAZINE_PTR_LOCK+free 的锁,libmalloc/magazine_tiny.c:2409/506)叠加争用的 atomic_exchange;② jemalloc 跨线程 free 免费——je_free 对小对象总进当前线程 tcache(不追踪分配者),无 remote-free 惩罚,快路径仍无锁(jemalloc/include/jemalloc/internal/cache_bin.h:348),只付 atomic 争用。→ per-CPU 锁局部性真实但不足以抵消"两次锁/次"的固定开销;jemalloc per-thread 无锁 tcache 在所有吞吐场景都赢。
scudo(LLVM Sanitizer Combined Allocator successor)是 Android 11+ 的默认分配器,设计目标是安全加固而非极致速度。上游 scudo standalone 只有 linux.cpp/fuchsia.cpp/trusty.cpp 三个平台层,没有 Darwin 端口。本次测试写了完整的 Darwin 平台层(darwin.cpp:用 os_unfair_lock 替代 Linux futex、arc4random_buf 替代 getrandom syscall、mach_task_basic_info 替代 /proc/self/statm),是首个在真实 iOS 设备上跑的 scudo benchmark。
| 维度 | libmalloc | jemalloc | mimalloc | scudo |
|---|---|---|---|---|
| pair_32b 速度 | 88 ns | 21 ns | 28 ns | 60 ns |
| threaded_4t | 21.6 ns | 5.1 ns | 7.2 ns | 15.5 ns |
| shared_churn_6t | 105.8 ns | 16.7 ns | 28.4 ns | 30.4 ns |
| pair_1mb(大块) | 2193 ns | 423 ns | 407 ns | 8298 ns(最慢) |
| hold_20k retained | 17 MB | 81 MB | 194 MB | 206 MB(最高) |
| 实验 | pair_32b | threaded_4t | shared_churn_6t | retained |
|---|---|---|---|---|
| 默认(quarantine OFF) | 61.0 ns | 16.1 ns | 31.6 ns | 206 MB |
| 显式 quarantine_size_kb=0 | 61.0 ns | 16.0 ns | 31.4 ns | 206 MB |
| quarantine_size_kb=1024 | 崩溃(Darwin 移植 quarantine 路径 bug) | |||
| 工作负载 | 4 轮中位数 | 范围 | CV |
|---|---|---|---|
| pair_32b | 60.8 ns | [60.4, 61.0] | 0.4% |
| threaded_4t | 16.1 ns | [15.5, 16.3] | 2.2% |
| shared_churn_6t | 31.3 ns | [30.4, 31.4] | 1.5% |
| pair_1mb | 8362 ns | [8259, 9145] | 5.0% |
评分标准:速度=10×(1-ns/100ns),内存=10×(1-retained/206MB),安全=功能数/8×10,协同=平台集成度/3×10
A14 Bionic 有 6 核(2 Firestorm 性能核 + 4 Icestorm 能效核)。测试方法:N 个线程各自做 64B malloc/free 循环 0.5 秒,统计总 ops/sec。关键问题:哪个分配器扩展性最好?
| 分配器 | 1 线程 | 2 线程 | 4 线程 | 6 线程 | 1→6 扩展 | 4→6 增量 |
|---|---|---|---|---|---|---|
| libmalloc | 11.2M | 22.5M | 43.4M | 44.1M | 3.93× | +1.7% |
| jemalloc | 46.7M | 93.7M | 179.7M | 182.7M | 3.91× | +1.7% |
| mimalloc | 33.8M | 69.1M | 133.3M | 135.3M | 4.00× | +1.5% |
| scudo | 16.1M | 32.2M | 62.3M | 62.7M | 3.90× | +0.6% |
分配大小如何影响延迟?测试 3 种大小(32B = 小对象 / 4KB = 页大小 / 1MB = 大对象)的 malloc+free pair 延迟:
| 分配器 | 32B | 4KB | 1MB | 32B→1MB 倍率 | 说明 |
|---|---|---|---|---|---|
| libmalloc | 88.2 ns | 87.4 ns | 2,228 ns | 25.3× | 小对象走 tiny/small magazine(<1μs),1MB 走 large,需 mmap+page fault |
| jemalloc | 20.7 ns | 28.4 ns | 420 ns | 20.3× | 32B 走 tcache(无锁),4KB 走 tcache_bin(稍慢),1MB 走 huge 路径 |
| mimalloc | 28.3 ns | 43.1 ns | 415 ns | 14.7× | 32B 走 thread-local heap,4KB/1MB 走 segment 路径 |
| scudo | 60.9 ns | 61.6 ns | 8,146 ns | 133.8× | 小对象走 primary size-class,1MB 走 secondary(mmap+chunk header) |
前面的数据都是批量均值(总时间÷操作数)。但延迟分布的尾部(p99/p99.9)对 UI 流畅性更重要——一个 p99 卡顿就可能导致掉帧。本节用 10 万次单次 malloc+free 采样,测量完整延迟分布:
| 后端 | 大小 | p50 | p90 | p99 | p99.9 | max | p99/p50 | 说明 |
|---|---|---|---|---|---|---|---|---|
| libmalloc | 32B | 41ns | 42ns | 42ns | 42ns | 21,209ns | 1.02× | ★ p50-p99.9 完全一致(无尾部) |
| libmalloc | 4KB | 41ns | 42ns | 42ns | 42ns | 19,833ns | 1.02× | 同样无尾部 |
| jemalloc | 32B | 42ns | 42ns | 42ns | 42ns | 34,459ns | 1.00× | 32B 无尾部(tcache 充足) |
| jemalloc | 4KB | 42ns | 42ns | 125ns | 167ns | 32,541ns | 2.98× | 4KB 有可见尾部! |
两者都有安全加固,但加固的对象和层次完全不同。
| 安全维度 | libmalloc(iOS) | scudo(Android) | 源码依据 |
|---|---|---|---|
| 校验和保护对象 | free list 指针(链表节点) | 每 chunk header(每个块头) | libmalloc: magazine_inline.h:194 / scudo: chunk.h:122 |
| 校验和强度 | arm64e: PAC 全签名 / 非 arm64e: 4-bit | 16-bit CRC32(A14 用 __crc32cd 硬件指令) | magazine_inline.h:236 / chunk.h:23-46 |
| UAF 检测(quarantine) | ❌ 没有 | ✅ free 进 quarantine 延迟回收 | scudo/combined.h:1128 |
| 每块 State 追踪 | ❌ 没有 | ✅ Available/Allocated/Quarantined 三态 | scudo/chunk.h:62 |
| double-free 检测 | ❌ 没有(free list 重链入) | ✅ State≠Allocated 即报错 | scudo/combined.h:568 |
| MTE(硬件标签) | ❌ | ✅ setRandomTag → UAF 100% 硬件检测 | scudo/memtag.h:30 |
| GWP-ASan(概率保护页采样) | ❌ | ✅ #ifdef GWP_ASAN_HOOKS | scudo/combined.h:29 |
| guard page(大块) | ✅ 大块有 | ✅ secondary 前后都有 | magazine_tiny.c:1317 / scudo/secondary.h:25 |
| PAC(arm64e 指针签名) | ✅ ptrauth_sign 签 free list 指针 | ❌ 用 CRC 替代 | magazine_inline.h:196-200 |
| ASLR | ✅ DISABLE_ASLR | ✅ PrimaryEnableRandomOffset | magazine_malloc.c:1707 |
| 释放后内存毒化 | ❌ | ✅ PatternOrZeroFill 0xAB | scudo/common.h:203 |
| 释放栈追踪 | ❌ | ✅ storeDeallocationStackMaybe(MTE 时) | scudo/combined.h:1149 |
| VM tag | ✅ Darwin 原生 | ❌ Linux 无此机制 | magazine_malloc.c |
libmalloc 的加固哲学:"保护 free list 不被腐败,其余交给平台沙箱"。
① free list 指针校验和——两条路径(magazine_inline.h:190-253):
② 没有 UAF 检测:free → 直接链入 free list → 立即可复用。没有 quarantine、没有 State 追踪。UAF 在复用窗口内被静默吞掉。
③ 没有 double-free 检测:第二次 free 只是把指针再链入(如果 checksum 过了)。没有 State 字段说"这个块已经是 Available 了"。
scudo 的加固哲学:"每个 chunk 从分配到释放到回收都有 header 追踪 + CRC32 校验 + quarantine 延迟"。
① 每 chunk header 有 State + CRC32(chunk.h:62-73):8 字节 header 含 ClassId(8bit)/State(2bit)/Origin(2bit)/Size(20bit)/Offset(16bit)/Checksum(16bit)。每次操作都校验——loadHeader 重算 checksum 对比,不符即 reportHeaderCorruption。16-bit CRC32 碰撞 1/65536。
② quarantine——UAF 检测的核心(combined.h:1128-1141):free 不直接回收,State 从 Allocated→Quarantined。进 QuarantineCache 延迟释放。quarantine 窗口内 UAF 访问 → 下次 drain 回收时 header 被腐败 → checksum 不匹配 → 报错。这是 libmalloc 完全没有的机制。
③ MTE——100% 硬件级 UAF 检测(memtag.h:30,combined.h:1158):在 MTE 硬件上,free 时 setRandomTag 换标签。UAF 用旧 tag 访问 → CPU tag check 硬件 trap(SIGSEGV),100% 检测率。libmalloc 不支持 MTE(A14 未实现 MTE)。
④ GWP-ASan——概率性保护页采样(combined.h:29-33):编译时开 GWP_ASAN_HOOKS,抽样分配放 guard page 区域,overflow/underflow 直接 SIGSEGV 并附栈追踪。libmalloc 没有;WebKit 有独立的 bmalloc 做类似的事。
scudo 确实比 libmalloc 快(60 vs 88 ns),安全机制也更全。但"快+安全"不是 iOS 不换 scudo 的原因——libmalloc 有 4 件 scudo 架构层面做不到的事,其中第 1 件是 iOS 流畅性的命根。
这是 iOS App 后台能保命的核心。libmalloc 把"何时还内存"的决策权下放给内核,scudo 架构里没有这个通道:
| libmalloc 协同机制 | scudo 对应物 | 差距 |
|---|---|---|
| MADV_FREE_REUSABLE(Darwin 专属:"我先用着但压力来时让给你") | MADV_DONTNEED(Linux:"我不要了") | 语义完全不同——REUSABLE 让内核优先回收而非杀进程 |
| 收到 memorystatus 压力事件 → malloc_memory_event_handler → pressure_relief 主动 flush | ❌ 没有被动响应内核压力的机制 | scudo 只定时 decay,不会在 jetsam 发信号时主动还 |
| VM_MAKE_TAG 让 jetsam 按任务定位大消耗者 | ❌ Linux 无 VM tag | jetsam 无法精确定位"哪个 App 哪类分配最耗内存" |
| vm_purgable_control 可清除分配 | ❌ scudo 无此抽象 | WebKit 的 purgeable image 靠这个 |
关键反证:把 libmalloc 换成 scudo → App 后台时 scudo 不响应 jetsam 压力信号 → jetsam 看到占着 RAM 不还 → 直接杀进程。这是 iOS 不能接受的结果——流畅性的第一支柱"进程还在"就塌了。
scudo 的 16-bit CRC 可碰撞;libmalloc arm64e 用 ptrauth_sign 硬件密码学签名。对 free list 篡改这一个威胁,iOS 已有比 scudo 更强的方案。scudo 多出的 quarantine/MTE/GWP-ASan 防的是 UAF/overflow——这些在 iOS 上由代码签名 + 沙箱 + WebKit bmalloc 在平台层消化了。
Apple 既造 SoC(A14=6 核)又出 OS,已知固定核数。per-CPU 杂志的锁 cache line 永远只被同一核碰——不跨核弹跳。scudo 的 per-thread TSD(pthread_key_create)每次访问多一层 pthread_getspecific,线程多时 tcache 膨胀。
libmalloc 是 Bertrand Serlet 1999 年设计(magazine_malloc.c:26),25 年来与 XNU/CoreFoundation/Foundation/UIKit 深度耦合:malloc_zone_t 注册机制、scalable_zone_info_task 跨任务安全读(诊断/Instruments 基础)、malloc_memory_event_handler 注册在 dispatch 源上(事件总线)。scudo 没有这些 Darwin 集成。
↑ 以上四节的实测数据揭示了一个共同模式:每个分配器都在不同维度上做权衡——速度、内存、安全、平台协同——没有"全能最优解"。下一节从源码层面解释这些数据差异的根因。
libmalloc:杂志按当前 CPU 选,不是按线程。libmalloc/magazine_rack.c:228:
unsigned int rack_get_thread_index(rack_t *rack) {
index = _malloc_cpu_number(); // 当前 CPU 号,不是线程号
}
// magazine_tiny.c:2399 tiny_malloc_should_clear
mag_index = rack_get_thread_index(rack) % rack->num_magazines;
杂志数 = 核数:num_magazines = mag_max_magazines()(magazine_malloc.c:1699,注释"per-CPU magazines that scale way better")。
jemalloc:tcache 按线程绑,经 tsd(thread-specific data)。
| 维度 | per-thread(jemalloc) | per-CPU(libmalloc) |
|---|---|---|
| cache 数量 | 随线程数增长(8 线程=8 个) | 恒定=核数(6 个) |
| 同核争用 | 无(各自私有) | 有——同核多线程共享该核杂志,争同一把锁 |
| 跨核争用 | 无 | 无(不同核=不同杂志,锁不弹来弹去) |
| 迁核后 | cache 跟线程走 | cache 留在旧核,但新核的杂志元数据是硬件 cache 热的 |
Apple 为何选 per-CPU:① 核数已知且小(A14=6),杂志数恒定省内存(线程多时 jemalloc tcache 膨胀);② 杂志锁的 cache line 永远只被同一核拿→不跨核弹跳(锁真正开销在跨核 cache line 迁移,per-CPU 避开了);③ 杂志元数据硬件 cache 局部性钉在核上;④ 同核争用在 iOS cooperative 调度下低频。
libmalloc:快路径仍持 magazine 锁,即使命中单槽 cache:magazine_tiny.c:2409
SZONE_MAGAZINE_PTR_LOCK(tiny_mag_ptr); // ← 加锁
ptr = tiny_mag_ptr->mag_last_free; // 唯一一个 cache 槽
if (mag_last_free_msize == msize) {
SZONE_MAGAZINE_PTR_UNLOCK(tiny_mag_ptr); // 命中也锁了
return ptr;
}
jemalloc:无锁无原子,走线程私有栈:cache_bin.h:348
void *ret = *bin->stack_head; // 线程私有栈,无 lock/atomic
if (likely(low_bits != bin->low_bits_low_water)) {
bin->stack_head = new_head;
*success = true; return ret;
}
libmalloc 的快 cache 仅 mag_last_free 单槽;jemalloc 是深栈(每 size-class 几百个)。libmalloc 每操作还付 free 的校验和(free_list_checksum_ptr,抗堆攻击)。→ 估算 88ns 里大头是:锁开销(~30-40ns)+校验和(~10-15ns)+链表慢路径(~15-20ns)。注:此拆解为源码推算,非 dtrace 实测(iOS 无 dtrace,MallocScribble 等检查开关会改变行为路径无法隔离单一因素)。
libmalloc:region 进 depot 后才逐页 MADV_FREE_REUSABLE(magazine_tiny.c:839,pinned_to_depot 保证锁外 madvise 安全)。回收是 pressure/recirc 驱动;大块走 MADV_REUSABLE death-row cache。
jemalloc:decay_dirty/decay_muzzy(默认 10s,jemalloc/src/pac.c:78)→ purge:MADV_FREE(→muzzy)/MADV_DONTNEED(decommit)。定时、可调、可确定。
→ 解释实测 retained:libmalloc 17MB(pressure 下 depot madvise 起作用)、jemalloc 默认 81MB(10s decay 没触发)、jemalloc0 6MB(decay=0 立即 decommit)。
| 维度 | iOS libmalloc(杂志区) | jemalloc 5.3 |
|---|---|---|
| 快路径定位 | per-CPU(_malloc_cpu_number()) | per-thread(tsd) |
| 快路径锁 | 持锁(命中也锁) | 无锁(线程私有栈) |
| free list | 双向链表,校验和指针 | per-thread 栈 |
| 内存获取 | mach_vm_allocate+VM tag | mmap |
| 回收 | depot 逐页 MADV_FREE_REUSABLE,pressure 驱动 | decay 定时,可调 |
| 安全加固 | 校验和 + guard pages | 默认无 |
源码文件头注释(magazine_malloc.c:26):Author Bertrand Serlet, August 1999; 2008 多线程增强"in the spirit of Hoard"(Emery Berger ASPLOS 2000 的 per-processor heap)。libmalloc 是 Hoard 在 Apple 硬件上的特化,四个核心思想:
内核 kern_memorystatus(jetsam) 监控压力,越阈值
→ 投递 NOTE_MEMORYSTATUS kqueue 事件(带 NORMAL/WARN/CRITICAL 级别)
→ 用户态 dispatch source DISPATCH_SOURCE_TYPE_MEMORYSTATUS 触发
→ libmalloc: malloc_memory_event_handler(event) [malloc.c:3139]
event & MALLOC_MEMORYSTATUS_MASK_PRESSURE_RELIEF
→ malloc_zone_pressure_relief(0, 0) [malloc.c:3142]
→ 每个 zone->pressure_relief(zone, 0) [malloc.c:3181]
→ 杂志区: szone_pressure_relief [magazine_malloc.c:1425]
① tiny/small/medium_madvise_pressure_relief [magazine_tiny.c:976]
遍历每 per-CPU 杂志每 region,RACK_DISPOSE_DELAY
→ 迁 depot → 对每个 free span MADV_FREE_REUSABLE
② Large death-row: 锁外拷出后逐个 mvm_deallocate_pages(全 munmap)
置 flotsam_enabled=FALSE 使压力期新大块 free 不再回缓存
净效果:jetsam 发压力信号后毫秒级内,libmalloc 把所有 free/cached 页归还内核 → jetsam 的回收轮拿走 RAM 而无需杀进程。
scalable_zone_info_task(task, memory_reader_t reader, ...)(magazine_malloc.c:954):跨任务(用 memory_reader_t)安全读 malloc zone 统计。
理论分析了 jetsam 协同链,但 MADV_FREE_REUSABLE 真的有延迟优势吗?实测验证:分配 512MB → 写入全部页(minor fault)→ madvise(MADV_FREE_REUSABLE) → sleep 1s → 重新访问全部页,对比两种路径:
| 阶段 | free_pages | compressor_pages | ns/fault | 说明 |
|---|---|---|---|---|
| 分配前 | 188,775 | 26,475 | — | 系统 ~2.9GB 空闲 |
| 分配+写入后 | 155,952 | 26,475 | 2,865 | free ↓32K(=512MB),minor fault 延迟符合基线 |
| MADV_FREE_REUSABLE 后 | 155,952 | 26,475 | — | 不立即释放——仅标记页为"可复用"(volatile) |
| sleep 1s 后 | 155,883 | 26,475 | — | 无内存压力→内核未回收,数据仍在 RAM |
| 重新访问(reactivation) | 155,902 | 26,475 | 1,964 | ★ 比 minor fault 快 31%!数据仍在 RAM,只需标记"非 volatile" |
bench 证 libmalloc 慢 4-6×,但手里的 iPhone 依然丝滑。因为分配器快路径延迟根本不是流畅性的瓶颈——流畅性是一个系统级指标,由完全不同的因素决定。
libmalloc 一次 32B malloc+free = ~88ns。一个 App 每帧(16.6ms@60Hz)做 10 万次 alloc/free(已是很重的逻辑):100,000 × 88ns = 8.8ms < 16.6ms,不掉帧。jemalloc 同负载省下的 6.7ms 在 16.6ms 帧预算里省不出肉眼可感区别。
Android Bionic 用 jemalloc(后换 Scudo),分配器更快。但 Android 卡顿来源是:后台进程多→RAM 竞争→LMK 频繁杀→切回冷启动;ART GC 停顿;RenderThread 与 App 共进程争用。Android 选择了快的分配器,输在了"内存/进程保命"。这反向印证:分配器快慢不是关键,内存策略协同才是。libmalloc 慢但协同好,iOS 反而更流畅。
"App 不被杀"是流畅性第一支柱——但 jetsam 真的会杀吗?本节用 mproc 压力测试实测:启动 N 个进程同时分配内存,观察 jetsam 的杀进程行为。两组实验:8 进程快杀(压缩→全杀)和16 进程渐进杀(先杀 2→再杀余)。
| 时间 | 存活进程 | 空闲页 | active 页 | inactive 页 | 压缩页 | 压缩比 |
|---|---|---|---|---|---|---|
| t=0.0s | 8 | 146,946 | 71,924 | 69,523 | 22,334 | 0.577 |
| t=1.0s | 8 | 89(↓ RAM 将满) | 134,057 | 133,455 | 42,169(↑ 压缩启动) | 0.623 |
| t=2.0s | 0(全杀) | 178,178(↑ 释放) | 55,282 | 55,100 | 22,197 | 0.630 |
| 时间 | 存活进程 | 空闲页 | active 页 | 压缩页 |
|---|---|---|---|---|
| t=0.0s | 16 | 145,083 | 78,611 | 25,489 |
| t=0.5s | 16 | 57,465(↓) | 144,572 | 25,489 |
| t=1.0s | 16 | 35,300(→0) | 161,094 | 25,489 |
| t=1.5s | 16 | 35,293 | 161,069 | 25,489 |
| t=2.5s | 16 | 35,327 | 161,002 | 25,489 |
| t=3.5s | 14(← 首杀 2 进程) | 55,626(↑ 释放) | 130,626 | 25,489 |
| t=4.0s | 0(全杀) | 166,844(↑ 大释放) | 62,093 | 25,489 |
| 场景 | 进程数 | 每进程 | 总额定 | 策略 | 杀进程方式 |
|---|---|---|---|---|---|
| 实验 A | 8 | 256MB | 2GB | 压缩扛→全杀 | t=2s 一次性 |
| 实验 B | 16 | 128MB | 2GB | 等→渐进杀 | t=3.5s 杀 2 + t=4s 杀余 |
↑ 上面实测了 jetsam 的杀进程级联。但如果分配器快慢是二阶项,那一阶项——内存管理的底层延迟——是多少?下一节直接测量 page fault 延迟,这是内存管理的最底层操作。
原始需求的第一部分:验证此设备的 pagefault 性能。测试通过 mmap 匿名映射后首次写入触发 minor fault(zero-fill on demand),测量不同大小(4MB→256MB)和访问模式(顺序 vs 随机)下的 per-fault 延迟。每轮重复 5 次取均值。设备页大小 16384(16K 页)。
| 参数 | 值 | 说明 |
|---|---|---|
| 页大小 | 16384 bytes(16K) | iOS 14.8 Darwin 20.6.0,A14 Bionic |
| 测试大小 | 4 / 16 / 64 / 256 MB | 从 L2(256KB) 到远超 LLC |
| 访问模式 | seq(顺序)/ rand(随机) | 顺序 = TLB 友好,随机 = TLB 压力 |
| fault 类型 | minor fault(zero-fill) | 匿名映射首次写 = 内核分配物理页 + 清零 |
// 1. mmap 匿名映射(不分配物理页,仅建 VM 映射)
void *base = mmap(NULL, bytes, PROT_READ | PROT_WRITE,
MAP_ANON | MAP_PRIVATE, -1, 0);
// 2. 顺序/随机访问顺序
for (long i = 0; i < pages; i++) order[i] = i;
if (strcmp(pattern, "rand") == 0) {
// Fisher-Yates 洗牌 → 随机访问
for (long i = pages - 1; i > 0; i--) {
long j = rand() % (int)(i + 1);
long t = order[i]; order[i] = order[j]; order[j] = t;
}
}
// 3. 写入每页首字节 → 触发 minor fault(零填充)
getrusage(RUSAGE_SELF, &ru0); // fault 前计数
double t0 = now_us();
for (long i = 0; i < pages; i++) {
char *addr = p + (size_t)order[i] * (size_t)psize;
*addr = (char)(i & 0xff); // ← minor fault 在这里触发
}
double t1 = now_us();
getrusage(RUSAGE_SELF, &ru1); // fault 后计数
// 4. 交叉验证:实际 minor fault 次数 = ru_minflt 差值
minflts[rep] = ru1.ru_minflt - ru0.ru_minflt;
// 实测:minflt_delta == pages → 每个 page 确实触发一次 fault
| 大小 | 页数 | 顺序 ns/fault | 随机 ns/fault | faults/s(顺序) |
|---|---|---|---|---|
| 4 MB | 256 | 3185 | 3039 | 313,934 |
| 16 MB | 1,024 | 2907 | 2933 | 343,946 |
| 64 MB | 4,096 | 3093 | 2965 | 323,297 |
| 256 MB | 16,384 | 3089 | 3050 | 323,752 |
16K 页减少了 page table 项数,但 TLB 容量仍是有限的。测试 stride 1/2/4/8/16(每隔 N 页访问一个)下的非 fault 访问延迟(页面已 fault-in,纯 TLB/cache 效应):
| 大小 | stride 1 | stride 2 | stride 4 | stride 8 | stride 16 | 1→2 倍率 |
|---|---|---|---|---|---|---|
| 4 MB | 11.9 ns | 8.8 ns | 5.2 ns | 5.2 ns | 5.2 ns | 1.35× |
| 16 MB | 266.3 ns | 11.0 ns | 10.7 ns | 13.4 ns | 10.4 ns | 24.2× |
| 64 MB | 313.3 ns | 12.2 ns | 11.7 ns | 11.2 ns | 11.9 ns | 25.7× |
| 256 MB | 477.2 ns | 25.5 ns | 18.9 ns | 12.8 ns | 11.9 ns | 18.7× |
补测 COW fault:fork() 后子进程写入共享页,触发内核复制物理页。测试方法:父进程 mmap + 首写触发 minor fault → fork() → 子进程写入每页 → 计时。对比 minor fault 的 ~3000ns。
| 大小 | 页数 | Minor fault ns/fault | COW fault ns/fault | COW/minor 倍率 |
|---|---|---|---|---|
| 4 MB | 256 | 2815 | 5211 | 1.85× |
| 16 MB | 1,024 | 2613 | 5592 | 2.14× |
| 64 MB | 4,096 | 2867 | 5001 | 1.74× |
| 256 MB | 16,384 | 2977 | 4434 | 1.49× |
补测 guard page fault:mmap 以 PROT_NONE 映射一个页 → 写入触发 SIGSEGV → sigsetjmp/siglongjmp 恢复 → 计时。这测量的是信号传递延迟(内核 trap → 信号投递 → 用户态恢复),不包括磁盘 I/O。
| 轮数 | ns/guard_fault | 说明 |
|---|---|---|
| 100 | 3,584 | 少量轮次,含首次预热 |
| 1,000 | 3,556 | 稳定值 |
| 10,000 | 3,570 | 稳定值 |
| 50,000 | 3,574 | 50K 轮仍稳定 |
| Fault 类型 | 延迟 | 触发条件 | 内核做了什么 | 多余开销来源 |
|---|---|---|---|---|
| Minor fault | ~3000 ns | 首次写匿名 mmap 页 | 分配空闲页 + 清零 16K + 建 PTE | (基线)清零开销 |
| Guard page fault | ~3570 ns | 写 PROT_NONE 页 | trap → SIGSEGV → 信号投递 → siglongjmp | +570ns 信号投递链 |
| COW fault | ~5000 ns | fork() 后写共享页 | 检测写保护 + 复制 16K + 双 PTE | +2000ns 页面复制 |
| Reactivation fault | ~10000-15000 ns | 访问被压缩回收的页 | 从 compressor 解压 WKdm → 恢复页内容 | +5000-12000ns 解压 |
| Major fault (swap-in) | ~100μs-4ms | 访问被换出到 flash 的页 | 从加密 swap 文件读取 → 解密 → 建 PTE | +100μs-4ms 磁盘 I/O |
| 指标 | 实测值 | 含义 |
|---|---|---|
| Swap 总量 | 256 MB(2 × 128MB 加密文件) | /private/var/vm/swapfile0,1 |
| Swap 已用 | 133 MB (52%) | 系统日常运行已用一半 |
| Swapouts 累计 | 8,613 页 | 共换出 8613 × 16K = 134MB 到 flash |
| Swapins 累计 | 192 页 | 极少换入——说明大部分换出后未再访问(被 kill 而非换回) |
| 压缩累计 | 607,428 次 | 压缩机累计压缩 60 万次 |
| 解压累计 | 229,226 次 | 解压 23 万次 |
| Compressor 当前存储 | 85,513 页(1,330 MB 原始 → 397 MB 压缩) | 压缩比 3.35× |
| Compressor 占用 | 25,468 页(397 MB) | 用 397MB 存了 1.3GB 内容 |
5GB 压力测试:分配 5GB 匿名内存 → 写入全部页面(触发 minor fault 填满 RAM)→ sleep 3s(让内核回收)→ 重新访问全部页面并逐页计时。采样 5041 页:
| 延迟区间 | 页数 | 占比 | p50 | p90 | p99 | max |
|---|---|---|---|---|---|---|
| <1μs(cached in RAM) | 1,166 | 23.1% | 2,875 ns | 11,041 ns | 15,583 ns | 3,945,292 ns |
| 1-5μs(minor fault) | 1,640 | 32.5% | ||||
| 5-20μs(reactivation from compressor) | 2,211 | 43.9% | ||||
| 20-100μs(slow reactivation) | 17 | 0.34% | ||||
| 100μs-1ms(major fault from swap) | 6 | 0.12% | ||||
| >1ms(swap with I/O contention) | 2 | 0.04% |
通过 sysctl -a 提取的内核内存管理计数器,揭示 iOS 内存管理的完整子系统:
| Sysctl | 实测值 | 含义 |
|---|---|---|
| vm.compressor_mode | 32 | 压缩器模式 32——不同于 mode 1(WKdm) / mode 4(LZ4),是 XNU 较新的通用压缩器路径 |
| vm.compressor_input_bytes | 14.9 GB | 累计输入压缩器的原始字节数 |
| vm.compressor_compressed_bytes | 3.1 GB | 累计压缩后输出字节数 → 总压缩比 4.77× |
| vm.compressor_bytes_used | 466 MB | 当前压缩器在 RAM 中占用的空间 |
| vm.compressor_is_active | 1 | 压缩器激活 |
| vm.compressor_eval_period_in_msecs | 250 | 每 250ms 评估一次压缩/解压策略 |
| vm.wk_compressions | 0 | WKdm 压缩次数 = 0! |
| vm.lz4_compressions | 0 | LZ4 压缩次数 = 0! |
| vm.lz4_threshold | 12288 | LZ4 触发阈值:压缩后 >12KB 才考虑 LZ4 |
| Sysctl | 实测值 | 含义 |
|---|---|---|
| kern.memorystatus_freeze_count | 2 | 实际冻结操作仅 2 次——极度保守 |
| kern.memorystatus_freeze_pageouts | 8,465 | 冻结写出的页数(8465 × 16K = 132MB) |
| kern.memorystatus_freezer_below_threshold_count | 369,284 | 36.9 万次"考虑冻结但页面数低于阈值"——选择性强 |
| kern.memorystatus_freezer_process_considered_count | 9 | 仅 9 个进程被考虑冻结 |
| kern.memorystatus_freeze_min_processes | 4 | 至少 4 个进程才启动冻结 |
| kern.memorystatus_freeze_processes_max | 20 | 最多冻结 20 个进程 |
| kern.memorystatus_freeze_jetsam_band | 8 | 冻结发生在 jetsam band 8(后台非活跃区) |
| kern.memorystatus_freezer_error_no_swap_space_count | 0 | 从未因 swap 空间不足而冻结失败 |
| Sysctl | 实测值 | 含义 |
|---|---|---|
| vm.compressor_swapper_swapout_fragmentation_detected | 3,597 | swap 文件碎片化检测 3597 次 |
| vm.compressor_swapper_swapout_thrashing_detected | 0 | 从未检测到 thrashing(频繁换入换出) |
| vm.compressor_swapper_swapout_threshold_exceeded | 0 | swap-out 阈值从未超限 |
| Level | 压缩 MiB/s | 解压 MiB/s | 压缩比 |
|---|---|---|---|
| 1 | 40 | 304 | 0.507 |
| 6(默认) | 33 | 309 | 0.506 |
| 9(最高) | 32 | 305 | 0.506 |
| 维度 | 内核行为 | 源码依据 |
|---|---|---|
| WKdm 算法可重入 | ✅ 安全 | scratch 是参数传入;nm 显示只引用只读查表(_hashLookupTable/_table_*bits,全 U 导入),无可写全局 |
| 压缩(encode) | ❌ 单线程 | vm_compressor_swap_trigger_thread(init 启动一次)+ compaction_swapper_running 守卫 → 同时仅一个压缩线程 |
| 解压(decode) | ✅ 6 核并发 | compressor_cpus = hinfo.max_cpus(=6),compressor_scratch_bufs[cpu_number()*decode_scratch_size],assert(my_cpu_no < compressor_cpus)(XNU/vm_compressor.c:4298) |
| 模式 | 压缩 | 解压(单核) | 解压(6核并发) | 倍率 |
|---|---|---|---|---|
| ALL_ZEROS | ~4500 | — | — | sv,无解压 |
| MOSTLY_ZEROS | ~3150 | ~52000 | ~51000 | ×0.97(MZV 平凡,带宽限) |
| TYPICAL | ~1600 | ~1980 | ~8000 | ×4.07 |
| RANDOM | ~14000 | — | — | 不可压,无解压 |
TYPICAL 6 核聚合解压 ~8000 MiB/s ≈ 4× 单核——内核真实聚合解压能力。未达理想 6× 因内存带宽/缓存争用。压缩为单线程,~1600 MiB/s 即设备上限,故 bench 单线程压缩有代表性;解压单线程低估内核(已补 6 核并发测)。
iOS 内核用 WKdm,Android zram 用 LZ4/LZO。哪个更好?本节实测 5 种算法在相同数据模式下的压缩/解压吞吐量,回答"内核为什么选 WKdm"。
| 算法 | 来源 | 用在 | 特点 |
|---|---|---|---|
| WKdm | Wilson-Kaplan (XNU 内核) | iOS/macOS vm_compressor | 字典编码,低延迟,固定复杂度——为内存页面压缩设计 |
| LZ4 | Collet (开源) | Android zram / 用户态 | 极快通用压缩,解压 ~21000 MiB/s(random) |
| LZFSE | Apple | macOS/iOS 用户态 (compression.framework) | Apple 自研,介于 LZ4 和 zlib 之间,解压快 |
| LZMA | 7-Zip | 通用压缩 | 最高压缩比但极慢(~8 MiB/s 压缩),不适合实时 |
| zlib | GNU/RFC1950 | 通用压缩 / HTTP gzip | 经典 deflate,平衡但均不突出 |
| 算法 | 模式 | 压缩 MiB/s | 解压 MiB/s | 压缩比 | 1MB 解压延迟 |
|---|---|---|---|---|---|
| LZ4 | zero | 6513 | 1308 | 0.006 | 0.78 ms |
| text | 6155 | 2348 | 0.009 | 0.45 ms | |
| mixed | 805 | 15989 | 0.507 | 0.067 ms | |
| random | 423 | 21448 | 1.000 | 0.049 ms | |
| LZFSE | zero | 130 | 13833 | 0.001 | 0.075 ms |
| text | 156 | 13015 | 0.001 | 0.080 ms | |
| mixed | 88 | 594 | 0.507 | 1.73 ms | |
| random | 59 | 305 | 1.010 | 3.37 ms | |
| LZMA | zero | 8.2 | 278 | 0.000 | 3.72 ms |
| text | 7.8 | 1528 | 0.001 | 0.68 ms | |
| mixed | 3.5 | 19 | 0.508 | 53.9 ms | |
| random | 2.3 | 4299 | 1.000 | 0.24 ms | |
| zlib | zero | 125 | 6615 | 0.001 | 0.16 ms |
| text | 124 | 5644 | 0.005 | 0.19 ms | |
| mixed | 35 | 259 | 0.506 | 3.96 ms | |
| random | 19.7 | 15009 | 1.000 | 0.068 ms | |
| WKdm | ALL_ZEROS | 4468 | — | 0.0 | — |
| MOSTLY_ZEROS | 3145 | 52632 | 0.004 | 0.019 ms | |
| TYPICAL | 1594 | 1978 | 0.369 | 0.52 ms | |
| RANDOM | 13825 | — | 1.0 | — |
↑ 内核选 WKdm 是"可预测 + 压缩快 + 6 核并发放大"的综合权衡。下一节对比鸿蒙 NEXT 如何用不同的方案(memcg + zswapd)实现同样的"reclaim→compress→kill"分层。
| 旧鸿蒙 | 鸿蒙 NEXT(统一渲染) | iOS | |
|---|---|---|---|
| 栅格化 | App 进程内 RenderThread | RS 进程集中栅格化 | backboardd 进程 |
| 合成 | RS 进程 | RS 进程 | backboardd 进程 |
| App 提交 | 已栅格化图层 | 渲染命令树(display list) | layer 树快照 |
统一渲染把"栅格化"从 App 进程搬到 RS 进程——结构上向 iOS 的 backboardd 靠拢。App UI 线程卡顿不再直接卡栅格化,和 iOS"App 卡了当帧仍能渲染"同一机理。渲染耦合维度:撤销原"iOS 略优",改判持平。
核实 OpenHarmony kernel v6.1 的 mm/ 目录,发现一整套 Huawei 自研、非 mainline 的内存子系统:
| OH 内核文件 | 作用 | iOS 对应物 |
|---|---|---|
| memcg_control.c | per-app memcg 记账 | per-task VM tag + ledger |
| memcg_reclaim.c | per-app 定向回收冷页(cgroup_reclaim(sc)) | jetsam 压力回收(libmalloc MADV_FREE_REUSABLE + flush) |
| zswapd.c | 内核线程(kthread_run)做 RAM 内压缩交换到 zram | vm_compressor(WKdm/LZ4) |
| zswap.c | 压缩池(zpool+crypto compressor,默认 lz4/lzo) | WKdm 汇编压缩器 |
| vmpressure.c | 压力通知到用户态 | memorystatus / NOTE_MEMORYSTATUS |
| oom_kill.c(upstream) | 兜底杀进程 | jetsam 兜底杀 |
关键发现:① drivers/staging/android/lowmemorykiller.c 在该分支不存在——OH 不带传统 in-kernel LMK;② memcg_reclaim.c 头注释 (c) 2020-2022 Huawei,引用 hyperhold_inf.h——OH 有个叫 HyperHold 的内存子系统;③ zswapd 的 ub_zram2ufs_ratio/ub_ufs2zram_ratio 是分层压缩交换:冷页先压到 zram(压缩 RAM,快)→ 不够再换到 UFS(闪存),与 iOS"vm_compressor 压 RAM → 极端压力 swap 到闪存"是同一分层策略。
| 维度 | iOS | 鸿蒙 NEXT | 判定 |
|---|---|---|---|
| 渲染耦合 | 进程外 backboardd | RS 集中栅格化+合成 | ≈ 持平 |
| 内存保命 | jetsam+libmalloc 主动协同、vm_compressor | memcg 定向回收+zswapd 压缩+zram↔UFS 分层 | ≈ 结构持平 |
| GC 停顿 | ARC 无 GC | ArkTS 仍有 GC(据称分代/并发) | iOS 占优 |
| 范式负担 | 保留模式+编译期+ARC | 声明式 diff+ArkCompiler AOT | iOS 略优 |
| 算力冗余 | A 系顶尖+统一 | 麒麟追赶+仍多元 | iOS 占优 |
鸿蒙 = "Linux memcg 的细粒度 cgroup 定向回收 + iOS 式系统级压缩"的混合。iOS 干脆没用 cgroup,走 per-task+全局压缩+优先级带这条更简的路。三者 reclaim→compress→kill 都到位,但分工哲学不同:Linux 重在"组内精细定向",iOS 重在"全局压缩最冷+按 App 重要性杀+分配器主动还",鸿蒙两者都要。
鸿蒙若卡,卡在哪(修正版):① GC 停顿(ArkTS,头号嫌疑,iOS 无);② 声明式 diff+复杂 list(AOT 缓解但结构性在);③ 中低端芯片算力不足放大以上。不再大概率卡在"被杀冷启动"(鸿蒙已有分层回收)。若鸿蒙想追平 iOS 流畅性下限,该补的不是分配器(二阶项),而是继续把 ArkTS GC 往接近无感调。
iOS 没有 memcg(cgroups 子系统),但有完整功能对等物,且架构与 Linux memcg 在关键点上不同。XNU kern_memorystatus.c(5355 行)源码级核实:
| iOS 组件 | 源码 | 对应 Linux memcg |
|---|---|---|
| per-task ledger(资源账本) | get_task_phys_footprint/task_get_phys_footprint_limit(:968) | mem_cgroup 的 RSS+swap 记账 |
| phys_footprint 记账指标 | memorystatus_get_task_phys_footprint_page_counts(task, *internal_pages, *internal_compressed_pages, ...)(:826) | memory.usage_in_bytes |
| per-task 内存限制(active/inactive 双限 + fatal/nonfatal) | p_memstat_memlimit/memlimit_active/memlimit_inactive/P_MEMSTAT_FATAL_MEMLIMIT(:973) | memory.limit_in_bytes + oom_control |
| jetsam 优先级带(kill 顺序) | JETSAM_PRIORITY_IDLE/AGING_BAND1..2/.../MAX(:307,909) | cgroup OOM priority |
| vm_compressor(回收先于杀) | kill cause 含 kMemorystatusKilledVMCompressorThrashing/VMCompressorSpaceShortage(:92-93) | memcg reclaim + zswap |
Linux memcg 记账 RSS+swap+cache,可压缩的匿名页按原大小全额计入。iOS phys_footprint 把页拆成 internal_pages(未压缩)+ internal_compressed_pages(已压缩)分别记——压缩后 footprint 变小,App 看起来更省。iOS 的记账天然激励"压缩回收":压缩后 footprint 降、不触限;Linux memcg 除非开 zswap 记账否则激励不同。
Linux memcg = 任意 cgroup 树,可多进程归一组共享预算、可嵌套、父子传播(灵活但 page_cgroup 每页挂钩开销大)。iOS = per-task(进程),Darwin 每任务天生有独立 vm_map,页天然归一 task,无需挂钩,再加优先级带归类。无"多进程共享预算"原生抽象,粒度粗但简单、零挂钩开销。
Linux memcg:一个 memory.limit_in_bytes,超限触发回收。iOS:per-task 有 memlimit_active(前台)+ memlimit_inactive(后台)两套限(:973),加 FATAL/NONFATAL 标志(fatal=超限杀,nonfatal=只回收不杀)。前台 App 给大限、后台小限、自动切换——移动端专用调优,Linux 要靠 cgroup 切换+多 limit 模拟。
Linux memcg 回收是 per-cgroup 定向——try_to_free_mem_cgroup_pages() 回收这个 cgroup 的 LRU。iOS 回收是 系统级压缩 + per-task 压力事件双轨:内核 vm_compressor 压缩全系统最冷页(不挑哪个 task,挑最冷),同时给被压 task 发 memorystatus 压力事件 → 该 task 的 libmalloc malloc_memory_event_handler 跑 pressure_relief → 主动 MADV_FREE_REUSABLE 自己的可回收页。iOS 把回收责任部分下放给分配器(libmalloc 主动提示),Linux memcg 模型里分配器只被动被回收——这是 iOS 一个真实但较小的精细度优势。
Linux memcg:cgroup 超 fatal 限 → 该 cgroup 内 OOM killer 杀一个。iOS:系统压力到阈值 → jetsam 按全系统优先级带从低到高杀(IDLE → AGING_BAND → ... → 前台最后),max_kill_priority(:909)控制杀到哪一带为止。iOS 杀是全局 App 优先级驱动(前台几乎不可能被杀,后台 IDLE 先死),Linux 是组内 OOM,与 App 重要性无关。
| 维度 | Linux memcg | iOS(memorystatus+ledger+vm_compressor) |
|---|---|---|
| 记账 | RSS+swap+cache(压缩页全计) | phys_footprint(压缩页按压缩后计,分开) |
| 分组 | 任意 cgroup 树 | per-task + 优先级带 |
| 限额 | 单 limit | active/inactive 双限 + fatal/nonfatal |
| 回收 | per-cgroup 定向 LRU | 系统级压缩 + per-task 压力事件下放分配器 |
| 杀 | 组内 OOM | 全局按 App 优先级带 |
本文反复提到"Darwin""XNU""Mach""BSD"——这里统一解释。
Darwin 是 Apple 所有操作系统(macOS / iOS / iPadOS / watchOS / tvOS / visionOS)的开源内核 + 核心用户态。它是盖在 GUI 之下的那一层操作系统底座:
┌─────────────────────────────────────┐
│ UIKit / SpringBoard (iOS 的触控 UI) │ ← iOS 专属层
│ AppKit / Aqua (Mac 的桌面 UI) │ ← macOS 专属层
├─────────────────────────────────────┤
│ Darwin ← 就是这一层 │
│ ├─ XNU 内核 (Mach + BSD + IOKit) │
│ ├─ libSystem (libc / libmalloc…) │
│ └─ launchd / 核心守护进程 │
├─────────────────────────────────────┤
│ 硬件 (A14 / M1 / M2 …) │
└─────────────────────────────────────┘
Darwin 是共同的根。iPhone 和 Mac 跑的是同一个内核家族,只是上层 UI 不同——这就是为什么越狱 iPhone 能跑很多 Mac 的命令行工具(uname、sysctl、mach_vm_allocate 都是同一套)。本文测试设备 Darwin 20.6.0 = iOS 14.8(对应 macOS 11 Big Sur 内核版本号)。
Darwin 的内核叫 XNU("X is Not Unix",递归缩写),是一个混合内核,三部分粘在一起跑在同一个内核地址空间:
| 组成 | 来源 | 职责 |
|---|---|---|
| Mach 3.0 | 卡内基梅隆大学微内核 | 任务/线程/端口/消息(IPC)、虚拟内存、调度原语 |
| BSD 层 | FreeBSD 衍生 | POSIX API(fork/exec/信号)、VFS 文件系统、BSD socket 网络、进程模型 |
| I/O Kit | Apple 自研 | C++ 设备驱动框架(显卡/USB/网络/存储驱动都基于它) |
不是学术意义的微内核——Mach 没有跑在用户态,而是与 BSD 共享内核地址空间,是混合内核。
| 源码文件 | Darwin 层级 | 本文用途 |
|---|---|---|
| bsd/kern/kern_memorystatus.c | BSD 层 | jetsam 优先级带 + per-task phys_footprint |
| osfmk/vm/vm_compressor.c | Mach 层 | WKdm 压缩器(解压 6 核并发) |
| osfmk/vm/vm_map.c | Mach 层 | phys_footprint 定义 |
| libmalloc/magazine_tiny.c | libSystem(用户态) | per-CPU 杂志 + free list 校验和 |
scudo 上游只有三个平台层:linux.cpp(Linux futex、getrandom、/proc/self/statm)、fuchsia.cpp、trusty.cpp。没有 Darwin 版——因为 Android/Fuchsia 不跑在 Darwin 上,Google 不会写。Darwin 的 API 跟 Linux 不同:
| 功能 | Linux(scudo 原用) | Darwin(本项目移植) |
|---|---|---|
| 互斥锁 | futex(SYS_futex) | os_unfair_lock |
| 随机数 | getrandom syscall | arc4random_buf |
| RSS 查询 | /proc/self/statm 文件 | mach_task_basic_info(Mach 调用) |
| CRC32 检测 | getauxval(AT_HWCAP) | return true(armv8+ 必有) |
| 页释放提示 | MADV_DONTNEED | 同上(Darwin 也有,但 libmalloc 用更强的 MADV_FREE_REUSABLE) |
所以"Darwin 移植"= 把这些 Linux 特有调用换成 Darwin 等价物。这不是优化,是让 scudo 能在 XNU 内核上编译运行的基础工作。本项目写了 darwin.cpp + DarwinConfig,是首个在真实 iOS 设备上跑的 scudo benchmark。
Apple 在 github.com/apple-oss-distributions 开源了 Darwin 的内核(XNU)和核心库(libmalloc、Libc、libdispatch 等)。这就是能拉到 xnu-7195.141.2 和 libmalloc-521.120.7 源码逐行核对的原因。但 GUI 层(AppKit/UIKit/Aqua)不开源。Darwin 是开源的;macOS/iOS 是闭源的商业产品 = Darwin + 闭源 GUI 框架。
| 常见说法 | 实测结论 | 证据出处 |
|---|---|---|
| "iOS 没有 swap" | 错误。iOS 有 256MB 加密 swap(2×128MB swapfile0,1),已用 133MB。但 jetsam 杀进程避免 swap-in,所以 swapouts=8613 而 swapins 仅 192。 | §5.3c: sysctl vm.swapusage |
| "iOS 用 LMK 杀进程" | 错误。iOS 用 jetsam(按优先级 band 从低到高杀)。策略动态:8 进程"先压缩扛→全杀",16 进程"先等 2.5s→渐进杀"。不是 LMK 的"压力来了就杀"。 | §4.4: mproc 双实验 |
| "libmalloc 慢所以 iOS 卡" | 错误。libmalloc 88ns × 10万次/帧 = 8.8ms < 16.6ms 帧预算。流畅性一阶项是"App 不被杀 + 渲染独立 + 无 GC",不是分配器速度。 | §4.1-4.2 |
| "iOS 内核压缩器用 LZ4" | 需修正。vm.compressor_mode=32 是通用压缩器路径,vm.lz4_compressions=0 且 vm.wk_compressions=0——mode 32 不走传统 per-algorithm 计数器。底层仍含 WKdm。 | §5.3d: sysctl 计数器 |
| "scudo 的 quarantine 默认开启" | 错误。scudo standalone 上游默认 quarantine_size_kb=0(OFF)。BypassQuarantine=true。所有 scudo 基准数据都是 quarantine-OFF。 | §1.4: flags.inc:17, combined.h:1135 |
| "MADV_FREE_REUSABLE = MADV_DONTNEED" | 部分错误。无压力时两者 reactivation 延迟相近(~1950ns vs ~1900ns)。但语义不同:REUSABLE 让内核"优先回收而非杀",DONTNEED 是"我不要了"。REUSABLE reactivation 比 fresh minor fault 快 31%(省了清零 16K)。 | §3.2D: 实测 |
| "大页(16K)对 malloc 没影响" | 需细化。16K 页不影响 fast path(88ns regardless),但影响 page fault 次数(256MB/16K = 16384 fault vs 4K 页 = 65536 fault → 4× 减少)。大块 malloc(1MB) 延迟 ≈ 1 次 page fault。 | §5, §1.4b |
| "iOS 默认用 nano malloc" | 错误。nano_probe 直接打印 default_zone="DefaultMallocZone"(非 NanoMallocZone)。MallocNanoZone=1 反而更慢(101.6 vs 88.4ns)。 | §1.1: 三重互证 |
| 术语 | 含义 | 本文出处 |
|---|---|---|
| minor fault | 首次访问匿名 mmap 页触发的内核缺页——分配物理页 + 清零 + 建 PTE(不涉及磁盘 I/O) | §5 |
| COW fault | Copy-on-Write 缺页——fork 后写共享页触发,内核复制物理页到新页 | §5.3a |
| guard page fault | 写 PROT_NONE 页触发 SIGSEGV——测量信号传递延迟 | §5.3b |
| reactivation fault | 访问被内核压缩器回收的页——解压 WKdm 恢复页面内容 | §5.3c |
| major fault | 访问已换出到加密 swap 文件的页——flash I/O + 解密 | §5.3c |
| jetsam | XNU 的内存压力杀进程机制——按优先级 band 从低到高杀,保前台 | §3.2, §4.4 |
| compressor (mode 32) | XNU 内核内存压缩器——压缩冷页到 RAM 中的 compressor pool,延迟远低于 swap | §5.3d |
| freezer | XNU 后台进程冻结机制——把进程页写到 compressor/swap 但不杀进程,可解冻恢复 | §5.3d |
| WKdm | Wilson-Kaplan-Diwan-Williams 字典压缩算法——XNU 默认内核压缩器,固定复杂度可预测 | §6 |
| per-CPU 杂志 (magazine) | libmalloc 的缓存模型——每个 CPU 有独立 magazine cache,无需锁但需 spinlock 切换 | §2.1 |
| per-thread tcache | jemalloc/mimalloc 的缓存模型——每个线程有独立 thread cache,完全无锁 | §2.1 |
| quarantine | scudo 的 UAF 检测机制——free 后的块放入延迟队列而非立即回收 | §1.4 |
| MADV_FREE_REUSABLE | libmalloc 的回收提示——告诉内核"这些页可回收",触发 jetsam 协同链 | §3.2 |
| phys_footprint | iOS 的进程内存计量指标——RSS - 共享页 + 压缩页,是 jetsam 判断杀进程的依据 | §7 |
| memcg / zswapd | Linux 的内存 cgroup + 压缩 swap 守护进程——鸿蒙 NEXT 的分层回收机制 | §6 |