4. 页表锁的起源:为什么需要页表锁、单核时代的简单锁、多核引入的竞争条件
页表锁(Page Table Lock)是操作系统内核中用于保护页表结构并发访问的同步原语。理解页表锁的起源,需要从单核时代的简单锁机制出发,逐步分析多核处理器引入的竞争条件与性能瓶颈。本章将系统阐述页表锁诞生的背景、单核时代的锁实现,以及多核环境下竞争条件的本质。
4.1 为什么需要页表锁
页表是虚拟内存系统的核心数据结构,负责将虚拟地址映射到物理地址。在多任务操作系统中,多个进程或线程可能同时访问页表,执行以下操作:
- 页表遍历:MMU(内存管理单元)在地址翻译时读取页表项。
- 页表修改:内核在分配/释放内存、处理缺页异常、执行进程切换时更新页表项。
- TLB 刷新:修改页表后,需要使对应 TLB 条目失效。
如果不对页表访问进行同步,就会产生数据竞争(Data Race),导致页表项损坏、地址映射错误、系统崩溃等严重后果。页表锁的核心作用就是确保对页表的读-改-写操作是原子的,避免并发访问导致的不一致状态。
核心问题:页表是共享的可变数据结构,任何并发修改都需要互斥保护。没有锁,多核环境下的页表操作将不可预测。
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 前 |
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(核间中断),开销巨大。
4.4 从单核到多核的锁演进
页表锁的演进反映了操作系统对多核并发支持的深化过程:
| 阶段 | 锁机制 | 并发度 | 主要问题 |
|---|---|---|---|
| 单核早期 | 关中断 | 无并发 | 不支持多核 |
| 单核后期 | 全局自旋锁 | 极低 | 多核下锁争用严重 |
| 多核初期 | 每进程页表锁 | 中等 | 进程间共享页表时仍竞争 |
| 现代多核 | 读写锁 + 细粒度锁 | 高 | 实现复杂,需处理死锁 |
关键洞察:页表锁的设计本质是在并发安全与性能之间做权衡。单核时代追求简单正确,多核时代追求高吞吐与低延迟。理解竞争条件的根源,是设计高效页表锁的第一步。