1. 为什么内核需要同步
Linux内核是多任务、多核、抢占式的复杂系统。当多个执行路径(进程上下文、中断上下文、软中断、Tasklet)并发访问共享数据时,不加保护就会导致数据竞争(Data Race)、内存不一致、甚至系统崩溃。本文将从实际问题出发,系统梳理Linux内核同步机制的全貌。
2. 原子操作:同步的基石
原子操作是内核同步最底层、最基本的原语。它们保证对单个变量的"读-修改-写"操作不可被打断。
2.1 原子类型与API
atomic_t counter = ATOMIC_INIT(0);
atomic_inc(&counter); // 原子加1
atomic_dec(&counter); // 原子减1
atomic_add(5, &counter); // 原子加5
atomic_sub(3, &counter); // 原子减3
int v = atomic_read(&counter); // 原子读
atomic_set(&counter, 10); // 原子写
// 条件操作
atomic_inc_not_zero(&counter); // 非零则加1,返回旧值
atomic_cmpxchg(&counter, old, new); // 比较并交换
atomic_add_unless(&counter, a, u); // 除非值=u,否则+=a
2.2 原子位操作
原子位操作常用于标志位管理,比全量加锁更轻量:
unsigned long flags;
set_bit(3, &flags); // 位置1
clear_bit(3, &flags); // 位清0
change_bit(3, &flags); // 位翻转
test_bit(3, &flags); // 测试位值
test_and_set_bit(3, &flags); // 测试旧值并置1
test_and_clear_bit(3, &flags); // 测试旧值并清0
实战技巧:用原子位实现简单的状态机锁,替代互斥锁来管理设备状态标志。
3. 自旋锁(Spinlock)
自旋锁是最基本的内核锁机制。当一个CPU获取锁失败时,它会"自旋"等待(忙等),而不是让出CPU。因此自旋锁只能在不能睡眠的上下文中使用。
3.1 基本用法
spinlock_t my_lock;
spin_lock_init(&my_lock); // 初始化
spin_lock(&my_lock); // 加锁
// 临界区:操作共享数据
spin_unlock(&my_lock); // 解锁
3.2 自旋锁的变体
// 保存本地中断状态并禁用中断(防止中断处理程序抢锁)
unsigned long flags;
spin_lock_irqsave(&my_lock, flags);
// 临界区
spin_unlock_irqrestore(&my_lock, flags);
// 简单禁用中断版本(不保存状态)
spin_lock_irq(&my_lock);
spin_unlock_irq(&my_lock);
// 禁用软中断(防止软中断/Tasklet抢锁)
spin_lock_bh(&my_lock);
spin_unlock_bh(&my_lock);
3.3 自旋锁的工作原理
在 x86 上,spinlock 底层使用 LOCK 前缀指令 + XCHG 或 CMPXCHG 实现原子交换。在 ARM64 上,使用 LDXR/STXR 独占加载/存储指令配合全局监视器(Global Monitor)。
内核配置 CONFIG_DEBUG_SPINLOCK:开启后会检测多次解锁、未初始化锁使用、中断中获取非irq锁等Bug。
3.4 何时使用自旋锁
- 临界区极短(几条指令),等待时间远大于上下文切换开销
- 中断处理程序中与其他上下文共享数据
- 下半部(软中断/Tasklet)之间共享数据
- 多核之间共享的短时操作(链表操作、计数器增减)
4. 互斥锁(Mutex)
互斥锁是睡眠锁的一种。获取互斥锁失败时进入睡眠等待,适合临界区较长或需要睡眠的场景。
4.1 基本用法
struct mutex my_mutex;
mutex_init(&my_mutex); // 初始化
mutex_lock(&my_mutex); // 加锁(无法获取时睡眠)
// 临界区:可以睡眠、可以阻塞
mutex_unlock(&my_mutex); // 解锁
// 尝试加锁(非阻塞)
if (mutex_trylock(&my_mutex)) {
// 获取成功
mutex_unlock(&my_mutex);
}
// 检查锁是否可用(不等待)
mutex_is_locked(&my_mutex);
4.2 Mutex vs Spinlock 选择
| 场景 | 自旋锁 | Mutex |
|---|---|---|
| 临界区很短(几条指令) | ✅ | ❌ |
| 临界区需要睡眠/阻塞 | ❌ | ✅ |
| 中断上下文 | ✅(irqsafe变体) | ❌ |
| 进程上下文长临界区 | ❌ | ✅ |
| 可能需要优先级继承 | ❌ | ✅(RT-mutex) |
| 软中断上下文 | ✅(_bh变体) | ❌ |
4.3 Mutex内部实现
Linux Mutex 基于 rt_mutex(RT抢占锁)实现,内部维护一个等待队列。当锁被持有时,新进入的加锁任务自旋一小会尝试获取;若仍失败,则加入等待队列并调度出去。优势在于"乐观自旋"机制 — 对于短临界区持锁者很快会释放,新任务自旋比直接调度更省开销。
5. 读写锁(rwlock_t & rw_semaphore)
5.1 读写信号量
struct rw_semaphore rwsem;
init_rwsem(&rwsem);
down_read(&rwsem); // 读锁(允许多读并发)
up_read(&rwsem);
down_write(&rwsem); // 写锁(独占)
up_write(&rwsem);
down_read_trylock(&rwsem); // 非阻塞读锁
down_write_trylock(&rwsem); // 非阻塞写锁
Linux rw_semaphore 在 5.x 内核后引入了"写入者优先"、"乐观自旋"等优化。写者等待期间,新读者会被阻塞,防止写饥饿。
5.2 读写自旋锁
rwlock_t my_rwlock;
rwlock_init(&my_rwlock);
unsigned long flags;
read_lock_irqsave(&my_rwlock, flags);
// 读取临界区
read_unlock_irqrestore(&my_rwlock, flags);
write_lock_irqsave(&my_rwlock, flags);
// 写入临界区
write_unlock_irqrestore(&my_rwlock, flags);
关键限制: 读写自旋锁在持锁期间不能睡眠,适用场景为读多写少且临界区在上下文中不能睡眠的情况。
6. RCU(Read-Copy-Update)— 无锁读取之王
RCU 是 Linux 内核最独特和强大的同步机制。神奇之处在于读取者完全不加任何锁,可并发读取。
6.1 RCU 三原则
Read-Copy-Update名称揭示了RCU的完整范式:
- Read(读):读侧使用 rcu_read_lock/rcu_read_unlock 标记临界区,内核不会在临界区内调度
- Copy(复制):写者不在原地修改,而是创建一份副本修改
- Update(更新):写者用修改后的副本通过 rcu_assign_pointer 原子替换旧指针
6.2 读取侧API
// 基本读取
rcu_read_lock();
struct my_node *node = rcu_dereference(head);
if (node)
do_something_with(node);
rcu_read_unlock();
// 支持睡眠的读取(SRCU)
int idx = srcu_read_lock(&my_srcu);
do_something_with(data);
srcu_read_unlock(&my_srcu, idx);
6.3 写入侧API
// 链表操作
struct my_node *new_node = kmalloc(sizeof(*new_node), GFP_KERNEL);
new_node->value = value;
INIT_LIST_HEAD(&new_node->list);
list_add_rcu(&new_node->list, &head); // 原子插入
list_del_rcu(&old_node->list); // 标记删除
// 指针替换
struct my_node *new_root = create_new_tree();
rcu_assign_pointer(global_root, new_root);
synchronize_rcu(); // 等待宽限期
kfree(old_root); // 安全释放
6.4 宽限期(Grace Period)机制
RCU 的核心魔法在于宽限期。synchronize_rcu() 会等待所有已经进入 RCU 读取临界区的 CPU 退出(经历了一次上下文切换),才允许写者释放旧数据。这是"读零开销"的代价 — 写者需要一次同步等待。
6.5 RCU 性能对比
| 机制 | 读侧延迟 | 写侧延迟 | 适用场景 |
|---|---|---|---|
| Spinlock | ~50ns | ~50ns | 短临界区读写 |
| Mutex | ~100ns(竞争时ms级) | ~100ns | 长临界区进程上下文 |
| RCU | ~5ns(几乎零) | 变长(需等待宽限期) | 读多写少,读性能极高 |
经典案例:路由表查找、文件系统 dentry 缓存、进程内存映射 VMA 遍历、网络协议核心数据结构。
7. 信号量与完成量(Semaphore & Completion)
7.1 信号量(semaphore)
struct semaphore sem;
sema_init(&sem, 1); // 计数信号量,初值1=Mutex语义
sema_init(&sem, 5); // 允许5个并发
down(&sem); // P操作(减少)
// 临界区
up(&sem); // V操作(增加)
down_trylock(&sem); // 非阻塞P
down_interruptible(&sem); // 可被信号打断
7.2 完成量(Completion)
完成量适合"任务A等待任务B完成某事"的场景:
struct completion done;
init_completion(&done);
// 线程A等待
wait_for_completion(&done);
// 线程B完成事件
complete(&done);
对比:信号量适合资源计数("还有N个"),完成量适合事件通知("某事发生了")。
8. 内存屏障(Memory Barrier)
现代CPU采用乱序执行和Store Buffer技术,指令实际执行顺序可能与代码顺序不一致。内存屏障告诉CPU/编译器不要跨越屏障重排内存操作。
8.1 内存屏障分类
barrier(); // 编译器屏障(防止编译器重排)
mb(); // 全内存屏障(读写都排后)
rmb(); // 读屏障(读都排后)
wmb(); // 写屏障(写都排后)
smp_mb(); // SMP kernel only 全屏障
smp_rmb(); // SMP kernel only 读屏障
smp_wmb(); // SMP kernel only 写屏障
8.2 实际场景示例
// DMA描述符队列:必须确保数据写入在推入指针前完成
desc->addr = dma_addr;
desc->len = size;
wmb(); // 写屏障
desc->flags |= DESC_FLAG_READY;
// 读取状态:必须在读取数据后
while (!(status->flags & FLAG_DONE))
cpu_relax();
rmb(); // 读屏障
data = status->result;
8.3 隐式屏障
多数锁API(spinlock、mutex)和原子操作都包含隐式内存屏障,不需要显式使用 mb()。只有在无锁编程、跨CPU通信、设备寄存器访问时才需要手动插入屏障。
9. Lockdep — 内核死锁检测器
CONFIG_PROVE_LOCKING 提供的 Lockdep 是内核最强大的调试工具之一。它跟踪锁之间的使用模式,构建"锁依赖图",在运行时检测出潜在的死锁、递归锁、ABA问题。
9.1 启用Lockdep
- 启用
CONFIG_PROVE_LOCKING=y - 启动参数追加
lockdep
9.2 关键检查项
// Lockdep 开启后,自动检测:
// 1. 同一锁被重复获取(非递归死锁)
// 2. 锁顺序反转(A→B 和 B→A)
// 3. 中断上下文获取非irq锁(中断嵌套死锁)
// 4. 软中断安全违规
// 5. 读锁+写锁的组合死锁
// 6. 自旋锁在可能睡眠的上下文中使用
// 标记锁的上下文(当初始化、获取)
static struct lock_class_key __key;
spin_lock_init(&lock);
mutex_init(&mutex);
9.3 Lockdep 报告解读
当 Lockdep 检测到问题时,会产生完整回溯报告,包含具体的锁顺序违规、冲突位置的栈回溯、以及上下文使用信息。报告中的 ---> 标记精准指出代码行号,几乎可以直接定位Bug。
10. 并发Bug调试实战
10.1 KASAN(Kernel Address Sanitizer)
检测内存越界、Use-After-Free、Double-Free等内存错误:
// 内核配置
CONFIG_KASAN=y
CONFIG_KASAN_INLINE=y
// 启用后,每次 slab 分配增加 redzone 区域
// 释放后填充 0xFD 魔数,读取即报错
// 堆栈回溯精确到分配/释放位置
10.2 KCSAN(Kernel Concurrency Sanitizer)
专门检测数据竞争(Data Race):
CONFIG_KCSAN=y
// 动态插桩检测对同一变量的非同步访问
// 报告包含:竞争地址、访问栈轨迹、访问类型(读/写)
10.3 Spinlock/Mutex DEBUG 配置
CONFIG_DEBUG_MUTEXES=y // 检测互斥锁使用违规
CONFIG_DEBUG_SPINLOCK=y // 检测自旋锁使用违规
CONFIG_DEBUG_ATOMIC_SLEEP=y // 检测原子上下文中睡眠
CONFIG_DEBUG_LOCK_ALLOC=y // Lockdep相关
CONFIG_LOCK_STAT=y // 统计锁等待时间
10.4 常见的并发Bug模式
| Bug类型 | 症状 | 检测工具 |
|---|---|---|
| Data Race | 随机值、不一致状态 | KCSAN, ThreadSanitizer |
| 死锁 | 系统hung、无响应 | Lockdep, SysRq |
| 活锁(Livelock) | 高CPU但无进展 | perf top, lock_stat |
| ABA问题 | 链表损坏、数据丢失 | KASAN, 指针标记 |
| Use-After-Free | 随机崩溃、内存污染 | KASAN, DEBUG_LIST |
| 顺序违规 | 偶发性不一致 | 内存屏障检查、KCSAN |
11. 性能优化:无锁编程与Per-CPU变量
11.1 Per-CPU 变量
避免多核争用的最佳方法——让每个CPU有自己的数据副本:
// 定义per-cpu变量
DEFINE_PER_CPU(int, counter); // 静态定义
int *ptr = alloc_percpu(int); // 动态分配
// 使用
int *cpu_ptr = get_cpu_ptr(&counter); // 抢占禁用并获得指针
*cpu_ptr += value;
put_cpu_ptr(&counter); // 恢复抢占
// 读取其他CPU的per-cpu值
int val = per_cpu(counter, cpu_id);
11.2 无锁数据结构
基于CAS(Compare-And-Swap)原语构建的无锁队列、栈:
// Linux内核的kfifo实现
// 2的幂大小,单生产者-单消费者无锁
struct kfifo fifo;
kfifo_alloc(&fifo, PAGE_SIZE, GFP_KERNEL);
// 生产者(无锁入队)
unsigned int len = kfifo_in(&fifo, buffer, count);
// 消费者(无锁出队)
unsigned int out = kfifo_out(&fifo, recv, count);
// 利用head/tail指针 + 2的幂取模(&运算)
// 不需要额外锁,仅靠内存屏障保证顺序
11.3 Seqlock(顺序锁)
写入者无需等待读取者,适合极少写的场景:
// 写者
write_seqlock(&seqlock);
// 修改共享数据
write_sequnlock(&seqlock);
// 读者
unsigned int seq;
do {
seq = read_seqbegin(&seqlock);
// 读取共享数据
} while (read_seqretry(&seqlock, seq));
// 若期间发生了写入,seq会变化,读者重读
适用场景:系统时间(jiffies)、进程PID映射、网络统计计数器等"偶尔修改、频繁读取"的数据。
12. 同步机制选择决策树
面对一个需要保护的共享资源,如何选择同步机制?
- 读写操作都只是简单变量? → 原子操作
- 中断上下文访问? → spin_lock_irqsave
- 内核不能睡眠 + 临界区短? → spinlock
- 进程上下文长临界区? → mutex
- 读多写少 + 读性能敏感? → RCU
- 读多写少 + 需写入者等待读取侧? → rw_semaphore
- 读多写少 + 写入者可异步等待? → Seqlock
- 事件通知(完成/等待)? → completion
- 资源计数(最多N个访问)? → semaphore
- 完全避免锁? → Per-CPU变量 / CAS无锁结构
总结
Linux内核同步机制从最简单的原子操作,到自旋锁、互斥锁、读写锁,再到精妙的RCU、per-cpu变量,构成了一个从皮秒到毫秒子时延、从硬件指令到操作系统的完整体系。
理解每种机制的前提条件(能否睡眠、单核/多核)、代价(争用延迟、上下文切换成本、缓存失效开销)和边界(中断嵌套、睡眠禁止),是写出正确又高效内核代码的关键。
最后一条黄金法则:能用更轻量的就不加重的,能无锁的就避免加锁。 — 但这一切都建立在理解之上。

发表评论 取消回复