背景

per-VMA lock 问世已有数年:page fault 等路径在 RCU 保护下对单条 VMA 加读锁(lock_vma_under_rcu()),避开全局 mmap_lock 的争抢,使用已相当广泛。但它有个刚性限制:只有架构显式 select ARCH_SUPPORTS_PER_VMA_LOCK 且开启 SMP 与 MMU 时才可用。这个 Kconfig 选项名不副实,它并不表示架构能否支持 per-VMA lock,更像是「架构是否可能受益」的标记;per-VMA lock 依赖的原语 RCU、maple tree、refcount 在没有 SMP/MMU 的配置下同样能工作。

配置受限带来连锁反应:通用代码不能依赖 per-VMA lock,想用它的调用点(proc maps、binder、TCP zerocopy 等)都得自备一条 mmap_lock fallback 路径,同一段查找逻辑维护两份实现。

传统的 mmap_lock 惯用法本身也有硬伤。常见写法是 mmap_read_lock() 后 vma_lookup(),操作期间一直持有读锁;若操作触发 fault(例如 copy_from_user 缺页),fault handler 要再次获取 mmap_read_lock(),此时只要有另一线程在等待 mmap_write_lock(),新读锁就被排在 writer 后面阻塞,而 writer 又在等本线程已持有的读锁,形成死锁环。

本系列的直接动机来自 x86 shadow stack:其代码要在通用路径上规避 mmap_lock 递归加锁问题,per-VMA lock 是理想工具,但配置受限使其在通用代码里不可用。

查找 VMA 的两条路各有短板
                        ┌─────────────────────────────────────┐
                   ┌───▶│ 传统路径持 mmap_lock 期间缺页可死锁 │
┌──────────────┐   │    └─────────────────────────────────────┘
│ 查找并锁 VMA ├───┤
└──────────────┘   │    ┌─────────────────────────────────────┐
                   └───▶│ per-VMA lock 会失败须自备 fallback  │
                        └─────────────────────────────────────┘

问题

  • per-VMA lock 仅 select 架构且依赖 SMP 与 MMU,通用代码无法依赖
  • lock_vma_under_rcu() 会失败,调用点须常备 mmap_lock fallback
  • 持 mmap_read_lock 期间触发 fault,与等待中的 writer 形成死锁环
  • 同一查找逻辑两份实现并存,proc、binder、TCP 各自重复

方案

先拆掉配置门槛让 per-VMA lock 无条件可用,再在其上提供一个会等待 writer 的新查找 helper,把 fallback 从各个调用点收进 helper 内部。

第一步是删除 ARCH_SUPPORTS_PER_VMA_LOCK 与 CONFIG_PER_VMA_LOCK,vm_area_struct 的 vm_lock_seq、vm_refcnt 与 mm_struct 的 vma_writer_wait、mm_lock_seq 等锁字段转为无条件编译;全树由 #ifdef 包出的双份实现(proc task_mmu、userfaultfd 等)只保留 per-VMA 版本。唯一代价是 !MMU/!SMP 构建的 VMA 与 mm_struct 多出这几个字段。NOMMU 配置做保守处理:vma_mark_attached() 为空操作使 VMA 永远处于 detached 状态,per-VMA 读锁永远失败并自动落回 mmap_lock;stack_map_lock_vma() 在 !MMU 下继续持有 mmap_lock,bpf_iter_task_vma_new() 在 !MMU 下直接返回 -EOPNOTSUPP。

第二步是核心:新增 helper vma_start_read_unlocked(),RCU 查找的「等 writer」版本。

struct vm_area_struct *vma_start_read_unlocked(struct mm_struct *mm,
                           unsigned long address)
{
    struct vm_area_struct *vma;

    vma = lock_vma_under_rcu(mm, address);
    if (vma)
        return vma;

    mmap_read_lock(mm);
    vma = vma_lookup(mm, address);
    if (vma && !vma_start_read_locked(vma))
        vma = NULL;
    mmap_read_unlock(mm);

    return vma;
}
vma_start_read_unlocked 内部流程
┌───────────────────────────────────────┐
│          按地址查找并锁 VMA           │
└───────────────────┬───────────────────┘
                    ▼
┌───────────────────────────────────────┐
│    fast path 走 lock_vma_under_rcu    │  成功即返回,完全不碰 mmap_lock
└───────────────────┬───────────────────┘
                    ▼
┌───────────────────────────────────────┐
│ 失败则临时持 mmap_read_lock 等 writer │  锁上 VMA 立即放 mmap_lock
└───────────────────┬───────────────────┘
                    ▼
┌───────────────────────────────────────┐
│       返回持 per-VMA 读锁的 VMA       │  调用方全程不持有 mmap_lock
└───────────────────────────────────────┘

三点性质使它区别于两种旧写法:

  • 与 writer 碰撞不需要调用方准备 fallback,helper 内部会等 writer 离开,调用方拿到的要么是持锁 VMA 要么是 NULL
  • 它从不持有 mmap_lock 返回,slow path 里 mmap_read_lock() 只用于等 writer 离开并稳定锁住 VMA,锁上即放;调用方操作 VMA 期间再触发 fault,fault 即便去拿 mmap_read_lock() 也只是排在 writer 之后等待,不构成死锁环,因此它能用于会发生缺页的上下文
  • fast path 完全避开 mmap_lock

常见的「mmap_read_lock() 加 vma_lookup() 再 mmap_read_unlock()」惯用法,于是可以整体换成 vma_start_read_unlocked() 加 vma_end_read()。

在此之上清理三处调用点。binder shrinker 的回收路径不能等待,原本在 lock_vma_under_rcu() 失败后再试 mmap_read_trylock();其实 lock_vma_under_rcu() 本质已是 trylock,其失败主因正是 writer 持有 mmap_write_lock,而那也是 mmap_read_trylock() 必败的场景,fallback 只是徒劳重试,故整条删掉、只靠 per-VMA lock;拿不到锁时再经 binder_alloc_is_mapped() 确认映射状态,VMA 仍在则直接放弃,已被解除映射则无须持锁继续释放页。

shrinker 的 mmap_lock fallback 为何徒劳
┌─────────────────────────────────────┐
│       lock_vma_under_rcu 失败       │
└──────────────────┬──────────────────┘
                   ▼
┌─────────────────────────────────────┐
│ 主因是 writer 正持 mmap_write_lock  │
└──────────────────┬──────────────────┘
                   ▼
┌─────────────────────────────────────┐
│   此时 mmap_read_trylock 同样失败   │
└──────────────────┬──────────────────┘
                   ▼
┌─────────────────────────────────────┐
│ shrinker 不能等待 fallback 只是徒劳 │
│ 重试                                │
└─────────────────────────────────────┘

binder page insert(C 与 Rust 两种实现,Rust 侧同步提供 helper 绑定)与 TCP zerocopy receive 则换用新 helper:mmap_lock fallback 路径整体删除,find_tcp_vma() 连「当前持的是哪把锁」的 mmap_locked 状态回传也一并取消。

收益

作者未提供性能数据,从代码逻辑推断的预期收益:

  • 新 helper 的 fast path 完全避开 mmap_lock,降低其锁竞争
  • 调用方操作 VMA 期间可安全触发 fault,消除与 writer 的死锁环
  • per-VMA lock 无条件可用,通用代码免去 #ifdef 双份实现
  • binder 与 TCP 删除 fallback 路径