锁进阶:atomic_flag 自旋锁、shared_mutex 读写锁与无锁的代价

std::mutex 不是免费的:一旦发生竞争,抢不到锁的线程会被挂进内核 ,等锁释放再被唤醒,这一挂一醒就是两次上下文切换,微秒级开销。可如果你的临界区只有「++counter」这么短,等锁的时间可能比干活的时间还长。这篇讲清锁的三条进阶路线:极短临界区用自旋锁(atomic_flag 实现)、读多写少用读写锁(shared_mutex)、以及「别迷信无锁,先测再选」。

1. 引子:mutex 慢在「陷入内核」

std::mutex 在 Linux 上底层是 futex(fast userspace mutex)。它的聪明之处是无竞争时全程在用户态 (一条 cmpxchg 就拿到锁);但一旦竞争,失败者要调用 futex 系统调用把自己挂起,等释放者唤醒它。挂起和唤醒各走一次内核调度,代价比一次普通指令高好几个数量级。

于是有了一个直觉:如果临界区短到「进内核」比「原地等一会儿」还贵,那还不如在用户态空转(spin)等锁。这就是自旋锁(spinlock)存在的理由。

text 复制代码
   两种锁的竞争行为对比(时间 ──►)

   mutex(有竞争时)
   线程 A  [拿锁──临界区──放锁]                          [拿锁──...]
   线程 B         [拿不到 → 陷入内核挂起 → 被唤醒 → 再抢]
                    └── 两次上下文切换,微秒级 ──┘

   自旋锁(有竞争时)
   线程 A  [拿锁──临界区──放锁]
   线程 B         [拿不到 → 空转 test_and_set → 拿不到 → 空转 → 拿到]
                    └── 全程用户态,但白烧 CPU ──┘

一个烧 CPU、一个烧「挂起/唤醒」的开销。临界区越短,前者越划算;临界区越长、竞争越烈,后者越划算。

2. 用 atomic_flag 写一把自旋锁

std::atomic_flag 是唯一被标准保证始终无锁 的原子类型,它的 test_and_set() 就是自旋锁的全部原料:原子地「读出旧值并把值置为 true」,返回旧值。旧值是 false 说明抢到了,是 true 说明别人占着,就继续转。

cpp 复制代码
// spinlock.cpp --- 编译: g++ -std=c++17 -Wall -O2 -pthread spinlock.cpp -o spinlock
#include <atomic>
#include <cstdio>
#include <mutex>
#include <thread>
#include <vector>

class SpinLock {
    std::atomic_flag flag_ = ATOMIC_FLAG_INIT;

public:
    void lock() {
        while (flag_.test_and_set(std::memory_order_acquire)) {
            // 空转:临界区极短时才划算
        }
    }
    void unlock() { flag_.clear(std::memory_order_release); }
};

int main() {
    constexpr int kThreads = 4;
    constexpr int kRounds = 50000;

    SpinLock spin;
    long long counter = 0;

    std::vector<std::thread> ws;
    for (int t = 0; t < kThreads; ++t) {
        ws.emplace_back([&spin, &counter] {
            for (int k = 0; k < kRounds; ++k) {
                std::lock_guard<SpinLock> lock(spin);   // 自旋锁满足 BasicLockable,能直接用标准 RAII
                ++counter;
            }
        });
    }
    for (auto& w : ws) w.join();
    std::printf("自旋锁计数 = %lld(期望 %d)\n", counter, kThreads * kRounds);
}
text 复制代码
自旋锁计数 = 200000(期望 200000)

两个细节值得说:

  1. test_and_set(acquire) + clear(release) 是成对的 。acquire 保证「拿到锁之后,能看到上一个持锁者释放前的所有写」;release 保证「放锁之前,我做的所有写都对外可见」。这对内存序在 x86 上几乎免费,在 ARM 上却是实实在在的屏障。
  2. 自旋锁满足 BasicLockable (有 lock()/unlock()),所以 std::lock_guard<SpinLock> 直接能用,这满足了 Core Guidelines CP.20「锁用 RAII」的要求,不会漏 unlock。

