Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

C++ 内存模型(并发)

单线程程序里,「代码按顺序执行」是理所当然的。但在多线程环境下,编译器会重排指令、CPU 会乱序执行、缓存会让不同核心看到不同的值——你以为的顺序,在硬件层面完全不存在。

C++11 引入的内存模型(memory model)就是用来约束这些行为的:它规定了多线程程序在什么条件下能观察到什么值。理解它是写出正确并发代码的前提。

这一章偏理论,但每个概念都会落到实际用法上。

一、数据竞争

数据竞争(data race)的定义很明确:

两个线程同时访问同一内存位置,其中至少一个是写操作,且没有同步措施。

数据竞争的后果是未定义行为——不是「读到旧值」这么简单,而是编译器可以做任何假设,程序行为完全不可预测。

int counter = 0;   // 普通 int

void increment()
{
    for (int i = 0; i < 100000; ++i)
        ++counter;    // 数据竞争
}

int main()
{
    std::thread t1(increment);
    std::thread t2(increment);
    t1.join();
    t2.join();

    std::cout << counter << '\n';   // 大概率不是 200000
}

++counter 实际是「读-改-写」三步,两个线程可能同时读到相同的值,导致丢失更新。

消除数据竞争只有两条路:加锁或用原子操作。

二、happens-before 关系

内存模型的核心概念是 happens-before。如果操作 A happens-before 操作 B,那么 A 的所有内存写入对 B 都可见。

happens-before 由几种关系组合而成:

1. 同线程内的顺序

同一线程中,前面的语句 happens-before 后面的语句:

int x = 1;      // A
int y = x + 1;  // B:A happens-before B

2. 锁的同步

一个线程解锁,另一个线程加同一个锁,则解锁 happens-before 加锁:

std::mutex mtx;
int data = 0;

// 线程 A
{
    std::lock_guard<std::mutex> lock(mtx);
    data = 42;          // 写
}                       // 解锁

// 线程 B
{
    std::lock_guard<std::mutex> lock(mtx);
    std::cout << data;  // 保证读到 42
}                       // 加锁

这是互斥量能保证可见性的原因——它不只是「互斥」,还建立了同步关系。

3. 原子操作的同步

原子操作可以建立跨线程的 happens-before:

std::atomic<bool> ready{false};
int data = 0;

// 线程 A
data = 42;
ready.store(true, std::memory_order_release);   // 释放

// 线程 B
while (!ready.load(std::memory_order_acquire)) { }  // 获取
std::cout << data;   // 保证读到 42

release 和 acquire 配对,建立起 A 到 B 的同步关系。

4. 传递性

happens-before 具有传递性:如果 A → B 且 B → C,那么 A → C。

三、内存序

原子操作默认使用 std::memory_order_seq_cst(顺序一致),这是最严格也最慢的。C++ 提供了六种内存序:

内存序用途保证
memory_order_relaxed只需要原子性无同步,只保证操作不可分割
memory_order_consume数据依赖(很少用)已不推荐
memory_order_acquire读操作之后的读写不能重排到它之前
memory_order_release写操作之前的读写不能重排到它之后
memory_order_acq_rel读-改-写操作兼具 acquire 和 release
memory_order_seq_cst默认,最安全全局统一顺序

1. relaxed:只要原子性

std::atomic<int> counter{0};

void increment()
{
    for (int i = 0; i < 100000; ++i)
        counter.fetch_add(1, std::memory_order_relaxed);
}
// 结果是确定的 200000,但不保证与其他变量的顺序关系

relaxed 只保证「计数不会丢失」,不建立任何同步。适合纯粹的计数器。

2. acquire / release:建立同步

这是最常用的一对,用于「生产者写完数据,通知消费者」:

std::atomic<bool> ready{false};
int payload = 0;

// 生产者
void produce()
{
    payload = 42;                                    // 先写数据
    ready.store(true, std::memory_order_release);    // 再发布
}

// 消费者
void consume()
{
    while (!ready.load(std::memory_order_acquire)) { }  // 等待发布
    std::cout << payload;   // 保证看到 42
}

关键点:release 之前的写入,对执行 acquire 的线程可见。如果这里用 relaxed,消费者可能看到 ready == true 但 payload 还是 0。

3. seq_cst:全局一致顺序

std::atomic<bool> x{false}, y{false};
std::atomic<int> z{0};

// 线程 A
x.store(true);   // 默认 seq_cst

// 线程 B
y.store(true);

// 线程 C
while (!x.load()) { }
if (y.load())
    ++z;

