2. 内核同步机制概览

在并发编程中,保护共享资源免受竞态条件(Race Condition)的破坏是核心挑战。Linux 内核在过去三十年的演进中,针对不同的并发场景(如中断上下文、SMP 多核、读写不对称负载),逐步发展出了一套层次分明的同步机制家族。理解这些机制的演进关系,是掌握性能调优的前提。

核心认知: 同步机制的选择本质上是在 “并行度” 与 之间做权衡。越早期的机制(如自旋锁)越简单粗暴,越现代的机制(如 RCU)越精细复杂,但适用场景也越窄。

2.1 同步机制演进全景图

下图展示了 Linux 内核同步机制从“忙等”到“无锁化”的演进脉络。每种机制都解决了前一代机制的特定痛点。

内核同步机制演进关系图 Linux 内核同步机制演进关系 自旋锁 (Spinlock) 1990s · 忙等 无法睡眠 信号量 (Semaphore) 1990s · 可睡眠 语义复杂 互斥锁 (Mutex) 2000s · 简化语义 读写不对称 读写锁 (rwlock) 2000s · 读并发 写者仍需等待 RCU (读-复制-更新) 2000s · 无锁读 演进驱动力: 1. 上下文限制:中断上下文不能睡眠 → 自旋锁 2. 等待效率:忙等浪费 CPU → 信号量/互斥锁(睡眠等待) 3. 读写不对称:读多写少场景 → 读写锁(读并发) 4. 写者延迟:读写锁写者饥饿 → RCU(无锁读,写者无等待) 5. 性能极致:RCU 读者几乎零开销,适合大规模读多写少负载

2.2 五种核心机制对比

下表从 等待行为、上下文限制、并发度、公平性、典型开销 五个维度,对五种机制进行横向对比。

机制 等待行为 可用上下文 读者并发度 写者公平性 临界区开销(典型)
自旋锁 忙等(spin) 任意(含中断) 互斥(1个) 无保证(按 CPU 调度) ~10-50 ns(无竞争)
信号量 睡眠(down) 进程上下文 可配置(计数) 无保证 ~1-10 μs(含调度)
互斥锁 睡眠 + 乐观自旋 进程上下文 互斥(1个) 有(MCS 队列) ~100 ns - 1 μs
读写锁 读:共享;写:互斥 进程/中断(rwlock_t) 多个读者 写者可能饥饿 ~50-200 ns(读路径)
RCU 读者无等待;写者延迟回收 任意(读者) 无限(无锁) 写者无阻塞 ~1-5 ns(读路径)
关键洞察: 从自旋锁到 RCU,读者路径的开销从 ~10-50 ns 降低到 ~1-5 ns,降低了 1-2 个数量级。但代价是写者路径的复杂度和延迟回收成本。性能调优的核心就是判断你的负载是“读多写少”还是“写多读少”。

2.3 自旋锁(Spinlock)—— 最基础的忙等锁

自旋锁是最简单的同步原语。当锁被持有时,等待的线程会在一个 tight loop 中不断检查锁状态,直到持有者释放。它 不睡眠,因此可以在中断上下文(Interrupt Context)中使用。

  • 适用场景: 临界区极短(通常 < 25 个指令周期),且持有锁的时间远小于一次线程切换开销。
  • 变体: spin_lock()(普通)、spin_lock_irqsave()(禁用本地中断)、spin_lock_bh()(禁用下半部)。
  • 性能陷阱: 高竞争下 CPU 空转浪费严重,且可能引发优先级反转(Priority Inversion)。
// 自旋锁典型用法(驱动中保护设备寄存器)
DEFINE_SPINLOCK(dev_lock);

void dev_write(struct device *dev, u32 val) {
    unsigned long flags;
    spin_lock_irqsave(&dev_lock, flags);
    writel(val, dev->reg);
    spin_unlock_irqrestore(&dev_lock, flags);
}

2.4 信号量(Semaphore)与互斥锁(Mutex)—— 可睡眠的锁

信号量允许进程在无法获取锁时 睡眠等待,从而避免 CPU 空转。但信号量的计数语义(count > 1 允许多个持有者)容易导致设计复杂。互斥锁是信号量的简化版:强制单一持有者,并引入了 乐观自旋(Optimistic Spinning) 优化——当锁持有者正在运行时,等待者先自旋一小段时间,避免立即睡眠带来的调度开销。

特性 信号量(struct semaphore) 互斥锁(struct mutex)
持有者计数 可 > 1(计数信号量) 固定为 1
调试支持 弱 强(owner 跟踪、持有者检查)
乐观自旋 无 有(MCS 锁机制)
递归加锁 允许(需小心) 不允许(会死锁)
内核推荐度 不推荐(遗留接口) 推荐(新代码首选)
// 互斥锁典型用法(保护复杂数据结构)
struct mutex my_mutex;

void complex_update(struct my_data *data) {
    mutex_lock(&my_mutex);
    // 临界区:可能包含内存分配、文件操作等耗时操作
    data->counter++;
    kfree(data->old_buf);
    data->old_buf = kmalloc(1024, GFP_KERNEL);
    mutex_unlock(&my_mutex);
}