用 ThreadSanitizer(clang 19)复验,确认这把自旋锁在 atomic_flag 的 acquire/release 下没有 data race:

text 复制代码
$ clang++ -std=c++17 -O1 -fsanitize=thread -g -pthread spinlock.cpp -o spin_tsan && ./spin_tsan
自旋锁计数 = 200000(期望 200000)
(stderr 无任何 ThreadSanitizer 报告 ------ 说明没有 data race)

官方文档:std::atomic_flag · std::atomic_flag::test_and_set · std::lock_guard

3. 自旋锁的代价与「缓存行对齐」防伪共享

自旋锁不是银弹,两个致命短板:

  1. 白烧 CPU:空转期间这个核 100% 忙,别的线程想用这个核还得被挤走。临界区稍微长一点,或者线程数多于核数,自旋就是纯浪费。
  2. 伪共享(false sharing)放大开销 :缓存行(cache line)通常 64 字节,两个线程各写自己的变量时,如果两个变量落在同一条缓存行里,一个写就会让另一个线程的缓存行失效,逼它去内存重新读,即使它们逻辑上毫不相关。

第 2 点用一段代码就能看清。两个线程各自增自己的 计数器,只是第一种写法让两个计数器紧挨着(共享缓存行),第二种用 alignas(64) 各自独占一行:

cpp 复制代码
// false_sharing.cpp --- 编译: g++ -std=c++17 -Wall -O2 -pthread false_sharing.cpp -o falseshare
#include <atomic>
#include <chrono>
#include <cstdio>
#include <thread>

struct alignas(64) PaddedCounter {          // 对齐到缓存行边界
    std::atomic<long long> value{0};
};

int main() {
    constexpr int kRounds = 5000000;        // 每个线程 500 万次自增

    std::atomic<long long> a{0}, b{0};      // 紧挨着,共享一条 64B 缓存行
    auto t0 = std::chrono::steady_clock::now();
    {
        std::thread t1([&] { for (int k = 0; k < kRounds; ++k) a.fetch_add(1, std::memory_order_relaxed); });
        std::thread t2([&] { for (int k = 0; k < kRounds; ++k) b.fetch_add(1, std::memory_order_relaxed); });
        t1.join(); t2.join();
    }
    auto t1 = std::chrono::steady_clock::now();

    PaddedCounter pa, pb;                   // 各占一条缓存行
    auto t2 = std::chrono::steady_clock::now();
    {
        std::thread t3([&] { for (int k = 0; k < kRounds; ++k) pa.value.fetch_add(1, std::memory_order_relaxed); });
        std::thread t4([&] { for (int k = 0; k < kRounds; ++k) pb.value.fetch_add(1, std::memory_order_relaxed); });
        t3.join(); t4.join();
    }
    auto t3 = std::chrono::steady_clock::now();

    const auto plain = std::chrono::duration_cast<std::chrono::milliseconds>(t1 - t0).count();
    const auto padded = std::chrono::duration_cast<std::chrono::milliseconds>(t3 - t2).count();
    std::printf("紧挨着(伪共享)   计数 %lld + %lld,耗时 %lld ms\n", a.load(), b.load(), static_cast<long long>(plain));
    std::printf("对齐 64B(无伪共享)计数 %lld + %lld,耗时 %lld ms\n", pa.value.load(), pb.value.load(), static_cast<long long>(padded));
}
text 复制代码
紧挨着(伪共享)   计数 5000000 + 5000000,耗时 ~~ ms
对齐 64B(无伪共享)计数 5000000 + 5000000,耗时 ~~ ms

