ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Folly SmallLocks 深度指南:用 MicroSpinLock 与 PicoSpinLock 实现字节级、比特级的细粒度锁

Folly SmallLocks 深度指南:用 MicroSpinLock 与 PicoSpinLock 实现字节级、比特级的细粒度锁 Folly SmallLocks 深度指南用 MicroSpinLock 与 PicoSpinLock 实现字节级、比特级的细粒度锁【免费下载链接】follyAn open-source C library developed and used at Facebook.项目地址: https://gitcode.com/GitHub_Trending/fol/follyfolly/synchronization/SmallLocks.h为 MetaFacebook开源的 C 库 folly 提供了两种内存占用极小、专为“高内存约束、低竞争概率”场景设计的互斥锁单字节的MicroSpinLock与寄生在现有整数上、仅占一个比特位的PicoSpinLock。本文以 SmallLocks.md 为骨架结合 SmallLocks.h、两个锁的完整实现与测试代码讲解它们的设计动机、API 用法、底层原理与适用边界帮助你在大规模数据结构的每条记录上实现零额外内存成本的细粒度并发保护。一、为什么需要小到极致的锁在服务端大规模数据结构的场景下内存往往是稀缺资源。设想一个拥有数百万甚至数十亿条记录的巨型哈希表或链表如果为每条记录都配一个std::mutex通常数十字节内存开销会完全失控但如果整表共用一把大锁又会带来严重的竞争。folly 的解决思路是让锁小到可以塞进每条记录已有的空闲位——很多记录里恰好有一个闲置的字节甚至一个闲置的比特位此时加锁可以做到几乎零额外内存成本。这正是 SmallLocks 模块的定位它不是通用互斥锁的替代品而是为内存极度受限、且竞争概率很低的场景专门设计的锁。文档开篇就明确了这一适用前提SmallLocks.h 头文件注释也强调这些锁适合临界区极小、竞争不激烈的场景。注意该模块目前仅支持 x64 架构文档原话 This module is currently x64 only.在移植到其他架构前需要自行评估。二、两种锁的定位与取舍SmallLocks.h向外导出了两种锁它们解决的是略有不同的小锁类型最小内存占用工作原理适用场景MicroSpinLock1 字节独占整个字节用 0/1 表示空闲/占用记录里刚好有 1 个空闲字节可用PicoSpinLockT2/4/8 字节1 个比特位在已有的整数类型上借用最高位做锁记录里已有整数且恰好有 1 个未用比特位文档特别解释了为什么两种锁都要保留x64 的btsbit test and set指令无法直接作用在单个字节上因此MicroSpinLock可以把体积压到 1 字节sizeof(MicroSpinLock)恰好为 1而PicoSpinLock至少要寄生在 16 位及以上的整数上。这意味着若你追求绝对最小体积1 字节选MicroSpinLock若你手头已有带空闲位的整数选PicoSpinLock可做到完全不增加内存。两种锁都完整实现了 C11 的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_guardMicroSpinLock g(r.lock); // RAII符合 Lockable 概念 r.value v; }配合std::unique_lock同样可行。此外 folly 还提供了别名using MSLGuard std::lock_guardMicroSpinLock;见 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 还顺带导出了一个SpinLockArrayT, 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成功返回 truevoid lock() const阻塞获取内部用Sleeper退避等待void unlock() const原子地清除锁位不动其余位4.3 使用示例借用整数的最高位#include folly/synchronization/SmallLocks.h #include mutex struct CachedEntry { // 业务数据只用低 31 位最高位留给锁 uint32_t generation 0; PicoSpinLockuint32_t lock; // 寄生在同一个 4 字节上 CachedEntry() { lock.init(0); } }; void bump(CachedEntry e) { std::lock_guardPicoSpinLockuint32_t g(e.lock); e.lock.setData(e.lock.getData() 1); // 持锁读写数据位 }核心价值在于锁和数据共用同一个整数对象内存零开销。如果某个记录里恰好有一个整数的高位从未被使用PicoSpinLock就能把它变成一把完整的互斥锁。测试 SmallLocksTest.cpp 还验证了有符号类型如int16_t场景PicoSpinLockint16_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_refUIntType::required_alignment)对齐声明保证原子位操作在目标平台上合法。由于Bit默认取最高位init()/setData()内部都有FOLLY_SAFE_CHECK拒绝侵占锁位的非法输入。五、为什么两者不可互相替代文档给出了两者并存的根本原因值得展开指令集的限制x64 的btsbit 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); // 打包结构PicoSpinLockuint32_t int16_t 6 字节 static_assert(folly::kMscVer || sizeof(ignore2) 6, Size check failed);两条static_assert分别验证了两种锁可以安全放入紧凑打包结构且各自的最小体积符合预期。简言之体积最小选 MicroSpinLock零成本复用选 PicoSpinLock。六、与 C11 Lockable 概念无缝衔接两种锁都提供lock()/unlock()/try_lock()三件套符合标准库对互斥量的要求因此可以用std::lock_guard/std::unique_lock做 RAII 作用域管理示例见上文测试 SmallLocksTest.cpp 大量使用直接用std::unique_lock配合条件变量等待folly 内部也有此用法。这意味着把它们接入现有基于标准互斥量编写的泛型代码时几乎不需要改动——这也是Lockable 概念设计带来的直接收益。七、重要提醒优先考虑 MicroLock两个头文件的文件头注释都放着一句醒目的忠告MicroSpinLock.h 与 PicoSpinLock.hN.B. You most likely donotwant to use MicroSpinLock or any other kind of spinlock. Consider MicroLock instead.原因是在抢占式多任务操作系统里用户态自旋锁有严重缺陷——等待线程反复轮询一个被阻塞线程持有的锁纯属浪费时间片让 OS 调度器把线程挂起睡眠对系统响应性和吞吐量都更有利。自旋锁更适合内核态。因此 folly 提供了第三种选择MicroLockMicroLock.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/setDataTSAN 死锁检测开启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/次、PicoSpinLockstd::uint16_t约 20.38ns/次明显快于std::mutex的 16.42ns/次但在多线程高竞争如 32 线程以上下自旋锁的公平性与吞吐会急剧恶化最大等待时间可达数百毫秒这印证了文档竞争概率低的前提——请务必用自己真实的负载重新基准。九、总结如何选择你的处境推荐能接受 1 字节额外空间追求绝对最小锁MicroSpinLock记录里已有整数且高位空闲想零内存成本加锁PicoSpinLockT愿意用 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),仅供参考
返回列表