引言
在 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 能识别以下经典死锁模式:
- AA 死锁:同一锁被同一上下文获取两次(Lockdep 报告 "recursive")。
- AB-BA 死锁:线程1先 A 后 B,线程2先 B 后 A(最常见的交叉死锁)。
- 环形依赖链:三个或以上锁的环形顺序 (A->B->C->A)。
- 中断上下文违规:在 hardirq 或 softirq 中使用可能睡眠的互斥锁。
- 读写死锁:同一任务以写模式第二次获取同一个 rw_semaphore(Linux 的可重入读锁除外)。
- 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_t | atomic_t | 无需锁,CPU 原生支持 |
| 短临界区,进程 context(可能阻塞) | spinlock vs mutex | mutex | 进程 context 中 spinlock 会浪费 CPU 周期 |
| 短临界区,中断 context | spinlock | spinlock | 中断 context 不可睡眠,只能 spin |
| 读多写少数据结构(临界区较长) | rw_semaphore vs RCU | rw_semaphore | RCU 不适合写者需要阻塞操作的场景 |
| 频繁读、极少写、简单标量数据 | seqlock vs rwlock | seqlock | 读者无锁通过,最佳热路径性能 |
| 线程等待初始化完成 | wait_queue_head_t vs completion | completion | 语义更清晰,不会意外唤醒 |
| 用户态 futex 快速路径 | futex vs sys_futex | futex | 纯用户态无竞争时无系统调用 |
十、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 ***
诊断结论链:
- SCSI error handler(中断上下文)持有 host_lock,尝试获取 timeout_lock;
- blk-mq timeout work(进程上下文)持有 timeout_lock,尝试获取 host_lock;
- 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_inc | 2 ns | 14 ns | N/A |
| spinlock/unlock | 18 ns | 125 ns | ~550K ops/s |
| mutex lock/unlock(无争用) | 25 ns | 180 ns | N/A (可能睡眠) |
| down_read/up_read | 22 ns | 130 ns | ~750K ops/s |
| read_seqbegin/retry | 8 ns | 45 ns | ~1.2M ops/s |
| rcu_read_lock/unlock | 0 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)的异步完成机制之间的依赖关系和内存屏障实现。

发表评论 取消回复