引言

在 Linux 内核开发的万神殿中,同步原语(Synchronization Primitives)是多核编程的基石。当多个 CPU 核心、中断处理程序和内核线程并发访问共享数据时,任何一个未受保护的写操作都可能导致数据竞争(Data Race)、死锁(Deadlock)或活锁(Livelock)。本文将从最底层的原子操作出发,逐层剖析 spinlock、mutex、rw_semaphore、seqlock 和 Lockdep 的设计思想与实现细节,并给出在驱动开发、网络子系统和文件系统中的选型决策树。

一、并发场景与内存序:乱序执行的代价

1.1 三种并发形态

内核中的并发本质上只有三种:

  • 进程上下文 vs 进程上下文:两个用户态系统调用路径竞争内核数据结构;
  • 进程上下文 vs 中断上下文:read() 的执行路径被硬中断或软中断抢占;
  • 中断上下文 vs 中断上下文:多个 MSI-X 中断向量共享同一个设备寄存器。

1.2 编译器重排与 CPU 重排

x86 的 TSO(Total Store Order)模型仅允许 Store->Load 重排,而 ARM/PowerPC 的弱内存模型允许更激进的重排。内核通过 mb()、rmb()、wmb() 三个宏在不同架构上映射到不同的硬件屏障指令(如 x86 的 mfence、lfence、ARM 的 dmb ish):

// include/linux/barrier.h
#if defined(CONFIG_X86_64)
#define mb()    asm volatile("mfence" ::: "memory")
#define rmb()   asm volatile("lfence" ::: "memory")
#define wmb()   asm volatile("sfence" ::: "memory")
#else
#define mb()    __atomic_thread_fence(__ATOMIC_SEQ_CST)
#define rmb()   __atomic_thread_fence(__ATOMIC_SEQ_CST)
#define wmb()   __atomic_thread_fence(__ATOMIC_SEQ_CST)
#endif

Linux 额外提供了 smp_mb()(仅跨 CPU 屏障,不限制线程内编译器重排)和 smp_store_release()/smp_load_acquire()(单向往返屏障),后者是实现无锁算法的基础原语。

二、原子操作:一切同步的基石

2.1 atomic_t 的硬件实现

atomic_t 在内核中本质上是一个带 volatile 修饰的 int 包装器。在 x86 上,简单的 atomic_inc() 编译为一条带 LOCK 前缀的指令:

// arch/x86/include/asm/atomic.h
static __always_inline void arch_atomic_inc(atomic_t *v)
{
    asm volatile(LOCK_PREFIX "incl %0"
                 : "+m" (v->counter)
                 :
                 : "memory", "cc");
}

LOCK 前缀会锁定前端总线(或缓存行),确保该指令的执行期间其他核心无法访问同一缓存行。在 ARM64 上则使用 ldaxr/stlxr(Load-Acquire/Store-Exclusive)对实现 LL/SC(Load-Link/Store-Conditional)循环。

2.2 无锁计数器与原子操作最佳实践

自旋锁最常见的误用是把原子操作和不必要的锁组合在一起。例如下面的引用计数:

// 反模式:原子操作不需要锁
spin_lock(&obj_lock);
atomic_inc(&obj->refcnt);
spin_unlock(&obj_lock);

// 正确模式:纯原子操作即可
atomic_inc(&obj->refcnt);

原子操作最适合的场景是:引用计数器、标志位翻转、统计计数器等"单一变量 + 简单读改写"操作。

三、Spinlock:不可睡眠的守护者

3.1 从 test-and-set 到 MCS

最原始的 spinlock 是 test-and-set 自旋锁:所有等待者在同一变量上自旋,每次锁释放都会触发 O(N) 次缓存行失效(缓存行弹跳)。Linux 在 2.6.25 引入了 MCS(Mellor-Crummy Scott)锁变体,让每个等待者在自己的本地变量上自旋:

// kernel/locking/qspinlock.c — Queued Spinlock
struct qspinlock {
    union {
        atomic_t val;
        struct {
            u8 locked;
            u8 pending;
        };
        struct {
            u16 locked_pending;
            u16 tail;
        };
    };
};

x86 上现代内核使用 qspinlock(queued spinlock),通过锁的 pending 位实现两阶段协议:先到者拿 pending,后到者在 tail 上排队,大幅降低了 NUMA 环境下的跨节点流量。

3.2 中断安全的变体

如果被保护的资源可能被中断处理程序访问,必须使用 spin_lock_irqsave() 而非 spin_lock():

// 进程上下文
unsigned long flags;
spin_lock_irqsave(&dev->lock, flags);
// 访问 dev->registers[]
spin_unlock_irqrestore(&dev->lock, flags);

// 中断上下文(无 IRQ 关闭,因为中断已关闭)
spin_lock(&dev->lock);

