Folly SmallLocks 深度指南:用 MicroSpinLock 与 PicoSpinLock 实现字节级、比特级的细粒度锁
【免费下载链接】follyAn open-source C++ library developed and used at Facebook.项目地址: https://gitcode.com/GitHub_Trending/fol/folly
folly/synchronization/SmallLocks.h为 Meta(Facebook)开源的 C++ 库 folly 提供了两种内存占用极小、专为“高内存约束、低竞争概率”场景设计的互斥锁:单字节的MicroSpinLock与寄生在现有整数上、仅占一个比特位的PicoSpinLock。本文以 SmallLocks.md 为骨架,结合 SmallLocks.h、两个锁的完整实现与测试代码,讲解它们的设计动机、API 用法、底层原理与适用边界,帮助你在大规模数据结构的每条记录上实现零额外内存成本的细粒度并发保护。
一、为什么需要"小到极致"的锁
在服务端大规模数据结构的场景下,内存往往是稀缺资源。设想一个拥有数百万甚至数十亿条记录的巨型哈希表或链表,如果为每条记录都配一个std::mutex(通常数十字节),内存开销会完全失控;但如果整表共用一把大锁,又会带来严重的竞争。folly 的解决思路是:让锁小到可以"塞进"每条记录已有的空闲位——很多记录里恰好有一个闲置的字节甚至一个闲置的比特位,此时加锁可以做到几乎零额外内存成本。
这正是 SmallLocks 模块的定位:它不是通用互斥锁的替代品,而是为"内存极度受限、且竞争概率很低"的场景专门设计的锁。文档开篇就明确了这一适用前提,SmallLocks.h 头文件注释也强调:这些锁适合临界区极小、竞争不激烈的场景。
注意:该模块目前仅支持 x64 架构(文档原话 "This module is currently x64 only."),在移植到其他架构前需要自行评估。
二、两种锁的定位与取舍
SmallLocks.h向外导出了两种锁,它们解决的是略有不同的"小":
| 锁类型 | 最小内存占用 | 工作原理 | 适用场景 |
|---|---|---|---|
MicroSpinLock | 1 字节 | 独占整个字节,用 0/1 表示空闲/占用 | 记录里刚好有 1 个空闲字节可用 |
PicoSpinLock<T> | 2/4/8 字节(1 个比特位) | 在已有的整数类型上借用最高位做锁 | 记录里已有整数,且恰好有 1 个未用比特位 |
文档特别解释了为什么两种锁都要保留:x64 的bts(bit test and set)指令无法直接作用在单个字节上,因此MicroSpinLock可以把体积压到 1 字节(sizeof(MicroSpinLock)恰好为 1),而PicoSpinLock至少要寄生在 16 位及以上的整数上。这意味着:
- 若你追求绝对最小体积(1 字节),选
MicroSpinLock; - 若你手头已有带空闲位的整数,选
PicoSpinLock,可做到完全不增加内存。
两种锁都完整实现了 C++11 的Lockable 概念(lock/unlock/try_lock),因此可以直接配合std::lock_guard、std::unique_lock做 RAII 管理,无需额外包装。
三、MicroSpinLock:单字节自旋锁实战
3.1 定义与初始化
MicroSpinLock定义在 MicroSpinLock.h,内部就是一个裸的uint8_t lock_,没有构造函数——这是刻意为之:它必须保持 POD 类型,以便能放进__attribute__((packed))之类的紧凑结构体中(gcc 不允许 packed 结构包含非 POD 成员)。
struct MicroSpinLock { enum { FREE = 0, LOCKED = 1 }; uint8_t lock_; void init() noexcept; // 置为 FREE,等价于零初始化 bool try_lock() noexcept; // 尝试获取,返回是否成功 void lock() noexcept; // 阻塞获取(自旋 + 休眠) void unlock() noexcept; // 释放 };初始化有两种等价方式:调用init(),或直接零初始化——因为空闲状态被保证为全零比特,MicroSpinLock lock{0};即可直接使用。folly 自身的代码就是这么做的,例如 IOBuf.h 中MicroSpinLock observerListLock{0};。
3.2 使用示例
#include <folly/synchronization/SmallLocks.h> #include <mutex> struct Record { uint64_t key; uint32_t value; MicroSpinLock lock; // 1 字节,塞进记录不心疼 Record() : lock() {} // 零初始化即就绪,无需调用 init() }; void update(Record& r, uint32_t v) { std::lock_guard<MicroSpinLock> g(r.lock); // RAII,符合 Lockable 概念 r.value = v; }配合std::unique_lock同样可行。此外 folly 还提供了别名using MSLGuard = std::lock_guard<MicroSpinLock>;(见 MicroSpinLock.h),测试代码 SmallLocksTest.cpp 中的用法就是MSLGuard g(v.lock);。
3.3 底层实现原理
从源码看,MicroSpinLock的获取与释放围绕"交换"这一原子操作展开:
try_lock():执行xchg_acquire(LOCKED)——把字节原子地交换为 1,若交换回的值是FREE(0)则说明成功抢到锁。这里使用std::atomic_exchange_explicit并施加memory_order_acquire,保证抢到锁后能看到临界区之前的写入。lock():先尝试一次交换,失败后进入两级退避循环:外层反复交换,内层用detail::Sleeper等待——Sleeper会先pause指令自旋、再yield让出时间片,避免无意义地烧 CPU。unlock():用memory_order_release把字节存回FREE,保证临界区内的写入在解锁前对其他线程可见。
锁状态被保证为全零(FREE = 0),这正是零初始化即可用的原因。该类型还带有static_assert强制其为标准布局且平凡类型(MicroSpinLock.h)。
3.4 附带福利:SpinLockArray(防伪共享的分片锁数组)
MicroSpinLock.h 还顺带导出了一个SpinLockArray<T, N>:以hardware_destructive_interference_size(通常 64 字节)为对齐与填充粒度,把 N 把锁排列成数组,每个锁独占一条缓存行,避免相邻分片锁之间发生伪共享(false sharing)。它适合"基于分片(shard)的加锁实现",且每个元素内部做了静态断言,保证锁不会跨缓存行。folly 的线程局部存储实现 ThreadLocalDetail.h 中即使用了 MicroSpinLock 相关机制。
四、PicoSpinLock:寄生在整数上的单比特锁
4.1 模板参数与初始化
PicoSpinLock定义在 PicoSpinLock.h,是一个类模板:
template <class IntType, int Bit = sizeof(IntType) * 8 - 1> struct PicoSpinLock { ... };IntType:宿主整数类型,仅支持16、32、64 位的有符号/无符号整型(源码有static_assert约束,小于 2 字节的类型无法使用)。Bit:用作锁的比特位下标,默认取最高位(如 32 位整数的第 31 位),因此正常业务数据应保证不使用最高位。
它同样刻意没有构造函数以保持 POD 性,使用前二选一:调用init(initialValue),或直接零初始化(此时等价于已解锁且getData() == 0)。
4.2 核心 API
| 方法 | 语义 |
|---|---|
void init(IntType initialValue = 0) | 初始化数据值并置为解锁态;initialValue不得占用锁位 |
IntType getData() const | 读取"其余位"的数据(锁位被掩掉),无需持锁即可安全调用 |
void setData(IntType w) | 写入其余位数据,应在持锁时调用(除非能保证无并发) |
bool try_lock() const | 原子地把锁位从 0 置 1,成功返回 true |
void lock() const | 阻塞获取,内部用Sleeper退避等待 |
void unlock() const | 原子地清除锁位,不动其余位 |
4.3 使用示例:借用整数的最高位
#include <folly/synchronization/SmallLocks.h> #include <mutex> struct CachedEntry { // 业务数据只用低 31 位,最高位留给锁 uint32_t generation = 0; PicoSpinLock<uint32_t> lock; // 寄生在同一个 4 字节上 CachedEntry() { lock.init(0); } }; void bump(CachedEntry& e) { std::lock_guard<PicoSpinLock<uint32_t>> g(e.lock); e.lock.setData(e.lock.getData() + 1); // 持锁读写数据位 }核心价值在于:锁和数据共用同一个整数对象,内存零开销。如果某个记录里恰好有一个整数的高位从未被使用,PicoSpinLock就能把它变成一把完整的互斥锁。测试 SmallLocksTest.cpp 还验证了有符号类型(如int16_t)场景:PicoSpinLock<int16_t, 0>可以用Bit = 0借用最低位,且getData()/setData()对负数同样正确。
4.4 底层实现原理
与MicroSpinLock用"整字节交换"不同,PicoSpinLock依赖单比特的原子位操作(这正是文档提到 x64bts指令的原因):
- 获取:
atomic_fetch_set(ref, Bit, memory_order_acquire)——原子地置位并返回旧值,若旧值为 0 说明抢锁成功(PicoSpinLock.h); - 释放:
atomic_fetch_reset(ref, Bit, memory_order_release)——原子地清位并施加 release 屏障(PicoSpinLock.h); - 读写数据位:
getData()用load + 掩码,setData()用load→修改→store保留锁位。
lock_成员还带有alignas(atomic_ref<UIntType>::required_alignment)对齐声明,保证原子位操作在目标平台上合法。由于Bit默认取最高位,init()/setData()内部都有FOLLY_SAFE_CHECK拒绝侵占锁位的非法输入。
五、为什么两者不可互相替代
文档给出了两者并存的根本原因,值得展开:
- 指令集的限制:x64 的
bts(bit test and set)等位操作指令不能作用于单字节操作数,PicoSpinLock只能寄生于 2 字节及以上的整数,因此其最小尺寸也大于 1 字节; - 因此
sizeof(MicroSpinLock)可以比PicoSpinLock更小:当记录里只有一个空闲字节、没有合适整数可用时,MicroSpinLock是唯一选择。
测试 SmallLocksTest.cpp 用编译期断言锁定了这一点:
static_assert(sizeof(MicroSpinLock) == 1, "Size check failed"); // 打包结构:MicroSpinLock + int16_t = 3 字节 static_assert(sizeof(ignore1) == 3, "Size check failed"); // 打包结构:PicoSpinLock<uint32_t> + int16_t = 6 字节 static_assert(folly::kMscVer || sizeof(ignore2) == 6, "Size check failed");两条static_assert分别验证了两种锁可以安全放入紧凑打包结构,且各自的最小体积符合预期。简言之:体积最小选 MicroSpinLock,零成本复用选 PicoSpinLock。
六、与 C++11 Lockable 概念无缝衔接
两种锁都提供lock()/unlock()/try_lock()三件套,符合标准库对互斥量的要求,因此可以:
- 用
std::lock_guard/std::unique_lock做 RAII 作用域管理(示例见上文,测试 SmallLocksTest.cpp 大量使用); - 直接用
std::unique_lock配合条件变量等待(folly 内部也有此用法)。
这意味着把它们接入现有基于标准互斥量编写的泛型代码时几乎不需要改动——这也是"Lockable 概念"设计带来的直接收益。
七、重要提醒:优先考虑 MicroLock
两个头文件的文件头注释都放着一句醒目的忠告(MicroSpinLock.h 与 PicoSpinLock.h):
N.B. You most likely donotwant to use MicroSpinLock or any other kind of spinlock. Consider MicroLock instead.
原因是:在抢占式多任务操作系统里,用户态自旋锁有严重缺陷——等待线程反复轮询一个被阻塞线程持有的锁,纯属浪费时间片;让 OS 调度器把线程挂起睡眠,对系统响应性和吞吐量都更有利。自旋锁更适合内核态。因此 folly 提供了第三种选择:
MicroLock(MicroLock.h):同样是 1 字节,但用2 个比特位(bit0 = held 持有位,bit1 = wait 等待位)实现了"先自旋、后让出、再通过folly::atomic_wait真正睡眠"的渐进退避策略(慢路径见 MicroLock.cpp),并把剩余6 个比特位开放给用户存数据(lockAndLoad/unlockAndStore/LockGuardWithData),还能与指针等对象做 union 复用其低位。- 它的模板参数
MaxSpins、MaxYields可调节自旋与让出的次数,决定它"多像一把自旋锁"。
也就是说,如果允许付出 2 个比特位,MicroLock通常是比两个自旋锁更稳妥的默认选择;MicroSpinLock与PicoSpinLock则适合那些"只有 1 个比特/1 个字节、且竞争确实极低"的极端内存约束场景。建议在动手前先阅读 MicroLock.h 的完整文档注释,再按自己的负载做基准测试。
八、测试与性能数据佐证
8.1 正确性测试
SmallLocksTest.cpp 覆盖了:
- 尺寸与打包:上文已展示的
sizeof与 packed 结构静态断言; - 多线程压力测试:
SpinLockCorrectness用available_concurrency() * 2个线程并发写数组并在持锁时校验一致性(L141-L155);另有基于simpleStressTest的lock/try_lock压力测试(2 线程与硬件并发数两档); - PicoSpinLock 符号数据:验证有符号整数在持锁/未持锁下的
getData/setData; - TSAN 死锁检测:开启
FOLLY_SANITIZE_THREAD时验证错误加锁顺序会触发 "Cycle in lock order graph" 报告; - 寄存器破坏回归:
RegClobber测试专门防止编译器寄存器分配导致的try_lock语义被破坏。
8.2 性能参考
基准程序 SmallLocksBenchmark.cpp 同时测量了无竞争与多线程竞争场景。源码中记录了历史运行数据(如 Intel Xeon E5-2680 v4 @ 2.40GHz 上的输出,L788-L1000):无竞争时MicroSpinLockUncontendedBenchmark约 10.95ns/次、PicoSpinLock<std::uint16_t>约 20.38ns/次,明显快于std::mutex的 16.42ns/次;但在多线程高竞争(如 32 线程以上)下,自旋锁的公平性与吞吐会急剧恶化(最大等待时间可达数百毫秒),这印证了文档"竞争概率低"的前提——请务必用自己真实的负载重新基准。
九、总结:如何选择
| 你的处境 | 推荐 |
|---|---|
| 能接受 1 字节额外空间,追求绝对最小锁 | MicroSpinLock |
| 记录里已有整数且高位空闲,想零内存成本加锁 | PicoSpinLock<T> |
| 愿意用 2 个比特位换取更好的等待策略 | MicroLock(默认更推荐) |
| 完全没有内存压力 | std::mutex(更快、更成熟) |
SmallLocks 家族的价值在于把"锁"的粒度细化到了字节乃至比特,让大规模数据结构的细粒度并发成为可能。入手源码时,建议按 SmallLocks.h → MicroSpinLock.h → PicoSpinLock.h → MicroLock.h 的顺序阅读,再对照 SmallLocksTest.cpp 与 SmallLocksBenchmark.cpp 验证你的理解。
【免费下载链接】follyAn open-source C++ library developed and used at Facebook.项目地址: https://gitcode.com/GitHub_Trending/fol/folly
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考