背景
把一个 folio 插入 page cache 时,__filemap_add_folio() 持有 mapping->i_pages.xa_lock 并关掉中断,在临界区内完成 XArray 插入和 mapping->nrpages 更新。这两件事必须受锁保护,否则并发插入会互相破坏。
但临界区内还夹着第三类工作:lruvec 统计。每个普通 folio 更新一次 NR_FILE_PAGES,PMD-mappable 的 folio 再追加一次 NR_FILE_THPS。CONFIG_MEMCG 下每次统计更新都要级联写 per-cpu 的 memcg 与 lruvec 计数器,并通知 cgroup rstat。统计本身不碰 XArray,根本不需要 xa_lock,却实打实地拉长着这段关中断临界区。
┌───────────────────────────────────────────┐
│ XArray 插入与 nrpages 更新需要 xa_lock │
│ 保护 │
└─────────────────────┬─────────────────────┘
▼
┌───────────────────────────────────────────┐
│ lruvec 统计也挤在同一临界区,CONFIG_MEMCG │
│ 下级联更新 per-cpu 计数并通知 rstat │
└─────────────────────┬─────────────────────┘
▼
┌───────────────────────────────────────────┐
│ 关中断临界区被拉长,page cache 插入 │
│ 路径锁竞争加剧 │
└───────────────────────────────────────────┘
问题
- lruvec 统计挤在 xa_lock 关中断临界区内,本不需要锁保护
- 普通 folio 一次、PMD-mappable folio 两次统计更新拉长临界区
- CONFIG_MEMCG 下级联写 per-cpu 计数并通知 rstat,开销更重
- 临界区拉长直接加剧 page cache 插入路径的锁竞争
方案
思路直接:统计不需要锁,就把它挪到锁外。XArray 插入与 nrpages 更新照常在 xa_lock 关中断临界区内完成,xas_unlock_irq() 解锁开中断、确认插入真正成功之后,再做 lruvec 统计。
┌──────────────────────────────────────────┐
│ 持 xa_lock 关中断,完成 XArray 插入 │
│ 与 nrpages 更新 │
└────────────────────┬─────────────────────┘
▼
┌──────────────────────────────────────────┐
│ 解锁开中断,确认插入真正成功 │
└────────────────────┬─────────────────────┘
▼
┌──────────────────────────────────────────┐
│ 锁外更新 lruvec 统计,folio 引用与 folio │
│ lock 保证安全 │
└──────────────────────────────────────────┘
挪出去之后统计为何仍然安全,靠两重保证。其一,插入成功后 page cache 的引用已经钉住这个 folio,它不会被释放。其二,调用方仍持有 folio lock,folio->mapping 保持稳定,截断与回收路径拿不到锁就无法把它移出 page cache,统计要读的 mapping 归属因此不会在读取途中变化。
收益
作者在生产主机上做了 30 秒系统级 perf lock contention 采样,归因到 __filemap_add_folio() 的最热调用栈记录:
| 指标 | 数值 |
|---|---|
| 竞争次数 | 20,867 |
| 总等待时间 | 557.930 ms |
| 采样内全部锁等待 | 3.998 s |
| 占总锁等待比例 | 14% |
补丁把该路径每个 folio 一到两次 lruvec 统计更新移出关中断临界区,直接削减这部分持锁等待;普通 folio 省一次 NR_FILE_PAGES 更新的锁内时间,PMD-mappable folio 再省一次 NR_FILE_THPS 更新。