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 和无锁结构。实在不行才上自旋锁和读写锁。你想想看,锁的本质是串行化,而多核的优势是并行。用锁就是在牺牲并行度,所以能少用就少用。
最后提醒一句:不要为了炫技而用无锁结构。如果你的场景锁竞争不激烈,普通互斥锁的性能完全够用。我曾经见过一个团队,为了「无锁」折腾了两个月,最后发现用自旋锁性能反而更好。嗯,选型要务实。
公众号:蓝海资料掘金营,微信deep3321