写点什么

如何设计一个高性能可扩展的自旋锁

作者:程序员老王
  • 2025-05-22
    山东
  • 本文字数:5687 字

    阅读完需:约 19 分钟

如何设计一个高性能可扩展的自旋锁

网友提问:

为什么 OB 不采用 内核提供的 spin_lock() 自旋锁,而是用户态态自己定义,并且还定义好多类型 ​


  • ​短期临界区​​:优先 ObSpinLock(轻量互斥)

  • ​高并发读​​:优先 ObQSyncLock(无死锁检测但高性能)

  • ​复杂控制需求​​:ObLatch(统计信息)、ObSpinRWLock(Guard 类)提供额外管理能力。


如何理解 ?用户态实现,无系统调用开销

为了帮助 快速理解下面内容 模拟对话的方式

小义:充当候选人老王:充当面试官核心内容:


  1. 原子操作:c++11 提供比较并交换(Compare and Swap,CAS) 和内存模型

  2. 锁冲突后处理策略。




老王:在工作中使用过自旋锁吗?谈谈你对自旋锁理解?


小义:自旋锁是一种基于忙等待的锁机制,条件不满足时候,一直循环占用 cpu


老王: 这完全是按照字面意思翻译 ,不像使用过的样子,使用场景是什么?小义:自旋锁 是高并发场景下,多线程(多核)同步机制,相比自旋锁,通过一个原子变量判断是否加锁?


老王:原子操作 只能修饰简单整数吗?通过++操作完成?还有呢 原理是什么?小义:在 c++11 中,原子操作是一个模版 类 struct atomic<U*> ,


  • 提供写操作 store,

  • 读操作 load

  • 比较并交换(Compare and Swap,CAS) compare_exchange


老王:c++内存模型一共 5 个方式,memory_order_acquire 是什么含义?



小义: 每个变量都存在一个虚拟地址,其中每个程序员都应该知道的延迟数字 ,地址 在 L1 L2 缓存 也可能 L3 缓存,也可能在物理内存,甚至磁盘上,多线程读写同一个变量,不是通过(锁,条件变量方式)内核方式,通过指令方式保证读 写操作原子性:


例如:一个整数,操作:先读取(1),然后打印(2)这个代码顺序,设置 memory_order_acquire 后,保证代码(2) 不在 代码(1)之前执行,也就是说,步骤(1)读取最新数据,然后执行步骤 2.


如何保证步骤 1 正确读取,需要不同循环执行比较并交换(Compare and Swap,CAS)


老王:然后呢,还有吗?自旋锁 占用 cpu 比较高如何解决?小义:如果第一加锁不满足条件,可以尝试 sleep 方式,避免多 cpu 抢占


老王:sleep 不是放弃 cpu 了吗?这个退避算法不合理?小义:在自旋锁加锁冲突情况下,可以参考 folly::MicroSpinLock 和 Futex 机制,


✅ 特点


  • 使用一个 字节(uint8**_**t) 实现的超轻量锁;

  • 调用者会进行自旋(通常用 pause 指令减轻总线压力);

  • 如果没抢到,调用 sleeper.wait() 执行 指数退避(exponential backoff)

  • 一开始忙等自旋;

  • 然后逐步用 pause()

  • 然后可能 std::this_thread::yield();【这个时候哈仔用户态】

  • 最后可能 sleep 一小会;

  • 全部仍然在用户态完成,不进入内核。🎯 适用场景:

  • 临界区 非常小(几十纳秒);

  • 高并发 + 短冲突窗口

  • 场景:如锁粒度极细的统计数据、metrics 缓存更


2️⃣ Futex(Fast Userspace Mutex)


这是 Linux 提供的用户态锁机制,用来在锁竞争严重时自动切换到内核阻塞


✅ 特点:


  • 通常实现为:

  • 尝试快速在用户态获取锁;

  • 如果失败,调用 futex_wait()内核挂起当前线程

  • 解锁时调用 futex_wake() → 唤醒阻塞线程;

  • pthread**_**mutex, std::mutex 等的底层实现方式



