← 返回书架首页

分配器对比与跨平台内存管理分析

源码级实测:libmalloc vs jemalloc vs mimalloc vs scudo · jetsam 协同链 · 为什么 iOS 慢却流畅 · 鸿蒙 NEXT 统一渲染+memcg/zswapd · iOS 的 memcg 对等物
设备:iPhone 12 Pro(A14 / iOS 14.8 / Darwin 20.6.0,越狱)· 16K 页 · 源码:XNU xnu-7195.141.2、libmalloc-521.120.7、jemalloc 5.3.0、mimalloc 2.1.7、OpenHarmony kernel_linux_5.10 v6.1

目录

  1. 概述与方法论
  2. 分配器实测对比(libmalloc / jemalloc / mimalloc / scudo)
    1. libmalloc vs scudo 安全性源码级对比
    2. 为什么 iOS 不用 scudo 替代 libmalloc
  3. 源码级对比:libmalloc vs jemalloc
  4. libmalloc 设计思想与 jetsam 协同链
  5. 为什么 libmalloc 慢但 iOS 流畅 (含 jetsam 杀进程级联实测)
  6. Page Fault 延迟实测
  7. 内核 WKdm 多线程解压 + 用户态算法横向对比
  8. 鸿蒙 NEXT 对比:统一渲染 + memcg/zswapd
  9. iOS 的 memcg 对等物:per-task ledger + phys_footprint
  10. 背景:Darwin 与 XNU 内核是什么
  11. 结论与方法论

📊 全文核心数据一览

指标libmallocjemallocmimallocscudoWKdm(内核)LZ4
快路径延迟88 ns★21 ns28 ns60 ns
跨线程争用106 ns★17 ns28 ns30 ns
retained 内存★17 MB81 MB194 MB206 MB
大块延迟(1MB)2193 ns★423 ns407 ns8298 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(信号投递链)
压缩吞吐(TYPICAL)1594 MiB/s805 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

0. 概述与方法论

本文是一组围绕"移动端内存管理"的源码级分析,全部基于在真实越狱 iPhone 上的实测 + 官方开源仓库逐行核对。覆盖原始需求的两部分:① pagefault 延迟实测(§5)② 内存压缩/解压性能(§6)+ 分配器对比(§1)。核心方法:

测试环境与方法细节

验证方法
设备iPhone 12 Pro (iPhone13,3) A14 Bionic, 6GB RAMsysctl hw.model=D53pAP hw.physicalcpu=6
OSiOS 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 -O2xcrun -sdk iphoneos clang
分配器隔离每后端独立 fork() 子进程避免后端间 RSS 污染
RSS 测量mach_task_basic_info.resident_sizeMach 调用,非 /proc
默认 zone 验证MallocNanoZone=0 = 默认(89.5 vs 88.4ns)排除 nano zone
scudo 稳定性4 轮独立运行,CV < 2.2%中位数取值
quarantine 验证quarantine_size_kb=0 vs 默认 = 相同证实上游默认 OFF
Key Takeaways(一句话总结)
  1. 分配器速度是流畅性的二阶项——libmalloc 慢 4-6× 但 iOS 仍流畅,因为 App 不被杀(jetsam 协同)比分配速度更重要。
  2. jemalloc0(decay=0)综合最优(21ns + 6MB retained),scudo 安全但慢(60ns + 206MB),libmalloc 保命最优(17MB retained + jetsam 协同)。
  3. scudo 的 quarantine 默认关闭(实测验证)——上游 standalone quarantine_size_kb=0,Android 通过构建配置启用。
  4. Page fault ~3000ns, COW ~5000ns, Guard ~3570ns——三种 fault 全测:minor(零填充) < guard(信号投递) < COW(页面复制)。一个 fault = ~143 次 jemalloc malloc。iOS 避免 major fault(磁盘 swap)是一切内存策略的根基。
  5. 内核选 WKdm 而非 LZ4 因为"可预测"——LZ4 解压快 8× 但 WKdm 压缩快 2× 且延迟可预测,6 核并发放大弥补单核劣势。
  6. iOS 不用 scudo 因为 jetsam 协同链——MADV_FREE_REUSABLE + pressure_relief 是 scudo 架构里没有的通道。
  7. 鸿蒙 NEXT 已结构性追平 iOS(统一渲染 + memcg/zswapd),剩余差距在 ArkTS GC 而非分配器。
诚实边界:本文对 XNU / libmalloc / jemalloc / mimalloc / OpenHarmony 内核的判断是源码级核对过的;对鸿蒙 ArkUI 运行时、ArkTS GC 的细节属公开架构 + 推理(无 ArkTS 运行时源码逐行核对),均已标出不确定处。鸿蒙用户态杀进程调度器未在仓库列表精确定位(可能在 foundation 大仓内)。

1. 分配器实测对比(libmalloc / jemalloc / mimalloc / scudo)

1.1 测试方法与配置依据

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 出货默认
mimalloc0purge_delay=0 + mi_collect(true)与 jemalloc0 对称的调参实验
scudo standaloneLLVM 16.x 上游默认(Darwin 移植)Android 11+ 默认分配器;无上游 darwin.cpp自写 Darwin 平台层os_unfair_lock 替 futex、arc4random 替 getrandom、mach_task_basic_info 替 /proc);DarwinConfigPrimaryRegionSizeLog=24,因 iOS 拒绝 180GB 虚拟预留);sc_ 前缀

1.2 实测结果(hold_20k:live≈170MB)

后端peakRSSretained(释放后)32B 速度速度代价
libmalloc168 MB17 MB88 ns
jemalloc(默认)178 MB81 MB21 ns
jemalloc0(decay=0)178 MB6 MB(最低)21 ns零损失
mimalloc194 MB194 MB28 ns
mimalloc0194 MB194 MB(不变)28 ns大块 churn 暴跌 100-1900×
scudo209 MB206 MB60 ns大块 pair_1mb 8298 ns(3.8× 慢于 libmalloc)
关键发现 1:decay 调参是 jemalloc 的免费午餐。jemalloc0 让 retained 81→6MB(甚至低于 libmalloc 的 17MB),速度零损失(pair_32b 21→21ns,threaded 5.1→5.1ns,realloc/large 不变)。→ 调 decay 后 jemalloc 综合最优。
关键发现 2:mimalloc 调参却是坏调参(对称实验)。mimalloc0 的 purge_delay=0 + mi_collect(true)
  1. retained 仍 194MB(194000→193984,几乎不动)——源码 mimalloc/src/prim/unix/prim.c:397 显示 decommit 走 MADV_DONTNEED,但 mi_collect 只回收 abandoned/retired 段,不释放当前工作集段(保留以复用)。
  2. 大块 churn 暴跌:purge_delay=0 使每次 free 同步触发 purge,大块(1MB) 每次释放即 madvise+重提交 → pair_1mb 410→43359 ns/op(106×)、large_1mb 368→708969 ns/op(1927×)
