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的完整范式:

  1. Read(读):读侧使用 rcu_read_lock/rcu_read_unlock 标记临界区,内核不会在临界区内调度
  2. Copy(复制):写者不在原地修改,而是创建一份副本修改
  3. 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. 同步机制选择决策树

面对一个需要保护的共享资源,如何选择同步机制?

  1. 读写操作都只是简单变量? → 原子操作
  2. 中断上下文访问? → spin_lock_irqsave
  3. 内核不能睡眠 + 临界区短? → spinlock
  4. 进程上下文长临界区? → mutex
  5. 读多写少 + 读性能敏感? → RCU
  6. 读多写少 + 需写入者等待读取侧? → rw_semaphore
  7. 读多写少 + 写入者可异步等待? → Seqlock
  8. 事件通知(完成/等待)? → completion
  9. 资源计数(最多N个访问)? → semaphore
  10. 完全避免锁? → Per-CPU变量 / CAS无锁结构

总结

Linux内核同步机制从最简单的原子操作,到自旋锁、互斥锁、读写锁,再到精妙的RCU、per-cpu变量,构成了一个从皮秒到毫秒子时延、从硬件指令到操作系统的完整体系。

理解每种机制的前提条件(能否睡眠、单核/多核)、代价(争用延迟、上下文切换成本、缓存失效开销)和边界(中断嵌套、睡眠禁止),是写出正确又高效内核代码的关键。

最后一条黄金法则:能用更轻量的就不加重的,能无锁的就避免加锁。 — 但这一切都建立在理解之上。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部