在前面的系列文章中,我们深入分析了Linux内核的内存页回收与Swap机制、进程调度器(CFS与EEVDF),以及缺页异常与内存映射。这些子系统都面临一个共同的挑战:如何在多核并发环境下保护共享数据? 本文将全面解析Linux内核的同步机制,从底层原子操作一路走到RCU无锁读优化,帮你在生产环境中做出正确的技术选型。

一、为什么需要同步?从竞态条件说起

在SMP(对称多核)系统中,多个CPU核心可能同时访问同一块内核数据结构。经典的竞态场景包括:

  • 并发修改链表:CPU0正在遍历链表,CPU1同时删除同一节点,导致use-after-free
  • 读取-修改-写回非原子:两个CPU同时执行counter++,实际只加了一次
  • 指令重排:编译器或CPU为了优化性能可能重排内存操作顺序,导致逻辑错误

Linux内核提供了分层的同步原语栈,从最底层的硬件原语到高层的无锁机制:

┌─────────────────────────────────────────────┐
│  RCU(读多写少,无锁读)                      │
│  Seqlock(轻量写优先)                        │
│  rwlock / rw_semaphore(读写分离)             │
│  mutex(互斥锁,可睡眠)                       │
│  spinlock(自旋锁,忙等待)                    │
│  atomic_t / bitops(原子操作)                 │
│  barrier(内存屏障)                          │
│  Hardware CAS / LL-SC / Memory Ordering       │
└─────────────────────────────────────────────┘

二、底层基石:原子操作与内存屏障

2.1 原子操作(Atomic Operations)

原子操作是不可中断的最小执行单元,Linux内核通过atomic_t和atomic64_t类型提供架构无关的原子语义:

// 原子变量定义与初始化
atomic_t ref_cnt = ATOMIC_INIT(1);

// 原子读取
int val = atomic_read(&ref_cnt);

// 原子设置
atomic_set(&ref_cnt, 2);

// 原子加减
atomic_inc(&ref_cnt);  // ++ref_cnt
atomic_dec(&ref_cnt);  // --ref_cnt
atomic_add(3, &ref_cnt); // ref_cnt += 3

// 原子比较并交换(CAS)- 无锁算法的基础
int expected = 1;
bool success = atomic_compare_exchange_strong(&ref_cnt, &expected, 0);
// 如果 ref_cnt == expected,则 ref_cnt = 0,返回 true
// 否则 expected = ref_cnt,返回 false

// 原子位操作
unsigned long flags = 0;
set_bit(3, &flags);        // flags |= (1<<3)
clear_bit(3, &flags);      // flags &= ~(1<<3)
test_and_set_bit(0, &flags); // 返回旧值并设置
change_bit(2, &flags);      // 翻转位

// 无锁队列中经典的CAS循环
void list_push(atomic_list_head *head, struct node *new) {
    do {
        new->next = atomic_load(&head->next);
        // 在load和CAS之间,可能有其他线程修改了head->next
    } while (!atomic_compare_exchange_weak(&head->next, &new->next, new));
    // CAS失败则重试,weak版本在伪失败时也返回false,更适合循环
}

ARM64架构下原子操作的底层实现:

// ARM64 atomic_add: ldadd(load-acquire + add + store-release)
ldadd   w0, [x1]

// ARM64 CAS: ldaxr / stlxr pair(load-acquire-exclusive + store-release-exclusive)
// 如果中间有写入,stlxr返回1,需重试
casal   w0, w2, [x1]   // VMAv8.1+的CAS指令,一条指令完成

2.2 内存屏障(Memory Barrier / Fence)

现代CPU使用乱序执行(Out-of-Order)优化性能,但多核环境下可能导致跨核观察到的执行顺序不一致。内存屏障保证操作的有序性:

// Linux内核屏障层级(从弱到强)

// 1. 编译器屏障:仅阻止编译器重排
barrier();           // asm volatile("" ::: "memory")

// 2. 全内存屏障:阻止读写两侧的所有重排
mb();                // 读+写屏障(最强)
smp_mb();            // 多核间全屏障(单核下编译为barrier)

// 3. 读内存屏障
rmb();               // 所有读操作在barrier前后有序
smp_rmb();           // 多核间读屏障

// 4. 写内存屏障
wmb();               // 所有写操作在barrier前后有序
smp_wmb();           // 多核间写屏障

// 5. 获取-释放语义(Acquire-Release)
unsigned long flags_or_val = smp_load_acquire(ptr); // acquire: 之后的读写不能重排到前面
smp_store_release(ptr, new_val);                    // release: 之前的读写不能重排到后面