mimalloc 无"省内存又不损速"的好旋钮,与 jemalloc0 恰反。

快路径延迟对比(pair_32b,越低越快)

libmallocjemallocjemalloc0mimallocmimalloc0scudo

内存驻留对比(hold_20k retained,越低越好)

libmallocjemallocjemalloc0mimallocmimalloc0scudo

跨线程争用对比(shared_churn_6t,越低越快)

libmallocjemallocmimallocscudo

1.3 shared_churn_6t:验证假设·证伪

原假设:跨线程对象流动(A 分配的块 B 释放)会触发同核争用+跨核 cache 弹跳,libmalloc 的 per-CPU 杂志锁局部性优势应显现、缩小与 jemalloc 的 gap。workload:6 线程共享 2048 槽池,每线程 malloc(64)+atomic_exchange(slots[i],new)+free(old)

工作负载libmalloc ns/opjemalloc ns/opmimalloc ns/opscudo ns/oplibmalloc 慢倍数
pair_32b(单线程)87.820.928.160.44.2× vs jemalloc
threaded_4t(4线程争用)21.65.17.215.54.2× vs jemalloc
shared_churn_6t(跨线程流)105.816.728.430.46.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 在所有吞吐场景都赢。

1.4 scudo:安全优先的加固分配器

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。

维度libmallocjemallocmimallocscudo
pair_32b 速度88 ns21 ns28 ns60 ns
threaded_4t21.6 ns5.1 ns7.2 ns15.5 ns
shared_churn_6t105.8 ns16.7 ns28.4 ns30.4 ns
pair_1mb(大块)2193 ns423 ns407 ns8298 ns(最慢)
hold_20k retained17 MB81 MB194 MB206 MB(最高)
scudo 的定位:安全中间件。在四个分配器里 scudo 是唯一以安全为首要目标的。它比 libmalloc 快 31%(60 vs 88 ns)但比 jemalloc 慢 2.9×(60 vs 21 ns)。这个"不快不慢"的位置正是安全加固的代价。
  1. per-chunk 校验和:每个块头嵌入 16-bit CRC32(A14 用 __crc32cd 硬件指令,scudo/checksum.cpp:66)——检测堆腐败/UAF,但每 free 多一次校验。libmalloc 也有校验和(free_list_checksum_ptr),但 scudo 还多了 quarantine 和 MTE。
  2. 延迟回收队列(quarantine)⚠️ 本实测中 quarantine 实际是关闭的——scudo standalone 上游默认 quarantine_size_kb=0scudo/flags.inc:17),即 BypassQuarantine=truescudo/combined.h:1135),free 直接回收不进隔离队列。Android 通过构建配置将 quarantine_size_kb 设为非零值启用。本次实测的 60ns 延迟不含 quarantine 开销——纯粹是 CRC32 校验和 + header atomic CAS + TSD 访问。若 Android 启用 quarantine(默认 ~1MB),延迟会更高。
  3. per-thread 独占 TSDTSDRegistryExTscudo/tsd_exclusive.h:31pthread_key_create)——类似 jemalloc 的 per-thread tcache,但每次访问多一层 pthread_getspecific。在 shared_churn 中 scudo 30.4 ns vs jemalloc 16.7 ns:TSD 模型相同但 CRC32 校验和 + header atomic CAS 多了一层开销(非 quarantine 开销——quarantine 默认关闭)。
  4. 大块瓶颈:scudo pair_1mb = 8298 ns,是 jemalloc(423) 的 19.6×。根因:secondary cache 的 SecondaryCacheDefaultMaxEntrySize=512KBscudo/allocator_config.h:94),1MB 块绕过缓存直接 mmap/munmap——每次大块 free 都是一次内核 syscall。jemalloc/mimalloc 的大块缓存(death-row / large cache)避免了反复 mmap。
  5. retained 最高:206MB(vs libmalloc 17MB)。根因是 release interval = INT32_MAX(scudo/allocator_config.h:88,默认不释放)+ secondary cache 缓存大块——不是 quarantine(本次实测 quarantine 关闭)。三重保留中 quarantine 占 0,secondary cache + release interval 是主因。验证实验:SCUDO_OPTIONS=quarantine_size_kb=0 vs 默认——retained 不变(206MB),证实 quarantine 不是 retained 主因。
  6. DarwinConfig 调参:DefaultConfig 的 PrimaryRegionSizeLog=32(4GB/region × ~45 size class = 180GB 虚拟预留),iOS 内核拒绝("internal map failure NO MEMORY requesting 188743680KB")。改 PrimaryRegionSizeLog=24(16MB/region,~720MB 总预留)后通过——这是 Darwin 移植的真实坑。
实验验证(quarantine on/off 对照):设 SCUDO_OPTIONS=quarantine_size_kb=0(显式关闭 quarantine)vs 默认运行,结果完全一致(pair_32b 61.0 vs 61.0 ns,retained 206MB vs 206MB)——证实上游默认 quarantine 就是关闭的。尝试启用 quarantine(quarantine_size_kb=1024)导致 Darwin 移植崩溃(fork 子进程 SIGKILL,0 条 scudo 结果输出)——quarantine 路径有移植 bug(可能是 quarantine batch 分配 + fork 不安全),留作后续修复。→ 本文档所有 scudo 数据均为 quarantine OFF 状态下的测量值。
实验pair_32bthreaded_4tshared_churn_6tretained
默认(quarantine OFF)61.0 ns16.1 ns31.6 ns206 MB
显式 quarantine_size_kb=061.0 ns16.0 ns31.4 ns206 MB
quarantine_size_kb=1024崩溃(Darwin 移植 quarantine 路径 bug)
多轮稳定性验证(4 次运行,中位数):scudo 数据经 4 轮独立运行验证,变异系数 (CV) 极低:
工作负载4 轮中位数范围CV
pair_32b60.8 ns[60.4, 61.0]0.4%
threaded_4t16.1 ns[15.5, 16.3]2.2%
shared_churn_6t31.3 ns[30.4, 31.4]1.5%
pair_1mb8362 ns[8259, 9145]5.0%
→ 数据高度稳定,原文 60.4ns 为代表性值。pair_1mb CV 略高(5%)因 secondary cache mmap/munmap 敏感。

四分配器全工作负载对比(log 纵轴,越低越快)

libmallocjemallocmimallocscudo

分配器雷达图:多维综合评分(4 维,满分=10)

libmallocjemallocmimallocscudo

评分标准:速度=10×(1-ns/100ns),内存=10×(1-retained/206MB),安全=功能数/8×10,协同=平台集成度/3×10