https://github.com/facebook/folly/blob/main/folly/SpinLock.hSpinLock--->folly::MicroSpinLock
/* * folly::MicroSpinLock * ==================== * A really, *really* small spinlock for fine-grained locking of lots * of teeny-tiny data. * * Designed to be POD (Plain Old Data) so it can be * packed into other structures without extra overhead. * * 特性: * 1. 极小自旋锁,适用于超细粒度场景 * 2. POD 类型,可零初始化,相当于调用 init() * 3. 用户态实现,无系统调用开销 * 4. 支持动态检测工具插桩(ThreadSanitizer 等) */
struct MicroSpinLock { // FREE 表示锁可用,LOCKED 表示锁已被持有 enum { FREE = 0, LOCKED = 1 }; // // 用一个字节存储锁状态,避免使用 std::atomic<>,保持 POD 特性 //为什么用uint8_t表示 std::atomic<>? //这个和序列化有什么关系?怎么实现的 typedef unsigned char uint8_t; uint8_t lock_; //locked?
/** * 尝试获取锁,非阻塞 * @return true: 获得锁;false: 未获得 */ bool try_lock() noexcept { // 调用 xchg 将新值置为 LOCKED,返回旧值 bool ret = (xchg(LOCKED) == FREE); // 插桩:记录尝试获取锁的结果,便于动态分析 annotate_rwlock_try_acquired( this, annotate_rwlock_level::wrlock, ret, __FILE__, __LINE__); return ret; }
/** * 获取锁,阻塞自旋 * 在持锁竞争时,会进行退避等待以降低总线抖动 */ void lock() noexcept { detail::Sleeper sleeper; // 自旋尝试交换,如果旧值不为 FREE,表示竞争失败 while (xchg(LOCKED) != FREE) { // 进入退避等待循环 do { sleeper.wait(); // 调用 pause/yield 或指数退避 } while (payload()->load(std::memory_order_relaxed) == LOCKED); } // 加锁成功,断言当前状态为 LOCKED assert(payload()->load() == LOCKED); // 插桩:记录获取锁的事件 annotate_rwlock_acquired( this, annotate_rwlock_level::wrlock, __FILE__, __LINE__); }
/** * 释放锁 * 将状态设为 FREE,并使用 release 语义保证可见性 */ void unlock() noexcept { // 断言当前持锁状态 assert(payload()->load() == LOCKED); // 释放锁 write payload()->store(FREE, std::memory_order_release); }
private: /** * 内部:获取指向 lock_ 字段的原子指针 */ std::atomic<uint8_t>* payload() noexcept { // reinterpret_cast 到 std::atomic<uint8_t>* 类型 return reinterpret_cast<std::atomic<uint8_t>*>(&this->lock_); }
/** * 原子交换操作,将 lock_ 原子地置为 newVal,返回旧值 */ uint8_t xchg(uint8_t newVal) noexcept { return std::atomic_exchange_explicit( payload(), newVal, std::memory_order_acq_rel); } //A read-modify-write operation with this memory order is both an _acquire operation_ and a _release operation_};
复制代码


老王:还是每个线程都在抢占 锁这个变量 还有其他方式吗?


小义:还有一个方式 那就是 银行排队排号办理业务情况,依然是在用户态原子比较判断,但是定义 2 个变量


参考:https://mfukar.github.io/2017/09/08/ticketspinlock.htm  now_serving   next_ticket        |             |        V             V... 0 1 2 3 4 5 6 7 0 1 2 ...         \___________/           8 threads           holding one ticket each
struct TicketSpinLock { /** * Attempt to grab the lock: * 1. Get a ticket number * 2. Wait for it */ void enter() { /* We don't care about a specific ordering with other threads, * as long as the increment of the `next_ticket` counter happens atomically. * Therefore, std::memory_order_relaxed. */ const auto ticket = next_ticket.fetch_add(1, std::memory_order_relaxed);
while (now_serving.load(std::memory_order_acquire) != ticket) { spin_wait(); } //没等到我 我就是wait }}
复制代码


老王:在 NUMA 系统中,访问跨节点共享数据时,自旋锁导致 cache 频繁失效,性能下降。


小义:Liunx4.2 起默认使用 qspinlock,每个 cpu 读取各种缓冲区变量,避免 cache 失效


老王:能详细说说吗?多个 thread 对 spinlock 的读写造成的 cache bouncing 问题,我们引入了[per cpu]的 mcs lock,让 thread 自旋在各自 CPU 的 mcs lock,从而减少了缓存颠簸问题,我不理解


来源:# Linux 中的 spinlock 机制[三] - qspinlock


小义:类比 ThreadLocal,这个我没研究过,如果其他了解的 可以告诉我一下 Qspinlocks


老王:换个思路 单 cpu,内核状态自旋锁 需要禁用 中断吗?用户态为什么不需要?


小义:用户态不涉及中断上下文切换。cpu 有特权指令,同时面临竞争操作。

ob 代码中不同自旋锁实现方式 【可跳过不看】

ObSpinLock 与传统自旋锁的区别主要体现在以下几个方面:

实现机制不同:


  • 传统自旋锁:直接基于 CPU 原子指令(如 CAS)实现,线程持续轮询检查锁状态

  • ObSpinLock:基于 ObLatchMutex 实现,是一个更高级的抽象,不是直接使用原子操作循环


等待策略不同:


  • 传统自旋锁:一直忙等(busy-wait),持续占用 CPU 资源

  • ObSpinLock:采用混合等待策略,先自旋一定次数,若仍未获取到锁则主动让出 CPU,避免 CPU 资源浪费


可重入性检查:


  • 传统自旋锁:通常不支持自检是否可重入

  • ObSpinLock:提供 self_locked()方法,可以检查锁是否被当前线程持有


调试能力:


  • 传统自旋锁:调试困难,难以确定谁持有锁

  • ObSpinLock:可通过 get_wid()获取持有锁的线程 ID,便于调试和分析


代码 实现:ObSpinLock--->ObLatchMutex ---int ObLatchMutex::lock()


主要分析:加锁失败后怎么处理的


ObLatchMutex::lock 中加锁失败后的处理逻辑主要分为三个层级的策略:


  1. 自旋重试(Spinning):


  • 首先通过 low_try_lock 尝试以原子方式获取锁

  • 如果获取失败,会在一个紧凑的循环中多次尝试,直到达到最大自旋次数

  • 这个阶段线程处于活跃状态,持续消耗 CPU 资源,适合短暂的锁竞争情


  1. 主动让出 CPU(Yielding):   else if (yield_cnt < OB_LATCHES[latch_id].max_yield_cnt_) {     sched_yield();     ++yield_cnt;     continue;   }


线程挂起等待(Sleeping)


  • 调用 wait 方法将线程挂起,完全释放 CPU 资源

  • 在 wait 方法中,通过 futex 机制实现高效的线程挂起


关键设计思想:


  • 梯度递进的等待策略:从积极(自旋)到保守(挂起)递进,平衡响应速度与资源消耗

  • 效率优先:短期自旋避免线程调度开销,适合短临界区

  • 资源友好:长期等待时主动让出 CPU 或挂起,避免 CPU 浪费 sched_yield

  • 超时机制:支持等待超时,避免无限阻塞

  • 系统调用优化:使用高效的 futex 机制减少系统调用开

ObQSyncLock 怎么体现高并发

Sync 机制的优势:


  • 使用了单独的 write_flag_成员变量,与读计数分开,减少了资源争用

  • 基于 ObQSyncWrapper<MAX_REF_CNT>实现,这是一种专门为高并发读优化的机制

  • 读操作在无写入时几乎无锁化,只需更新计数器

  • 支持高并发度(MAX_REF_CNT=48),可同时允许最多 48 个并发读操作


读操作:


int ObQSyncLock::rdlock()
{ int ret = common::OB_SUCCESS; do { if (common::OB_EAGAIN == (ret = try_rdlock())) { sched_yield(); //sched_yield()会主动放弃当前CPU给其他进程使用; //但是如果当前CPU上无其他进程等待执行,则直接返回继续执行当前进程 // 退避策略:使用sched_yield()让出CPU,而不是无限自旋 } } while (common::OB_EAGAIN == ret); //读锁加锁失败,重试 return ret;
}
复制代码


int ObQSyncLock::try_rdlock(){     int ret = OB_SUCCESS;     if (OB_UNLIKELY(0 != ATOMIC_LOAD(&write_flag_))) {       ret = OB_EAGAIN;       //快速检查写标志:首先使用ATOMIC_LOAD原子操作检查write_flag_是否为0,       //如果不为0表示有写锁存在,直接返回OB_EAGAI     } else {       // 找到线程对应的引用槽位并增加计数       const int64_t idx = qsync_.acquire_ref();       if (OB_UNLIKELY(0 != ATOMIC_LOAD(&write_flag_))) {         qsync_.release_ref(idx);         ret = OB_EAGAIN;         // 第二次检查写锁状态(双重检查)       } else {         // success, do nothing       }     }     return ret;}
复制代码


  • GCC 提供的 atomic 非 c++提供高级抽象


#define ATOMIC_LOAD(x) __atomic_load_n((x), __ATOMIC_SEQ_CST)#define ATOMIC_LOAD_ACQ(x) __atomic_load_n((x), __ATOMIC_ACQUIRE)#define ATOMIC_LOAD_RLX(x) __atomic_load_n((x), __ATOMIC_RELAXED
复制代码


  • 性能优化关键点:分散的计数器管理

  • 每个线程使用固定的计数器槽位:idx = get_id() % ref_num_

  • 这大幅减少了缓存争用,特别是在多核系统上

  • 缓存友好设计   int64_t ref_ CACHE_ALIGNED;  // 确保每个计数器独占缓存行

  • 防止伪共享(false sharing)导致的性能下

为什么这对 ObQSyncLock 至关重要

ObQSyncLock 的核心设计是将读锁引用计数分散到多个槽位:


高频原子操作:


  • 每次读锁获取和释放都涉及对特定 ref_的原子增减操作

  • 如果没有缓存行对齐,这些高频原子操作会导致严重的伪共享问题


吞吐量影响:


  • 在读多写少的高并发场景中,不同线程可能同时尝试获取读锁

  • 如果多个 ref_共享缓存行,性能可能下降 5-10 倍甚至更多


按线程 ID 分配:


  • ObQSyncLock 将线程固定映射到特定的 ref_槽位:idx = get_id() % ref_num_

  • 这与 CACHE_ALIGNED 配合,确保同一线程总是访问同一缓存行

  • 大幅提高了缓存命中率


还是不明白?

总结

  • 多核情况下 如何解决 Cache Line 不命中问题,ob 和 内核采取类似方式。cpu/线程读取本地的数据。

  • 具体怎么实现的没看明白


缓存伪共享是多核处理器系统中的一个常见性能瓶颈:


  1. 原理:


  • 现代 CPU 的缓存以缓存行(Cache Line)为单位组织,通常是 64 字节

  • 当多个变量共享同一个缓存行时,任何一个变量被修改都会导致整个缓存行失效

  • 这会导致其他 CPU 核心必须重新从内存加载整个缓存行,即使它们只访问缓存行中未修改的变量


  1. 影响:


  • 在多线程环境中,如果多个线程频繁修改位于同一缓存行的不同变量

  • 会导致缓存行在各 CPU 核心之间不断"乒乓"传递

  • 极大降低缓存效率,增加内存访问延迟,导致性能下降

CACHE_ALIGNED 宏的作用

CACHE_ALIGNED 是一个编译器指令,用于确保变量按缓存行大小对齐


struct Ref {Ref(): ref_(0) {}~Ref() {}int64_t ref_ CACHE_ALIGNED; // 确保每个计数器独占缓存行};

我是谁

最动人的作品,为自己而写,刚刚好打动别人*刚刚好,是最难得的美好


这里的节奏刚刚好,不必焦虑,自有充实与希望;


这里的人情味儿刚刚好,不必刻意,


到处都是真情与温暖;


我在这里,我刚刚好。



用户头像

call me by v:watchpoint 2017-11-30 加入

如果有疑问 wang_cyi@163.com 联系

评论

发布
暂无评论
如何设计一个高性能可扩展的自旋锁_程序员老王_InfoQ写作社区