首页 > 编程笔记 > C++笔记 阅读:13

C++ std::memory_order枚举类型的用法(非常详细)

std::memory_order 是一个枚举类型,定义了多种内存顺序。

所谓内存顺序,有以下两个概念需要关注:
在 C++20 之前,它是一个普通枚举,从 C++20 开始,被定义为强枚举类型以增强类型安全。这些内存顺序为 std::atomic 操作提供了灵活的内存语义选择,帮助开发者在性能和正确性之间找到平衡。

std::memory_order 的枚举值如下表所示:

表:std::memory_order 的枚举值
内存顺序 说明 常见用途
std::memory_order_relaxed 不对执行顺序提供任何保证,仅保证操作的原子性 适用于不关心跨线程操作顺序的场景,如独立计数器
std::memory_order_consume 载入操作的直接依赖操作不能被重排到载入操作之前(C++20 废弃,视为 memory_order_acquire) 原本用于数据依赖场景,现建议使用 memory_order_acquire
std::memory_order_acquire 载入操作后的所有操作(读或写)不能被重排到载入操作之前 控制跨线程的数据依赖,保证数据的可见性和顺序
std::memory_order_release 存储操作之前的所有操作(读或写)不能被重排到存储操作之后 发布数据给另一线程,常与 memory_order_acquire 结合使用实现同步
std::memory_order_acq_rel 结合了获取和释放顺序的特性,用于读—修改—写操作 更新共享状态时,需要同时保证载入和存储顺序
std::memory_order_seq_cst 最严格的内存顺序,保证全局的顺序一致性 需要严格的全局顺序的场景,如跨线程的严格同步或统计全局事件发生次序

C++ std::memory_order枚举值

1、std::memory_order_relaxed

std::memory_order_relaxed(松散顺序)是原子操作中最不严格的内存顺序,不保证除原子操作本身外的任何同步或顺序。也就是说,使用此内存顺序的操作不会阻止指令被处理器或编译器重排,只要它们不违反单线程程序的执行语义。

std::memory_order_relaxed 的内存顺序主要用于那些不需要与其他线程同步,只需保证对单个原子变量本身的操作是原子的情况。

下面是一个使用 std::memory_order_relaxed 的示例,演示如何在不关心执行顺序的情况下使用它。
#include <atomic>
#include <iostream>
#include <thread>
#include <vector>
std::atomic<int> x(0);
void write_x() {
    x.store(1, std::memory_order_relaxed);  //对x的写入,不需要与其他线程同步
}
void read_x_and_print() {
    // 循环直到x变为1,但由于内存顺序是relaxed,这个变化可能无法立即在其他线程中观察到
    while (true) {
        int current_x = x.load(std::memory_order_relaxed);
        if (current_x == 1) {
            std::cout << "x was set to 1" << std::endl;
            break;
        }
    }
}
int main() {
    std::thread writer(write_x);
    std::thread reader(read_x_and_print);
    writer.join();
    reader.join();
    return 0;
}
在这个示例中,两个线程分别用于写入和读取原子变量 x。由于使用了 std::memory_order_relaxed,写入操作对读取操作几乎没有同步作用,这意味着读取线程可能会延迟看到 x 被设置为 1 的状态,甚至可能出现看似永无止境的循环,尤其在有弱内存模型的处理器上。

使用 std::memory_order_relaxed 的优点是性能较高,因为它减少了与内存屏障相关的开销。然而,这也使得编写正确的多线程程序变得更加困难,因为开发者必须非常小心地处理可能出现的所有竞争条件和内存可见性问题。

这种内存顺序适用于那些明确知道不需要跨线程同步保证的场景,例如统计计数器或状态标志,其中每次更新仅依赖于之前的值,而不依赖于其他变量的状态。

2、std::memory_order_consume

在 C++20 之前,memory_order_consume(消费顺序)旨在为基于依赖的加载操作提供一种更轻量级的同步模式,用于保证一个载入操作的后续操作(仅限于依赖于该载入操作的结果的操作)不能被重排到该载入操作之前。

然而,由于这种内存顺序在实践中难以正确实现并且很难被编译器优化,因此在实际的多数平台和编译器实现中,memory_order_consume 可能被处理得与 memory_order_acquire 相同。

到了 C++20,尽管语言规范中仍保留了 memory_order_consume,但它实际上已被废弃,通常会被当作 memory_order_acquire 来处理。这意味着在编写代码时,使用 memory_order_consume 的实际效果和使用 memory_order_acquire 是一样的。这个改变主要是因为 memory_order_consume 带来的实际性能优势不明显,而且其复杂性和潜在的错误风险远大于其理论上的性能提升。