为什么 Android 选 scudo 而非 jemalloc:scudo 的 quarantine+checksum+guard page 是针对 Android 生态(恶意应用、NDK native 代码不可信)的安全防线。2.9× 速度税换"堆腐败/UAF 可检测"——在 Android 安全模型里值得。iOS 不需要 scudo 因为:① App 沙箱 + 代码签名(native 代码可控);② libmalloc 自带 checksum+guard page(magazine_tiny.c:free_list_checksum_ptr);③ jetsam 协同保命比分配器安全更重要。→ iOS 选 libmalloc(安全+保命协同),Android 选 scudo(纯安全加固)——不同威胁模型决定不同选择

1.5 libmalloc vs scudo 安全性源码级对比

两者都有安全加固,但加固的对象和层次完全不同

安全维度libmalloc(iOS)scudo(Android)源码依据
校验和保护对象free list 指针(链表节点)每 chunk header(每个块头)libmalloc: magazine_inline.h:194 / scudo: chunk.h:122
校验和强度arm64e: PAC 全签名 / 非 arm64e: 4-bit16-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_HOOKSscudo/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
ASLRDISABLE_ASLRPrimaryEnableRandomOffsetmagazine_malloc.c:1707
释放后内存毒化PatternOrZeroFill 0xABscudo/common.h:203
释放栈追踪storeDeallocationStackMaybe(MTE 时)scudo/combined.h:1149
VM tag✅ Darwin 原生❌ Linux 无此机制magazine_malloc.c

libmalloc 的安全模型:保护元数据 + 信任平台沙箱

libmalloc 的加固哲学:"保护 free list 不被腐败,其余交给平台沙箱"

① free list 指针校验和——两条路径magazine_inline.h:190-253):

② 没有 UAF 检测:free → 直接链入 free list → 立即可复用。没有 quarantine、没有 State 追踪。UAF 在复用窗口内被静默吞掉。

③ 没有 double-free 检测:第二次 free 只是把指针再链入(如果 checksum 过了)。没有 State 字段说"这个块已经是 Available 了"。

scudo 的安全模型:每块全生命周期追踪

scudo 的加固哲学:"每个 chunk 从分配到释放到回收都有 header 追踪 + CRC32 校验 + quarantine 延迟"

① 每 chunk header 有 State + CRC32chunk.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:30combined.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 做类似的事。

一句话:libmalloc 的安全是"保护元数据(free list 指针)+ 信任平台沙箱";scudo 的安全是"不信任任何东西,每块全生命周期追踪"。对 free list 篡改这个具体威胁,arm64e 的 PAC 比 scudo CRC32 更强;对 UAF/double-free/overflow 这些威胁,scudo 的 quarantine+State+MTE+GWP-ASan 是 libmalloc 没有的——但 iOS 由代码签名 + 沙箱 + bmalloc 在平台层消化了这些威胁。

1.6 为什么 iOS 不用 scudo 替代 libmalloc

scudo 确实比 libmalloc 快(60 vs 88 ns),安全机制也更全。但"快+安全"不是 iOS 不换 scudo 的原因——libmalloc 有 4 件 scudo 架构层面做不到的事,其中第 1 件是 iOS 流畅性的命根

① jetsam 协同链(命根——scudo 完全没有)

这是 iOS App 后台能保命的核心。libmalloc 把"何时还内存"的决策权下放给内核,scudo 架构里没有这个通道:

libmalloc 协同机制scudo 对应物差距
MADV_FREE_REUSABLE(Darwin 专属:"我先用着但压力来时让给你")MADV_DONTNEED(Linux:"我不要了")语义完全不同——REUSABLE 让内核优先回收而非杀进程
收到 memorystatus 压力事件 → malloc_memory_event_handlerpressure_relief 主动 flush❌ 没有被动响应内核压力的机制scudo 只定时 decay,不会在 jetsam 发信号时主动还
VM_MAKE_TAG 让 jetsam 按任务定位大消耗者❌ Linux 无 VM tagjetsam 无法精确定位"哪个 App 哪类分配最耗内存"
vm_purgable_control 可清除分配❌ scudo 无此抽象WebKit 的 purgeable image 靠这个

关键反证:把 libmalloc 换成 scudo → App 后台时 scudo 不响应 jetsam 压力信号 → jetsam 看到占着 RAM 不还 → 直接杀进程。这是 iOS 不能接受的结果——流畅性的第一支柱"进程还在"就塌了。

② arm64e PAC 比 scudo CRC32 更强(对 free list 篡改这个具体威胁)

scudo 的 16-bit CRC 可碰撞;libmalloc arm64e 用 ptrauth_sign 硬件密码学签名。对 free list 篡改这一个威胁,iOS 已有比 scudo 更强的方案。scudo 多出的 quarantine/MTE/GWP-ASan 防的是 UAF/overflow——这些在 iOS 上由代码签名 + 沙箱 + WebKit bmalloc 在平台层消化了。

③ per-CPU 杂志——固定核数硬件的最优 cache 局部性

Apple 既造 SoC(A14=6 核)又出 OS,已知固定核数。per-CPU 杂志的锁 cache line 永远只被同一核碰——不跨核弹跳。scudo 的 per-thread TSD(pthread_key_create)每次访问多一层 pthread_getspecific,线程多时 tcache 膨胀。

④ 25 年深度集成

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 集成。

一句话回答:iOS 不用 scudo 不是因为 scudo 不好,而是因为 libmalloc 做的 4 件事里有 1 件(jetsam 协同)是 iOS 流畅性的命根,而 scudo 架构层面没有这个通道。你要让 scudo 做 MADV_FREE_REUSABLE + pressure_relief + VM tag + vm_purgable_control,就得把 libmalloc 的 Darwin 平台层全部移植过去——那不是"优化 scudo",那是"重写 libmalloc"。两套方案各自是各自生态的局部最优,不是同一个问题的两种解法。
"scudo 能更快"的错觉:scudo 关掉 quarantine 会更快,但关掉 quarantine 的 scudo 就不是 scudo 了——退化成"有 CRC 校验的普通分配器",不如直接用 jemalloc(21 ns,比关 quarantine 的 scudo 还快)。scudo 的安全价值全在 quarantine+MTE+GWP-ASan,这些全是性能开销——打开 = 60 ns(比 jemalloc 慢 2.9×);关掉 = 丢了安全优势。scudo 不能"既安全又快"——它的安全就是用慢换的

↑ 以上四节的实测数据揭示了一个共同模式:每个分配器都在不同维度上做权衡——速度、内存、安全、平台协同——没有"全能最优解"。下一节从源码层面解释这些数据差异的根因。

2. 源码级对比:libmalloc vs jemalloc

2.1 快路径定位:per-CPU vs per-thread(核心差异)

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 调度下低频。

2.2 快路径是否持锁:持锁 vs 无锁(~88ns vs ~21ns 根因)

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 等检查开关会改变行为路径无法隔离单一因素)。