计数两边都是精确的 5000000(伪共享只影响速度、不影响正确性);耗时随机器浮动,但紧挨着的那组通常慢一到两个量级 ,因为线程 1 每写一次 a,线程 2 的 b 所在缓存行就失效一次,两个线程在缓存一致性协议上互相踩脚。这个教训对锁同样成立:频繁竞争的两把锁、或锁和它保护的计数器,别让它们挤在同一条缓存行里 ,alignas(64) 是最省事的办法。

官方文档:alignas(对齐说明符)

4. 读写锁 shared_mutex:读多写少才划算

有些场景「读」压倒性地多、「写」极少(配置表、缓存、路由表)。如果所有读者都互斥,读操作也串行化,浪费并发。std::shared_mutex(C++17)允许多个读者同时持锁(共享锁),写者独占(独占锁):

cpp 复制代码
// rwlock.cpp --- 编译: g++ -std=c++17 -Wall -O2 -pthread rwlock.cpp -o rwlock
#include <atomic>
#include <cstdio>
#include <mutex>
#include <shared_mutex>
#include <thread>
#include <vector>

int main() {
    constexpr int kReaders = 4;
    constexpr int kReadsPer = 100000;
    constexpr int kWrites = 100;

    std::shared_mutex mtx;
    int value = 0;
    std::atomic<long long> total_reads{0};

    std::vector<std::thread> readers;
    for (int r = 0; r < kReaders; ++r) {
        readers.emplace_back([&] {
            for (int k = 0; k < kReadsPer; ++k) {
                std::shared_lock<std::shared_mutex> lock(mtx);   // 共享锁:读者之间不互斥
                total_reads.fetch_add(1, std::memory_order_relaxed);
                volatile int snapshot = value;                    // 读共享数据
                (void)snapshot;
            }
        });
    }

    std::thread writer([&] {
        for (int k = 0; k < kWrites; ++k) {
            std::unique_lock<std::shared_mutex> lock(mtx);        // 独占锁:写者互斥、也排斥读者
            ++value;
        }
    });

    for (auto& t : readers) t.join();
    writer.join();

    std::printf("总读次数 = %lld(期望 %d)\n", total_reads.load(), kReaders * kReadsPer);
    std::printf("最终 value = %d(期望 %d)\n", value, kWrites);
}
text 复制代码
总读次数 = 400000(期望 400000)
最终 value = 100(期望 100)

std::shared_lock 拿共享锁(读者可并发),std::unique_lock 拿独占锁(写者独享)。读写锁的价值有严格前提:读要足够多、且单次读要足够长 。因为共享锁本身的加解锁比普通 mutex 更贵(内部要维护读者计数),如果读只是一次 int 赋值,读写锁的额外开销可能抵消掉并发收益,这时普通 mutex 反而更快。记住这条判断:读多写少 + 读够长,才上 shared_mutex。

锁类型 读者并发 加解锁开销 适用场景
std::mutex 互斥 低(无竞争时一条 cmpxchg) 通用,临界区短或读写均衡
自旋锁(atomic_flag) 互斥 极低,但竞争时烧 CPU 临界区极短、竞争时间短
std::shared_mutex 读者共享 较高(维护读者计数) 读多写少且单次读够长

官方文档:std::shared_mutex · std::shared_lock · std::unique_lock

5. 无锁 vs 有锁:先测再选,别迷信

把自旋锁和 std::mutex 放在同一段代码里比一比。同样 200 万次「加锁、自增、放锁」:

cpp 复制代码
// spin_vs_mutex.cpp --- 编译: g++ -std=c++17 -Wall -O2 -pthread spin_vs_mutex.cpp -o svm
#include <atomic>
#include <chrono>
#include <cstdio>
#include <mutex>
#include <thread>
#include <vector>

class SpinLock {
    std::atomic_flag flag_ = ATOMIC_FLAG_INIT;
public:
    void lock() { while (flag_.test_and_set(std::memory_order_acquire)) { } }
    void unlock() { flag_.clear(std::memory_order_release); }
};

