C++之Memory order

一. 简介

       多线程之间为了避免数据竞争(比如由于编译器优化或者CPU指令执行导致的乱序可能会让程序的运行存在不确定),需要使用一些同步机制,比如互斥量、读写锁、自旋锁、原子变量等。在实际开发中使用的最多的可能是互斥量(mutex)和原子变量(atomic)两种,而两者中以atomic性能更好。

        所以本文主要介绍一下在使用atomic时,需要注意的一个重要的点就是: Memory order.

【注意】

        1. atomic性能相对更好,并不代表一定要使用atomic,更主要的是根据实际场景来使用。

        2. 编译器优化可能会产生指令乱序

        3. CPU在执行指令时可能会产生指令乱序

二. Memory order

1. 六大内存序简介

     自C++11开始,标准库中为atomic提供了六种不同的内存访问模型,如下表所示:

六大内存序
内存类型简介
memory_order_relaxed多线程中没有操作(store或load)的顺序制约,仅对此操作要求原子性(即使是同一个线程内的指令顺序也是无法保证的)
memory_order_consume主要用于load操作,当前线程中依赖于当前“load的值”的其他读或写操作不能被指令重排到此load前;其他release同一原子变量的线程中对所有依赖数据的写入操作,对于当前线程是可见的(也就是可以拿到最新的内存数据)
memory_order_acquire主要用于load操作, 当前线程中load操作后面的其他读或写操作不能被重排到此load前。其他release同一原子变量的线程的所有写入,能为当前线程所见
memory_order_release主要用于store操作,当前线程中的读或写不能被重排到此store操作后。当前线程的所有写入,可见于获得该同一原子变量的其他线程(acquire和consume),并且对该原子变量的带依赖写入变得对于其他consume同一原子对象的线程可见
memory_order_acq_rel主要用于load和store操作。当前线程的读或写内存不能被重排到此存储前或后。所有releaese同一原子变量的线程的写入可见于修改之前,而且修改可见于其他acquire同一原子变量的线程
memory_order_seq_cst主要用于load和store操作。对读修改写操作进行acquire操作和release操作,再加上存在一个单独全序,其中所有线程以同一顺序观测到所有修改,即所有线程看到的所有操作都有一个一致的顺序, 即使这些操作可能针对不同的变量, 运行在不同的线程
2. 六大内存序详解
a. relaxed内存序

        使用relaxed内存序的操作只保证当前操作的原子性,无法阻挡“指令乱序”,比如下面的例子:

#include <atomic>
#include <thread>

static std::atomic_int x;
static std::atomic_int y;

static int r1 = 0;
static int r2 = 0;

void xyTaskHandle()
{
    r1 = y.load(memory_order_relaxed); //! A
    x.store(r1, memory_order_relaxed); //! B
}

void yxTaskHandle()
{
    r2 = x.load(memory_order_relaxed); //! C
    y.store(100, memory_order_relaxed);//! D
}

int main(int argc, char* argv[])
{
    std::thread thid1(xyTaskHandle);
    std::thread thid2(yxTaskHandle);

    thid1.join();
    thid2.join();

    std::cout << "r1 = " << r1 << ", " \
              << "r2 = " << r2 << std::endl;
    return 0;
}

由于relaxed无法阻止 指令的乱序执行,所以这里A、B、C、D的执行顺序是不定的,这里也就可能会D->A->B->C的执行顺行,也就导致r1 == r2 == 100的结果(【注意】这里只是一种可能性,未必一定会发生)。

但是relaxed的性能消耗是这几个内存序里最低的,在一些场景中使用是非常高效而且有用的。例如 std::shared_ptr 的引用计数器,因为这只要求原子性,但不要求顺序或同步(注意 std::shared_ptr计数器自减要求与析构函数进行获得释放同步)。下面是一个引用计数的实现:

#include <vector>
#include <iostream>
#include <thread>
#include <atomic>
 
std::atomic<int> cnt = {0};
 
void f()
{
    for (int n = 0; n < 1000; ++n) {
        cnt.fetch_add(1, std::memory_order_relaxed);
    }
}
 
int main()
{
    std::vector<std::thread> v;
    for (int n = 0; n < 10; ++n) {
        v.emplace_back(f);
    }
    for (auto& t : v) {
        t.join();
    }
    std::cout << "Final counter value is " << cnt << '\n';
}
b. consume内存序

        consume内存序的主要用于load操作,同步仅在发生在release和consume同一原子对象的线程间建立。consume主要是保证原子操作中的依赖数据同步,对于其他的非依赖数据无法保证同步。

        若线程 A 中的原子store使用 memory_order_release, 而线程 B 中对同一原子load使用memory_order_consume ,则在线程 A 中的原子store操作之前的所有原子store操作依赖的数据内存写入(非原子和宽松原子的),在线程 B 中原子load操作之后,所有使用该load获得的值的运算符和函数,都能见到线程 A 写入内存的内容(原子store操作依赖的数据内存写入,包括非原子和宽松原子的)。

#include <thread>
#include <atomic>
#include <cassert>
#include <string>
 
std::atomic<std::string*> ptr;
int data;
 
void producer()
{
    std::string* p  = new std::string("Hello");
    data = 42;
    ptr.store(p, std::memory_order_release);
}
 