2.3 回收策略:pressure 驱动 vs decay 定时

libmalloc:region 进 depot 后才逐页 MADV_FREE_REUSABLEmagazine_tiny.c:839pinned_to_depot 保证锁外 madvise 安全)。回收是 pressure/recirc 驱动;大块走 MADV_REUSABLE death-row cache。

jemallocdecay_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 tagmmap
回收depot 逐页 MADV_FREE_REUSABLE,pressure 驱动decay 定时,可调
安全加固校验和 + guard pages默认无

3. libmalloc 设计思想与 jetsam 协同链

3.1 设计思想

源码文件头注释(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 硬件上的特化,四个核心思想:

  1. per-CPU 杂志而非 per-thread:Apple 既造 SoC 又出 OS,已知固定核数。在固定小核数硬件上缓存局部性最优、无 false sharing,无需为任意线程数动态建/毁 tcache。
  2. RAM 是瓶颈而非 CPU:4-6GB 手机上 retained → 被 jetsam 杀 → App 死。回收与内核压力信号耦合(reactive),而非 jemalloc 定时 decay("eventually 还"在突发压力下来不及)。把"何时还内存"决策权交给内核——它知道全局压力。
  3. 安全优先:手机跑不可信 Web 内容(WebKit),堆加固比纳秒更重要 → 校验和指针 + guard page。
  4. 原生集成:用 Darwin 独有的 VM-tag / MADV_FREE_REUSABLE / vm_purgable_control——这是 mmap-only 的 jemalloc/mimalloc 拿不到的协同通道。

3.2 jetsam 协同链(双层:主动提示 + 被动释放)

A. 主动提示层(常开——让内核优先回收 libmalloc 的页,而非杀进程)

  1. VM tag:每次 mach_vm_allocate(... VM_MAKE_TAG(VM_MEMORY_MALLOC_TINY))libmalloc/vm.c:89)。jetsam 杀选启发式按 per-task VM tag 找大消耗者。
  2. MADV_FREE_REUSABLECONFIG_MADVISE_STYLE):depot 中 free span 走 MADV_FREE_REUSABLEvm.c:463,注释 :467 确认)。Darwin 语义:标记页可复用 → 内核压力下优先回收这些页,先于杀进程。libmalloc 的"已释放但缓存"页是全系统最便宜的 RAM 回收源。
  3. Large death-rowlarge_entry_cache):大块 free → LIFO 进缓存 + MADV_REUSABLE;内核可回收,下次大块 alloc 直接复用。
  4. Purgeablevm_purgable_control(VM_PURGABLE_SET_STATE)):可清除分配 → 内核单方面清空。

B. 被动释放层(收到压力信号即 flush)——完整调用链

内核 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 而无需杀进程

C. 可观测层(供 jetsam/diagnostics 安全读他任务 zone)

scalable_zone_info_task(task, memory_reader_t reader, ...)magazine_malloc.c:954):跨任务(用 memory_reader_t)安全读 malloc zone 统计。

4. 为什么 libmalloc 慢但 iOS 流畅

bench 证 libmalloc 慢 4-6×,但手里的 iPhone 依然丝滑。因为分配器快路径延迟根本不是流畅性的瓶颈——流畅性是一个系统级指标,由完全不同的因素决定。

4.1 绝对量级:不是瓶颈

libmalloc 一次 32B malloc+free = ~88ns。一个 App 每帧(16.6ms@60Hz)做 10 万次 alloc/free(已是很重的逻辑):100,000 × 88ns = 8.8ms < 16.6ms,不掉帧。jemalloc 同负载省下的 6.7ms 在 16.6ms 帧预算里省不出肉眼可感区别。

4.2 流畅性真正依赖的 5 件事,libmalloc 全做对了

  1. 内存不膨胀 → 不被 jetsam 杀(最关键):libmalloc retained 低(17MB)+ jetsam 协同,App 后台能保命、前台不重启。jemalloc 默认(81MB)同压力下更早被杀 → 切回前台冷启动 → 真正可感的卡顿。流畅的前提是"进程还在"。
  2. UI 在独立高优先级线程,不与 App 逻辑争 malloc:iOS 渲染是 Core Animation + Render Server(独立进程 backboardd),App 只提交 layer 树。App malloc 慢只影响逻辑线程,Render Server 不用 App 的 malloc → 渲染帧率不受影响。
  3. UIKit 用对象池/缓存,规避了快路径:UICollectionView/TableView 的 cell 复用、UIImage 缓存——高频 UI 对象根本不走 malloc/free 循环,一次 dequeue 是查表不是分配。
  4. ARC + autorelease pool 的释放是批量的:ARC 释放延迟批量(autoreleasepool drain 一次性 free),不是紧耦合 alloc/free pair。libmalloc 的单槽 cache 在批量复用时命中率高。bench 的 pair_32b 是最不利于 libmalloc 命中率的模式——真实 App 没这么用。
  5. A14 算力冗余 + 大核突发:Firestorm 大核 ~3GHz,88ns ≈ 260 周期,微秒级抖动被算力冗余吃掉。UI 线程最高优先级 + 大核绑定。

4.3 对照:Android 用 jemalloc 但仍卡?

Android Bionic 用 jemalloc(后换 Scudo),分配器更快。但 Android 卡顿来源是:后台进程多→RAM 竞争→LMK 频繁杀→切回冷启动;ART GC 停顿;RenderThread 与 App 共进程争用。Android 选择了快的分配器,输在了"内存/进程保命"。这反向印证:分配器快慢不是关键,内存策略协同才是。libmalloc 慢但协同好,iOS 反而更流畅。

一句话:iOS 流畅不是因为 libmalloc 快,是因为 App 不被杀(内存协同 > 分配速度)+ 渲染走独立流水线 + UI 对象池化 + 大核吸收抖动。libmalloc 用 4-6× 速度税换来"App 活着、渲染独立、对象不分配"——这才是流畅的真正支柱。bench 测的"分配速度"在流畅性方程里是二阶项

4.4 实测:jetsam 杀进程级联(mproc 压力测试)

"App 不被杀"是流畅性第一支柱——但 jetsam 真的会杀吗?本节用 mproc 压力测试实测:启动 N 个进程同时分配内存,观察 jetsam 的杀进程行为。两组实验:8 进程快杀(压缩→全杀)和16 进程渐进杀(先杀 2→再杀余)。

实验 A:8 进程×256MB = 2GB → jetsam 级联

存活进程数空闲页(×100)压缩页
时间存活进程空闲页active 页inactive 页压缩页压缩比
t=0.0s8146,94671,92469,52322,3340.577
t=1.0s889(↓ RAM 将满)134,057133,45542,169(↑ 压缩启动)0.623
t=2.0s0(全杀)178,178(↑ 释放)55,28255,10022,1970.630
实验 A 关键发现t=0→1s 空闲页从 147K 跌到 89(→0),但进程数仍 8——jetsam 先用压缩(compressor 页 22K→42K,翻倍)扛住内存压力,不杀进程。t=1→2s 当压缩也撑不住(RAM 完全满),jetsam 一次性杀掉全部 8 个进程,释放 178K 空闲页。