// 经典场景:双重检查锁定(DCL)
struct instance *get_instance(void) {
    struct instance *inst = smp_load_acquire(&p_inst);  // acquire读
    if (!inst) {
        spin_lock(&lock);
        inst = p_inst;
        if (!inst) {
            inst = create_instance();
            // 保证inst完全初始化后,才让其他线程看到非NULL的p_inst
            smp_store_release(&p_inst, inst);  // release写
        }
        spin_unlock(&lock);
    }
    return inst;
}

acquire-release配对原理:release-store保证之前的所有写入在存储之前完成;acquire-load保证之后的所有读取在加载之后执行。两者配合,其他线程看到新值时也能看到对应的所有写入。x86的TSO模型天然提供acquire-release(除StoreLoad),ARM64需要ldar/stlr指令显式保证。

三、自旋锁(Spinlock):最基础的忙等待锁

3.1 核心机制

自旋锁是最简单的内核同步原语:获取失败时循环等待(spin)而非睡眠。适用于临界区极短(几条指令)、不允许睡眠的场景(如中断上下文)。

// ARM64上spinlock底层实现(LDXR/STXR exclusive monitor机制)
static inline void arch_spin_lock(arch_spinlock_t *lock)
{
    unsigned int tmp;
    arch_spinlock_t lockval, newval;

    asm volatile(
    // 1. 等待本地exclusive monitor空闲
    "   prfm    pstl1strm, %3\n"         // 预取提示
    "1: ldaxr   %w0, %3\n"               // 原子加载+exclusive
    "   add %w1, %w0, %w5\n"             // 计算新值(+1表示获取锁)
    "   stxr    %w2, %w1, %3\n"          // 尝试存储(exclusive)
    "   cbnz    %w2, 1b\n"               // 失败则重试
    // 2. 检查是否真的获得了锁(ticket机制)
    "   eor     %w1, %w0, %w0, ror #16\n" // 比较owner与next
    "   cbz     %w1, 3f\n"               // 相等则获得锁
    // 3. 排队等待(WFE低功耗自旋)
    "   sevl\n"                           // 防止错过event
    "2: wfe\n"                           // 等待event(省电)
    "   ldaxrh  %w2, %4\n"               // 加载当前owner
    "   cmp     %w1, %w2\n"              // 轮到我了吗?
    "   bne     2b\n"                    // 没有则继续等待
    "3:"                                  // 获得锁,执行临界区
    : "=&r" (lockval), "=&r" (newval), "=&r" (tmp), "+Q" (*lock)
    : "Q" (lock->owner), "I" (1 << 16), "Q" (lock->tickets)
    : "memory");
}

这段汇编展示了Linux内核自旋锁的三个关键设计:

  • Exclusive Monitor:硬件级别的独占访问跟踪,LDXR标记地址为独占,STXR仅在标记有效时写入
  • Ticket机制:高16位是"下一张票",低16位是当前叫号,保证FIFO公平性,避免旧版test-and-set的"thrashing"问题
  • WFE低功耗等待:等待时进入低功耗状态,比纯忙等节能90%+

3.2 变体与场景

// 1. 普通自旋锁
spinlock_t lock;
spin_lock_init(&lock);
spin_lock(&lock);
// ... 临界区(不能睡眠!)
spin_unlock(&lock);

// 2. 中断安全自旋锁(关闭本地中断)
unsigned long flags;
spin_lock_irqsave(&lock, flags);  // 保存中断状态并关中断
// ... 临界区
spin_unlock_irqrestore(&lock, flags);

// 3. 底半部安全自旋锁(关闭softirq/bh)
spin_lock_bh(&lock);
// ... 临界区
spin_unlock_bh(&lock);

// 4. 读写自旋锁
rwlock_t rwlock = RW_LOCK_UNLOCKED;
read_lock(&rwlock);    // 多个读者可同时持有
read_unlock(&rwlock);
write_lock(&rwlock);   // 写者独占
write_unlock(&rwlock);

// 5. 顺序锁(Seqlock)- 写优先,读者可能重试
seqlock_t seq_lock = SEQLOCK_UNLOCKED;
// 写者:
write_seqlock(&seq_lock);
// ... 写入共享数据
write_sequnlock(&seq_lock);
// 读者(无锁,但需检查序列号):
unsigned int seq;
do {
    seq = read_seqbegin(&seq_lock);
    // ... 读取共享数据
} while (read_seqretry(&seq_lock, seq));

3.3 自旋锁的陷阱

