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 本章知识结构图
下面这张图把原子操作、自旋锁、读写锁的关系梳理清楚了。你可以把它当作本章的“地图”。
我的个人习惯:在项目初期,先用最简单的自旋锁(比如 ticket spinlock)把功能跑通。然后通过性能分析工具(perf、vtune)找到热点,再针对性地换成读写锁或 RCU。过早优化是万恶之源,但完全不优化也是灾难。