实验 B:16 进程×128MB = 2GB → jetsam 渐进杀

存活进程数空闲页(×100)
时间存活进程空闲页active 页压缩页
t=0.0s16145,08378,61125,489
t=0.5s1657,465(↓)144,57225,489
t=1.0s1635,300(→0)161,09425,489
t=1.5s1635,293161,06925,489
t=2.5s1635,327161,00225,489
t=3.5s14(← 首杀 2 进程)55,626(↑ 释放)130,62625,489
t=4.0s0(全杀)166,844(↑ 大释放)62,09325,489
实验 B 关键发现:16 进程时 jetsam 策略更精细——先让进程"饿着"(t=1.0→3.5s 空闲页维持在 35K 共 2.5 秒,压缩页不增长因为已达上限),然后 渐进杀(t=3.5s 先杀 2 个→t=4.0s 杀余 14 个)。对比实验 A 的"压缩→全杀",实验 B 是"等→渐进杀"——策略随压力场景动态调整。
两组实验对比
场景进程数每进程总额定策略杀进程方式
实验 A8256MB2GB压缩扛→全杀t=2s 一次性
实验 B16128MB2GB等→渐进杀t=3.5s 杀 2 + t=4s 杀余
jetsam 不是"压力来了就杀",而是"先压缩扛 / 先等,扛不住再按优先级带杀"——动态策略。这解释了为什么 iOS App 后台能保命:只要 RAM 没满到极限,jetsam 不会杀你的进程。

↑ 上面实测了 jetsam 的杀进程级联。但如果分配器快慢是二阶项,那一阶项——内存管理的底层延迟——是多少?下一节直接测量 page fault 延迟,这是内存管理的最底层操作。

5. Page Fault 延迟实测

5.1 测试方法

原始需求的第一部分:验证此设备的 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)匿名映射首次写 = 内核分配物理页 + 清零

5.2 实测结果

Page Fault 延迟:顺序 vs 随机访问(越低越快)

顺序访问随机访问
大小页数顺序 ns/fault随机 ns/faultfaults/s(顺序)
4 MB25631853039313,934
16 MB1,02429072933343,946
64 MB4,09630932965323,297
256 MB16,38430893050323,752

5.3 分析

关键发现:minor fault 延迟 ~2900-3200 ns,且几乎不随大小/模式变化。这意味着:
  1. ~3000 ns/fault 是内核 zero-fill + 物理页分配的固定开销:每次 minor fault 内核要 ①找空闲物理页(buddy allocator)②清零 16K 页 ③建立 PTE。这 ~3000ns 不随大小变化,因为每页是独立处理的。
  2. 顺序 vs 随机差异极小(3185 vs 3039,差异 <5%):minor fault 不涉及磁盘 I/O(不像 major fault),所以没有"随机→磁头寻道"的惩罚。TLB miss 在 16K 页下页表项少(256MB / 16K = 16384 项 = L2 TLB 容量边缘),差异被内核处理时间淹没。
  3. 16K 页 vs 传统 4K 页:每页清零 16K 而非 4K,但 fault 次数少 4×。净效果:每 MB 的 fault 开销 = (1/4) × 单页开销,这是大页对 mmap 场景的优势——减少 fault 次数即减少 ~3000ns 的固定开销。
  4. 与分配器速度的关系:jemalloc malloc 21ns vs minor fault 3000ns——一个 fault = ~143 次 malloc。这就是为什么分配器要 cache:避免每次分配都 mmap(每次 mmap 首写 = 3000ns/fault)。jemalloc tcache 命中 = 21ns,比 mmap+fault 快 143×。
  5. 与 jetsam 的关系:当内存压力来了,内核 MADV_FREE_REUSABLE 回收页 → App 下次访问触发 reactivation fault(比 zero-fill 略贵,因为要先检查页是否被回收)。translation_faults sysctl = 36,354,089(设备累计)——这就是真实工作负载下 fault 的规模。
后续已补测:major fault(磁盘换入)在 6GB RAM 设备上很难触发(需把内存压满到 swap),仍未测。但 COW fault 已补测(§5.3a)和 guard page fault 已补测(§5.3b)。

5.3a COW(Copy-on-Write)Fault 延迟实测

补测 COW fault:fork() 后子进程写入共享页,触发内核复制物理页。测试方法:父进程 mmap + 首写触发 minor fault → fork() → 子进程写入每页 → 计时。对比 minor fault 的 ~3000ns。

Fault 类型对比:Minor Fault vs COW Fault(16K 页,越低越快)

Minor fault(zero-fill)COW fault(copy-on-write)
大小页数Minor fault ns/faultCOW fault ns/faultCOW/minor 倍率
4 MB256281552111.85×
16 MB1,024261355922.14×
64 MB4,096286750011.74×
256 MB16,384297744341.49×
关键发现:COW fault ~5000ns,比 minor fault 慢 ~1.7×。根因:
  1. Minor fault(~3000ns)= 找空闲页 + 清零 16K + 建 PTE
  2. COW fault(~5000ns)= 检测写保护 + 复制 16K 旧页到新页 + 更新两个 PTE + 恢复写权限
  3. 多出的 ~2000ns 主要是页面复制(16K memcpy ~1-2μs at 3GHz)+ 额外的 PTE 操作。清零和复制的内存带宽开销相近,但 COW 多了"检测 + 两个 PTE 更新"。
  4. 256MB 时 COW 反而更快(4434 vs 小块 5592ns):大块时 TLB 预热后 page table walk 更快,且大块连续写入触发 prefetcher。小块每次 fault 间有 TLB miss 开销。
  5. 与 jetsam 的关系:iOS 的 MADV_FREE_REUSABLE 回收页后,App 访问触发 reactivation fault(类似 COW——需要恢复页面内容)。reactivation 延迟 ~5000ns 级别,比 minor fault 贵 ~1.7×——但远比 major fault(磁盘 swap)快 1000×。这就是 jetsam"先压缩/回收→再 kill"策略的延迟优势:reactivation 卡 ~5μs,而杀进程+冷启动卡数秒。

5.3b Guard Page Fault(PROT_NONE → SIGSEGV)延迟实测

补测 guard page fault:mmapPROT_NONE 映射一个页 → 写入触发 SIGSEGV → sigsetjmp/siglongjmp 恢复 → 计时。这测量的是信号传递延迟(内核 trap → 信号投递 → 用户态恢复),不包括磁盘 I/O。

三种 Fault 类型汇总对比(16K 页,越低越快)

