背景
zsmalloc 是 zram 等压缩交换后端使用的对象分配器,按对象大小分到不同的大小类(size class)里管理。它的对象释放接口在内存压力下的取消映射路径上极为频繁。Android 上 LMK 成批回收换出到 zram 的页,x86 上 zswap 重负载下同样如此。每次释放要依次取 2 把锁。先是读侧的池锁,只为查到对象所属的大小类;随后是类锁,并持锁穿过整个 zspage 的释放。
┌────────────────────────────┐
│ 先取读侧池锁,只为查大小类 │ 读计数所在的缓存行在并发释放之间反复抖动
└─────────────┬──────────────┘
▼
┌────────────────────────────┐ 触碰 zone 锁,其等待扩散到同类的其他释放
│ 再取类锁,持锁穿过整页释放 │
└────────────────────────────┘
由此带来 2 笔代价。其一,池锁是读写锁,每个并发释放都取读侧,读计数所在的缓存行就在这些调用者之间反复抖动。其二,类锁持锁期间会走到整页释放,把页归还伙伴分配器时要取 zone 锁;一旦 zone 锁因内存压力而排队,类锁便跟着卡住,进而阻塞所有正在释放同一大小类的其他 CPU。
问题
- 释放路径取读侧池锁仅为查大小类,读计数缓存行在并发释放间抖动
- 类锁持锁穿过整页释放,zone 锁的排队经类锁扩散到同大小类的其他释放
- Android LMK 回收与 x86 zswap 重负载下,对象释放主导取消映射路径
- 多核并发时这 2 处锁竞争主导延迟
方案
2 处锁竞争相互独立,分别出在释放路径取的 2 把锁上。
整套修复围绕 2 条原则展开:无锁定位大小类,以及把整页释放移出类锁。
无锁定位大小类
池锁本只为查大小类,却让读计数缓存行在并发释放间抖动。要避开它,就得让释放路径不查表也能直接拿到大小类。做法是把大小类的索引编码进对象值本身。zsmalloc 的每个对象都用机器字编码,把页帧号、大小类索引、对象内偏移打包在一起,释放时直接从对象值里取出索引定位大小类。大小类索引在页迁移期间恒定不变(迁移只改写页帧号),所以这种无锁读永远有效。
能这样编码,靠的是 64 位机器字在页帧号之下还有富余位,把这部分拆成大小类索引和对象偏移 2 个子字段即可。32 位系统以及个别 64 位回退配置没有富余位,索引位宽折叠为零、编码自动失效,释放路径仍走原来的池锁。编码启用后,释放路径无锁取出索引、锁住对应大小类,再在类锁下重读对象值拿到稳定的页帧号,从而省掉池锁;读写对象值改用不可拆分的读写原语,消除无锁路径上的访存撕裂与数据竞争告警。
旧:池锁加类锁,双锁贯穿释放
┌───────────────────────────┐
│ 取读侧池锁仅为查大小类 │
└─────────────┬─────────────┘
▼
┌───────────────────────────┐
│ 持类锁穿过整页释放 │
└─────────────┬─────────────┘
▼
┌───────────────────────────┐
│ zone 锁等待扩散到同类释放 │
└───────────────────────────┘
新:无锁查大小类,释放移出类锁
┌────────────────────────────────┐
│ 大小类索引编码进对象 │
└───────────────┬────────────────┘
▼
┌────────────────────────────────┐
│ 无锁定位大小类,释放不再取池锁 │
└───────────────┬────────────────┘
▼
┌────────────────────────────────┐
│ 整页释放移出类锁,等待不再扩散 │
└────────────────────────────────┘
把整页释放移出类锁
类锁持锁穿过整页释放,zone 锁的排队便经它扩散,阻塞同大小类的其他释放。修法是缩小类锁的临界区。当某次释放把某个 zspage 腾空,先在类锁内把它从大小类链表摘下、更新计数,再把实际归还伙伴分配器的整页释放挪到类锁之外完成。这样类锁不再覆盖 zone 锁,排队也就不再扩散;若摘取时该 zspage 正被别处占用,则改交延迟释放兜底。
系列还为这次拆分产生的整页释放接口补了统一注释,交代各自的适用时机。
收益
基准:每个进程独立 mmap 256MB、写入数据、用 madvise(MADV_PAGEOUT) 经 zram(lzo-rle)换出,再并发 munmap。
Raspberry Pi 4B(4 核 ARM64 Cortex-A72):
| 模式 | Base | Patched | 加速 |
|---|---|---|---|
| single | 59.0 ms | 56.0 ms | 1.05x |
| multi 2p | 94.6 ms | 66.7 ms | 1.42x |
| multi 4p | 202.9 ms | 110.6 ms | 1.83x |
x86(20 核 Intel i7-12700,16 并发进程):
| 模式 | Base | Patched | 加速 |
|---|---|---|---|
| single | 11.7 ms | 9.8 ms | 1.19x |
| multi 2p | 24.1 ms | 17.2 ms | 1.40x |
| multi 4p | 63.0 ms | 45.3 ms | 1.39x |
单线程改善有限(1.05~1.19x),因为消除的是并发竞争而非单次开销;核数越多、并发越高,收益越显著,RPi4 上 4 进程并发提速 1.83x,正说明锁竞争才是多核瓶颈。