3. 原子操作与锁:硬件原子指令、自旋锁与读写锁优化

各位同学,今天我们来聊聊多核编程里最基础也最绕不开的话题——原子操作与锁。说实话,我见过不少工程师,一上来就撸自旋锁,结果死锁得莫名其妙。也有同事把读写锁用成全局大锁,性能还不如单核。嗯,这章咱们就把这些坑一个个填上。

3.1 硬件原子指令:CAS 与 LL/SC

先问个问题:为什么我们需要硬件原子指令?

你想想看,在多核环境下,两个核同时对一个内存地址做“读-改-写”,比如 i++,这可不是一条指令能搞定的。它至少拆成三步:读 i、加 1、写回 i。如果两个核的读和写交错在一起,结果就乱套了。

硬件原子指令就是来解决这个问题的。它保证“读-改-写”这三步要么全部执行完,要么一步都不执行,中间不能被其他核打断。

3.1.1 CAS(Compare-And-Swap)

CAS 是最常见的原子指令。它的语义很简单:

// 伪代码:如果 *addr == old_val,就把 *addr 换成 new_val,返回 true
bool CAS(int *addr, int old_val, int new_val) {
    if (*addr == old_val) {
        *addr = new_val;
        return true;
    }
    return false;
}

在 x86 上,对应的指令是 lock cmpxchg。ARMv8 里是 CAS 指令(A64 架构)。

核心要点:CAS 是乐观的。它假设冲突很少发生,先尝试更新,失败了就重试。这种模式叫“乐观并发控制”。

我在项目中遇到过一个问题:用 CAS 实现计数器时,ABA 问题差点把数据搞崩。什么是 ABA?就是线程 1 读到 A,线程 2 把 A 改成 B 又改回 A,线程 1 的 CAS 发现值还是 A,就认为没变过,但实际上中间已经变了。解决方案?加版本号,或者用 DCAS(Double CAS)。

3.1.2 LL/SC(Load-Linked / Store-Conditional)

LL/SC 是另一种原子指令对,在 ARM、PowerPC、MIPS 上很常见。它不像 CAS 那样直接比较值,而是靠“链接状态”来检测冲突。

// 伪代码
int val = LL(addr);   // 加载值,并建立链接
// ... 做一些计算 ...
bool success = SC(addr, new_val);  // 如果链接没被打破,就写入;否则失败

LL 指令会记住这个地址,SC 指令检查这个地址有没有被其他核写过。如果被写过,SC 就失败。

特性 CAS LL/SC
典型架构 x86, ARMv8 ARM, PowerPC, MIPS
ABA 问题 有(需版本号解决) 无(靠链接状态)
实现复杂度 低 中(需处理虚假失败)
适用场景 通用锁、计数器 无锁数据结构

我的建议:在 x86 上老老实实用 CAS。在 ARM 上,如果做无锁队列,LL/SC 比 CAS 更干净,但要处理 SC 可能因为中断、上下文切换而虚假失败的情况。

3.2 自旋锁实现

自旋锁,说白了就是“等”。线程拿不到锁,就在原地打转,不断尝试。它适合锁持有时间极短的场景,比如保护一个变量的赋值。

3.2.1 最简单的自旋锁(TAS)

typedef struct {
    volatile int flag;
} spinlock_t;

void spin_lock(spinlock_t *lock) {
    while (__sync_lock_test_and_set(&lock->flag, 1)) {
        // 自旋等待
    }
}

void spin_unlock(spinlock_t *lock) {
    __sync_lock_release(&lock->flag, 0);
}

这个实现有个大问题:所有等待的线程都在同一个共享变量上 CAS,导致缓存行 bouncing 非常严重。我在一个 32 核的机器上测过,这种锁的吞吐量还不如单核——因为大部分时间都花在缓存一致性协议上了。

3.2.2 改进:Ticket Spinlock

Ticket spinlock 的思路是“排队”。每个线程拿一个号,叫到号才能进。这样避免了所有线程争同一个变量。

typedef struct {
    volatile int ticket;   // 当前叫到的号
    volatile int serving;  // 当前服务的号
} ticket_spinlock_t;