ns/fault
轮数ns/guard_fault说明
1003,584少量轮次,含首次预热
1,0003,556稳定值
10,0003,570稳定值
50,0003,57450K 轮仍稳定
关键发现:guard page fault ~3570ns,介于 minor fault(~3000ns)和 COW fault(~5000ns)之间
  1. 完整路径:用户态写入 → MMU 检测权限违规 → 内核 trap(exception handler)→ 生成 SIGSEGV → 查找信号处理函数 → sigaction 调度 → 用户态信号处理函数执行 → siglongjmp 恢复
  2. 比 minor fault 贵 ~570ns(3570 vs 3000):多出的是信号投递链(trap → 找信号 handler → mask 操作 → 用户态返回),而 minor fault 只走内核 fault handler → 分配清零 → 建 PTE → 返回(无信号路径)
  3. 比 COW fault 便宜 ~1430ns(3570 vs 5000):guard page fault 不需要页面复制(16K memcpy)和双 PTE 更新——它只是 trap + 信号传递 + 恢复,没有页面操作
  4. 稳定性极高:100→50000 轮,3356→3574ns,CV < 0.5%——信号路径是固定开销,不随数据量变化
  5. 实践意义:iOS 用 guard page 实现 stack overflow detection(栈底 PROT_NONE 页)和 objc_destructivelyRealloc 的 use-after-free 检测。每次栈溢出检测的延迟代价 ~3.5μs——对调试版(MallocStackLogging)可接受,对 Release 版通过编译器静态分析替代。
三种 fault 完整画像
Fault 类型延迟触发条件内核做了什么多余开销来源
Minor fault~3000 ns首次写匿名 mmap 页分配空闲页 + 清零 16K + 建 PTE(基线)清零开销
Guard page fault~3570 ns写 PROT_NONE 页trap → SIGSEGV → 信号投递 → siglongjmp+570ns 信号投递链
COW fault~5000 nsfork() 后写共享页检测写保护 + 复制 16K + 双 PTE+2000ns 页面复制
Major fault~?访问已换出的页磁盘 I/O 读取 + 建 PTE+μs~ms 磁盘 I/O(未测)
延迟排序:minor (3μs) < guard (3.6μs) < COW (5μs) << major (磁盘,~ms 级)。iOS 避免 major fault 是一切内存策略的根基。

6. 内核 WKdm 多线程解压

6.1 源码结论:解压多 CPU 并发,压缩单线程

维度内核行为源码依据
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.2 实测(A14 / 6 核,每线程独立 dst+scratch 模拟 per-CPU)

模式压缩解压(单核)解压(6核并发)倍率
ALL_ZEROS~4500sv,无解压
MOSTLY_ZEROS~3150~52000~51000×0.97(MZV 平凡,带宽限)
TYPICAL~1600~1980~8000×4.07
RANDOM~14000不可压,无解压

WKdm 压缩/解压吞吐(MiB/s,越高越快)

压缩(encode)解压 1核解压 6核并发

TYPICAL 6 核聚合解压 ~8000 MiB/s ≈ 4× 单核——内核真实聚合解压能力。未达理想 6× 因内存带宽/缓存争用。压缩为单线程,~1600 MiB/s 即设备上限,故 bench 单线程压缩有代表性;解压单线程低估内核(已补 6 核并发测)。

互证:TYPICAL 压缩比 0.369 == 设备 info.compalgo.ratio 0.37(内核累计 sysctl)——独立两条路径给出相同比率,强证据这是设备真实压缩器。

6.3 用户态压缩算法横向对比:为什么内核选 WKdm 而非 LZ4

iOS 内核用 WKdm,Android zram 用 LZ4/LZO。哪个更好?本节实测 5 种算法在相同数据模式下的压缩/解压吞吐量,回答"内核为什么选 WKdm"。

算法来源用在特点
WKdmWilson-Kaplan (XNU 内核)iOS/macOS vm_compressor字典编码,低延迟,固定复杂度——为内存页面压缩设计
LZ4Collet (开源)Android zram / 用户态极快通用压缩,解压 ~21000 MiB/s(random)
LZFSEApplemacOS/iOS 用户态 (compression.framework)Apple 自研,介于 LZ4 和 zlib 之间,解压快
LZMA7-Zip通用压缩最高压缩比但极慢(~8 MiB/s 压缩),不适合实时
zlibGNU/RFC1950通用压缩 / HTTP gzip经典 deflate,平衡但均不突出

压缩吞吐对比(1MB block,越高越快)

LZ4LZFSELZMAzlibWKdm (内核)

解压吞吐对比(1MB block,越高越快)

LZ4LZFSELZMAzlibWKdm (内核)
算法模式压缩 MiB/s解压 MiB/s压缩比1MB 解压延迟
LZ4zero651313080.0060.78 ms
text615523480.0090.45 ms
mixed805159890.5070.067 ms
random423214481.0000.049 ms
LZFSEzero130138330.0010.075 ms
text156130150.0010.080 ms
mixed885940.5071.73 ms
random593051.0103.37 ms
LZMAzero8.22780.0003.72 ms
text7.815280.0010.68 ms
mixed3.5190.50853.9 ms
random2.342991.0000.24 ms
zlibzero12566150.0010.16 ms
text12456440.0050.19 ms
mixed352590.5063.96 ms
random19.7150091.0000.068 ms
WKdmALL_ZEROS44680.0
MOSTLY_ZEROS3145526320.0040.019 ms
TYPICAL159419780.3690.52 ms
RANDOM138251.0
关键发现:内核选 WKdm 不是因为快,而是因为"可预测"
  1. LZ4 解压比 WKdm 快 8×(LZ4 mixed 15989 vs WKdm TYPICAL 1978 MiB/s)——如果只看解压速度,LZ4 完胜。
  2. 但 LZ4 压缩 mixed 只有 805 MiB/s,WKdm 压缩 TYPICAL 有 1594 MiB/s——压缩端 WKdm 快 2×。内核是压缩(回收)→ 延迟解压(reactivation),压缩端速度更重要(回收发生在后台,解压发生在前台 → 解压延迟直接卡 UI)。
  3. WKdm 的真正优势是"固定复杂度":字典编码的每页处理时间是可预测的(~0.52ms/页),而 LZ4 对 mixed 数据的解压延迟波动大(0.067ms~3.96ms 跨 59×)。内核需要可预测的回收延迟——不能让某个页压缩花 10ms 卡住压缩线程。WKdm 的字典大小固定(~4KB),LZ4 的 hash table 随数据内容变化。
  4. LZMA 是反面教材:mixed 压缩 3.5 MiB/s(比 WKdm 慢 455×)、解压 19 MiB/s(比 LZ4 慢 841×)。虽然压缩比略好(0.508 vs 0.507),但延迟完全不可接受——53.9ms 解压一个 1MB 块,会直接卡帧。→ 实时系统不用 LZMA
  5. LZFSE 定位特殊:压缩很慢(88 MiB/s),但解压不错(594 MiB/s mixed)。Apple 用它做用户态文件压缩(如 app 瘦身),不用在内核——因为压缩太慢不适合回收路径。
  6. Android 用 LZ4 做 zram 的逻辑:zram 是用户可配置的,追求"解压极快 + 压缩够用",LZ4 解压 ~16000 MiB/s mixed 足以保证 reactivation 不卡。但 Android 的 zram 是单线程的,没有 iOS 的"6 核并发解压"——iOS 用 WKdm 的低并发放大(4×)弥补单线程劣势。