因此,尽管从规范上来看 C++20 仍区分这两者,但在实际应用中,可以认为它们是等价的,而且为了代码的可移植性和可维护性,推荐使用 memory_order_acquire 而非 memory_order_consume。

3、std::memory_order_acquire和std::memory_order_release

std::memory_order_acquire(获取顺序)是一种内存顺序,用于消费者(consumer)端,确保在此内存顺序的原子操作之后的所有读取和写入操作(在程序的执行顺序中)不会被重排到这个原子操作之前。换句话说,它用于确保当前线程看到另一线程在对应的 std::memory_order_release 原子操作之前发布的所有结果。这是实现线程间数据依赖同步的重要保证。

std::memory_order_release(释放顺序)则与 std::memory_order_acquire 相对应,用于生产者(producer)端。此内存顺序确保所有在此原子操作之前的写操作(在程序的执行顺序中)完成后,才会执行此原子操作。这意味着,所有的写入必须在原子操作实际发生之前发生,保证了在该点之后的读取操作可以看到这些写入的最新状态。

这两种内存顺序通常结合使用,形成所谓的“释放-获取”模式,如下图所示:


图 1 “释放-获取”模式

在此模式下,一个线程通过 std::memory_order_release 顺序写入某个标志(或者完成一系列写入),然后另一个线程通过 std::memory_order_acquire 读取这个标志,这样可以确保第二个线程看到第一个线程在写入标志之前的所有写入操作。这种机制是保障多线程程序中内存一致性和顺序性的关键手段。

下面是一个使用 std::memory_order_acquire和std::memory_order_release 的示例,展示如何在一个简单的生产者-消费者场景中正确同步数据。在这个例子中,生产者将几个数据项放入一个共享缓冲区,并通过一个原子标志来通知消费者数据已准备好。
#include <atomic>
#include <iostream>
#include <thread>
#include <vector>

std::atomic<bool> ready(false);  // 原子变量,用于同步生产者和消费者
std::vector<int> data;        // 共享数据

void producer() {
    // 生产者准备数据
    data.push_back(42);       // 添加数据项
    data.push_back(1997);     // 继续添加数据项
    ready.store(true, std::memory_order_release);  // 释放顺序,发布数据
    std::cout << "Producer has published the data.\n";
}

void consumer() {
    // 消费者等待数据就绪
    while (!ready.load(std::memory_order_acquire)) {  // 获取顺序,等待数据
        std::this_thread::yield();  // 让出CPU,防止忙等
    }
    // 当从循环中出来时,保证看到生产者发布的所有写入
    std::cout << "Consumer sees the data: ";
    for (int val : data) {
        std::cout << val << " ";
    }
    std::cout << std::endl;
}

int main() {
    std::thread producerThread(producer);  // 创建生产者线程
    std::thread consumerThread(consumer);  // 创建消费者线程

    producerThread.join();  // 等待生产者线程结束
    consumerThread.join();  // 等待消费者线程结束
    return 0;
}
在上述代码中:
如此一来,即使在多核处理器系统上,也可以保证线程间的数据一致性和同步。通过正确使用这些内存顺序,开发者可以避免因内存操作重排导致的不一致和错误。

1) 线程内顺序与线程间可见性

std::memory_order_acquire 和 std::memory_order_release 首先确保的是线程内的操作顺序:
std::memory_order_acquire 和 std::memory_order_release 同时关注线程间数据的可见性。如果一个线程在一个变量上执行了带有 std::memory_order_release 的原子写操作,然后另一个线程在同一变量上执行了带有 std::memory_order_acquire 的原子读操作,那么第一个线程的所有先前写入都将在第二个线程中变得可见。这种同步确保了第二个线程能“看到”第一个线程在发布前的所有写操作。

2) 线程间协调

std::memory_order_acquire 和 std::memory_order_release 并不直接提供两个线程间操作的互斥执行,只保证内存操作的顺序和可见性。如果两个线程试图同时修改同一数据(例如,都使用 std::memory_order_release 写同一个变量),则这种用法本身不能阻止竞争条件。因此,需要其他的同步机制(如互斥锁)来确保数据的一致性和线程安全。

3) 小结

std::memory_order_acquire 和 std::memory_order_release 的设计是为了解决多核处理器中数据可见性和顺序一致性的问题。它们允许开发者细粒度地控制内存操作,以优化性能,特别是在不需要完全序列化(互斥)的场景中。

这两种内存顺序的使用使得线程间可以高效地同步数据,但对于数据修改的直接同步(如防止两个线程同时写),仍然需要额外的同步机制。

4) 解惑