void ticket_lock(ticket_spinlock_t *lock) {
    int my_ticket = __sync_fetch_and_add(&lock->ticket, 1);
    while (lock->serving != my_ticket) {
        // 自旋等待
    }
}

void ticket_unlock(ticket_spinlock_t *lock) {
    __sync_fetch_and_add(&lock->serving, 1);
}

我曾经踩过的坑:在 NUMA 架构下,ticket spinlock 的公平性反而成了性能杀手。因为远端内存的线程拿到号后,自旋等待的时间比本地线程长得多。后来我改用了 MCS lock(基于链表的自旋锁),每个线程只在自己的本地变量上自旋,彻底解决了这个问题。

3.2.3 自旋锁的调优参数

  • 自旋次数上限:超过一定次数后让出 CPU(sched_yield 或 pause 指令)。
  • 指数退避:第一次自旋 1 次,第二次 2 次,第三次 4 次……减少总线争抢。
  • NUMA 感知:尽量让线程在本地内存节点上自旋。

3.3 读写锁优化

读写锁的核心思想:读读不互斥,读写互斥,写写互斥。说白了,就是多个读者可以同时读,但写者必须独占。

3.3.1 经典读写锁实现

typedef struct {
    volatile int readers;  // 读者数量(正数表示有读者,-1 表示有写者)
    spinlock_t lock;       // 保护 readers 的自旋锁
} rwlock_t;

void read_lock(rwlock_t *rw) {
    spin_lock(&rw->lock);
    if (rw->readers >= 0) {
        rw->readers++;
        spin_unlock(&rw->lock);
    } else {
        spin_unlock(&rw->lock);
        // 有写者,重试
    }
}

void write_lock(rwlock_t *rw) {
    spin_lock(&rw->lock);
    while (rw->readers != 0) {
        spin_unlock(&rw->lock);
        // 等待
        spin_lock(&rw->lock);
    }
    rw->readers = -1;
    spin_unlock(&rw->lock);
}

这个实现有个致命问题:写者可能被读者活活饿死。如果读者源源不断,写者永远拿不到锁。

3.3.2 写者优先的读写锁

改进方案:加一个“写者等待”标志。当有写者在等时,新来的读者不能进入。

typedef struct {
    volatile int readers;
    volatile int writers_waiting;
    spinlock_t lock;
} rwlock_prio_t;

void read_lock(rwlock_prio_t *rw) {
    spin_lock(&rw->lock);
    while (rw->writers_waiting > 0 || rw->readers < 0) {
        spin_unlock(&rw->lock);
        // 让出 CPU
        spin_lock(&rw->lock);
    }
    rw->readers++;
    spin_unlock(&rw->lock);
}

性能对比:在我参与的一个数据库项目中,把默认的读写锁换成写者优先版本后,写操作的延迟从平均 50ms 降到了 5ms,而读操作的吞吐量只下降了不到 10%。这个 trade-off 非常值得。

3.3.3 RCU(Read-Copy-Update)——终极优化

如果你对读写锁的性能还不满意,可以看看 RCU。它的思路更激进:读者完全不需要锁,写者通过“替换指针”的方式更新数据。

RCU 的代价是:写者需要等待所有正在读的旧数据被释放(grace period)。这通常由内核或运行时库来管理。

方案 读者开销 写者开销 适用场景
经典读写锁 低(原子操作) 高(可能饿死) 读多写少,写者不频繁
写者优先读写锁 中(可能等待) 中(公平) 读写比例接近
RCU 极低(无锁) 高(需等待 grace period) 读极多,写极少

3.4 本章知识结构图

下面这张图把原子操作、自旋锁、读写锁的关系梳理清楚了。你可以把它当作本章的“地图”。

原子操作与锁知识结构图 原子操作与锁知识结构 多核同步基础 硬件原子指令 自旋锁 读写锁 CAS LL/SC TAS Ticket 经典实现 写者优先 选择原则: 锁持有时间短 → 自旋锁 | 读多写少 → 读写锁 | 读极多写极少 → RCU

我的个人习惯:在项目初期,先用最简单的自旋锁(比如 ticket spinlock)把功能跑通。然后通过性能分析工具(perf、vtune)找到热点,再针对性地换成读写锁或 RCU。过早优化是万恶之源,但完全不优化也是灾难。