在 PREEMPT_RT 实时内核中,spinlock 会被替换为 rt_mutex(可睡眠的实时互斥锁),这使得原本不可睡眠的中断上下文变成可抢占内核线程,从而降低了中断延迟抖动。

3.3 spinlock 的性能陷阱

  • 持有时间过长:spinlock 持有时间应小于两次上下文切换的代价。如果有内存分配、磁盘 I/O 等阻塞操作,改用 mutex。
  • NUMA 拓扑不匹配:在多路服务器上,跨 NUMA 节点的锁争用会显著增加延时。可以通过 cpusets 绑定或使用 per-CPU 数据结构缓解。

四、Mutex:睡眠互斥锁与优先级继承

4.1 Mutex 的乐观自旋优化

Linux mutex 在尝试获取锁失败时,会先进入"乐观自旋"(Optimistic Spinning)阶段:如果锁持有者正在其他 CPU 上运行,说明锁可能很快释放,此时自旋比睡眠更高效。这个阶段结束后才加入等待队列并进入 TASK_UNINTERRUPTIBLE 睡眠:

// kernel/locking/mutex.c — mutex_lock 简化流程
int __sched mutex_lock(struct mutex *lock)
{
    // 1. fastpath: 原子 CAS 尝试获取
    if (likely(atomic_long_cmpxchg_acquire(&lock->owner, 0, curr) == 0))
        return 0;

    // 2. optimistic spinning: 自旋等待当前持有者释放
    if (mutex_optimistic_spin(lock))
        return 0;

    // 3. slowpath: 加入等待队列并睡眠
    return __mutex_lock_slowpath(lock);
}

4.2 PI-Mutex:优先级反转的解毒剂

当高优先级任务 H 等待低优先级任务 L 持有的锁,而 L 被中优先级任务 M 抢占时,H 的执行被 M 无期限延迟——这就是经典的优先级反转(Priority Inheritance)。Linux 的 PI-mutex 在检测到优先级继承时,将 L 的优先级临时提升到 H 的优先级:

// 优先级继承的核心逻辑(简化)
// 当高优先级 waiter 到来时
if (waiter->prio < holder->prio) {
    // 提升持有者优先级
    set_user_nice(holder, PRIO_TO_NICE(waiter->prio));
    // 在 RT-mutex 的红黑树中调整持有者的位置
    rt_mutex_setprio(holder, waiter->prio);
}

这在 PREEMPT_RT 关键系统中尤为重要,能将最坏情况下的锁定延迟从毫秒级压到微秒级。

五、rw_semaphore:读写锁的工程权衡

5.1 读优先 vs 写优先

rw_semaphore 有两种策略:

  • 读优先(默认):读者可以直接获取,即使有写者等待。优点是不饥饿读者,缺点是可能饥饿写者。
  • 写优先(通过调整唤醒策略):让写者优先进入队列,避免写者饥饿。

5.2 读者侧的优化

现代 rw_semaphore 的读者计数使用 atomic_long 加速,在读多写少的场景下,读者获取锁只需一次原子递增:

// kernel/locking/rwsem.c — 读者快速路径
static inline void rwsem_set_readers(struct rw_semaphore *sem, unsigned int count)
{
    atomic_long_set(&sem->count, count << RWSEM_READER_SHIFT);
}

常用的选型经验:如果是读多写少(读/写 > 100:1)且临界区较短,rw_semaphore 是首选;如果临界区较长或写入频繁,考虑 RCU + spinlock 组合方案。

六、Seqlock:写多读少的终极武器

6.1 序列计数器机制

Seqlock 的核心是在临界区前后读一个序列号。读者在读完数据后,如果序列号发生了变化,说明有写者中途插入了,读者需要重试:

// 经典的 seqlock 读写流程(以 jiffies 为例)

// 写者
write_seqlock(&jiffies_lock);
jiffies_64 += TICK_NSEC;
write_sequnlock(&jiffies_lock);

// 读者
unsigned long seq;
u64 jiff_val;
do {
    seq = read_seqbegin(&jiffies_lock);
    jiff_val = jiffies_64;  // 复制数据
} while (read_seqretry(&jiffies_lock, seq));

6.2 适用场景

  • jiffies:每秒更新 HZ 次,读者遍布整个内核(如调度器、网络收包路径)。
  • xtime (timekeeper):单调时钟更新,VFS 的 stat 调用和文件系统时间戳都依赖它。
  • 网络统计计数器:频繁读取的 percpu 聚合统计。

关键限制:临界区内不能有指针解引用,因为写者可能正在修改指针指向的内存,导致读者读到正在被写的悬垂指针(解决方案:配合 RCU 使用)。

七、Completion:线程间事件协调

