Table of contents
Open Table of contents
TL;DR
C++11 之前没有语言级内存模型,多线程代码在编译器 + CPU 双重乱序下不可能正确(Hans Boehm 2005 论文证明)。C++11 一次性引入内存模型、std::atomic、std::thread、std::future 四件套,给了 C++ 官方并发语义。六种 memory order 是理解的核心——在 x86 上 release/acquire 几乎免费、seq_cst 要 mfence,在 ARM 上差距更大,这正是暴露细粒度 order 的意义。
1. 为什么需要标准并发库:Hans Boehm 的证明
C++03 的困境:标准里完全没有”线程”概念——编译器假设程序单线程执行。多线程靠 pthread/Win32 API,不可移植。
更致命的是没有内存模型。考虑经典代码:
int data = 0;
bool ready = false;
// 线程 A
void producer() {
data = 42;
ready = true; // 先写 data 后写 ready?
}
// 线程 B
void consumer() {
while (!ready) {}
std::cout << data; // 一定能读到 42 吗?
}
C++03 里这是 UB:编译器可任意重排(data 和 ready 无数据依赖)、CPU 乱序执行、StoreBuffer 让其他核看到的写入顺序不同。没有内存模型就没有任何同步保证。
Hans Boehm 2005 年论文《Threads Cannot Be Implemented as a Library》直接指出:没有语言级内存模型,pthread 无论怎么设计都不可能在 C/C++ 上正确工作。这篇论文推动了 C++11 和 C11 把并发纳入标准。
C++11 四件套:
- 内存模型 — 定义什么情况下线程间写入对其他线程可见
std::atomic<T>— 基于内存模型的原子操作std::thread/std::mutex/std::condition_variable— 高层同步原语std::future/std::promise/std::async— 异步结果传递
这是 C++ 历史上第一次有”官方并发语义”。
2. std::thread
2.1 基本用法
#include <thread>
void worker(int id) { /* ... */ }
int main() {
std::thread t(worker, 42); // 可变参数传递
t.join(); // 等待结束
// 或 t.detach(); // 分离,后台运行
}
std::thread 是 move-only(像 unique_ptr),禁止拷贝——管理的是 OS 线程资源,所有权唯一。
2.2 未 join 就析构 = std::terminate(fail-fast 设计哲学)
{
std::thread t(worker, 1);
} // 析构时既没 join 也没 detach → std::terminate() 直接杀进程
为什么不默认 detach 或默认 join?委员会讨论(N3225):两种默认都是错的。
- 默认 detach:线程会在 main 结束后访问已销毁的栈变量,导致悄无声息的内存破坏。最糟糕的失败模式——难复现、难调试
- 默认 join:如果构造线程的作用域因异常退出,默认 join 会让整个程序等待一个可能根本不会结束的线程
委员会选 terminate,本质是”你忘了决策就是 bug,立即暴露”,符合 fail-fast 原则。
2.3 C++20 std::jthread 修正一切
#include <thread>
#include <stop_token>
void worker(std::stop_token st) {
while (!st.stop_requested()) { /* ... */ }
}
std::jthread t(worker);
// 析构时自动 request_stop() + join()
两大改进:
- 析构自动 join(RAII 完整化)
- 协作取消(通过
stop_token,比pthread_cancel的异步取消安全得多——线程决定什么时候响应)
C++11 不这样做是因为当时没有 stop_token,默认 join 可能永远等卡死的线程。C++20 通过 stop_token 解决了取消问题才敢默认 join。
2.4 hardware_concurrency
unsigned n = std::thread::hardware_concurrency();
// 只是提示,可能返回 0
经验规划:CPU 密集型用 n 个工作线程,IO 密集型 2n~4n。
3. 互斥原语与 RAII 锁守卫
| 类型 | 引入 | 用途 |
|---|---|---|
std::mutex | C++11 | 基础 |
std::recursive_mutex | C++11 | 同线程可多次获取(慎用,通常是设计问题) |
std::timed_mutex | C++11 | 支持 try_lock_for 超时 |
std::shared_mutex | C++17 | 读写锁 |
3.1 四个 RAII 锁守卫
std::mutex m;
// 1. lock_guard — 最轻量,构造 lock,析构 unlock
{ std::lock_guard<std::mutex> lg(m); /* ... */ }
// 2. unique_lock — 灵活,支持延迟加锁、手动解锁、与 cv 配合
{
std::unique_lock<std::mutex> ul(m, std::defer_lock);
ul.lock();
ul.unlock();
}
// 3. scoped_lock (C++17) — 一次锁多个,自动死锁规避
std::mutex m1, m2;
{ std::scoped_lock lock(m1, m2); /* ... */ }
// 4. shared_lock (C++14) — 配合 shared_mutex 的读锁
std::shared_mutex sm;
{ std::shared_lock<std::shared_mutex> rl(sm); /* ... */ }
3.2 AB-BA 死锁与 std::lock
// 错误:获取锁的顺序相反 → AB-BA 死锁
void transfer(Account& from, Account& to, int amt) {
std::lock_guard<std::mutex> l1(from.mtx);
std::lock_guard<std::mutex> l2(to.mtx); // 另一函数顺序相反
// ...
}
// 正确(C++17):scoped_lock 一次锁多个
void transfer(Account& from, Account& to, int amt) {
std::scoped_lock lock(from.mtx, to.mtx);
from.balance -= amt;
to.balance += amt;
}
std::scoped_lock / std::lock 内部用 try-and-back-off 算法:尝试依次锁住所有 mutex,某一步失败就释放已持有的锁换顺序重试,避免任何固定顺序的死锁。
4. 条件变量
#include <condition_variable>
std::mutex mtx;
std::condition_variable cv;
std::queue<int> q;
bool done = false;
// 消费者
void consumer() {
while (true) {
std::unique_lock<std::mutex> lock(mtx);
cv.wait(lock, []{ return !q.empty() || done; });
// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
// 谓词:自动循环检查,避免虚假唤醒
if (q.empty() && done) return;
int v = q.front(); q.pop();
lock.unlock();
process(v);
}
}
// 生产者
void producer(int x) {
{
std::lock_guard<std::mutex> lk(mtx);
q.push(x);
}
cv.notify_one(); // 先解锁再 notify,减少锁竞争
}
4.1 虚假唤醒(spurious wakeup)
cv.wait() 可能在没有任何 notify 时返回。这不是 bug 而是 POSIX 语义——避免虚假唤醒的成本极高。必须在 while 循环里重新检查谓词。cv.wait(lock, predicate) 这个二参数形式内部就是个 while 循环。
4.2 notify_one vs notify_all
notify_one:唤醒一个。适合任务队列(一个事件只有一个消费者能处理)notify_all:唤醒所有。适合 shutdown 广播、状态变更
陷阱:惊群效应:一个资源 notify_all 唤醒 100 个线程,只有 1 个能拿到锁,99 个白白唤醒。
4.3 先 notify 后 wait = 信号丢失
std::thread producer([]{ cv.notify_one(); }); // 马上 notify
std::this_thread::sleep_for(1s);
std::unique_lock<std::mutex> lock(mtx);
cv.wait(lock); // 永远等不到
notify 不会被”记住”——没有 wait 时 notify 直接丢失。这是为什么必须搭配共享状态 + mutex,wait 前先检查状态。
5. 原子操作与 memory order(核心)
5.1 atomic 的本质
std::atomic<int> counter{0};
counter.fetch_add(1);
std::atomic<T> 给你两个保证:
- 原子性——CPU 指令层面不可分割(x86 的
lock前缀,ARM 的 LL/SCldxr/stxr) - 可控的内存顺序——编译器和 CPU 不会跨越原子操作做有害重排
is_always_lock_free:不是所有 atomic<T> 都是 lock-free 的。atomic<int> / atomic<void*> 一般是,atomic<struct{int a,b,c;}> 会退化成内部加锁。C++17 提供编译期常量 atomic<T>::is_always_lock_free。
5.2 六种 memory order
enum memory_order {
memory_order_relaxed, // 只要原子性,不管顺序
memory_order_consume, // 数据依赖的 acquire(已弃用)
memory_order_acquire, // 读-获取
memory_order_release, // 写-释放
memory_order_acq_rel, // acquire + release(RMW)
memory_order_seq_cst // 顺序一致性(默认)
};
5.3 relaxed:只要原子性
std::atomic<int> counter{0};
counter.fetch_add(1, std::memory_order_relaxed);
典型场景是计数器——只关心最终总和,不关心”线程 A 看到 counter=3 时线程 B 的其他变量处于什么状态”。如果想用计数器做同步(如”counter == N 时代表所有线程都到了这里”),relaxed 就错了。
5.4 release-acquire:构建 happens-before(经典”数据 + flag”模式)
std::atomic<bool> ready{false};
int data = 0;
// 线程 A
void producer() {
data = 42; // (1)
ready.store(true, std::memory_order_release); // (2)
}
// 线程 B
void consumer() {
while (!ready.load(std::memory_order_acquire)) {} // (3)
assert(data == 42); // (4) 保证成立
}
关键语义:
- release (2):保证之前的读写 不会被重排到它之后。(1) 一定在 (2) 之前完成
- acquire (3):保证之后的读写 不会被重排到它之前。(4) 一定在 (3) 之后执行
- release-acquire 配对:当 (3) 读到 (2) 写入的
true时,(2) 之前的所有写入对 (3) 之后的所有读取可见
这就建立了 happens-before 关系:(1) → (2) → (3) → (4)。
对比 seq_cst:seq_cst 不仅有 release-acquire 语义,还保证所有 seq_cst 操作有全局总顺序(所有线程都看到相同顺序)。最强也最贵。
5.5 CPU 层面的物理意义(硬核点)
x86/x64(强内存模型 TSO):
relaxedload/store:普通mov,零开销acquireload:普通mov(x86 load 天然带 acquire 语义)releasestore:普通mov(x86 store 天然带 release 语义)seq_cststore:mov+mfence(或lock xchg)—— 这里才有真正开销- RMW(
fetch_add):lock xadd等lock前缀指令
所以 x86 上 acquire/release 几乎免费,只有 seq_cst 需要 mfence。
ARM/ARM64(弱内存模型):
relaxed:ldr/stracquire:ldar(load-acquire)release:stlr(store-release)seq_cst:ldar+stlr或额外dmb ish- RMW:
ldaxr/stlxr配对 + 循环(LL/SC)
ARM 上 acquire/release 和 seq_cst 差距较大,这就是为什么 C++ 暴露这么细的 memory order——x86 上你写 seq_cst 没什么代价,ARM 上用 release/acquire 能省掉全屏障。
具体指令生成建议用 Compiler Explorer (godbolt) 实测确认(需验证)。
5.6 CAS 与 weak / strong
std::atomic<int> value{0};
int expected = 0;
// 循环里用 weak(允许虚假失败,性能更好)
while (!value.compare_exchange_weak(expected, expected + 1)) {}
// 单次尝试用 strong(避免误判)
bool ok = value.compare_exchange_strong(expected, 1);
底层:ARM 的 LL/SC 实现 CAS 本身就可能 spurious fail,strong 版需要内部再包一层循环屏蔽失败。既然你自己就在循环里,直接用 weak 更好。
5.7 ABA 问题
Node* top;
void pop() {
Node* old_top = top; // 读到 A
Node* new_top = old_top->next; // A 的 next
// 此时线程 B pop 了 A,又 pop 了 B,又 push 回 A(地址复用)
// old_top 仍然是 A 地址,但 A->next 已经变了!
cas(&top, old_top, new_top); // CAS 成功但语义错了
}
解决方案:
- Tagged pointer:指针 + 版本号打包,
atomic<pair<Node*, int>>,x86-64 上用cmpxchg16b - Hazard pointer(Maged Michael 2004):每线程声明”我正在访问这个指针”,其他线程不能释放
- Epoch-based reclamation:类 RCU,延迟回收
5.8 双检查锁 DCLP
C++11 之前的错误写法:
Singleton* instance = nullptr;
std::mutex mtx;
Singleton* getInstance() {
if (instance == nullptr) {
std::lock_guard<std::mutex> l(mtx);
if (instance == nullptr) {
instance = new Singleton(); // (*) 问题在这里
}
}
return instance;
}
instance = new Singleton() 其实是三步:分配内存 / 调构造 / 赋值。编译器或 CPU 可以把后两步重排,另一个线程读到非 null 但构造还没跑完 → UB。
2026 年的正确写法:用 static 局部变量(C++11 起保证线程安全初始化,“Magic Statics”):
Singleton& getInstance() {
static Singleton instance; // 线程安全,零同步开销
return instance;
}
DCLP 只应该在面试里讨论,生产代码用 Magic Statics。
6. future / promise / async
// 1. std::async —— 最高层
auto f = std::async(std::launch::async, []{ return compute(); });
int r = f.get();
// 2. std::packaged_task —— 包装 callable 产出 future
std::packaged_task<int()> task([]{ return compute(); });
auto f = task.get_future();
std::thread(std::move(task)).detach();
// 3. std::promise —— 手动设置值/异常
std::promise<int> p;
auto f = p.get_future();
std::thread([&p]{ p.set_value(42); }).detach();
int r = f.get();
shared_future:普通 future::get() 只能调一次(move-only 语义),shared_future 可多次 get,适合广播。异常通过 p.set_exception(std::current_exception()) 传递。
6.1 std::async 是失败的 API
问题 1:默认策略的”惰性陷阱”
auto f = std::async([]{ return compute(); }); // 没指定策略
默认策略是 launch::async | launch::deferred——由实现自由选择。可能真起新线程,也可能等到 get() 时才在调用线程同步执行。你以为 100 个任务并发跑,实际可能都排队串行。必须显式指定:
auto f = std::async(std::launch::async, task);
问题 2:future 析构阻塞(几乎没人知道)
{
std::async(std::launch::async, long_task); // 临时 future
// 这里 future 立即析构,但析构会阻塞直到 long_task 完成!
}
标准要求 async 返回的 future 的析构必须 join 背后的线程。结果是 std::async(...); 单独一行根本不是异步,完全等同于同步调用。Scott Meyers 在 Effective Modern C++ Item 36 称之为 “最大的 API 设计失误之一”。
教训:不要用 std::async。用线程池(BS::thread_pool 等)、C++20 协程 + executor、或自己写的工作队列。
6.2 为什么 C++ 异步不如 Go 轻量
| 维度 | Go goroutine | C++ std::thread |
|---|---|---|
| 栈 | 起始 2KB,可增长 | 固定 ~1MB(glibc 8MB) |
| 调度 | 用户态 M:N | 内核态 1:1 |
| 创建 | μs 级 | ms 级(需 syscall) |
| 切换 | 用户态 ~100ns | 内核态 ~1-10μs |
| 千万级并发 | 可以 | 内存爆炸 |
C++ 没有内置 M:N 调度器。要达到类似 Go 的效果要用 C++20 协程 + executor(标准库 C++23 仍没给出,得靠 cppcoro / folly / asio)。future/promise 有堆分配,thread 每次创建要 syscall,根本无法与 goroutine 比。
7. C++20 新增原语
// jthread + stop_token
std::jthread t([](std::stop_token st) {
while (!st.stop_requested()) {}
});
// latch —— 一次性倒数屏障
std::latch l(3);
for (int i = 0; i < 3; i++) {
std::thread([&l]{ l.count_down(); }).detach();
}
l.wait();
// barrier —— 可重用屏障
std::barrier b(4, []{ std::cout << "phase done\n"; });
// counting_semaphore —— 信号量终于进标准
std::counting_semaphore<10> sem(3);
sem.acquire(); // P
sem.release(); // V
// atomic<shared_ptr>
std::atomic<std::shared_ptr<Config>> config;
auto p = config.load(); // 无锁读配置
config.store(std::make_shared<Config>(newCfg));
// atomic_ref —— 对非 atomic 对象提供原子视图
int x = 0;
std::atomic_ref<int> ar(x);
ar.fetch_add(1, std::memory_order_relaxed);
atomic<shared_ptr> 的意义:C++20 前多线程安全更新 shared_ptr 要用 std::atomic_load(&sp) 这种丑陋的自由函数接口(C++20 起弃用),现在标准化了,典型应用:无锁配置热更新、无锁对象池。
8. 无锁编程基础
8.1 进展性层次
- Wait-free:每个线程有限步内完成(最强最难)
- Lock-free:至少一个线程有限步内完成
- Obstruction-free:单线程独占运行时能完成
- Blocking(有锁):任何线程挂起可能导致系统卡死
8.2 SPSC 无锁队列(单生产单消费,最容易正确实现)
template<typename T, size_t N>
class SpscQueue {
static_assert((N & (N - 1)) == 0, "N must be power of 2");
T buffer[N];
alignas(64) std::atomic<size_t> head{0}; // 消费者
alignas(64) std::atomic<size_t> tail{0}; // 生产者
public:
bool push(const T& v) {
size_t t = tail.load(std::memory_order_relaxed);
size_t next = (t + 1) & (N - 1);
if (next == head.load(std::memory_order_acquire)) return false;
buffer[t] = v;
tail.store(next, std::memory_order_release);
return true;
}
bool pop(T& v) {
size_t h = head.load(std::memory_order_relaxed);
if (h == tail.load(std::memory_order_acquire)) return false;
v = buffer[h];
head.store((h + 1) & (N - 1), std::memory_order_release);
return true;
}
};
关键设计:
head和tail分别放在不同 cache line(alignas(64))避免 false sharing- 2 的幂容量 + 位运算取模
release-acquire配对:生产者 release tail 保证 buffer 写入在 tail 更新前完成
8.3 MPMC 无锁队列为什么难
ABA 问题、内存回收(什么时候能释放被弹出的节点)、多个 CAS 之间的协调。业界公认的 MPMC 无锁队列:moodycamel::ConcurrentQueue、folly::MPMCQueue、Vyukov bounded MPMC queue。不要自己写。
8.4 无锁不一定快
CAS 失败会重试,高竞争下:
- 线程互相挤着 CAS,成功率低
- cache line 在各核间弹来弹去(cache line ping-pong),总线带宽爆炸
- 10 个线程争抢一个无锁队列的吞吐可能比单线程还低
实践中 fine-grained lock + 分区(shard)常常比大一统的无锁结构更快。
8.5 False Sharing 与 hardware_destructive_interference_size
struct Bad {
std::atomic<int> a; // 线程 A 频繁写
std::atomic<int> b; // 线程 B 频繁写
// a 和 b 在同一个 64 字节 cache line 里
// 每次 A 写 a,B 的 cache line 被 invalidate → 巨大开销
};
struct Good {
alignas(std::hardware_destructive_interference_size) std::atomic<int> a;
alignas(std::hardware_destructive_interference_size) std::atomic<int> b;
};
hardware_destructive_interference_size 是 C++17 引入的编译期常量。x86-64 上一般 64 字节,Apple Silicon 是 128(M 系列 prefetcher 连续预取两个 cache line,需验证)。
实测:计数器从 30ns 降到 3ns 是常见案例。对高频原子操作,false sharing 是”性能悬崖”级陷阱。
9. 陷阱总表(面试必背)
| 陷阱 | 说明 |
|---|---|
| 忘记 join/detach → terminate | thread RAII 不是默认安全 |
std::async 默认策略 | 可能根本不并发 |
std::async future 析构阻塞 | 单独一行的 std::async(...); 等同同步 |
volatile 不等于原子 | C++ volatile 只防编译器优化,不防 CPU 乱序/多核可见性 |
| 条件变量忘 while 谓词检查 | 虚假唤醒返回错误数据 |
shared_ptr 本身不是原子 | 引用计数是原子的,但赋值本身不是,多线程写同一 shared_ptr 变量仍 UB |
| 双检查锁经典错误 | C++11 前是 UB,2026 年用 Magic Statics |
| False sharing 性能悬崖 | 高频原子变量必须 cache line 对齐 |
| ABA 问题 | 无锁结构高频陷阱 |
scoped_lock 空参数陷阱 | std::scoped_lock lock; 因 CTAD 编译通过但不锁任何东西 |
thread_local 构造/析构顺序不可靠 | 跨 TU 无序 |
std::mutex 不可跨进程 | 进程间互斥用 POSIX 命名信号量或共享内存 futex |
volatile vs atomic 尤其容易混:Java 的 volatile 有内存屏障语义,C++ 的没有。C++ 里多线程共享变量必须用 std::atomic。
10. 对比表
mutex vs atomic vs lock-free
| 维度 | mutex | atomic | lock-free 数据结构 |
|---|---|---|---|
| 开销(无竞争) | 15-30ns | 1-10ns | 5-20ns |
| 开销(有竞争) | 上下文切换 μs 级 | CAS 重试 | CAS 重试 |
| 正确性难度 | 低 | 中(memory order) | 高(ABA、回收) |
| 适用 | 临界区大、竞争低 | 单变量读写 | 高吞吐热点 |
std::async vs std::thread vs 线程池
| std::async | std::thread | 线程池 | |
|---|---|---|---|
| 资源管理 | 自动(但析构阻塞坑) | 手动 join/detach | 复用 |
| 返回值 | future 自带 | 自己做 promise | 通常返回 future |
| 策略灵活性 | 差 | 高 | 高 |
| 生产推荐 | 否 | 少量重型任务 | 大量短任务 |
11. 生产 Checklist
- 新代码默认用
std::jthread(C++20),老代码std::thread必须保证 join/detach - 所有类型的移动构造都写
noexcept(vector 扩容性能) - 不要用
std::async,用线程池 - 共享变量用
std::atomic或 mutex,绝不用volatile - 条件变量 wait 必须带谓词
- 高频原子变量 cache line 对齐
- 单例用 Magic Statics,不要手写 DCLP
- 多 mutex 用
std::scoped_lock避免死锁 - 跨线程
shared_ptr用std::atomic<std::shared_ptr<T>>(C++20)
12. 关键信息来源
- Hans Boehm,《Threads Cannot Be Implemented as a Library》 (2005) — 推动 C++11 内存模型的论文
- ISO C++11 标准 N3337 — memory model 规范
- **Scott Meyers,《Effective Modern C++》**Item 35-40 — async 设计失败、thread 析构等
- Anthony Williams,《C++ Concurrency in Action》 — 最权威的 C++ 并发教材
- Herlihy & Shavit,《The Art of Multiprocessor Programming》 — 无锁数据结构理论
- cppreference.com
<atomic>、<thread>、<mutex>页面
置信度说明:
- memory order 语义、jthread API、async 析构阻塞、scoped_lock 算法、Magic Statics 线程安全——确定(标准明确规定)
- x86/ARM 具体指令映射——需验证,建议 godbolt 实测
- Apple Silicon 128 字节 cache line——需验证,来自 LLVM 实现
- Hans Boehm 论文、Scott Meyers 对 async 的批评——确定