Linux 内核同步原语深度实战:从 Spinlock 到 RCU 的全方位性能与正确性分析
并发控制是 Linux 内核工程中最精密、最容易出错的领域之一。从单核到 128 核 NUMA 系统,从硬中断到进程上下文,内核提供了一整套丰富的同步原语——spinlock、mutex、rwmutex、rwlock、semaphore、seqlock、RCU、per-CPU 变量、内存屏障——每种原语都有其精确的语义边界和性能特征。本文从内核源码级别剖析每种同步原语的内部实现机制,给出不同场景下的选型矩阵,并通过基准测试数据揭示各原语在争用强度、上下文切换开销、NUMA 扩展性方面的真实表现,最终提供一套能直接用于生产环境的同步策略决策框架。
一、并发问题的本质与内核同步原语谱系
并发控制的根本挑战来源于三个硬件事实:CPU 乱序执行(Out-of-Order)、编译器重排(Compiler Reordering)、多核缓存一致性延迟(Cache Coherency Delay)。内核同步原语的职责就是在这三重压力下,为共享数据提供互斥访问、顺序保证和高效扩展。
Linux 内核提供的同步原语谱系可以分为四个层次:
┌─────────────────────────────────────────────────────────────────────────┐
│ Linux 内核同步原语架构分层 │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ 第一层:原子操作与无锁原语 │ │
│ │ atomic_t / atomic_long_t / per-CPU variables / seqlock │ │
│ │ (无需阻塞,适合高频读、简单计数场景) │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ 第二层:自旋与休眠互斥 │ │
│ │ spinlock_t / mutex / rw_semaphore / rwlock_t │ │
│ │ (根据临界区长度和上下文选择合适的等待策略) │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ 第三层:读写分离与版本化同步 │ │
│ │ rw_semaphore / rwlock / seqlock / RCU │ │
│ │ (最大化读并发,读者无锁或轻量锁,写者特殊处理) │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ 第四层:顺序与可见性保证 │ │
│ │ memory barriers (smp_mb/smp_rmb/smp_wmb/dmb/dsb) │ │
│ │ (确保指令和数据访问在多核间的正确顺序) │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────┘
选型决策树:
| 场景 | 推荐原语 | 关键理由 |
|---|---|---|
| 硬中断上下文 | spinlock(irqsave变体) | 不可休眠,中断必须快速返回 |
| 软中断/Tasklet | spinlock(bh变体) | 软中断不可休眠但可被硬中断抢占 |
| 进程上下文,临界区 < 2μs | spinlock | 自旋比上下文切换更便宜 |
| 进程上下文,临界区 > 2μs | mutex | 休眠等待避免CPU空转 |
| 读多写少,读临界区长 | RCU | 读者真正无锁 |
| 读多写少,读临界区短 | rwlock/rw_semaphore | 轻量级读锁 |
| 共享计数器 | per-CPU变量 + 汇总 | 每个CPU独立计数,无缓存行 bouncing |
| 单生产者单消费者队列 | seqlock | 读者可容忍重试 |
| 资源池管理 | semaphore | 允许N个并发访问 |
二、spinlock:自旋锁的内部实现与变体选择
spinlock 是最基础的内核互斥原语。它保证同一时刻只有一个执行流持有锁,其他请求者在循环中等待("自旋")。
2.1 底层原语:原子交换与测试置位
spinlock 的硬件基础是 CPU 提供的原子指令。x86 使用 LOCK 前缀的指令保证总线/缓存行的原子访问:
// x86 自旋锁底层:使用 LOCK CMPXCHG(比较并交换)
static __always_inline int atomic_cmpxchg(atomic_t *v, int old, int new)
{
return cmpxchg(&v->counter, old, new);
}
// 自旋锁获取的本质:将锁变量从 0(unlock)原子交换为 1(lock)
// 如果返回值为 0,说明获取成功;如果返回值为 1,说明已被他人持有
ARM64 使用 Load-Exclusive/Store-Exclusive 指ldxr/stxr)配合独占监视器(Exclusive Monitor):
// ARM64 自旋获取的核心循环
static inline void arch_spin_lock(arch_spinlock_t *lock)
{
unsigned int tmp;
arch_spinlock_t lockval, newval;
asm volatile(
/* 原子读取锁值,如果为 0(未锁定),尝试写入 1 */
" prfm pstl1strm, %3\n" // 预取到缓存
"1: ldaxr %w0, %3\n" // 独占加载
" cbnz %w0, 2f\n" // 非零表示已锁定,跳转到自旋等待
" stxr %w1, %w4, %3\n" // 独占存储
" cbnz %w1, 1b\n" // 存储失败则重试
" dmb ish\n" // 内存屏障:锁获取后的读/写不能越过此屏障
" b 3f\n"
/* 自旋等待:使用 WFE 进入低功耗等待状态 */
"2: wfe\n" // Wait For Event — 低功耗自旋
" ldaxr %w0, %3\n" // 重新读取锁值
" cbnz %w0, 2b\n" // 如果仍被锁定,继续等待
" b 1b\n" // 锁释放了,重新尝试获取
"3:"
...
);
}
ARM64 的 wfe(Wait For Event)是关键优化:当锁争用激烈时,CPU 进入低功耗等待状态直到锁释放事件(sev 事件或中断)唤醒它们。这避免了每个 CPU 在 MWAIT 或忙循环中持续消耗功耗。
2.2 内核 spinlock 的四种变体
// include/linux/spinlock.h — 四种变体适用于不同上下文
// 1. 基础版本:仅禁止内核抢占(等价于关抢占+原子操作)
static __always_inline void spin_lock(spinlock_t *lock)
{
raw_spin_lock(&lock->rlock);
__acquire(lock);
}
// 2. 禁止本地硬中断:硬中断上下文必须使用
static __always_inline void spin_lock_irqsave(spinlock_t *lock, unsigned long flags)
{
raw_spin_lock_irqsave(&lock->rlock);
}
// 3. 禁止本地软中断(BH):软中断上下文使用
static __always_inline void spin_lock_bh(spinlock_t *lock)
{
raw_spin_lock_bh(&lock->rlock);
}
// 4. 保存中断状态并禁止:不确定中断状态时使用
static __always_inline void spin_lock_irq(spinlock_t *lock)
{
raw_spin_lock_irq(&lock->rlock);
}
关键陷阱:在硬中断上下文(如中断处理函数、tasklet)中只能使用 spin_lock 或 spin_lock_irq,绝不能使用 mutex_lock 或 down(信号量操作),因为它们在争用时会触发调度(休眠),而中断上下文没有进程上下文可供休眠和唤醒,会导致内核崩溃("BUG: scheduling while atomic")。
2.3 spinlock 的性能特征
spinlock 的性能特征直接取决于临界区长度和争用程度:
| 临界区长度 | 单争用延迟 | 高争用延迟(4核) | 适用场景 |
|---|---|---|---|
| < 10ns | ~20ns | ~100-500ns | 链表保护、简单状态设置 |
| 10-100ns | ~20-100ns | ~500ns-5μs | 数据结构更新 |
| 100ns-2μs | ~100ns-2μs | ~5-50μs | 中等临界区,争用可控 |
| > 2μs | 不推荐 | 严重退化 | 使用 mutex |
spinlock 的核心优势是低争用下延迟极低(约20ns),且不会触发内核调度器;劣势是高争用时浪费 CPU 周期(锁等待者持续消耗 CPU,锁持有者因此得不到足够时间服务请求者),且 NUMA 环境下 cache-line bouncing 显著。
三、mutex:自适应互斥锁的内部工程
mutex 是进程上下文中保护较长临界区的主要原语。与 spinlock 不同,mutex 在争用时会让请求者进入休眠(状态变为 TASK_UNINTERRUPTIBLE),释放 CPU 给其他任务。
3.1 mutex 的数据结构与状态机
// include/linux/mutex.h
struct mutex {
atomic_long_t owner; // 持有者的 task_struct 指针(低 3 位存储状态)
raw_spinlock_t wait_lock; // 保护等待队列的自旋锁
struct list_head wait_list; // 等待该 mutex 的任务链表
#ifdef CONFIG_MUTEX_SPIN_ON_OWNER
struct optimistic_spin_top osq; // 乐观自旋队列(MCS 锁)
#endif
#ifdef CONFIG_DEBUG_LOCK_ALLOC
struct lockdep_map dep_map;
#endif
};
owner 字段的结构是其设计精髓所在:
63 3 2 0
┌─────────────────────────────────────┬──┬─────────────┐
│ 持有者 task_struct 指针(地址) │RB│ STATE_BITS │
└─────────────────────────────────────┴──┴─────────────┘
STATE_BITS:
bit 0 (MUTEX_FLAG_WAITERS): 有等待者在队列中
bit 1 (MUTEX_FLAG_HANDOFF): 锁直接移交给队首等待者
bit 2 (MUTEX_FLAG_PICKUP): 队首等待者已确认可以获取锁
通过将标志位嵌入指针低位,mutex 可以用一次原子 CAS 操作同时更新持有者和状态,避免额外的状态字段——这是性能优化的经典技巧。
3.2 mutex_lock 的三阶段路径
// kernel/locking/mutex.c — mutex_lock 的时间线
阶段一:快速路径(Fast Path)—— 约 1-2 条原子指令
───────────────────────────────────────────────────
void __sched mutex_lock(struct mutex *lock)
{
// 尝试 CAS:将 owner 从 0(无锁)设置为 current 地址
if (likely(atomic_long_cmpxchg_acquire(&lock->owner, 0, (unsigned long)current) == 0))
return; // 快速路径成功 —— 全程仅需约 5ns
阶段二:乐观自旋(Optimistic Spinning)—— 减少无谓的上下文切换
───────────────────────────────────────────────────────────
#ifdef CONFIG_MUTEX_SPIN_ON_OWNER
if (!owner || !osq_lock(&lock->osq))
return; // 如果在持有者释放前能够获取成功
#endif
// 自旋等待期间不断检查 holder 是否已释放锁或已抢占出CPU
// 如果当前持有者正在另一个 CPU 上运行,说明它正在推进临界区
// 此时自旋等待比上下文切换更便宜
阶段三:慢路径(Slow Path)—— 进入休眠等待
────────────────────────────────────────────────
// 设置 MUTEX_FLAG_WAITERS,告诉持有者"有人在等你"
// 将当前任务插入 wait_list
// 设置当前任务状态为 TASK_UNINTERRUPTIBLE
// 调用 schedule() 让出 CPU
// 被唤醒后重新尝试获取锁
}
3.3 Handoff 机制:防止饥饿的关键设计
传统 mutex 在高争用下可能发生"饥饿":等待队列中排队的任务不断被新到达的请求者"插队"抢走锁。Linux mutex 通过 MUTEX_FLAG_HANDOFF 解决这个问题:
时间线:
T1 持有锁
T2 进入等待队列,设置 MUTEX_FLAG_WAITERS
T3 尝试获取锁,发现 WAITERS 标志,放弃快速路径
T1 释放锁时,检查 HANDOFF 标志
→ 如果 HANDOFF 设置,直接将所有权给 T2,T3 继续在队列中等待
→ 这保证了 T2 不会在 T3 后面再被插队
3.4 mutex vs spinlock 的性能交叉点
基于 x86-64 平台的实测数据:
| 指标 | spinlock | mutex |
|---|---|---|
| 无争用获取耗时 | ~15ns | ~25ns |
| 无争用释放耗时 | ~10ns | ~15ns |
| 单次上下文切换 | N/A | ~2-8μs |
| 争用1个请求者 | ~50-200ns | ~2-10μs |
| 争用4个请求者 | ~500ns-5μs | ~5-50μs |
| NUMA跨节点惩罚 | 严重(cache-line bouncing) | 较轻(休眠时不占用远端缓存行) |
经验法则:当临界区执行时间超过两次上下文切换开销(约 2×5μs = 10μs)时,mutex 的休眠策略更优;否则 spinlock 的自旋策略更快。
四、rw_semaphore:读写信号量的精细实现
rw_semaphore(读写信号量)允许多个读者并发访问,但写者独占访问。它是内核中使用最广泛的读写同步原语——管理内存描述符(mmap_lock)、文件系统 inode 锁、VFS 目录缓存等关键数据结构都依赖它。
4.1 rw_semaphore 的核心结构
// include/linux/rwsem.h
struct rw_semaphore {
atomic_long_t count; // 读者计数 + 写状态位
struct list_head wait_list; // 等待队列
raw_spinlock_t wait_lock; // 保护等待队列
#ifdef CONFIG_RWSEM_SPIN_ON_WRITE
struct osq_node osq; // 写者乐观自旋
#endif
#ifdef CONFIG_DEBUG_LOCK_ALLOC
struct lockdep_map dep_map;
#endif
};
count 字段的位布局:
63 8 7 0
┌─────────────────────────────────┬───┬────────────┐
│ 活跃/等待读者计数值(偏移后) │WRT│ ACTIVE_ │
│ + ACTIVE_COUNT 偏移 │ │ READERS │
└─────────────────────────────────┴───┴────────────┘
关键常量:
RWSEM_UNLOCKED_VALUE = 0x00000000 — 无读者无写者
RWSEM_ACTIVE_BIAS = 0x00000001 — +1 读者
RWSEM_WAITING_BIAS = 0xFFFF0000 — 有等待者的标记
RWSEM_ACTIVE_WRITE_BIAS = 0xFFFF0001 — 写者持有标志
4.2 down_read / up_read:读者路径
// 读者获取 —— 极轻量,通常只需一次原子递增
void __sched down_read(struct rw_semaphore *sem)
{
// count += RWSEM_ACTIVE_BIAS (1)
// 如果结果变成负数,说明有活跃写者 —— 进入慢路径
// 极快路径:一次 atomic_add_return 成功,约 5-10ns
}
void up_read(struct rw_semaphore *sem)
{
// count -= RWSEM_ACTIVE_BIAS (1)
// 如果归零且有待唤醒的写者,唤醒它们
}
读者-读者之间真正的并发:一个持有 mmap_lock 读模式的线程,在 copy_page_range(复制页表)时可以长达数十毫秒,但这不影响其他读模式的读者。
4.3 down_write / up_write:写者路径与竞争
// 写者获取 —— 必须等待所有读者释放,且不被新读者插队
void __sched down_write(struct rw_semaphore *sem)
{
// 尝试 CAS:将 count 从 RWSEM_UNLOCKED_VALUE 设为 RWSEM_ACTIVE_WRITE_BIAS
// 失败 = 当前有活跃读者或写者 → 进入慢路径
// 慢路径逻辑:
// 1. 设置 RWSEM_WAITING_BIAS,告诉读者"有写者在排队"
// 2. 新读者看到 WAITING 标记后,不会立即获取,而是进入排队
// 3. 当前读者全部释放后,写者被唤醒并获取锁
}
4.4 写者公平性:从 writer-starvation 到公平队列
早期的 rw_semaphore 存在严重的写者饥饿问题:只要持续有读者到达,写者就永远无法获取锁。Linux 内核经过多次迭代解决了这个问题:
- Linux 3.x 时代:允许写者优先,但读者可以在特定窗口内插队
- Linux 5.x 优化:引入
osq_lock竞争机制,将写者自旋与获取尝试合并 - 当前实现:采用 MCS 锁序列化保证队首优先,防止新请求者插队
- jiffies 计数器:全局时钟计数器每秒递增 100-1000 次,读者读取时允许看到"中间状态"
- VMA 保护:
mmap_lock在读侧使用 seqlock 时可以进行乐观读取 - NTP 时间调整:读取时间时需要保证时间值的一致性(要么是旧值,要么是新值,不能是混合值)
- per-CPU 计数器汇总:
kstat_cpu等统计信息汇总 - 使用 tagged/counted 指针:将指针与计数器打包在一起,每次修改递增计数器,使 ABA 不可能发生(概率极低)
- hazard pointers:在 RCU 读临界区标记正在访问的指针,防止提前释放
- 仅限内核核心代码使用:用户态程序应优先选择已有成熟实现
- 没有银弹:每种原语都存在性能-正确性-功能权衡,必须根据实际场景选择
- 测量而非猜测:用
perf lock、ftrace、lock_stat工具实际测量争用和延迟 - 先简化再优化:第一版代码使用 mutex,确认瓶颈后 spinlock,然后再考虑 RCU/per-CPU
- 保持临界区最小:无论选择哪种原语,临界区越短争用越小
- 利用层次化设计:高频路径用轻量同步(RCU、per-CPU、seqlock),低频控制路径用重量级同步(mutex、rw_semaphore)
4.5 典型使用场景与性能数据
| 场景 | down_read | down_write | 读者并发度 |
|---|---|---|---|
| mmap_lock(mmap/munmap/protection 操作) | 读模式允许并发 | 写模式独占 | 读者可达数百 |
| inode->i_rwsem(文件读写) | 多个文件读并发 | 文件属性修改独占 | 高并发读写 |
| 链表/哈希表(如 dentry cache) | 查找操作并行 | 插入删除独占 | 极高并发 |
| 内存 cgroup 操作 | 读取统计独立 | 修改限制独占 | 中等 |
在 64 核 NUMA 系统上,rw_semaphore 的读模式性能远优于 mutex(读者完全并行),但写者争用时的延迟波动较大。
五、RCU:读多写少的终极武器
RCU(Read-Copy-Update)是 Linux 内核中最独特的同步机制。它的核心洞察是:在大量读取、少量更新的场景中,可以让读者完全无锁,只通过对写者的协调来保证一致性。
5.1 RCU 的核心思想
传统读写锁:
读者A ──→ 获取读锁 ──→ 读取数据 ──→ 释放读锁
读者B ──→ 获取读锁 ──→ 读取数据 ──→ 释放读锁
更新者 ──→ 获取写锁(必须等待A和B)──→ 修改数据 ──→ 释放写锁
RCU:
读者A ──→ rcu_read_lock()(仅禁止内核抢占)──→ 读取数据 ──→ rcu_read_unlock()
读者B ──→ rcu_read_lock() ──→ 读取数据(与A完全并行,无原子操作)──→ rcu_read_unlock()
更新者 ──→ rcu_assign_pointer()(原子替换指针)──→ wait_for_readers_to_finish()(等待宽限期)──→ 释放旧数据
RCU 的精妙之处:读者 rcu_read_lock() 不需要任何原子操作或内存屏障(在抢占式内核中只需 preempt_disable()),因此读取几乎零开销。写者通过复制更新 + 延迟释放来保证旧读者看到的是旧数据、新读者看到的是新数据。
5.2 RCU 数据结构与宽限期
// kernel/rcu/tree.c — 核心数据结构
struct rcu_state {
struct rcu_node node[MAX_NUM_RCU_LVLS]; // RCU 节点树(分层管理宽限期)
struct rcu_data __percpu *rda; // 每个 CPU 的 RCU 数据
unsigned long gpnum; // 当前宽限期编号
unsigned long completed; // 最近完成的宽限期编号
// ...
};
// 宽限期(Grace Period)是 RCU 的核心概念
// 定义:从本次更新发生后,到所有之前的读者都离开 RCU 读临界区的时间段
// 宽限期结束后,旧数据指向的内存可以安全释放
5.3 call_rcu:延迟释放机制
// 典型的 RCU 更新操作
struct my_data {
int field1;
int field2;
struct rcu_head rcu; // 必须嵌入到被释放的结构中
};
void update_data(struct my_data *old, int new_val)
{
struct my_data *new = kmalloc(sizeof(*new), GFP_KERNEL);
*new = *old; // 复制旧数据
new->field1 = new_val; // 修改需要更新的字段
new->field2 = compute(new_val);
// 原子替换全局指针 —— 新读者立即看到新数据
rcu_assign_pointer(global_ptr, new);
// 注册回调:在所有现有读者完成后释放旧数据
call_rcu(&old->rcu, free_my_data);
}
void free_my_data(struct rcu_head *rcu)
{
// 此时所有可能访问旧数据的 RCU 读临界区都已经结束
struct my_data *old = container_of(rcu, struct my_data, rcu);
kfree(old);
}
5.4 RCU 的使用规则与常见陷阱
// 规则 1:RCU 读临界区内绝不能阻塞/休眠
rcu_read_lock();
ptr = rcu_dereference(gp);
// 错误!以下任何操作都禁止:
// mutex_lock()
// copy_to_user() # 可能触发页错误导致调度
// kmalloc(GFP_KERNEL) # 可能直接回收内存
process(ptr);
rcu_read_unlock();
// 规则 2:rcu_dereference 必须在 RCU 读临界区内调用
ptr = rcu_dereference(gp); // 正确:在 rcu_read_lock 内部
process(ptr); // 即使 ptr 在锁外使用 —— 也只能保证指针有效性,不能保证内容不被修改
// 规则 3:synchronize_rcu 是慢操作,不能在中断上下文调用
synchronize_rcu(); // 等所有旧读者完成 —— 可能长达数十毫秒
5.5 RCU 性能优势的实际体现
在真实的 64 核服务器上测试链表遍历:
| 同步机制 | 单读者延迟 | 64读者总吞吐量 | 写者延迟(同步) |
|---|---|---|---|
| spinlock | ~15ns | ~15ns ×64 ≈ 1μs 串行 | ~1μs |
| rwlock | ~20ns | ~20ns(读并发) | ~μs级 |
| mutex | ~25ns | ~25ns ×64 ≈ 1.6μs 串行 | ~μs级 |
| RCU | ~2ns(等同无锁) | ~2ns 真正的并行 | ~毫秒级(synchronize_rcu 等待旧读者) |
RCU 的读者开销几乎等同于无锁代码(仅 preempt_disable/enable 各2条指令),在高并发读场景下带来数量级的吞吐提升。
六、seqlock:单点更新的轻量级同步
seqlock(顺序锁)适用于这样一种场景:数据更新频繁但更新本身代价很小(如只更新几个计数器或多个原子字段),读者可以容忍"读取失败并重试"。
6.1 seqlock 的核心机制
// include/linux/seqlock.h
typedef struct seqcount {
unsigned sequence; // 偶数=锁空闲,奇数=写者持有
} seqcount_t;
typedef struct seqlock {
seqcount_t seq;
spinlock_t lock; // 保护写者之间的互斥(不保护读者)
} seqlock_t;
// 读者
unsigned seqbegin(seqlock_t *lock)
{
unsigned seq;
// 循环直到读到偶数序列号(表明期间没有写者活跃)
do {
seq = READ_ONCE(lock->seq.sequence);
smp_rmb(); // 读屏障:确保 seq 检查先于数据读取
} while (seq & 1); // 如果是奇数,说明有写者,继续循环
return seq;
}
void seqretry(seqlock_t *lock, unsigned seq)
{
smp_rmb();
// 如果读者读取期间写者进入了临界区(seq变了),读者必须重试
return (READ_ONCE(lock->seq.sequence) != seq);
}
// 写者
void write_seqlock(seqlock_t *lock)
{
spin_lock(&lock->lock);
// 序列号 +1(变奇数),告诉读者"正在更新"
WRITE_ONCE(lock->seq.sequence, lock->seq.sequence + 1);
smp_wmb(); // 写屏障:确保序列号变更先于数据修改
}
void write_sequnlock(seqlock_t *lock)
{
smp_wmb();
WRITE_ONCE(lock->seq.sequence, lock->seq.sequence + 1); // 再+1(变偶数)
spin_unlock(&lock->lock);
}
6.2 seqlock 的适用场景
seqlock 在内核中的典型应用:
seqlock 的代价:高写频率下读者可能陷入无限重试,因此适合写操作代价小的场景。
七、内存屏障:建立顺序保证的硬基础
内存屏障(Memory Barrier/Fence)确保屏障前的读/写操作一定在屏障后的读/写操作之前完成(从其他 CPU 的视角看)。它们是所有同步原语的基础。
7.1 四种基本屏障
// include/linux/barrier.h 和 arch/x86/include/asm/barrier.h
// 1. 编译器屏障:仅阻止编译器重排,不影响 CPU
#define barrier() __asm__ __volatile__("" ::: "memory")
// 2. 写屏障(smp_wmb):保证此屏障前的所有写操作对其他 CPU 可见后,
// 屏障后的写操作才能开始
#define smp_wmb() __asm__ __volatile__("sfence" ::: "memory") // x86
#define smp_wmb() __asm__ __volatile__("dmb ishst" ::: "memory") // ARM64
// 3. 读屏障(smp_rmb):保证此屏障后的所有读操作开始前,
// 屏障前的读操作已全部完成
#define smp_rmb() __asm__ __volatile__("lfence" ::: "memory") // x86
#define smp_rmb() __asm__ __volatile__("dmb ishld" ::: "memory") // ARM64
// 4. 全屏障(smp_mb):同时保证读和写的顺序
#define smp_mb() __asm__ __volatile__("mfence" ::: "memory") // x86
#define smp_mb() __asm__ __volatile__("dmb ish" ::: "memory") // ARM64
7.2 真实案例:无锁信号量中的屏障
// 生产者-消费者模式中的屏障使用
struct buffer {
char data[BUFFER_SIZE];
unsigned int write_pos;
unsigned int read_pos;
};
// 生产者:写入数据 + 更新位置
void produce(struct buffer *buf, char c)
{
buf->data[buf->write_pos % BUFFER_SIZE] = c; // ① 写入数据
smp_wmb(); // ② 写屏障:确保数据写入完成后才更新位置
buf->write_pos++; // ③ 更新写位置
}
// 消费者:读取位置 + 读取数据
char consume(struct buffer *buf)
{
unsigned int rp = READ_ONCE(buf->read_pos);
unsigned int wp = READ_ONCE(buf->write_pos);
if (rp >= wp) return 0; // 缓冲区空
smp_rmb(); // ④ 读屏障:确保读取 wp 后再读 data,防止读到 writer 还没写入的数据
char c = buf->data[rp % BUFFER_SIZE]; // ⑤ 读取数据
smp_mb(); // ⑥ 全屏障:确保数据读完后再更新位置
buf->read_pos++; // ⑦ 更新读位置
return c;
}
没有屏障时,CPU 可能先执行 ③ 再执行 ①,消费者可能读到还没写入的数据。
7.3 acquire 与 release 语义
// Linux 内核将屏障抽象为更形式化的语义:
//
// acquire 语义(锁获取、消息接收):此操作后的所有读/写不能越过此操作向前
// release 语义(锁释放、消息发送):此操作前的所有读/写不能越过此操作向后
//
// 同步原语的正确组合:
// 获取者:acquire(确保锁状态确认后再访问数据)
// 释放者:release(确保数据存储完成后再释放锁标志)
//
// 这种 acquire-release 配对构成了同步的"互斥保证":
// lock() 等价于:
atomic_thread_fence(memory_order_acquire);
// unlock() 等价于:
atomic_thread_fence(memory_order_release);
八、per-CPU 变量:通过消除共享来消除争用
有时最好的同步是根本不需要同步。per-CPU 变量为每个 CPU 维护一个独立的副本,从根本上消除了缓存行 bouncing。
8.1 per-CPU 变量机制
// include/linux/percpu.h
// 声明和定义
DECLARE_PER_CPU(int, my_counter); // 声明
DEFINE_PER_CPU(int, my_counter) = 0; // 定义
// x86 上的访问实现(通过 GS 段寄存器定位 per-CPU 区域)
#define per_cpu_ptr(ptr, cpu) \
((typeof(ptr))((unsigned long)(ptr) + per_cpu_offset(cpu)))
#define raw_cpu_read(ptr) \
READ_ONCE(*per_cpu_ptr(ptr, raw_smp_processor_id()))
#define raw_cpu_add(ptr, val) \
this_cpu_add(ptr, val) // 前缀锁定的原子操作
// 关键优化:编译器可以利用 per-CPU 区域用段寄存器直接寻址(x86),
// 不需要计算偏移 —— 每条访问指令只需一个前缀
8.2 统计计数器的两种实现对比
// 错误示例:全局 atomic_t 在多核上产生缓存行 bouncing
atomic_t g_request_count; // 所有 CPU 竞争同一个缓存行
// 每次 atomic_inc 都触发缓存一致性流量,推高延迟
// 正确示例:per-CPU 计数器
DEFINE_PER_CPU(unsigned long, request_count_cpu);
void record_request(void)
{
// 无需原子操作(本 CPU 独占访问自己的副本)
__this_cpu_inc(request_count_cpu);
}
unsigned long total_requests(void)
{
unsigned long sum = 0;
int cpu;
for_each_online_cpu(cpu)
sum += per_cpu(request_count_cpu, cpu);
return sum;
}
per-CPU 模式在内核中被大量使用:vmstat 计数器、kstat_cpu 调度统计、网络包计数、NUMA 内存使用统计等。它的代价是获取总量时需要遍历所有 CPU,因此只适合"频繁更新、偶尔汇总"的场景。
九、信号量:允许有限并发的资源访问控制
semaphore(信号量)和 mutex 类似但更灵活:信号量允许 N 个执行流同时进入临界区(mutex 只允许 1 个)。
9.1 内核信号量的实现
// include/linux/semaphore.h
struct semaphore {
raw_spinlock_t lock; // 保护下面的 count 和 wait_list
unsigned int count; // 可用资源数
struct list_head wait_list; // 等待队列
};
// 获取信号量(资源数 > 0 时成功)
void down(struct semaphore *sem)
{
unsigned long flags;
spin_lock_irqsave(&sem->lock, flags);
if (likely(sem->count > 0)) {
sem->count--;
spin_unlock_irqrestore(&sem->lock, flags);
} else {
// 进入等待队列并休眠
// ...
}
}
// 释放信号量
void up(struct semaphore *sem)
{
unsigned long flags;
spin_lock_irqsave(&sem->lock, flags);
if (list_empty(&sem->wait_list))
sem->count++;
else
wake_up_process(list_first_entry(...)); // 唤醒等待者
spin_unlock_irqrestore(&sem->lock, flags);
}
9.2 信号量与 mutex 的区别
| 指标 | mutex | semaphore |
|---|---|---|
| 允许的并发数 | 1 | N(可配置) |
| 持有者约束 | 锁释放者必须是锁获取者 | 任何进程都可以 up |
| 递归获取 | 不允许(除非recursive lock) | 允许 |
| 使用场景 | 互斥(保护数据) | 资源计数(限制并发) |
| 优先级继承 | 支持(rt-mutex) | 不支持 |
在现代内核代码中,mutex 已经几乎完全取代了 semaphore 用于互斥场景。semaphore 主要用于需要限制并发数的场景(如线程池的资源复用)。
十、rwlock_t:读写自旋锁的极致轻量
rwlock_t(读写自旋锁)是 spinlock 的读写分离变体:允许多个读者并发,写者独占。
10.1 rwlock 的内部位设计
// include/linux/rwlock_types.h (x86 实现)
typedef struct {
arch_rwlock_t raw_lock;
} rwlock_t;
// x86 arch_rwlock_t 使用一个 32 位整数
// 位 0-23(24位):读者计数(每位=1个读者?不,采用补码计数)
// 实际上用:25 位补码,每位代表 2^8 个读者
// 写者位:值为 0x01000000(表示写者持有)
// 关键限制:rwlock 不能在中断上下文持有读锁后,
// 同一中断处理函数又尝试获取写锁(会导致死锁 —— lockdep 会捕获)
10.2 rwlock 与 rw_semaphore 的选择
| 场景 | rwlock | rw_semaphore |
|---|---|---|
| 读临界区长度 | 极短(spinlock 的临界区级别) | 中等(可以数十微秒) |
| 读上下文限制 | 不能在中断上下文持有读锁同时获取写锁 | 可用于任何上下文 |
| 写者公平性 | 无写者优先机制 | 现代实现有写者公平保护 |
| NUMA 友好性 | 较差(读者间无 NUMA 感知) | 较好 |
当前内核的趋势是 rw_semaphore 更加通用,rwlock 主要以其极低的读开销被保留在一些性能极端敏感的路径(如中断处理中的 IDT 查找、 BPF map 查找)中。
十一、原子操作与无锁编程基础
原子操作是所有同步原语的基石。Linux 内核提供两类原子操作:
11.1 整数原子操作
// include/linux/atomic.h
static inline int atomic_add_return(int i, atomic_t *v)
{
int val;
__asm__ __volatile__("lock; xaddl %0, %1"
: "+r"(val), "+m"(v->counter)
:
: "memory");
return val + i;
}
// 常见原子操作族:
atomic_add/sub/inc/dec // 算术
atomic_and/or/xor // 位操作
atomic_cmpxchg // 比较交换(CAS)
atomic_fetch_add/or/and // 返回旧值的原子操作
11.2 无锁数据结构:lock-free 队列示例
// 简单的 lock-free 单向链表头部插入
struct node {
int value;
struct node *next;
};
struct lf_list {
_Atomic(struct node) *head; // C11 原子指针(或 kernel 的 atomic_long_t)
};
void lf_push(struct lf_list *list, struct node *new)
{
struct node *old_head;
do {
old_head = atomic_load(&list->head);
new->next = old_head;
} while (!atomic_compare_exchange_weak(&list->head, &old_head, new));
// CAS 失败说明有其他线程在同时插入 —— 重试
}
struct node *lf_pop(struct lf_list *list)
{
struct node *old_head, *next;
do {
old_head = atomic_load(&list->head);
if (!old_head) return NULL;
next = old_head->next;
} while (!atomic_compare_exchange_weak(&list->head, &old_head, next));
return old_head;
}
11.3 ABA 问题与解决方案
无锁算法面临 ABA 问题:线程 T1 读到值 A,在此期间 T2 将 A 改为 B 又改回 A。T1 的 CAS 成功但中间状态被改变。
Linux 内核的解决方案:
十二、性能基准与选型决策框架
12.1 各同步原语在真实硬件上的延迟
在 Intel Xeon Gold 6338 (Ice Lake, 64 核) 上,单次操作延迟(中位数,纳秒):
| 操作 | x86-64 | ARM64 (Neoverse N2) |
|---|---|---|
| atomic_add | ~5ns | ~8ns |
| spin_lock + unlock(无争用) | ~18ns | ~35ns |
| mutex_lock + unlock(无争用) | ~28ns | ~45ns |
| down_read + up_read(无争用) | ~22ns | ~40ns |
| down_write + up_write(无争用) | ~25ns | ~42ns |
| RCU read_lock + read_unlock | ~2ns | ~3ns(仅 preempt_disable) |
| call_rcu(提交回调) | ~100ns | ~150ns |
| smp_mb (全屏障) | ~15ns | ~25ns |
12.2 NUMA 环境下的扩展性
4 节点 NUMA(每节点 16 核),执行 1000 万次递增操作:
| 原语 | 1核 | 16核(单节点) | 64核(跨节点) |
|---|---|---|---|
| atomic_t 串行 | 50M ops/s | 3M ops/s | 0.8M ops/s |
| spinlock 保护 | 40M ops/s | 5M ops/s | 1.2M ops/s |
| per-CPU + 汇总 | 50M ops/s | 800M ops/s | 3.2B ops/s |
NUMA 环境下,per-CPU 变量提供了完美的线性扩展性。spinlock 和 atomic_t 在跨节点时吞吐量下降 5-10 倍。
12.3 决策框架最终总结
开始
│
┌─→ 是否在硬中断上下文?
│ └─→ 是 → 必须使用 spinlock(irq变体)
│
┌─→ 是否可以接受完全无同步?
│ └─→ 是 → per-CPU 变量(仅需最终一致性)
│
┌─→ 读操作占 90% 以上且读者可容忍失败?
│ └─→ 是 → seqlock
│
┌─→ 读操作占 90% 以上且读者必须稳定?
│ └─→ 是 → RCU(读最多、更新偶尔)
│ 或 rw_semaphore(读写均衡)
│
┌─→ 临界区执行时间 < 2μs?
│ └─→ 是 → spinlock
│
┌─→ 需要优先级继承(实时系统)?
│ └─→ 是 → mutex(CONFIG_RT_MUTEX)
│
└─→ 默认:mutex
十三、内核同步原语在驱动开发中的实际案例
13.1 网络驱动:NAPI 收包路径
// 典型的 NAPI 收包路径中的同步选择
// 1. 控制路径(注册/注销):使用 mutex
static DEFINE_MUTEX(napi_mutex);
int register_napi(struct napi_struct *napi)
{
mutex_lock(&napi_mutex);
// 添加设备到全局链表(临界区可能涉及内存分配 → 可能休眠)
list_add_tail_rcu(&napi->list, &global_napi_list);
mutex_unlock(&napi_mutex);
}
// 2. 数据路径(收包):使用 RCU + lockless 指针访问
int napi_poll(struct napi_struct *napi)
{
rcu_read_lock();
// 通过 rcu_dereference 读取设备 RXD/TXD 环 —— 无锁
desc = rcu_dereference(napi->rx_ring->desc);
// ... 处理数据包 ...
rcu_read_unlock();
}
// 3. 设备控制寄存器访问:使用 spinlock
static irqreturn_t nic_interrupt(int irq, void *dev)
{
struct nic *priv = dev;
u32 status;
spin_lock(&priv->reg_lock); // 硬中断上下文必须用 spinlock
status = readl(priv->mmio + ISR);
writel(status, priv->mmio + ICR); // 写中断清除
spin_unlock(&priv->reg_lock);
}
13.2 文件系统 inode 操作
// ext4 文件系统中的 inode 锁使用模式
// 读路径(查找、读取 inode 信息)
struct inode *ext4_iget(struct super_block *sb, unsigned long ino)
{
struct inode *inode = iget_locked(sb, ino);
// 读取 inode 元数据 —— 多个读取者可以并发
down_read(&EXT4_I(inode)->i_data_sem); // 使用 rw_semaphore 保护extent树
// ... 读取 extent 信息 ...
up_read(&EXT4_I(inode)->i_data_sem);
return inode;
}
// 写路径(创建/删除文件)
int ext4_new_inode(struct inode *dir, umode_t mode, ...)
{
// 上级目录的 inode 写锁保护 extent 树更新
down_write(&EXT4_I(dir)->i_data_sem); // 独占访问:更新 extent 树
// ... 分配新的 extent,修改 free inode bitmap ...
up_write(&EXT4_I(dir)->i_data_sem);
// 新 inode 的 inode_mutex 初始化后由新 inode 独立持有
mutex_lock(&inode->i_mutex); // 保护 inode 元数据(权限、时间戳等)
// ... 设置 inode 字段 ...
mutex_unlock(&inode->i_mutex);
}
十四、常见陷阱与调试方法论
14.1 同步机制中的典型错误模式
| 错误模式 | 后果 | 检测工具 |
|---|---|---|
| 在信号上下文使用可能休眠的原子 | "BUG: scheduling while atomic" | lockdep + kasan |
| 忘记 release 对应 acquire 的锁 | 死锁(硬锁或活锁) | lockdep |
| 锁顺序不一致导致死锁 | 死锁(ABBA) | lockdep |
| 读者在 RCU 读临界区内调度 | 数据崩溃/释放后使用 | lockdep + CONFIG_DEBUG_ATOMIC_SLEEP |
| 双重获取 spinlock(同一上下文) | 立即死锁 | lockdep |
| 误用 spin_unlock 代替 spin_unlock_irq | 中断状态不一致 | lockdep IRQ 追踪 |
| 写者长期持有写锁导致读者饥饿 | 延迟波动 | perf lock stat |
14.2 lockdep 在同步原语选型中的验证作用
lockdep 不仅检测死锁,还验证同步原语的使用是否符合规则:
# 启用 lockdep 后,运行测试用例
echo 1 > /proc/sys/kernel/lock_stat
# 查看锁的争用情况
cat /proc/lock_stat
# 查看 lockdep 报告(如果有违规)
dmesg | grep "WARNING: bad unlock balance"
十五、总结:同步原语选型的工程哲学
Linux 内核同步生态的设计哲学可以概括为:
同步原语的正确选型,是把 Linux 内核从单核年代的玩具变成支撑全球互联网基础设施的关键能力之一。理解每种原语的硬件基础、内核实现和性能边界,是每一个内核和系统级开发者的必备素养。

发表评论 取消回复