陷阱说明正确做法
临界区内睡眠自旋锁持有期间若触发调度,另一个CPU会死锁自旋需要睡眠时用mutex,或用workqueue延迟
持有时间过长其他CPU空转浪费能量,破坏实时性缩短临界区,考虑RCU或per-cpu拆分
中断中重复获取同一CPU在中断中spin_lock会导致死锁使用spin_lock_irqsave关闭中断
递归获取spin_lock不支持递归(UNIQUE owner)使用mutex或重构避免递归

四、互斥锁(Mutex):可睡眠的重量级互斥

struct mutex 是Linux内核最通用的睡眠锁原语,适用于可能阻塞的临界区。

struct mutex {
    atomic_long_t       owner;     // 0=空闲,非0=持有者task_struct指针
    spinlock_t          wait_lock; // 保护等待队列
    struct list_head    wait_list; // 等待队列(FIFO)
    // 开启CONFIG_DEBUG_MUTEX后添加:调试信息字段
};

// 标准用法
struct mutex data_lock;
mutex_init(&data_lock);
mutex_lock(&data_lock);      // 获取(可能睡眠)
// ... 临界区(可以睡眠、可以触发缺页)
mutex_unlock(&data_lock);

// 尝试获取(不睡眠)
if (mutex_trylock(&data_lock)) {
    // ... 临界区
    mutex_unlock(&data_lock);
} else {
    // 锁已被占用,执行替代逻辑
}

// 带超时的获取(5.15新增)
int ret = mutex_lock_timeout(&data_lock, msecs_to_jiffies(1000));
if (ret == -ETIMEDOUT) { /* 超时处理 */ }

// 锁管理器(lockdep)检测死锁
mutex_lock(&lock_A);
mutex_lock(&lock_B);  // lockdep检查:B是否可能在其他路径已经持有A?
// ... 临界区
mutex_unlock(&lock_B);
mutex_unlock(&lock_A);

Mutex与Spinlock选型:

  • 临界区可能睡眠/阻塞 → Mutex
  • 临界区极短(几条原子指令)、中断上下文 → Spinlock
  • 读多写少 → rw_semaphore 或 RCU
  • 写多读少、写操作很短 → Seqlock

五、RCU(Read-Copy-Update):读多写少的终极武

5.1 设计哲学

RCU的核心思想是分离读写路径,让读者完全无锁。读者不需要任何原子操作或内存屏障(在大多数架构上),性能接近无锁代码。代价是写操作延迟回收旧数据,这就需要grace period(宽限期)。

// RCU读侧临界区
struct my_node *find_node(int key) {
    struct my_node *node;
    rcu_read_lock();                     // 标记读侧开始(仅是抢占点+屏障)
    node = rcu_dereference(head);        // 加载指针(acquire语义)
    while (node && node->key != key) {
        node = rcu_dereference(node->next);
    }
    // 不能在这里睡眠或切换任务!rcu_read_lock仅是标记
    rcu_read_unlock();                   // 标记读侧结束
    return node;

5.2 写侧:复制更新与延迟回收

// RCU写侧:复制更新(Copy-on-Write模式)
void update_node(int key, int new_value) {
    struct my_node *old, *new;

    // 1. 创建新节点(复制旧数据+修改)
    old = rcu_dereference_protected(head, lockdep_is_held(&update_mutex));
    new = kmalloc(sizeof(*new), GFP_KERNEL);
    *new = *old;                         // 复制
    new->value = new_value;              // 修改

    // 2. 原子切换全局指针(读者看到新数据)
    spin_lock(&update_mutex);
    rcu_assign_pointer(head, new);       // release语义存储
    spin_unlock(&update_mutex);

    // 3. 注册宽限期回调:所有现有读者完成后回收旧数据
    call_rcu(&old->rcu_head, free_node);// 宽限期后回调free_node()
}

static void free_node(struct rcu_head *rh) {
    struct my_node *old = container_of(rh, struct my_node, rcu_head);
    kfree(old);
}

5.3 Grace Period:宽限期的本质

Grace period 是指从写侧更新指针开始,到所有在读侧临界区开始之前就存在的读者都离开读侧临界区为止的时间段。


时间线:
CPU0(读者): |rcu_read_lock|........读取旧节点.......|rcu_read_unlock|
CPU1(读者):         |rcu_read_lock|.....读取旧节点.....|rcu_read_unlock|
CPU2(写者):                              |rcu_assign_pointer| ← 更新指针
                                                     ↓
                                            grace period开始
                                                          ↓
                                            grace period结束
                                                 ↓
                                          call_rcu 回调执行 → kfree(旧节点)

关键:grace period 内的读者在CPU0和CPU1都已退出读侧临界区后才结束
call_rcu注册的回调此时安全执行,因为不可能再有读者访问旧数据

grace period的两代实现:

  • Classic RCU:等待所有CPU经过一次上下文切换(context switch),因为读者不能在内核态睡眠
  • Tree RCU(CONFIG_PREEMPT_RCU):基于树的层次化追踪,两阶段grace period,避免每次GP都需要全QS扫描
  • Sleepable RCU(SRCU,Linux 5.x+):允许读侧睡眠,使用计数器+等待队列替代context-switch检测,代价是读者需要原子操作

5.4 经典应用场景

场景1:路由表查找(路由子系统)

// 路由缓存使用RCU:查路由高频写路由低频
struct rtable *ip_route_output(struct net *net, ...) {
    // 读侧:rcu_read_lock → 查fib_lookup → rcu_dereference结果 → rcu_read_unlock
    // 零原子操作零内存屏障开销
}

场景2:进程描述符访问(find_task_by_pid)

// 任务哈希表:经常查(ps, kill, ptrace),少创建(fork)
struct task_struct *find_task_by_pid(pid_t pid) {
    struct task_struct *task;
    rcu_read_lock();
    task = pid_hash_find(pid);  // 读侧遍历
    rcu_read_unlock();
    return task;
}
// fork时:
rcu_assign_pointer(pid_hash[hash], new_task); // 插入
exit时:
call_rcu(&task->rcu, delayed_put_task_struct);  // 延迟释放

场景3:文件描述符表(FD table)


// 文件描述符表:每次read/write/poll都要访问fd table,dup2才修改
// 使用RCU管理,PCB中的files_struct通过rcu_dereference读取
task->files = files → smp_store_release(写侧)
current->files → rcu_dereference + rcu_read_lock(读侧)

5.5 性能对比

:原子LD+序列号校验(~5 cycles)
原语读者开销写者开销延迟影响最大读者数
Spinlock原子XCHG+屏障(~50 cycles)原子XCHG(~50 cycles)阻塞链无限制
Mutexatomic CAS+可能睡眠(~100~∞)atomic CAS(~100 cycles)不可预测1持有者
RWLock原子ADD+屏障(~30 cycles)原子XCHG(~50 cycles)写者饥饿大量读者
SeqLockatomic ADD(~5 cycles)读者重试大量读者
RCU(经典)</td><td><strong>纯内存读取(0 cycles!)</strong></td><td>原子XCHG+call_rcu=</td><td>宽限期延迟</td><td>无限制</td></tr</tbody></table>

5.6 与前一篇文章的联系

在缺页异常与内存映射一文中我们提到VMA红黑树管理。Linux内核的mm_struct中的VMA链表正是使用RCU保护读取的——每次缺页异常查找VMA时,通过rcu_dereference遍历红黑树,无需任何锁开销。而VMA修改(mmap/munmap)则通过mmap_write_lock保证互斥。这是RCU在内存管理子系统中的经典应用。

六、高级技巧:无锁数据结构与顺序锁

6.1 Lock-Free队列(kfifo / lfq)

Linux内核的kfifo实现了一个经典的单生产者-单消费者(SPSC)无锁队列:

// kfifo 核心结构
struct __kfifo {
    unsigned int    in;       // 生产者写入指针
    unsigned int    out;      // 消费者读取指针
    unsigned int    mask;     // size-1 (2^n)
    unsigned int    esize;    // 元素大小
    void            *data;    // 缓冲区
};

// 生产者写入(无需锁):
unsigned int kfifo_in(struct __kfifo *fifo, const void *from, unsigned int len)
{
    unsigned int l, off;
    off = fifo->in & fifo->mask;
    // 环形缓冲区:in-out = 已用空间
    l = min(len, fifo->size - (fifo->in - fifo->out));
    smp_wmb();                               // 保证数据写入在in更新前可见
    memcpy(fifo->data + off, from, l);
    memcpy(fifo->data, from + l, len - l);    // 环绕
    smp_wmb();                               // 内存屏障
    fifo->in += len;                         // 更新指针(消费者通过smp_rmb看到)
    return len;
}

// 消费者读取(无需锁):
unsigned int kfifo_out(struct __kfifo *fifo, void *to, unsigned int len)
{
    unsigned int l;
    smp_rmb();                               // 保证读in之前先看到数据
    len = min(len, fifo->in - fifo->out);
    l = min(len, fifo->size - (fifo->out & fifo->mask));
    memcpy(to, fifo->data + (fifo->out & fifo->mask), l);
    memcpy(to + l, fifo->data, len - l);    // 环绕
    smp_rmb();                               // 内存屏障
    fifo->out += len;                        // 更新指针
    return len;
}
// 关键:只有生产者写in,只有消费者写out,使用smp_wmb/rmb保证顺序

6.2 Seqlock:写优先的顺序锁

Seqlock解决了读写锁中写者饥饿的问题。每次写操作更新序列号,读者通过校验序列号检测写冲突并重来。

// 适合场景:极短写操作、可能频繁读取、容忍偶尔重试
// Linux内核用时钟(jiffies)就是seqlock保护的:
u64 get_jiffies_64(void)
{
    unsigned int seq;
    u64 ret;

    do {
        seq = read_seqbegin(&jiffies_lock);
        ret = jiffies_64;
    } while (read_seqretry(&jiffies_lock, seq));
    return ret;
}

6.3 Per-CPU变量

消除跨核竞争的终极方案:每个CPU维护一份数据副本,需要时合并结果。

// 声明per-cpu变量
DEFINE_PER_CPU(struct cpu_stats, cpu_stats);

// CPU间天然无竞争
struct cpu_stats *stats = get_cpu_ptr(&cpu_stats);
stats->irq_count++;    // 无原子操作,因为只有本CPU写
stats->sched_count++;
put_cpu_ptr(&cpu_stats);

// 读取所有CPU汇总
struct cpu_stats total = {};
int cpu;
for_each_online_cpu(cpu) {
    const struct cpu_stats *s = per_cpu_ptr(&cpu_stats, cpu);
    total.irq_count += s->irq_count;
    total.sched_count += s->sched_count;
}

注意:per-cpu变量在抢占/中断安全性和统计准确性之间存在权衡。如果任务被抢占后迁移到另一CPU,per-cpu计数器就会"丢失"——内核通过put_cpu_var禁止抢占来避免。

七、生产环境选型指南

场景推荐原语理由
长临界区 + 可能睡眠mutex唯一允许睡眠的互斥原语
极短临界区 + 原子上下文(中断)spinlock(+irqsave)零调度开销
读多写少(读热点)RCU读者零开销
读多写少 + 写操作极短seqlock写无需等待读者
读写均衡rw_semaphore读者并发写者独占
单生产单消费 + FIFOkfifo无锁、cacheline对齐友好
纯计数器atomic_t / per-cpu最高性能、零争用

性能调优实战

# 1. 查看spinlock争用情况
$ cat /proc/lock_stat | grep -A5 spin
# 关注: waittime-total 和 acq-bytes(争用程度)

# 2. 查看RCU状态
$ cat /sys/kernel/debug/rcu/rcu_preempt/rcudata
# 关注:gp序号增长速度(grace period频率)

# 3. 使用perf分析锁争用
$ perf record -e 'syscalls:sys_enter_futex' ./your_app
$ perf record -e L1-dcache-load-misses ./your_app

# 4. lockdep 死锁检测
$ echo 1 > /proc/sys/kernel/lockdep
# 内核编译时开启 CONFIG_PROVE_LOCKING

八、总结

Linux内核的同步原语设计哲学体现了"没有银弹"的工程思维:

  • Spinlock 是最朴素的方案,为极短的临界区设计,代价是忙等待消耗CPU
  • Mutex 支持睡眠,代价是上下文切换开销
  • RCU 是读多写少的极致优化,写侧成本换取读侧零开销
  • Seqlock 是写操作的轻量级方案,代价是读者可能重试
  • per-CPU + kfifo 是消除竞争的根本手段

理解这些原语的底层实现(原子指令、内存屏障、exclusive monitor)不仅能让我们正确选型,更能帮助我们在面对复杂并发问题时设计出高效且正确的同步方案。在我们的系列文章中,从Swap机制的页面链表保护(Spinlock + LRU),到缺页异常中的VMA红黑树遍历(RCU),再到调度器中的rq锁(Spinlock)和CFS红黑树(rq->lock),可以说每个并发场景背后都有这些同步原语的影子。

系列文章回顾:

  • Linux内核内存页回收与Swap机制深度实战 — 分配层(页面释放与换入换出)
  • Linux内核进程调度器深度实战 — 调度层(CFS权重分配与EEVDF截止时间调度)
  • Linux内核缺页异常与内存映射深度实战 — 映射层(四级页表与VMA管理)
  • 本文:Linux内核同步机制深度实战 — 并发层(Spinlock到RCU全链路)
点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论