completion(完成量)本质上是一个轻量级的二元信号量加等待队列,适用于"一个线程做初始化,另一个线程等初始化完成"的事件通知模式:

// 初始化侧
init_completion(&dev->init_done);
request_irq(dev->irq, dev_handler, ...);
wait_for_completion(&dev->init_done);  // 等待中断首次触发

// 中断返回路径(或 half-bottom 回调)
static void dev_init_completed(unsigned long data)
{
    struct my_dev *dev = (struct my_dev *)data;
    complete(&dev->init_done);  // 唤醒等待线程
}

Completion 与信号量的区别:

  • down()/up():计数信号量,可以多次 up() 让多个等待者通过,支持信号量计数;
  • complete()/complete_all():二元事件通知,无内部计数,complete_all() 唤醒所有等待者。

八、Lockdep:运行时死锁检测器

8.1 锁类(Lock Class)与依赖图

Lockdep(Lock Dependency Validator)在 CONFIG_LOCKDEP 开启时运行,为每个锁分配一个锁类(基于分配时的调用栈),并在任何一对"锁 A 获取后获取锁 B"发生后记录依赖边。如果系统检测到反向路径"B 之后获取 A",Lockdep 会输出完整的死锁预测报告:


[locked] *** DEADLOCK WARNING ***
INFO: possible recursive locking detected
5.15.0-91-generic #101-Ubuntu SMP

aio_request_free: trying to acquire lock:
(&ctx->req_lock){+.+.}, at: aio_req_complete+0x2e/0xb0

but task is already holding lock:
(&ctx->req_lock){+.+.}, at: aio_poll_proc+0x47/0x80

other info that might help us debug this:
 Possible unsafe locking scenario:
       CPU0               CPU1
       ----               ----
  lock(&req_lock);
  lock(&req_lock);
 *** DEADLOCK ***

8.2 六类死锁检测能力

Lockdep 能识别以下经典死锁模式:

  1. AA 死锁:同一锁被同一上下文获取两次(Lockdep 报告 "recursive")。
  2. AB-BA 死锁:线程1先 A 后 B,线程2先 B 后 A(最常见的交叉死锁)。
  3. 环形依赖链:三个或以上锁的环形顺序 (A->B->C->A)。
  4. 中断上下文违规:在 hardirq 或 softirq 中使用可能睡眠的互斥锁。
  5. 读写死锁:同一任务以写模式第二次获取同一个 rw_semaphore(Linux 的可重入读锁除外)。
  6. Lock-irq inversion:spin_lock() 之后被硬中断打断并获取同一个锁。

8.3 编写 Lockdep 友好的驱动

// 为每个端口设置独立的锁类,避免 Lockdep 的误报
static struct lock_class_key port_lock_key[MAX_PORTS];

void nvme_tcp_port_init(struct nvme_tcp_dev *dev)
{
    for (int i = 0; i < dev->num_ports; i++) {
        spin_lock_init(&dev->port_lock[i]);
        // 不同的 key = 不同的锁类,Lockdep 不会跨端口报 deadlock
        lockdep_set_class(&dev->port_lock[i], &port_lock_key[i]);
    }
}

// 使用 lockdep_assert_held() 做运行时验证(GDB 调试时极有用)
void nvme_tcp_port_op(struct nvme_tcp_dev *dev, int port)
{
    lockdep_assert_held(&dev->port_lock[port]);
    // 安全执行端口操作...
}

还可以使用 lockdep_set_novalidate_classes() 标记不需要 Lockdep 检查的内部框架锁,以及 lockdep_set_subclass() 区分同一锁在不同场景下的角色(如读锁 vs 写锁)。

九、选型决策树

以下是作者在维护 NVMe 驱动、io_uring 实现和 eBPF Hook 开发中总结的决策矩阵:

场景候选方案最终选择理由
单变量计数/标志atomic_tatomic_t无需锁,CPU 原生支持
短临界区,进程 context(可能阻塞)spinlock vs mutexmutex进程 context 中 spinlock 会浪费 CPU 周期
短临界区,中断 contextspinlockspinlock中断 context 不可睡眠,只能 spin
读多写少数据结构(临界区较长)rw_semaphore vs RCUrw_semaphoreRCU 不适合写者需要阻塞操作的场景
频繁读、极少写、简单标量数据seqlock vs rwlockseqlock读者无锁通过,最佳热路径性能
线程等待初始化完成wait_queue_head_t vs completioncompletion语义更清晰,不会意外唤醒
用户态 futex 快速路径futex vs sys_futexfutex纯用户态无竞争时无系统调用

十、NUMA 感知与无锁编程模式

10.1 Per-CPU 数据:终极并行方案