2.5 读写锁(rwlock / rwsem)—— 区分读者与写者

读写锁是同步机制演进的重要里程碑。它 允许多个读者同时进入临界区,但写者必须独占。这完美匹配了“读多写少”的典型负载(如路由表查找、配置读取)。

  • rwlock_t(自旋版本): 读者自旋等待写者释放,适合短临界区;可在中断上下文使用。
  • struct rw_semaphore(睡眠版本): 读者/写者均可睡眠,适合长临界区;只能在进程上下文使用。
  • 主要问题: 写者可能被持续的读者流 饿死(Writer Starvation)。在高频读负载下,写者可能永远无法获得锁。
// 读写锁(自旋版本)典型用法
rwlock_t my_rwlock;

void read_path(void) {
    read_lock(&my_rwlock);
    // 多个读者可以同时执行这里
    pr_info("current count: %d\n", shared_counter);
    read_unlock(&my_rwlock);
}

void write_path(void) {
    write_lock(&my_rwlock);
    // 写者独占,等待所有读者释放
    shared_counter++;
    write_unlock(&my_rwlock);
}

2.6 RCU(Read-Copy-Update)—— 无锁读的终极方案

RCU 是 Linux 内核中最先进的同步机制,由 Paul E. McKenney 等人发明。它的核心思想是:读者完全不需要加锁,写者通过“复制-更新-延迟回收”的方式安全修改数据。

重要限制: RCU 并非万能。它要求被保护的数据结构主要通过指针访问(如链表、树),且写者必须容忍旧数据在一段“宽限期(Grace Period)”内仍然可被读者访问。RCU 不适合保护复杂的数据修改(如同时更新多个指针)。

RCU 工作流程(三步走):

  1. 复制(Copy): 写者分配新内存,将旧数据复制到新内存中。
  2. 更新(Update): 写者修改新内存中的数据,然后使用 rcu_assign_pointer() 原子地将全局指针指向新数据。
  3. 延迟回收(Deferred Reclaim): 写者调用 synchronize_rcu() 或 call_rcu(),等待所有正在访问旧数据的读者退出临界区后,再释放旧内存。
RCU 更新流程示意图 RCU 写者更新流程(指针替换 + 延迟回收) 阶段 1:旧数据 全局指针 → 旧节点 A 读者正在访问 A 写者开始 阶段 2:复制 分配新节点 B 复制 A → B rcu_assign_pointer 阶段 3:更新 全局指针 → B 新读者看到 B 延迟回收(Deferred Reclaim) • 写者调用 synchronize_rcu() 或 call_rcu() • 等待所有仍在访问旧节点 A 的读者退出临界区 • 确认无读者后,安全释放旧节点 A 的内存
// RCU 典型用法(保护链表查找与更新)
struct my_elem {
    int key;
    struct rcu_head rcu;  // 用于延迟回收
    /* ... 其他数据 ... */
};

// 读者路径:完全无锁
void reader_lookup(int key) {
    rcu_read_lock();
    struct my_elem *p = rcu_dereference(global_list_head);
    while (p) {
        if (p->key == key) {
            // 使用 p 的数据
            break;
        }
        p = rcu_dereference(p->next);
    }
    rcu_read_unlock();
}

// 写者路径:复制-更新-延迟回收
void writer_update(int old_key, int new_val) {
    struct my_elem *old, *new;
    
    // 1. 查找旧节点
    old = find_elem(old_key);
    
    // 2. 复制
    new = kmalloc(sizeof(*new), GFP_KERNEL);
    *new = *old;
    new->value = new_val;
    
    // 3. 更新指针(原子操作)
    rcu_assign_pointer(old->next, new->next);
    // 假设我们要替换整个链表头
    rcu_assign_pointer(global_list_head, new);
    
    // 4. 延迟回收旧节点
    synchronize_rcu();  // 阻塞等待所有读者退出
    kfree(old);
}

2.7 演进关系总结:如何选择?

在实际的内核开发中,选择同步机制可以遵循以下决策树:

  1. 临界区是否能在中断上下文执行?
    • 是 → 只能用 自旋锁 或 rwlock_t(不可睡眠)。
    • 否 → 进入下一步。
  2. 临界区是否极短(< 25 个指令)且竞争低?
    • 是 → 自旋锁 足够。
    • 否 → 进入下一步。
  3. 负载是“读多写少”还是“写多读少”?
    • 读多写少(读 > 90%) → 优先考虑 RCU(性能最优),其次 读写锁。
    • 写多读少 → 互斥锁 通常比读写锁更公平、性能更稳定。
  4. 是否需要保护复杂的数据结构(如红黑树、哈希表)?
    • 是 → 互斥锁 或 读写锁(RCU 实现复杂,需谨慎)。
    • 否(仅保护简单指针/链表) → RCU 是最佳选择。
性能调优黄金法则: 不要猜测,要测量。在决定使用哪种同步机制前,先用 perf lock 或 tracepoint 分析当前锁的竞争程度(contention)。如果锁竞争 < 5%,优化锁类型带来的收益微乎其微;如果竞争 > 30%,切换到更高效的机制(如从互斥锁切换到 RCU)可能带来 2-10 倍的性能提升。