3、锁的实现与优化:自旋锁、读写锁、RCU机制、无锁队列设计

多核环境下的数据同步,说白了就是一场「谁先拿到、谁先改、谁先读」的博弈。我做了十几年芯片架构,见过太多因为锁没用好导致性能崩掉的案例。今天咱们就把锁这件事彻底聊透。

3.1 自旋锁:最简单的锁,也最容易翻车

自旋锁的原理很简单——线程在等待锁的时候,不会睡眠,而是原地打转(spin)。为什么这么设计?因为上下文切换的开销可能比自旋几微秒还大。

核心要点:自旋锁适用于锁持有时间极短的场景。如果临界区超过几十个指令周期,自旋锁就是灾难。

我在项目中遇到过这样一个坑:一个驱动模块里用了自旋锁保护一个链表操作,结果临界区里调了个慢速外设的读函数。嗯,那一次整个核的利用率直接飙到100%,其他线程全在空转。

// 一个简单的自旋锁实现(ARMv8 架构)
static inline void spin_lock(volatile int *lock) {
    int tmp;
    asm volatile(
        "1: ldaxr %w0, [%1]\n"   // 独占加载
        "   cbnz %w0, 1b\n"      // 如果锁被占用,继续循环
        "   mov %w0, #1\n"
        "   stlxr %w0, %w0, [%1]\n" // 独占存储
        "   cbnz %w0, 1b\n"      // 存储失败,重试
        : "=&r" (tmp)
        : "r" (lock)
        : "memory"
    );
}

我的建议:自旋锁一定要配合内存屏障使用。ldaxr/stlxr 这类指令自带 acquire/release 语义,但如果你用普通的 load/store 实现自旋锁,记得手动加 dmb 指令。

3.2 读写锁:读多写少的利器

读写锁的核心思想是:多个读者可以同时读,但写者必须独占。你想想看,很多场景下读操作占了90%以上,这时候用普通互斥锁就太浪费了。

读写锁的实现其实有讲究。我见过不少实现是「写者饥饿」的——读者源源不断,写者永远拿不到锁。为什么会这样?因为读者之间不互斥,只要有一个读者在,写者就得等着。

实现方式 读者优先级 写者优先级 适用场景
读者优先 读极多、写极少
写者优先 写操作有实时性要求
公平策略 中等 中等 读写比例均衡

避坑指南:我曾经在一个网络包处理模块里用了读者优先的读写锁,结果某个瞬间突发大量写操作,延迟直接飙到毫秒级。后来改成写者优先,问题才解决。记住:读写锁不是银弹,选型要看业务特征。

3.3 RCU机制:读操作零等待

RCU(Read-Copy-Update)是我个人非常喜欢的一种同步机制。它的核心思想是:读操作完全不需要加锁,写操作通过「复制-修改-替换」的方式完成。

说白了就是:读者只管读,写者自己折腾。写者先复制一份数据,在副本上修改,然后用一个原子操作把指针指向新数据。老数据什么时候释放?等所有读者都离开后再释放。

// RCU 读端示例
rcu_read_lock();
ptr = rcu_dereference(g_shared_ptr);
// 此时可以安全地读取 ptr 指向的数据
val = ptr->data;
rcu_read_unlock();

// RCU 写端示例
new_ptr = kmalloc(sizeof(*new_ptr));
*new_ptr = *old_ptr;  // 复制
new_ptr->data = new_val;  // 修改
rcu_assign_pointer(g_shared_ptr, new_ptr);  // 替换
synchronize_rcu();  // 等待所有读者退出
kfree(old_ptr);  // 释放老数据

关键点:RCU 的读端开销极低——几乎就是一个内存屏障的开销。但写端开销较大,因为需要等待宽限期(grace period)结束。所以 RCU 适合读多写少、且写操作可以容忍延迟的场景。

我记得在某个内核模块里,用 RCU 替换了原来的读写锁,读操作的延迟从几百纳秒降到了几十纳秒。但代价是写操作的延迟从微秒级变成了毫秒级。嗯,这就是 trade-off。

3.4 无锁队列:终极性能追求

无锁队列,说白了就是不用锁的队列。它依赖原子操作(CAS、LL/SC)来实现并发安全。为什么追求无锁?因为锁会引发上下文切换、缓存行 bouncing、优先级反转等问题。

一个经典的无锁队列实现是 Michael-Scott 队列。它的核心是一个链表,通过 CAS 操作来管理头尾指针。

// 无锁队列的入队操作(简化版)
void lf_enqueue(queue_t *q, node_t *node) {
    node->next = NULL;
    node_t *tail;
    node_t *next;
    
    while (1) {
        tail = q->tail;
        next = tail->next;
        
        if (tail != q->tail) continue;  // 尾指针被修改,重试
        
        if (next != NULL) {
            // 尾指针落后了,帮它推进
            CAS(&q->tail, tail, next);
            continue;
        }
        
        if (CAS(&tail->next, NULL, node)) {
            // 入队成功,尝试更新尾指针
            CAS(&q->tail, tail, node);
            return;
        }
    }
}

我的经验:无锁队列的难点不在于算法本身,而在于内存回收。ABA 问题、内存泄漏、死循环——这些坑我都踩过。建议用 hazard pointer 或 epoch-based reclamation 来管理内存。

3.5 四种方案的对比与选型

聊了这么多,到底该用哪个?我整理了一张对比表,方便你快速决策。

方案 读开销 写开销 适用场景 实现复杂度
自旋锁 临界区极短
读写锁 读多写少
RCU 极低 读极多、写极少
无锁队列 高并发、低延迟 极高

我个人习惯是:能不用锁就不用锁。如果必须用,优先考虑 RCU 和无锁结构。实在不行才上自旋锁和读写锁。你想想看,锁的本质是串行化,而多核的优势是并行。用锁就是在牺牲并行度,所以能少用就少用。

最后提醒一句:不要为了炫技而用无锁结构。如果你的场景锁竞争不激烈,普通互斥锁的性能完全够用。我曾经见过一个团队,为了「无锁」折腾了两个月,最后发现用自旋锁性能反而更好。嗯,选型要务实。

多核数据同步方案选型决策树 数据同步需求 临界区执行时间是否极短(< 100 指令周期)? 自旋锁 读操作占比是否 > 90%? RCU 是否追求极致性能? 无锁队列 读写锁 注:实际选型需结合硬件架构、延迟要求、实现成本综合评估

公众号:蓝海资料掘金营,微信deep3321