2. 内核同步机制概览
在并发编程中,保护共享资源免受竞态条件(Race Condition)的破坏是核心挑战。Linux 内核在过去三十年的演进中,针对不同的并发场景(如中断上下文、SMP 多核、读写不对称负载),逐步发展出了一套层次分明的同步机制家族。理解这些机制的演进关系,是掌握性能调优的前提。
核心认知: 同步机制的选择本质上是在 “并行度” 与 之间做权衡。越早期的机制(如自旋锁)越简单粗暴,越现代的机制(如 RCU)越精细复杂,但适用场景也越窄。
2.1 同步机制演进全景图
下图展示了 Linux 内核同步机制从“忙等”到“无锁化”的演进脉络。每种机制都解决了前一代机制的特定痛点。
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 工作流程(三步走):
- 复制(Copy): 写者分配新内存,将旧数据复制到新内存中。
- 更新(Update): 写者修改新内存中的数据,然后使用
rcu_assign_pointer()原子地将全局指针指向新数据。 - 延迟回收(Deferred Reclaim): 写者调用
synchronize_rcu()或call_rcu(),等待所有正在访问旧数据的读者退出临界区后,再释放旧内存。
// 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 演进关系总结:如何选择?
在实际的内核开发中,选择同步机制可以遵循以下决策树:
- 临界区是否能在中断上下文执行?
- 是 → 只能用 自旋锁 或 rwlock_t(不可睡眠)。
- 否 → 进入下一步。
- 临界区是否极短(< 25 个指令)且竞争低?
- 是 → 自旋锁 足够。
- 否 → 进入下一步。
- 负载是“读多写少”还是“写多读少”?
- 读多写少(读 > 90%) → 优先考虑 RCU(性能最优),其次 读写锁。
- 写多读少 → 互斥锁 通常比读写锁更公平、性能更稳定。
- 是否需要保护复杂的数据结构(如红黑树、哈希表)?
- 是 → 互斥锁 或 读写锁(RCU 实现复杂,需谨慎)。
- 否(仅保护简单指针/链表) → RCU 是最佳选择。
性能调优黄金法则: 不要猜测,要测量。在决定使用哪种同步机制前,先用
perf lock 或 tracepoint 分析当前锁的竞争程度(contention)。如果锁竞争 < 5%,优化锁类型带来的收益微乎其微;如果竞争 > 30%,切换到更高效的机制(如从互斥锁切换到 RCU)可能带来 2-10 倍的性能提升。