int main() {
    constexpr int kThreads = 4;
    constexpr int kRounds = 500000;

    SpinLock spin;
    std::mutex mtx;
    long long spin_counter = 0;
    long long mtx_counter = 0;

    auto t0 = std::chrono::steady_clock::now();
    {
        std::vector<std::thread> ws;
        for (int t = 0; t < kThreads; ++t) {
            ws.emplace_back([&] {
                for (int k = 0; k < kRounds; ++k) {
                    std::lock_guard<SpinLock> lk(spin);
                    ++spin_counter;
                }
            });
        }
        for (auto& w : ws) w.join();
    }
    auto t1 = std::chrono::steady_clock::now();

    {
        std::vector<std::thread> ws;
        for (int t = 0; t < kThreads; ++t) {
            ws.emplace_back([&] {
                for (int k = 0; k < kRounds; ++k) {
                    std::lock_guard<std::mutex> lk(mtx);
                    ++mtx_counter;
                }
            });
        }
        for (auto& w : ws) w.join();
    }
    auto t2 = std::chrono::steady_clock::now();

    const auto spin_ms = std::chrono::duration_cast<std::chrono::milliseconds>(t1 - t0).count();
    const auto mtx_ms = std::chrono::duration_cast<std::chrono::milliseconds>(t2 - t1).count();
    std::printf("自旋锁计数 = %lld,耗时 %lld ms\n", spin_counter, static_cast<long long>(spin_ms));
    std::printf("mutex  计数 = %lld,耗时 %lld ms\n", mtx_counter, static_cast<long long>(mtx_ms));
}
text 复制代码
自旋锁计数 = 2000000,耗时 ~~ ms
mutex  计数 = 2000000,耗时 ~~ ms

计数两边都是精确的 2000000(谁都不会错,只是快慢不同)。耗时用占位符是因为它随机器、核数、负载大幅波动,写死没有意义,但结论的方向是稳定的:

  • 当临界区极短、线程数不超过物理核数时,自旋锁通常更快(省掉了进内核的开销);
  • 当线程数远大于核数、或临界区变长时,自旋锁的空转会反过来拖垮整体,mutex 反而更稳。

这就是「无锁/自旋不一定快」的真相:无锁省掉的是「挂起/唤醒」,付出的是「空转 + 更难写对」 。无锁数据结构的正确性要自己证明(ABA、内存序、内存回收),而出错的代价是诡异的 UB。所以务实顺序永远是:先用 std::mutex 写对 → 测出热点 → 再考虑自旋锁或原子操作 → 最后才想无锁结构。

6. 完整示例:缓存行对齐的自旋锁

把自旋锁和防伪共享合起来:两把自旋锁各自保护一个计数器,线程之间并不竞争同一把锁,但锁对象紧挨着时依然会伪共享。完整程序如下:

cpp 复制代码
// spin_padded.cpp --- 编译: g++ -std=c++17 -Wall -O2 -pthread spin_padded.cpp -o spinpad
#include <atomic>
#include <chrono>
#include <cstdio>
#include <mutex>
#include <thread>

struct SpinLock {
    std::atomic_flag flag_ = ATOMIC_FLAG_INIT;
    void lock() { while (flag_.test_and_set(std::memory_order_acquire)) { } }
    void unlock() { flag_.clear(std::memory_order_release); }
};

struct alignas(64) PaddedSpinLock {          // 对齐到缓存行,独占 64B
    std::atomic_flag flag_ = ATOMIC_FLAG_INIT;
    void lock() { while (flag_.test_and_set(std::memory_order_acquire)) { } }
    void unlock() { flag_.clear(std::memory_order_release); }
};