数据模式差异声明:用户态算法测的是"zero/text/mixed/random",内核 WKdm 测的是"ALL_ZEROS/MOSTLY_ZEROS/TYPICAL/RANDOM"——模式不完全对齐。但 WKdm TYPICAL (0.369) 与设备 sysctl compalgo.ratio (0.37) 互证,说明 TYPICAL 模式代表内核真实工作负载。LZ4 mixed (0.507) 压缩比更高(更好压缩),但这反映数据模式不同而非算法优劣。

↑ 内核选 WKdm 是"可预测 + 压缩快 + 6 核并发放大"的综合权衡。下一节对比鸿蒙 NEXT 如何用不同的方案(memcg + zswapd)实现同样的"reclaim→compress→kill"分层。

7. 鸿蒙 NEXT 对比:统一渲染 + memcg/zswapd

修正声明:作者最初按旧鸿蒙(App 进程内 RenderThread 栅格化 + Linux LMK 粗放杀)对比,被指正后核实 OpenHarmony 内核源码,两处结论大幅修正

7.1 统一渲染:渲染耦合从"iOS 略优"改为"持平"

旧鸿蒙鸿蒙 NEXT(统一渲染)iOS
栅格化App 进程内 RenderThreadRS 进程集中栅格化backboardd 进程
合成RS 进程RS 进程backboardd 进程
App 提交已栅格化图层渲染命令树(display list)layer 树快照

统一渲染把"栅格化"从 App 进程搬到 RS 进程——结构上向 iOS 的 backboardd 靠拢。App UI 线程卡顿不再直接卡栅格化,和 iOS"App 卡了当帧仍能渲染"同一机理。渲染耦合维度:撤销原"iOS 略优",改判持平。

7.2 内存保命:从"iOS 胜(鸿蒙靠 LMK 粗放杀)"改为"结构持平"

核实 OpenHarmony kernel v6.1 的 mm/ 目录,发现一整套 Huawei 自研、非 mainline 的内存子系统

OH 内核文件作用iOS 对应物
memcg_control.cper-app memcg 记账per-task VM tag + ledger
memcg_reclaim.cper-app 定向回收冷页(cgroup_reclaim(sc)jetsam 压力回收(libmalloc MADV_FREE_REUSABLE + flush)
zswapd.c内核线程kthread_run)做 RAM 内压缩交换到 zramvm_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 到闪存"是同一分层策略

修正:鸿蒙 NEXT 是 reclaim→compress→kill 分层回收(memcg 定向回收 + zswapd 压缩 + OOM 兜底杀),结构平行于 iOS,不是"粗放 LMK 杀进程"。内存保命维度:撤销原"iOS 胜",改判结构持平。

7.3 剩余真实差距(精细化)

维度iOS鸿蒙 NEXT判定
渲染耦合进程外 backboarddRS 集中栅格化+合成≈ 持平
内存保命jetsam+libmalloc 主动协同、vm_compressormemcg 定向回收+zswapd 压缩+zram↔UFS 分层≈ 结构持平
GC 停顿ARC 无 GCArkTS 仍有 GC(据称分代/并发)iOS 占优
范式负担保留模式+编译期+ARC声明式 diff+ArkCompiler AOTiOS 略优
算力冗余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 往接近无感调

8. iOS 的 memcg 对等物:per-task ledger + phys_footprint

iOS 没有 memcg(cgroups 子系统),但有完整功能对等物,且架构与 Linux memcg 在关键点上不同。XNU kern_memorystatus.c(5355 行)源码级核实:

8.1 iOS 的"memcg 对等物"组件

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

7.2 与 Linux memcg 的 5 点架构差异

① 记账指标:phys_footprint vs RSS+swap(iOS 更聪明)

Linux memcg 记账 RSS+swap+cache,可压缩的匿名页按原大小全额计入。iOS phys_footprint 把页拆成 internal_pages(未压缩)+ internal_compressed_pages(已压缩)分别记——压缩后 footprint 变小,App 看起来更省。iOS 的记账天然激励"压缩回收":压缩后 footprint 降、不触限;Linux memcg 除非开 zswap 记账否则激励不同。

② 分组单元:per-task vs cgroup

Linux memcg = 任意 cgroup 树,可多进程归一组共享预算、可嵌套、父子传播(灵活但 page_cgroup 每页挂钩开销大)。iOS = per-task(进程),Darwin 每任务天生有独立 vm_map,页天然归一 task,无需挂钩,再加优先级带归类。无"多进程共享预算"原生抽象,粒度粗但简单、零挂钩开销。

③ 限制:active/inactive 双限 vs 单 limit(iOS 为移动端调优)

Linux memcg:一个 memory.limit_in_bytes,超限触发回收。iOS:per-task 有 memlimit_active(前台)+ memlimit_inactive(后台)两套限(:973),加 FATAL/NONFATAL 标志(fatal=超限杀,nonfatal=只回收不杀)。前台 App 给大限、后台小限、自动切换——移动端专用调优,Linux 要靠 cgroup 切换+多 limit 模拟

④ 回收:系统级压缩 vs per-cgroup LRU(最根本架构差)

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_handlerpressure_relief → 主动 MADV_FREE_REUSABLE 自己的可回收页。iOS 把回收责任部分下放给分配器(libmalloc 主动提示),Linux memcg 模型里分配器只被动被回收——这是 iOS 一个真实但较小的精细度优势。

⑤ 杀:全局按优先级带 vs per-cgroup OOM

Linux memcg:cgroup 超 fatal 限 → 该 cgroup 内 OOM killer 杀一个。iOS:系统压力到阈值 → jetsam 按全系统优先级带从低到高杀(IDLE → AGING_BAND → ... → 前台最后),max_kill_priority(:909)控制杀到哪一带为止。iOS 杀是全局 App 优先级驱动(前台几乎不可能被杀,后台 IDLE 先死),Linux 是组内 OOM,与 App 重要性无关。

维度Linux memcgiOS(memorystatus+ledger+vm_compressor)
记账RSS+swap+cache(压缩页全计)phys_footprint(压缩页按压缩后计,分开)
分组任意 cgroup 树per-task + 优先级带
限额单 limitactive/inactive 双限 + fatal/nonfatal
回收per-cgroup 定向 LRU系统级压缩 + per-task 压力事件下放分配器
组内 OOM全局按 App 优先级带

