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)
两个细节值得说:
test_and_set(acquire)+clear(release)是成对的 。acquire保证「拿到锁之后,能看到上一个持锁者释放前的所有写」;release保证「放锁之前,我做的所有写都对外可见」。这对内存序在 x86 上几乎免费,在 ARM 上却是实实在在的屏障。- 自旋锁满足
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. 自旋锁的代价与「缓存行对齐」防伪共享
自旋锁不是银弹,两个致命短板:
- 白烧 CPU:空转期间这个核 100% 忙,别的线程想用这个核还得被挤走。临界区稍微长一点,或者线程数多于核数,自旋就是纯浪费。
- 伪共享(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. 延伸阅读
- std::atomic_flag --- cppreference ------ 标准保证无锁的唯一原子类型,自旋锁的原料
- std::atomic_flag::test_and_set --- cppreference ------ 原子「读并置位」,自旋锁抢锁就是循环调它
- std::shared_mutex --- cppreference ------ 读写锁,配
shared_lock/unique_lock使用 - C++ Core Guidelines CP.20 / CP.21 --- isocpp ------ 锁用 RAII、绝不手写裸
lock/unlock - Compiler Explorer --- godbolt.org ------ 把自旋锁贴进去看
test_and_set在 x86/ARM 上分别生成什么指令
本知识库内的相关篇目:
- 《内存序实战:release/acquire 三种经典模式与 seq_cst 的代价》 ------ 为什么原子操作默认用 seq_cst?因为它最不容易写错,但也是最贵的。
- 《无锁内存回收:hazard pointer 与 epoch-based reclamation 原理》 ------ 无锁栈 pop 出来的节点为什么不能立刻 delete?因为另一个线程可能还攥着它的指针。
- 《无锁栈:Treiber 栈的 CAS、ABA 问题与 tagged pointer》 ------ 无锁栈(Treiber stack)是理解无锁编程的入门课
8. 一句话总结
std::mutex 竞争时要把失败线程挂进内核、唤醒再抢,代价是两次上下文切换;极短临界区可以用 atomic_flag 的 test_and_set 写自旋锁在用户态空转,省掉进内核、但会烧 CPU,且必须 acquire/release 成对出现并用 RAII 包裹;读多写少且单次读够长时用 shared_mutex 让读者并发、写者独占;「无锁/自旋不一定快」------ 它省的是挂起唤醒、付的是空转和正确性风险,所以先写对、再测热点、最后才优化;无论锁还是计数器,别让频繁竞争的共享变量挤在同一条 64 字节缓存行里,alignas(64) 能避免伪共享。