背景

kmemleak 周期性地扫描内存,找出没有任何指针指向的疑似泄漏对象。扫描根除了 data/bss 段、per-cpu 区域和 task stack,还包括 struct page 数组:每个 online 且正在使用的页,它的 struct page 都可能保存着指向其他对象的指针,因此都要扫一遍。

扫法是逐页进行:扫描器遍历 zone 里的每个 PFN,跳过空洞、不属于本 zone 和空闲的页,把合格的页逐个交给 scan_block()。scan_block() 全程持有 kmemleak_lock 并关中断,于是每扫一个 64 字节的 struct page,就要完整地走一次取锁与放锁。

逐页扫描的取锁开销
┌──────────────────────────────────────┐
│ kmemleak 扫描根包含 struct page 数组 │
└──────────────────┬───────────────────┘
                   ▼
┌──────────────────────────────────────┐
│ 逐页调用 scan_block,每页仅 64 字节  │
└──────────────────┬───────────────────┘
                   ▼
┌──────────────────────────────────────┐
│ scan_block 全程持 kmemleak_lock 且关 │
│ 中断                                 │  每个 online PFN 都要抢一次锁
└──────────────────────────────────────┘

问题

  • struct page 逐页交给 scan_block(),每页都要抢一次 kmemleak_lock。
  • 每次取锁只扫 64 字节,取锁次数等于 online PFN 的数量。
  • 取锁的固定开销在 debug 内核上被放大,整轮扫描明显拖慢。

方案

把相邻的合格页攒成一段,整段交给 scan_block() 一次扫完,而不是每页一次。

跳过条件维持不变:PFN 空洞、不属于本 zone、不在使用的页依然不扫。改动在于,相邻的合格页先攒成一段,遇到不合格页、地址不再相邻或段长满 MAX_SCAN_SIZE 时,把攒好的段一次扫掉,再从当前页起新的一段。MAX_SCAN_SIZE 为 4 KiB,一批最多 64 个 struct page,取锁次数从页数降为段数。

相邻合格页攒段批量扫描
┌────────────────────────────────────────┐
│  遍历 zone 的 PFN,跳过空洞与不合格页  │
└───────────────────┬────────────────────┘
                    ▼
┌────────────────────────────────────────┐
│ 相邻合格页攒成一段,最多 MAX_SCAN_SIZE │  4 KiB,即 64 个 struct page
└───────────────────┬────────────────────┘
                    ▼
┌────────────────────────────────────────┐
│  段满或断开时,一次 scan_block 扫整段  │  取锁次数从页数降为段数
└────────────────────────────────────────┘

单次持锁时间的上界并没有因此变大。scan_large_block() 扫 data 段与 per-cpu 区域时,本来就按 MAX_SCAN_SIZE 分段持锁,这里只是把 struct page 扫描对齐到同一粒度;task stack 扫描更是一次交出整个 THREAD_SIZE,arm64 与 x86_64 上不低于 16 KiB,是 MAX_SCAN_SIZE 的四倍。scan_block() 逐指针检查 scan_should_stop(),kmemleak 被禁用、扫描线程被要求停止或进程有挂起信号时都会中途退出,扫长段同样可被打断。

收益

作者在 arm64 VM(24 GiB 内存、约 17 GiB 在用)上,以 Meta 的 debug 内核配置测得:

指标 Before After 改善
struct page 阶段 scan_block() 调用次数 4390k 次 69k 次 -98.4%
整轮扫描耗时 未给出绝对值 未给出绝对值 约 -20%

收益来自每次取锁的固定开销,未启用 lock debug 选项的内核上扫描时间基本不变。扫描覆盖范围不变:作者用临时断言核对过,每个 zone 每轮扫描实际交给 scan_block() 的页数与合格页数完全一致,包括扫描期间恰好有内存被释放的情形。