网友提问:

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

  • ​短期临界区​​:优先ObSpinLock(轻量互斥)
  • ​高并发读​​:优先ObQSyncLock(无死锁检测但高性能)
  • ​复杂控制需求​​:ObLatch(统计信息)、ObSpinRWLock(Guard类)提供额外管理能力。

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

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

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

  1. 原子操作:c++11 提供比较并交换(Compare and Swap,CAS) 和内存模型
  2. 锁冲突后处理策略。

自旋锁发展

Linux 版本 时间 主要进展 自旋锁相关说明
2.0 1996 支持 SMP,BKL 引入 全局大内核锁,串行化所有内核操作,无需细粒度锁
2.2 1999 引入基础自旋锁机制 初步在某些子系统中使用 spinlock_t,开始去BKL化
2.4 2001 更广泛使用自旋锁 自旋锁与 cli/sti(关中断)结合使用,锁粒度更细
2.6 2003 高度并发与抢占内核 引入 spin_lock_irqsave() 等多种锁版本,应对复杂场景
3.x 2011 BKL 基本移除 通过细粒度自旋锁和 mutex 替代 BKL
4.2 2015 引入 qspinlock MCS 队列自旋锁替代原始的 ticket lock,提高扩展性
5.x 2018+ NUMA、多核优化 更复杂的锁策略:退避、统计锁竞争、结合 RCU 等

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

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

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

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

  • 提供写操作store,
  • 读操作load
  • 比较并交换(Compare and Swap,CAS) compare_exchange

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

语义保证 开销
memory_order_relaxed 仅保证原子性,不保证与其他操作的顺序或可见性 最低
memory_order_acquire 保证此操作后续的读写不会在指令序上移到此操作之前 较低
memory_order_release 保证此操作前的读写不会在指令序上移到此操作之后 较低
memory_order_acq_rel 同时具备 acquire 和 release 语义,对应读写双向屏障 中等
memory_order_seq_cst 全局顺序一致性;在所有线程中可见操作顺序完全一致 最高

小义: 每个变量都存在一个虚拟地址,其中 每个程序员都应该知道的延迟数字 ,地址 在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 等的底层实现方式**;
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92

https://github.com/facebook/folly/blob/main/folly/SpinLock.h
SpinLock--->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个变量

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
参考: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个并发读操作

读操作:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
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;

}

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
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++提供高级抽象
1
2
3
#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 we wang_cyi