资讯动态

C++自旋锁的实现示例

发布时间:2026/10/26 2:29:45 来源:尧图企业网站定制
前言自旋锁spinlock是最简单的同步原语拿不到锁就不让出 CPU在原地循环重试直到锁被释放。它的适用场景很窄——临界区极短、线程数不多、且确实需要避免线程切换的开销。用错地方它会比std::mutex慢得多还会白白烧掉一个核心。初学者常有的误解是「自旋锁比互斥锁快所以能用就用」。实际上std::mutex在多数实现里会先短暂自旋再进入内核等待已经做了自适应手写自旋锁只有在你能确认「等待时间短于一次线程切换」时才有意义。另一个误解是「用volatile bool当标志就行」——那不是原子操作并发读写是数据竞争属于未定义行为。本文用 C11 起的std::atomic_flag实现一个可用的自旋锁讲清楚内存序为什么必须是 acquire/release配合 RAII 守卫避免忘记解锁最后说明它的局限和常见错误。一、自旋锁与互斥锁的区别两者都提供互斥mutual exclusion区别在等待策略维度自旋锁std::mutex等待方式忙等持续占用 CPU通常先自旋再休眠上下文切换无可能发生适合的临界区极短几条指令任意长度单核表现差等待者空转好让出 CPU实现依赖原子操作操作系统原语优先级反转可能严重通常有处理机制自旋锁的优势是「没有系统调用、没有线程唤醒延迟」。当临界区只有几十条指令、竞争又不激烈时这个优势能体现出来。但一旦临界区里有一次内存分配、一次 I/O、或者一次可能阻塞的调用自旋就变成纯粹浪费。一个经验判断如果临界区里出现了new、std::cout、文件读写、锁之外的任何互斥量就不该用自旋锁。二、用 atomic_flag 实现std::atomic_flag是标准保证无锁lock-free的原子布尔类型接口极简test_and_set和clear。这正是自旋锁需要的。// 适用于 C17#include atomicclass SpinLock {public:void lock() {while (flag_.test_and_set(std::memory_order_acquire)) {// 锁已被占用继续尝试}}void unlock() {flag_.clear(std::memory_order_release);}private:std::atomic_flag flag_ ATOMIC_FLAG_INIT;};逐点说明test_and_set原子地把标志设为true并返回之前的值。返回false表示「之前是 false」即抢锁成功返回true表示别人持有继续循环。ATOMIC_FLAG_INIT保证初始状态为 clear。C20 起std::atomic_flag的默认构造函数保证初始化为 clear可以直接写std::atomic_flag flag_{};但为了兼容 C17示例保留宏。std::atomic_flag不可拷贝、不可移动所以SpinLock也应当显式删除拷贝构造和拷贝赋值避免被意外复制。// 适用于 C17SpinLock(const SpinLock) delete;SpinLock operator(const SpinLock) delete;三、内存序与 RAII 守卫把acquire/release换成relaxed代码仍然能编译甚至在 x86 上「看起来正常」但它是错的。test_and_set用acquire的语义是抢锁成功之后后续的读操作不会被重排到这条指令之前。clear用release的语义是解锁之前的写操作不会被重排到这条指令之后。两者配对形成「前一个临界区的写 → 解锁 → 加锁 → 后一个临界区的读」的同步关系保证临界区内的修改对下一个持锁者可见。如果全用relaxed编译器和 CPU 都可以自由重排临界区里的写可能被挪到unlock之后下一个线程拿到锁时读到的还是旧值。这不是「概率性问题」而是标准层面的数据竞争属于未定义行为。C20 起std::atomic_flag还提供了test()和wait()/notify_one()可以用「自旋若干次后进入等待」的混合策略减少空转。这需要 C20 支持且wait的实现依赖平台使用前应确认工具链。3.1 RAII 守卫手写lock()/unlock()最大的风险是异常路径漏掉解锁。用一个作用域守卫解决// 适用于 C17class SpinLockGuard {public:explicit SpinLockGuard(SpinLock lock) : lock_(lock) {lock_.lock();}~SpinLockGuard() {lock_.unlock();}SpinLockGuard(const SpinLockGuard) delete;SpinLockGuard operator(const SpinLockGuard) delete;private:SpinLock lock_;};构造函数加锁、析构函数解锁即使临界区抛出异常栈展开时析构也会执行。这要求SpinLock本身不抛异常——lock()只做原子操作和循环unlock()只做一次clear满足要求。如果需要在作用域中途提前释放可以加一个unlock()成员并记录状态但大多数场景不需要提前释放往往意味着临界区划分得不合理。四、局限与改进方向自旋锁在以下情况会出问题单核环境持有者需要 CPU 时间片才能释放锁等待者却一直占着 CPU两者互相拖慢。操作系统调度器可能把等待线程一直运行下去导致持有者迟迟得不到调度。临界区长等待者空转的时间远大于休眠再唤醒的成本。竞争激烈所有线程在同一缓存行上反复test_and_set缓存一致性流量很大。常见的缓解是「测试-测试-设置」test and test-and-set先用便宜的读操作检查发现空闲再尝试原子置位。优先级反转低优先级线程持锁被抢占高优先级线程自旋等待中等优先级线程却一直运行。改进手段包括自旋若干次后调用std::this_thread::yield()让出时间片或者用 C20 的atomic_flag::wait或者干脆改用std::mutex。选择哪一种应当在目标平台上用std::chrono::steady_clock测量而不是凭直觉。完整示例// 适用于 C17#include atomic#include iostream#include thread#include vectorclass SpinLock {public:void lock() {while (flag_.test_and_set(std::memory_order_acquire)) {}}void unlock() {flag_.clear(std::memory_order_release);}SpinLock(const SpinLock) delete;SpinLock operator(const SpinLock) delete;SpinLock() default;private:std::atomic_flag flag_ ATOMIC_FLAG_INIT;};class SpinLockGuard {public:explicit SpinLockGuard(SpinLock lock) : lock_(lock) { lock_.lock(); }~SpinLockGuard() { lock_.unlock(); }SpinLockGuard(const SpinLockGuard) delete;SpinLockGuard operator(const SpinLockGuard) delete;private:SpinLock lock_;};int main() {SpinLock lock;long long counter 0;constexpr int kThreads 4;constexpr int kPerThread 100000;std::vectorstd::thread workers;workers.reserve(kThreads);for (int t 0; t kThreads; t) {workers.emplace_back([] {for (int i 0; i kPerThread; i) {SpinLockGuard guard(lock);counter;}});}for (auto th : workers) {th.join();}std::cout counter \n; // 按语义应为 400000return 0;}几个细节counter是普通的long long不是std::atomic。它只在持锁期间被访问锁提供了互斥和可见性所以不需要额外的原子类型。每个线程结束后必须join()否则std::thread析构时调用std::terminate。按标准语义最终结果应当是 400000这里不讨论耗时因为自旋锁的实际表现高度依赖核心数、竞争程度和调度策略。常见坑点❌ 用普通bool做标志cpp bool locked false; while (locked) { } // 数据竞争UB✅ 用std::atomic_flag或std::atomicbool。❌ 全部使用memory_order_relaxedcpp while (flag_.test_and_set(std::memory_order_relaxed)) { } flag_.clear(std::memory_order_relaxed);✅ 加锁用acquire解锁用release。❌ 手动加锁后忘记解锁尤其在有return或异常的分支cpp lock.lock(); if (bad) return; // 锁永远不释放 lock.unlock(); ✅ 用 RAII 守卫析构自动解锁。❌ 在自旋锁保护下做耗时操作cpp SpinLockGuard g(lock); std::this_thread::sleep_for(std::chrono::milliseconds(10)); ✅ 自旋锁只保护几条指令的临界区耗时操作放到锁外。❌ 允许拷贝锁对象cpp SpinLock a; SpinLock b a; // 两个锁保护同一份数据却互不知情 ✅ 删除拷贝构造和拷贝赋值。❌ 在单核或线程数远大于核心数的环境里使用自旋锁。✅ 改用std::mutex或自旋几次后yield()。❌ 同一个线程重复加锁cpp lock.lock(); lock.lock(); // 死锁自旋锁不可重入✅ 明确锁的层级或用std::recursive_mutex但通常说明设计有问题。❌ 认为std::atomic_flag一定比std::mutex快。✅ 是否更快取决于竞争程度和临界区长度需要在目标平台测量。总结要素正确做法原子类型std::atomic_flagATOMIC_FLAG_INIT初始化加锁test_and_set(std::memory_order_acquire)循环解锁clear(std::memory_order_release)异常安全RAII 守卫可拷贝性显式删除适用场景极短临界区、竞争不激烈自旋锁的代码只有十几行难点全在「什么时候该用」。它的正确性依赖 acquire/release 的配对它的性能依赖临界区足够短。把这两点想清楚再决定是手写还是直接用std::mutex。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