4. 页表锁的起源:为什么需要页表锁、单核时代的简单锁、多核引入的竞争条件

页表锁(Page Table Lock)是操作系统内核中用于保护页表结构并发访问的同步原语。理解页表锁的起源,需要从单核时代的简单锁机制出发,逐步分析多核处理器引入的竞争条件与性能瓶颈。本章将系统阐述页表锁诞生的背景、单核时代的锁实现,以及多核环境下竞争条件的本质。

4.1 为什么需要页表锁

页表是虚拟内存系统的核心数据结构,负责将虚拟地址映射到物理地址。在多任务操作系统中,多个进程或线程可能同时访问页表,执行以下操作:

  • 页表遍历:MMU(内存管理单元)在地址翻译时读取页表项。
  • 页表修改:内核在分配/释放内存、处理缺页异常、执行进程切换时更新页表项。
  • TLB 刷新:修改页表后,需要使对应 TLB 条目失效。

如果不对页表访问进行同步,就会产生数据竞争(Data Race),导致页表项损坏、地址映射错误、系统崩溃等严重后果。页表锁的核心作用就是确保对页表的读-改-写操作是原子的,避免并发访问导致的不一致状态。

核心问题:页表是共享的可变数据结构,任何并发修改都需要互斥保护。没有锁,多核环境下的页表操作将不可预测。
页表锁保护示意图 进程 A(读) 进程 B(写) 内核线程(写) 页表锁 互斥访问 页表 页表项 1 页表项 2 页表项 3 ... 页表项 N 所有对页表的访问都必须先获取页表锁,确保并发安全

4.2 单核时代的简单锁

在单核处理器时代,并发问题相对简单。由于同一时刻只有一个 CPU 核心在执行指令,页表锁的实现可以采用关中断(Disable Interrupts)或自旋锁(Spinlock)等简单机制。

4.2.1 关中断方式

最原始的保护方式是在修改页表前关闭中断,修改完成后重新开启。这种方式可以防止中断处理程序与当前代码并发访问页表。

/* 单核时代:关中断保护页表修改 */
void page_table_update(pte_t *entry, pte_t new_val) {
    unsigned long flags;
    local_irq_save(flags);   /* 关闭本地中断 */
    
    *entry = new_val;        /* 修改页表项 */
    tlb_flush();             /* 刷新 TLB */
    
    local_irq_restore(flags); /* 恢复中断状态 */
}
单核特性:关中断方式在单核上有效,因为只有当前执行流和中断处理程序可能竞争。但关中断时间过长会影响系统实时性。

4.2.2 自旋锁引入

随着操作系统支持更多并发场景(如中断嵌套、软中断),简单的关中断已不够用。自旋锁(Spinlock)成为单核时代页表保护的标准方案:

  • 实现简单:自旋锁在单核上退化为关中断或空操作。
  • 低开销:没有上下文切换,适合短临界区。
  • 局限性:在单核上,自旋锁无法解决多线程并发问题(因为只有一个核心,自旋等于死锁)。
/* 单核自旋锁实现(简化) */
typedef struct {
    int locked;
} spinlock_t;

void spin_lock(spinlock_t *lock) {
    /* 单核:只需关中断 */
    local_irq_disable();
    lock->locked = 1;
}

void spin_unlock(spinlock_t *lock) {
    lock->locked = 0;
    local_irq_enable();
}

4.2.3 单核页表锁的典型实现

特性 关中断方式 自旋锁方式
保护范围 当前 CPU 与中断 当前 CPU 与中断
实现复杂度 极低 低
临界区长度 必须极短 可稍长
多核支持 不支持 不支持(需改造)
典型应用 早期 Unix Linux 2.0 前
单核页表锁工作流程 单核 CPU 一次只执行一个线程 时间线 线程 A 运行 持有锁 线程 B 等待 锁释放 线程 B 继续 单核下,线程 B 必须等待线程 A 释放锁后才能访问页表

4.3 多核引入的竞争条件

多核处理器的普及彻底改变了页表锁的设计范式。当多个 CPU 核心同时运行并访问同一页表时,单核时代的简单锁机制暴露出严重的竞争条件(Race Condition)和性能问题。

4.3.1 多核竞争的本质

多核环境下,页表竞争条件表现为以下几种典型场景:

竞争类型 描述 后果
读-写竞争 一个核心读页表项,另一个核心同时写同一项 读到不一致的映射,导致进程崩溃
写-写竞争 两个核心同时修改同一页表项 页表项损坏,内存映射丢失
TLB 同步竞争 一个核心修改页表,另一个核心使用旧 TLB 缓存 访问错误物理地址,数据损坏
页表结构竞争 并发操作页表层级结构(如分配/释放页表页) 内存泄漏或双重释放

4.3.2 经典竞争条件示例

考虑以下多核并发场景:两个核心同时处理缺页异常,需要分配物理页并更新页表项。

/* 多核竞争条件示例:并发缺页处理 */
/* 核心 0 执行 */
void handle_page_fault_core0(vaddr_t addr) {
    pte_t *pte = get_pte(addr);
    if (pte_is_present(*pte)) {
        /* 页已存在,直接使用 */
        return;
    }
    /* 分配物理页 */
    phys_addr_t page = alloc_page();
    set_pte(pte, page | PTE_PRESENT);  /* 写页表项 */
    tlb_flush(addr);
}

/* 核心 1 同时执行 */
void handle_page_fault_core1(vaddr_t addr) {
    pte_t *pte = get_pte(addr);
    if (pte_is_present(*pte)) {
        return;
    }
    phys_addr_t page = alloc_page();
    set_pte(pte, page | PTE_PRESENT);  /* 写同一页表项 */
    tlb_flush(addr);
}
竞争后果:上述代码中,两个核心可能同时检查到页表项不存在,各自分配物理页,然后先后写入同一页表项。最终只有一个物理页被映射,另一个物理页泄漏。更严重的是,TLB 刷新可能不同步,导致后续访问使用错误映射。

4.3.3 多核锁的挑战

多核环境下,页表锁设计面临以下核心挑战:

  • 锁粒度:全局锁(单锁)保护整个页表,并发度低;细粒度锁(每进程锁、每页表层级锁)增加复杂度。
  • 锁争用:多个核心频繁竞争同一锁,导致缓存行 bouncing 和性能下降。
  • 死锁与活锁:多锁场景下,锁获取顺序不当可能导致死锁。
  • TLB 同步:修改页表后,需要跨核发送 TLB 刷新的 IPI(核间中断),开销巨大。
多核页表竞争条件示意图 核心 0 读页表项 → 不存在 分配物理页 A 核心 1 读页表项 → 不存在 分配物理页 B 页表 页表项 X:初始为空 核心 0 写入 → 物理页 A 核心 1 写入 → 物理页 B(覆盖) 结果:物理页 A 泄漏 页表项指向物理页 B 两个核心同时检查页表项为空,各自分配物理页,但最终只有一个映射生效

4.4 从单核到多核的锁演进

页表锁的演进反映了操作系统对多核并发支持的深化过程:

阶段 锁机制 并发度 主要问题
单核早期 关中断 无并发 不支持多核
单核后期 全局自旋锁 极低 多核下锁争用严重
多核初期 每进程页表锁 中等 进程间共享页表时仍竞争
现代多核 读写锁 + 细粒度锁 高 实现复杂,需处理死锁
关键洞察:页表锁的设计本质是在并发安全与性能之间做权衡。单核时代追求简单正确,多核时代追求高吞吐与低延迟。理解竞争条件的根源,是设计高效页表锁的第一步。