背景

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,正说明锁竞争才是多核瓶颈。