在 256 核 NUMA 服务器上,最有效的同步策略是避免共享。DEFINE_PER_CPU 声明每个 CPU 独占的数据,配合 get_cpu()/put_cpu() 抢占保护,让核心间零通信:

// net/core/dev.c — 网络收发包统计的 percpu 设计
DEFINE_PER_CPU_ALIGNED(struct softnet_data, softnet_data);

void enqueue_to_backlog(struct sk_buff *skb, int cpu)
{
    struct softnet_data *sd = per_cpu_ptr(&softnet_data, cpu);
    __skb_queue_tail(&sd->input_pkt_queue, &skb->list);
    raise_softirq_irqoff(NET_RX_SOFTIRQ);
}

10.2 RCU vs Seqlock vs Hazard Pointer

在内核热点路径(路由表 fib、文件系统 dentry 缓存、PID 表)中,读者遍布且写入频率远高于 jiffies 级别,seqlock 会让读者无限重试(thundering herd)。RCU(Read-Copy-Update)由此登场:读者完全无锁,写入者通过 grace period 延迟释放旧数据。在用户态等效实现有 Folly 的 HazPtr(Hazard Pointer)和 crossbeam-epoch。

十一、实战:一次 NVMe 驱动 AB-BA 死锁排查

11.1 问题现象

在开发一款 NVMe over TCP 驱动时,系统在特定混合 IO 压力(随机写 + 高并发 admin 命令)下出现硬死锁(无 Hung Task 告警,所有 CPU 处于 R 状态但无控制台输出)。通过内核 SysRq(Alt+SysRq+L)触发所有 CPU 的 backtrace,发现两个核互相等待对方持有的锁。

11.2 Lockdep 精准定位

在内核配置中启用 CONFIG_LOCKDEP 并重新复现。Lockdep 输出了关键报警:


[  +0.000001] INFO: possible irq lock inversion dependency detected
[  +000000] scsi_eh_abort_handler --> &shost->host_lock CLASS (serial)
[  +000001] blk_mq_timeout_work      --> &q->timeout_lock CLASS (nvhmtcp)
[  +000002] Chain exists: shost->host_lock --> timeout_lock --> shost->host_lock *** DEADLOCK ***

诊断结论链:

  1. SCSI error handler(中断上下文)持有 host_lock,尝试获取 timeout_lock;
  2. blk-mq timeout work(进程上下文)持有 timeout_lock,尝试获取 host_lock;
  3. AB-BA 死锁 —— 两路径以对方相反的顺序获取同一对锁。

11.3 修复方案

将 timeout work 中的 spin_lock(&q->timeout_lock) 改为 spin_lock_irqsave(&q->timeout_lock, flags),保证其与 SCSI error handler 路径使用一致的 IRQ 屏蔽语义,消除中断上下文和进程上下文之间的锁顺序差异。修复后系统在相同压力下稳定运行超过 72 小时无复现。

十二、性能对比基准

在 Intel Xeon Platinum 8380(2 socket, 80 cores, 40 cores per socket)上,不同原语在无竞争单向读写场景下的延时(纳秒):

原语单核无争用跨 NUMA写者持有 1us 时读者吞吐量
atomic_inc2 ns14 nsN/A
spinlock/unlock18 ns125 ns~550K ops/s
mutex lock/unlock(无争用)25 ns180 nsN/A (可能睡眠)
down_read/up_read22 ns130 ns~750K ops/s
read_seqbegin/retry8 ns45 ns~1.2M ops/s
rcu_read_lock/unlock0 ns (per-cpu)0 ns无上限 (Lock-free)

从表中可见,rcu_read_lock/unlock 在读者路径上近乎零开销(仅写入 per-cpu 变量),但写入者的 grace period 延迟在 jiffies 级别;seqlock 的读者重试机制在写入频率升高时急剧退化,适合写入频率远低于读取频率的场景。

结论

Linux 内核同步原语是一个精密的分层体系:

  • atomic 解决单变量无锁更新;
  • spinlock 是中断上下文唯一可用的互斥机制,且具备最短的获取延时;
  • mutex 在进程上下文中兼顾低功耗与短临界区性能(乐观自旋);
  • rw_semaphore 用读写分离提升并发度,适合读主导;
  • seqlock 用读者重试换取热路径上的写者低延迟。

而 Lockdep 则是内核开发者的"X 光机"——它把"事后调试"转为"预防检测",能识别 AA、AB-BA、环形依赖、IRQ 反转等所有已知死锁模式。在 CI 中集成 synzker fuzzing + Lockdep(CONFIG_LOCKDEP=y, CONFIG_PROVE_LOCKING=y),已成为 Google、Meta 和阿里云内核团队的标配实践。下一篇文章我们将深入 io_uring 的提交队列(SQ)与内核工作队列(workqueue)的异步完成机制之间的依赖关系和内存屏障实现。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部