seq_cst 保证所有线程看到同一个全局操作顺序。这让推理变简单,但代价是需要内存屏障,性能比 acquire/release 差。

建议:默认用 seq_cst,确认是瓶颈后再降级到 acquire/release。

四、内存屏障

有时需要在没有原子操作的情况下插入同步点:

std::atomic_thread_fence(std::memory_order_release);

屏障本身不操作数据,只是限制重排。典型用法:

// 生产者
payload = 42;
std::atomic_thread_fence(std::memory_order_release);
flag.store(true, std::memory_order_relaxed);   // 用 relaxed 存

效果和直接在 store 上用 release 类似,但把同步点独立出来了。实际代码中很少需要,直接用 acquire/release 更清晰。

五、volatile 不是同步手段

这是最经典的误解。volatile 在 C++ 中的含义是:

  • 告诉编译器「这个变量可能被外部修改」,不要优化掉对它的访问
  • 不提供任何原子性
  • 不提供任何线程间同步
  • 不建立 happens-before 关系
volatile int counter = 0;

void increment()
{
    ++counter;   // 仍然是数据竞争!
}

volatile 的正确用途是访问内存映射的硬件寄存器,或者配合 setjmp/longjmp。多线程同步必须用 std::atomic 或互斥量。

六、实际应用

1. 双重检查锁定

单例模式中常见的写法,但很容易写错:

class Singleton
{
public:
    static Singleton& instance()
    {
        if (!instance_)                              // 第一次检查(无锁)
        {
            std::lock_guard<std::mutex> lock(mtx_);
            if (!instance_)                          // 第二次检查
                instance_ = std::make_unique<Singleton>();
        }
        return *instance_;
    }

private:
    static std::unique_ptr<Singleton> instance_;
    static std::mutex mtx_;
};

这里的 instance_ 是普通指针,第一次检查是数据竞争。正确做法是用原子指针:

static std::atomic<Singleton*> instance_;

static Singleton& instance()
{
    Singleton* p = instance_.load(std::memory_order_acquire);
    if (!p)
    {
        std::lock_guard<std::mutex> lock(mtx_);
        p = instance_.load(std::memory_order_relaxed);
        if (!p)
        {
            p = new Singleton();
            instance_.store(p, std::memory_order_release);
        }
    }
    return *p;
}

不过更简单的做法是用 C++11 的局部静态变量——标准保证它是线程安全的:

static Singleton& instance()
{
    static Singleton inst;   // 线程安全的初始化
    return inst;
}

2. 无锁队列

用原子操作实现的生产者-消费者队列,核心是 CAS 循环:

template<class T>
class LockFreeStack
{
    struct Node
    {
        T data;
        Node* next;
    };

    std::atomic<Node*> head_{nullptr};

public:
    void push(T value)
    {
        Node* newNode = new Node{std::move(value), nullptr};
        newNode->next = head_.load(std::memory_order_relaxed);

        // CAS 循环:如果 head_ 还是 newNode->next,就换成 newNode
        while (!head_.compare_exchange_weak(newNode->next, newNode,
                                            std::memory_order_release,
                                            std::memory_order_relaxed))
        { }
    }
};

无锁编程极其困难,上面这个栈还有 ABA 问题。除非有明确的性能需求,否则用互斥量。

3. 原子计数器

最简单的场景:

std::atomic<std::size_t> requestCount{0};

void handleRequest()
{
    requestCount.fetch_add(1, std::memory_order_relaxed);   // 只需原子性
    // ...
}

这里 relaxed 就够了——我们只关心计数正确,不关心它和其他变量的顺序。

七、常见误区

「volatile 可以用于多线程同步」 —— 不能。它不提供原子性,也不建立 happens-before。这是 Java 程序员最常带入 C++ 的错误。

「原子操作就是无锁」 —— 不一定。std::atomic<T> 对某些类型可能内部用锁实现,可以用 is_lock_free() 检查。

「relaxed 是没用的」 —— 它保证了原子性,适合纯计数器场景,性能最好。

「seq_cst 太慢,应该都用 acquire/release」 —— 在 x86 上两者性能差异很小,在 ARM 上才明显。先保证正确性,再考虑优化。

「atomic 变量的所有操作都是原子的」 —— 单个操作是原子的,但复合操作不是:

std::atomic<int> x{0};

if (x > 0)      // 原子读
    x--;        // 原子写
// 但「检查再减」整体不是原子的,中间可能被插入

「多个原子变量之间有一致性」 —— 没有。两个独立的原子变量之间不保证任何顺序关系,除非用 seq_cst 或显式同步。

八、相关章节