void consumer()
{
    std::string* p2;
    while (!(p2 = ptr.load(std::memory_order_consume)))
        ;
    assert(*p2 == "Hello"); // 绝无出错: *p2 从 ptr 携带依赖
    assert(data == 42); // 可能也可能不会出错: data 不从 ptr 携带依赖
}
 
int main()
{
    std::thread t1(producer);
    std::thread t2(consumer);
    t1.join(); t2.join();
}
c. acquire内存序

        acquire内存序主要用于load的操作,同步仅发生在releaseacquire同一原子对象的线程之间。

        若线程 A 中的一个原子store使用memory_order_release ,而线程 B 中对同一原子load使用memory_order_acquire ,则线程 A 中所有发生在原子store操作之前的内存写入(非原子及宽松原子的),在线程 B 的原子load操作完成后,则保证线程 B 能观察到线程 A 写入内存的所有内容。

#include <thread>
#include <atomic>
#include <cassert>
#include <string>
 
std::atomic<std::string*> ptr;
int data;
 
void producer()
{
    std::string* p  = new std::string("Hello");
    data = 42;
    ptr.store(p, std::memory_order_release);
}
 
void consumer()
{
    std::string* p2;
    while (!(p2 = ptr.load(std::memory_order_acquire)))
        ;
    assert(*p2 == "Hello"); // 绝无问题
    assert(data == 42); // 绝无问题
}
 
int main()
{
    std::thread t1(producer);
    std::thread t2(consumer);
    t1.join(); t2.join();
}
d. release内存序

        release内存序主要用于store操作,配合acquire以及consume来保证多线程之间的同步。保证当前线程中的读或写不能被重排到此store操作后。当前线程的所有写入,可见于获得该同一原子变量的其他线程(acquire和consume),并且对该原子变量的带依赖写入变得对于其他consume同一原子对象的线程可见。

#include <thread>
#include <atomic>
#include <cassert>
#include <vector>
 
std::vector<int> data;
std::atomic<int> flag = {0};
 
void thread_1()
{
    data.push_back(42);
    flag.store(1, std::memory_order_release);
}
 
void thread_2()
{
    int expected=1;
    while (!flag.compare_exchange_strong(expected, 2, std::memory_order_acq_rel)) {
        expected = 1;
    }
}
 
void thread_3()
{
    while (flag.load(std::memory_order_acquire) < 2)
        ;
    assert(data.at(0) == 42); // 决不出错
}
 
int main()
{
    std::thread a(thread_1);
    std::thread b(thread_2);
    std::thread c(thread_3);
    a.join(); b.join(); c.join();
}
e. acq_rel内存序

        acq_rel内存序算是acquire内存序和release内存序的与操作,既可以用在store操作也可以用在load操作,对于一些同时执行read-modify-write的原子操作比较适用。

f. seq_cst内存序 

        seq_cst内存序可以用在load和store操作上,这是六大内存序中最严谨的内存序,也是性能开销最大的。在这个内存序下,所有线程看到的所有操作都有一个一致的顺序, 即使这些操作可能针对不同的变量, 运行在不同的线程。

#include <atomic>
#include <thread>

static std::atomic_bool x{false};
static std::atomic_bool y{false};
static std::atomic_int  z{0};

void xTaskHandle() 
{
    x.store(true, std::memory_order_seq_cst); //! A
}

void yTaskHandle() 
{
    y.store(true, std::memory_order_seq_cst); //! B
}

void xyTaskHandle() 
{
    while (!x.load(std::memory_order_seq_cst)); //! C

    if (y.load(std::memory_order_seq_cst)) ++z; //! D
}

void yxTaskHandle() 
{
    while (!y.load(std::memory_order_seq_cst)); //! E

    if (x.load(std::memory_order_seq_cst)) ++z; //! F
}

int main() {
    std::thread a(xTaskHandle);
    std::thread b(yTaskHandle); 
    std::thread c(xyTaskHandle);
    std::thread d(yxTaskHandle);
    
    a.join(); 
    b.join();
    c.join();
    d.join();
    
    assert(z.load() != 0);                     //! G
}


        上述示例中G处的断言一定不会失败,因为x和y的修改顺序是全局一致的 ,也就是A、B、D、E、F一定是保持这个顺序执行的,不会因为指令执行的不一致而导致G处的断言报错。

        由于现代 CPU 通常有多核, 每个核心还有自己的缓存. 为了做到全局顺序一致, 每次写入操作都必须同步给其他核心. 为了减少性能开销, 如果不需要全局顺序一致, 我们应该考虑使用更加宽松的顺序模型。

三. 总结

        我们可以将这六大内存序分为三类:

1. relaxed(宽松)

2. acquire-release(获取-释放)

3. sequential_consistency(内存一致序)

        这里2和3的主要区别在于 2只对store和load操作的 同一变量起作用,而3会对store和load的所有变量都起作用。 

        这三类从上到下对内存的操作是越来越严谨的,并且对性能的开销也是越来越大的,所以需要根据具体的场景对不同的内存序进行选择,std::atomic中操作默认都是seq_cst内存序,也可以根据自己的需要修改。

memory order详解

评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值