int main() {
    constexpr int kRounds = 3000000;

    SpinLock l1, l2;                         // 紧挨着:可能共享缓存行
    long long c1 = 0, c2 = 0;
    auto t0 = std::chrono::steady_clock::now();
    {
        std::thread a([&] { for (int k = 0; k < kRounds; ++k) { std::lock_guard<SpinLock> g(l1); ++c1; } });
        std::thread b([&] { for (int k = 0; k < kRounds; ++k) { std::lock_guard<SpinLock> g(l2); ++c2; } });
        a.join(); b.join();
    }
    auto t1 = std::chrono::steady_clock::now();

    PaddedSpinLock p1, p2;                   // 对齐:各占一条缓存行
    long long d1 = 0, d2 = 0;
    auto t2 = std::chrono::steady_clock::now();
    {
        std::thread c([&] { for (int k = 0; k < kRounds; ++k) { std::lock_guard<PaddedSpinLock> g(p1); ++d1; } });
        std::thread d([&] { for (int k = 0; k < kRounds; ++k) { std::lock_guard<PaddedSpinLock> g(p2); ++d2; } });
        c.join(); d.join();
    }
    auto t3 = std::chrono::steady_clock::now();

    const auto plain = std::chrono::duration_cast<std::chrono::milliseconds>(t1 - t0).count();
    const auto padded = std::chrono::duration_cast<std::chrono::milliseconds>(t3 - t2).count();
    std::printf("紧挨锁  计数 %lld + %lld,耗时 %lld ms\n", c1, c2, static_cast<long long>(plain));
    std::printf("对齐锁  计数 %lld + %lld,耗时 %lld ms\n", d1, d2, static_cast<long long>(padded));
}
text 复制代码
紧挨锁  计数 3000000 + 3000000,耗时 ~~ ms
对齐锁  计数 3000000 + 3000000,耗时 ~~ ms

两个线程各用各的锁、互不抢锁,正确性毫无差别,但锁对象的缓存行位置会影响吞吐 ------ 这就是性能工程里「写对了还不够,还得看数据怎么摆」的典型一课。

7. 延伸阅读

本知识库内的相关篇目:

8. 一句话总结

std::mutex 竞争时要把失败线程挂进内核、唤醒再抢,代价是两次上下文切换;极短临界区可以用 atomic_flag 的 test_and_set 写自旋锁在用户态空转,省掉进内核、但会烧 CPU,且必须 acquire/release 成对出现并用 RAII 包裹;读多写少且单次读够长时用 shared_mutex 让读者并发、写者独占;「无锁/自旋不一定快」------ 它省的是挂起唤醒、付的是空转和正确性风险,所以先写对、再测热点、最后才优化;无论锁还是计数器,别让频繁竞争的共享变量挤在同一条 64 字节缓存行里,alignas(64) 能避免伪共享。

相关推荐
ra90fy1 小时前
B3873 [GESP202309 六级] 小杨买饮料
c++·算法
尘客-追梦1 小时前
qmake / jom / 影子构建:把编译管起来
开发语言·c++·qt
Zootopia6262 小时前
论文复现|五机编队任务链
c语言·c++·考研·无人机·创业创新·学习方法·高考
王老师青少年编程2 小时前
2026年6月GESP真题及题解(C++八级): 选择题和判断题(题解)
c++·题解·真题·gesp·csp·信奥赛·八级
sinat_286945192 小时前
大模型推理:部署方式与性能优化思路
人工智能·算法·缓存·chatgpt·性能优化
longlongzihan2 小时前
LeetCode 283. 移动零:从辅助数组到双指针原地算法
c++·算法·leetcode
艾莉丝努力练剑2 小时前
【AI大模型接入SDK】C++ ChatSDK使用手册
开发语言·网络·c++·人工智能·学习·大模型
程序喵大人11 小时前
【C++入门】编译链接模型 - 01 一个 C++ 程序是怎样变成可执行文件的
开发语言·c++·编译链接模型
by2099912 小时前
vector 的模拟实现:扩容与边界的详细阐述(下)
c++·经验分享·vector·动态数组