跳转到正文
zeno's blog
返回

现代 C++(五):内存模型、atomic、thread 与 future

专题: 现代 C++

Table of contents

Open Table of contents

TL;DR

C++11 之前没有语言级内存模型,多线程代码在编译器 + CPU 双重乱序下不可能正确(Hans Boehm 2005 论文证明)。C++11 一次性引入内存模型、std::atomicstd::threadstd::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 四件套

  1. 内存模型 — 定义什么情况下线程间写入对其他线程可见
  2. std::atomic<T> — 基于内存模型的原子操作
  3. std::thread / std::mutex / std::condition_variable — 高层同步原语
  4. 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::threadmove-only(像 unique_ptr),禁止拷贝——管理的是 OS 线程资源,所有权唯一。

2.2 未 join 就析构 = std::terminate(fail-fast 设计哲学)

{
    std::thread t(worker, 1);
}  // 析构时既没 join 也没 detach → std::terminate() 直接杀进程

为什么不默认 detach 或默认 join?委员会讨论(N3225):两种默认都是错的。

委员会选 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()

两大改进:

  1. 析构自动 join(RAII 完整化)
  2. 协作取消(通过 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::mutexC++11基础
std::recursive_mutexC++11同线程可多次获取(慎用,通常是设计问题)
std::timed_mutexC++11支持 try_lock_for 超时
std::shared_mutexC++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_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> 给你两个保证:

  1. 原子性——CPU 指令层面不可分割(x86 的 lock 前缀,ARM 的 LL/SC ldxr/stxr
  2. 可控的内存顺序——编译器和 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) 保证成立
}

关键语义

这就建立了 happens-before 关系:(1) → (2) → (3) → (4)。

对比 seq_cst:seq_cst 不仅有 release-acquire 语义,还保证所有 seq_cst 操作有全局总顺序(所有线程都看到相同顺序)。最强也最贵。

5.5 CPU 层面的物理意义(硬核点)

x86/x64(强内存模型 TSO)

所以 x86 上 acquire/release 几乎免费,只有 seq_cst 需要 mfence

ARM/ARM64(弱内存模型)

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 成功但语义错了
}

解决方案:

  1. Tagged pointer:指针 + 版本号打包,atomic<pair<Node*, int>>,x86-64 上用 cmpxchg16b
  2. Hazard pointer(Maged Michael 2004):每线程声明”我正在访问这个指针”,其他线程不能释放
  3. 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 goroutineC++ 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 进展性层次

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;
    }
};

关键设计

8.3 MPMC 无锁队列为什么难

ABA 问题内存回收(什么时候能释放被弹出的节点)、多个 CAS 之间的协调。业界公认的 MPMC 无锁队列:moodycamel::ConcurrentQueuefolly::MPMCQueue、Vyukov bounded MPMC queue。不要自己写

8.4 无锁不一定快

CAS 失败会重试,高竞争下:

实践中 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 → terminatethread 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

维度mutexatomiclock-free 数据结构
开销(无竞争)15-30ns1-10ns5-20ns
开销(有竞争)上下文切换 μs 级CAS 重试CAS 重试
正确性难度中(memory order)高(ABA、回收)
适用临界区大、竞争低单变量读写高吞吐热点

std::async vs std::thread vs 线程池

std::asyncstd::thread线程池
资源管理自动(但析构阻塞坑)手动 join/detach复用
返回值future 自带自己做 promise通常返回 future
策略灵活性
生产推荐少量重型任务大量短任务

11. 生产 Checklist


12. 关键信息来源

置信度说明:


分享这篇文章:

上一篇
Go 工具链:Buf 如何替代 protoc 工作流
下一篇
现代 C++(四):模板元编程从 SFINAE 到 Concepts