9. 背景:Darwin 与 XNU 内核是什么

本文反复提到"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 的命令行工具(unamesysctlmach_vm_allocate 都是同一套)。本文测试设备 Darwin 20.6.0 = iOS 14.8(对应 macOS 11 Big Sur 内核版本号)。

9.1 XNU 内核 = Mach + BSD + I/O Kit

Darwin 的内核叫 XNU("X is Not Unix",递归缩写),是一个混合内核,三部分粘在一起跑在同一个内核地址空间:

组成来源职责
Mach 3.0卡内基梅隆大学微内核任务/线程/端口/消息(IPC)、虚拟内存、调度原语
BSD 层FreeBSD 衍生POSIX API(fork/exec/信号)、VFS 文件系统、BSD socket 网络、进程模型
I/O KitApple 自研C++ 设备驱动框架(显卡/USB/网络/存储驱动都基于它)

不是学术意义的微内核——Mach 没有跑在用户态,而是与 BSD 共享内核地址空间,是混合内核。

9.2 本文源码的归属

源码文件Darwin 层级本文用途
bsd/kern/kern_memorystatus.cBSD 层jetsam 优先级带 + per-task phys_footprint
osfmk/vm/vm_compressor.cMach 层WKdm 压缩器(解压 6 核并发)
osfmk/vm/vm_map.cMach 层phys_footprint 定义
libmalloc/magazine_tiny.clibSystem(用户态)per-CPU 杂志 + free list 校验和

9.3 为什么 scudo 需要"Darwin 移植"

scudo 上游只有三个平台层:linux.cpp(Linux futex、getrandom/proc/self/statm)、fuchsia.cpptrusty.cpp没有 Darwin 版——因为 Android/Fuchsia 不跑在 Darwin 上,Google 不会写。Darwin 的 API 跟 Linux 不同:

功能Linux(scudo 原用)Darwin(本项目移植)
互斥锁futex(SYS_futexos_unfair_lock
随机数getrandom syscallarc4random_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。

9.4 开源

Apple 在 github.com/apple-oss-distributions 开源了 Darwin 的内核(XNU)和核心库(libmalloc、Libc、libdispatch 等)。这就是能拉到 xnu-7195.141.2libmalloc-521.120.7 源码逐行核对的原因。但 GUI 层(AppKit/UIKit/Aqua)不开源。Darwin 是开源的;macOS/iOS 是闭源的商业产品 = Darwin + 闭源 GUI 框架。

10. 结论与方法论

核心论断分配器快路径延迟是流畅性的二阶项。真正的一阶项是:内存保命策略(reclaim→compress→kill 分层)+ 有无 GC + 渲染是否进程外 + 算力冗余
  1. iOS libmalloc 慢 4-6× 但 iOS 流畅——分配器速度是二阶项。一阶项:App 不被杀(jetsam 协同实测"先压缩扛再杀 / 先等再渐进杀"——动态策略)+ 渲染走独立流水线 + 无 GC。
  2. jemalloc0(decay=0)速度最快且内存最省 → 综合最优(速度 21ns、retained 6MB)。decay 调参是 jemalloc 的免费午餐。mimalloc 调参反而是坏调参(不降 RSS 还拖垮大块)。
  3. per-CPU 杂志的锁局部性优势真实但不足以抵消"每操作两次锁"的固定开销——shared_churn 证伪了"跨线程流让 libmalloc 追平"的假设,gap 反从 4.2× 拉到 6.3×。jemalloc per-thread 无锁 tcache 在所有吞吐场景都赢。
  4. scudo(Android 11+ 默认)是"安全优先"的中间件:比 libmalloc 快 31%(60 vs 88 ns),比 jemalloc 慢 2.9×(60 vs 21 ns)。retained 最高(206MB)。大块瓶颈(8298 ns,secondary cache max=512KB 绕过缓存)。其 quarantine+checksum+MTE+GWP-ASan 是针对 Android 威胁模型的安全防线——iOS 不需要(沙箱+代码签名+libmalloc PAC+bmalloc),Android 需要(恶意应用+不可信 NDK)。不同威胁模型决定不同分配器选择
  5. iOS 不用 scudo 替代 libmalloc——不是 scudo 不好,而是 libmalloc 有 4 件 scudo 架构层面做不到的事:jetsam 协同链MADV_FREE_REUSABLE + pressure_relief 回调 + VM tag——scudo 只有 MADV_DONTNEED + 定时 decay,不响应内核压力信号,换上去 App 会被 jetsam 杀)、arm64e PAC(对 free list 篡改比 scudo CRC32 更强)、per-CPU 杂志(固定核数最优)、25 年深度集成。让 scudo 做 Darwin 协同 = 重写 libmalloc。两套方案是各自生态的局部最优。
  6. 鸿蒙 NEXT 在"渲染耦合"和"内存保命"两个一阶维度已结构性追平 iOS(统一渲染 + memcg/zswapd 分层回收),不再是"粗放 LMK 杀进程"。剩余 iOS 真实优势收窄为:无 GC(vs ArkTS GC)+ 算力统一冗余 + 分配器级主动回收提示。鸿蒙该补的是 ArkTS GC 而非分配器。
  7. iOS 没有 memcg,但 per-task ledger+phys_footprint+memlimit(active/inactive)+jetsam 带+vm_compressor 是其功能对等物,且在"压缩页分开记账""active/inactive 双限""全局压缩+分配器主动还""按 App 优先级杀"这几点上比 Linux memcg 更为移动端调过。三者 reclaim→compress→kill 都到位,分工哲学不同
  8. Page fault 延迟 ~3000ns/fault(16K 页)——这是内核 zero-fill + 物理页分配的固定开销,不随大小/模式变化。一个 fault = ~143 次 jemalloc malloc(21ns)——这就是分配器要 cache 的根本原因:避免每次分配都 mmap。16K 页相比 4K 页减少 4× fault 次数,是大页对 mmap 场景的核心优势。
  9. 内核选 WKdm 而非 LZ4 做内存压缩,不是因为快而是因为"可预测"——LZ4 解压比 WKdm 快 8×(15989 vs 1978 MiB/s),但 WKdm 压缩端快 2×(1594 vs 805 MiB/s),且字典编码固定复杂度(~0.52ms/页 可预测,LZ4 波动 59×)。内核需要可预测的回收延迟——6 核并发解压(4× 放大)弥补单线程劣势。LZMA 是反面教材(53.9ms 解压,不可接受)。LZFSE 太慢(88 MiB/s 压缩)不适合回收路径。→ iOS 用 WKdm + 6 核并发,Android 用 LZ4 单线程——不同架构,同一目标。

方法论自省