有些读者可能会疑惑,使用 std::memory_order_release 保证写入后其他线程可见就可以了,为什么还要和 std::memory_order_acquire 一起用呢?

这就涉及另一个概念。在多处理器系统中,每个核心通常有自己的缓存,这使得数据的写入操作首先发生在局部缓存中,并不会立即写回主内存。这意味着其他核心上的线程可能无法立即看到这些更改,除非有一种机制来保证数据的一致性和可见性。这种机制通常涉及缓存一致性协议(如 MESI 协议)和内存屏障。

std::memory_order_acquire 是为了确保消费者线程在读取由生产者线程发布的数据时,能看到一个一致和更新的视图。虽然生产者线程使用 std::memory_order_acquire 确保了其写入操作完成并发布,但消费者线程如果不使用 std::memory_order_acquire,就有可能由于以下原因而看不到这些更新:

因此,std::memory_order_acquire 在消费者端是必需的,它与生产者端的 std::memory_order_release 配合使用,才能确保内存操作的顺序和数据的一致性。这样的同步机制是必要的,尤其在高并发的多线程应用中,可以避免数据竞争和提供可靠的同步。

4、std::memory_order_acq_rel(获取–释放顺序)

std::memory_order_acq_rel 同时包含了 std::memory_order_acquire 和 std::memory_order_release 的语义。这种内存顺序常用于同时需要获取和释放语义的操作,例如 std::atomic::exchange。

实例演示:
#include <atomic>
#include <iostream>
#include <thread>
std::atomic<int> x(0);
void thread1() {
    int expected = 0;
    // 尝试将x从0改为1,同时确保这个操作的释放和获取语义
    if (x.compare_exchange_strong(expected, 1, std::memory_order_acq_rel)) {
        std::cout << "Thread 1: Successfully changed." << std::endl;
    }
}
void thread2() {
    int expected = 1;
    // 尝试将x从1改为2,同样确保释放和获取语义
    if (x.compare_exchange_strong(expected, 2, std::memory_order_acq_rel)) {
        std::cout << "Thread 2: Successfully changed." << std::endl;
    }
}
int main() {
    std::thread t1(thread1);
    std::thread t2(thread2);
    t1.join();
    t2.join();
    std::cout << "Final value of x: " << x.load() << std::endl;
}
这个示例使用 std::memory_order_acq_rel 在一个原子操作中同时实现获取和释放语义。这在需要保证在一个操作中修改一个值,并确保这个操作之前和之后的相关操作都按预期顺序进行时非常有用,常见于需要精确控制执行顺序的复杂同步场景。

5、std::memory_order_seq_cst(顺序一致性)

std::memory_order_seq_cst 是最严格的内存顺序,它确保操作在多个线程间具有全局一致性。这意味着所有使用 std::memory_order_seq_cst 的原子操作在所有线程中看起来是按照同一序列发生的。

std::memory_order_seq_cst 的内存顺序不仅能保证获取和释放语义,还能确保所有线程观察到的操作顺序是一致的。

以下示例展示如何使用这种内存顺序来同步多个线程间的操作:
#include <atomic>
#include <iostream>
#include <thread>
#include <vector>
std::atomic<int> x(0);
std::atomic<bool> go(false);
void thread_function(int id) {
    while (!go.load(std::memory_order_seq_cst)) {
        // 循环等待,直到主线程发出开始信号
    }
    // 安全地进行计算或状态更新,所有线程都将看到相同的操作顺序
    int value = x.load(std::memory_order_seq_cst);
    std::cout << "Thread " << id << " sees x = " << value << std::endl;
}
int main() {
    std::vector<std::thread> threads;
    for (int i = 0; i < 5; ++i) {
        threads.emplace_back(thread_function, i);
    }
    // 准备数据
    x.store(1, std::memory_order_seq_cst);
    // 通知所有线程开始执行
    go.store(true, std::memory_order_seq_cst);
    for (auto& t : threads) {
        t.join();
    }
    return 0;
}
在这个示例中,所有对 x 和 go 执行的操作都使用了默认的 std::memory_order_seq_cst 内存顺序,确保所有线程在访问这些变量时都能看到一致的值和顺序。

使用 std::memory_order_seq_cst 的优势在于它简化了原子操作的顺序理解,但代价是可能会有性能上的降低,因为它需要更多的硬件协作来保证全局的操作顺序。

这个内存顺序适合那些需要严格顺序保证的场景,例如初始化单例。这是默认的内存顺序,也是最易于理解和使用的内存顺序。

如何选择正确的内存顺序

选择正确的内存顺序是至关重要的,因为它可以影响程序的性能和正确性。以下是一些关于如何选择内存顺序的建议:

相关文章