内存乱序

内存乱序指的是内存操作出现乱序,CPU缓存、编译器优化、处理器指令优化等都会改变内存顺序,造成内存乱序。

学习内存顺序容易陷入了一个误区,因为内存顺序是和CPU架构、编译器息息相关的,想要去深入理解CPU缓存怎么导致内存乱序的,编译器优化和处理器指令又是怎么导致内存乱序的,很容易陷入一个又一个填不了的坑。要去了解各种编译器优化技术、了解各种CPU的指令集,甚至连ARM的v7和v8区别都去看了一下。

鲁迅说过,学海无涯,回头是岸。内存顺序或者说内存模型其实是语言层面的东西,所以可以直接从语言层面去理解,所谓内存乱序,在语言层面的表现就是虽然你写了A、B、C三行代码,但是最后在线程中执行顺序可能是CBA,而另外一个线程,观察该线程得到的执行顺序可能是ACB。这些是由于CPU缓存、编译器优化、处理器指令优化等共同造成的。

上述各种优化导致的内存乱序并不是随意地乱序,是有底线的,这个底线就是单线程场景下,优化后程序的执行结果要和优化前保持一致,即让写代码的人感觉是没有优化存在的,代码就是按照他写的顺序执行的。

但是在多线程场景下,内存乱序就会给开发者创造惊喜了,程序的执行结果可能和开发者的预期不一致,看起来像是执行出现问题了,比如下面两个线程:

//线程1
//a,b,c的初始值为0
Thread1()
{
  a = 1; //A  
  b = 2; //B
  c = 3; //C
}

//线程2
Thread2()
{
  printf("a:%d, b:%d, c:%d", a, b,c); 
}

两个线程同时执行,一般来说线程2打印出来的应当是下面四种可能的结果,不应该有其他结果的出现。

  • a:0, b:0, c:0
  • a:1, b:0, c:0
  • a:1, b:2, c:0
  • a:1, b:2, c:3

但是,因为内存乱序的存在,线程1的执行顺序可能是C、B、A。假设线程1观察到的顺序也是C、B、A,那么线程2的输出就可能是(a:0, b:2, c:3),而由于内存乱序也会导致其他线程观察到的执行顺序和线程实际的执行顺序不一致,线程2观察到的顺序可能是A、C、B,那么线程2的输出就是(a:1, b:0, c:2)。

解决上述问题的一个最简单的方式是使用互斥锁,每次访问共享数据之前都加上互斥锁,但是这个很麻烦,意味着每个需要共享的变量都需要在访问前后加上互斥锁。另外一方面,互斥锁很“强”,在一些不需要强一致性的场景下使用互斥锁,就有点杀鸡用牛刀了,会不必要地降低程序性能。

内存屏障(Memory barrier)

如果想要避免内存乱序,可以加上内存屏障。内存屏障简单来说就是告诉编译器和CPU等在优化的时候,不要跨过内存屏障,以上面的代码为例,我们假设内存屏障是mb(),加上内存屏障后的代码如下

//线程1
Thread1()
{
  a = 1; //A  
  mb();
  b = 2; //B
  c = 3; //C
}

//线程2
Thread2()
{
  printf("a:%d, b:%d, c:%d", a, b,c); 
}

这里我们添加了一个mb,告诉编译器、CPU等不要把A移动到mb的后面,不要把B和C移动到mb的前面,保证了A一定先于B和C执行。线程2中如果观察到B和C的新值,就一定也能观察到A的新值。因为B和C之间没有内存屏障,所以这里B和C之间还是可能发生内存乱序的。

内存屏障本质上就是为了保证程序执行的顺序一致性,即保证了内存屏障前面的代码一定先于内存屏障后面的代码执行。

内存屏障虽然很强大,但是直接使用内存屏障存在很多缺点,比如在每个需要避免内存乱序的地方都要手动加上mb,容易出错。另外是用内存屏障限制了编译器和CPU的优化空间。内存屏障阻止了不希望出现的内存乱序,同时也阻止了可以接受的内存乱序,导致无法对性能进行进一步的优化。

对于每个地方需要手动加上mb的问题,C++提供了atomic来解决这个问题,一个变量被声明为atomic后,所有和改变量相关的操作都会加上内存屏障。

性能方面,atomic提供了几种不同的内存顺序选项,可以理解为给内存屏障进行了弱化,不同的内存顺序选项只对部分操作的顺序进行限制,避免一刀切全部限制。开发者可以根据对一致性的要求选择最合适的内存顺序,保证功能正确性的同时保障代码的执行效率。

C++ atomic提供的几种memory order

C++总共提供了以下6种memory order

  • memory_order_seq_cst
  • memory_order_acq_rel
  • memory_order_release
  • memory_order_acquire
  • memory_order_consume
  • memory_order_relaxed

这6种内存顺序由上到下,对一致性的要求逐渐降低。其中release和acquire一般是搭配使用的。

这里的release对应写操作,acquire对应读操作。比如atomicstore 就是release,atomic::load 就属于acquire。

relaxed

Atomic all or nothing,不保证内存顺序,只保证原子性和当前原子变量写顺序的一致性。原子性比较好理解,写顺序的一致性是比较费解的,这里指的是对原子变量X的所有修改,所有线程观察到的写顺序是一致的。比如X的值分别被写了1,2,3。那么所有线程观察到的顺序也是1,2,3,不会出现某个线程观察到了2,1,3或者1,3,2等。这个解释还是有点绕,更通俗理解就是如果某个线程当前读到了2,那么后面它只可能读到2或者3,不会读到1,读到1说明乱序了。

比如下面的代码,x和y都是原子变量,初始值均为0.

// 线程1
r1 = y.load(std::memory_order_relaxed); // A
x.store(r1, std::memory_order_relaxed); // B

// 线程2
r2 = x.load(std::memory_order_relaxed); // C 
y.store(42, std::memory_order_relaxed); // D

线程1先读y,然后把读到的值写入到x。线程2先读x,然后把42写入y。

因为B依赖了A写入的变量r1,所有A一定是先于B执行的。如果C也是先于D执行,那么语句的执行顺序可能是ABCD、ACBD、ACDB、CADB、CABD、CDAB,这六种顺序执行完,r1、r2的值有两种可能:r1=0, r2=0 或者r1=42, r2=0。

但是由于内存顺序使用了relaxed,所以只会保证x或者y本身的读、写操作是原子的,而C和D之间是没有相互依赖关系的,不能保证C一定先于D执行。那么两个线程的语句执行顺序可能是D-A-B-C,执行结果是r1=42,r2=42。

所以releaxed不能随便用,可能会导致意想不到的的结果。但是在某些不关注内存顺序,只关注原子性的场景下,releaxed可以提供最优的性能。

比如在聊一聊原子操作一文中的多线程计数,不需要保证顺序一致性,就可以把内存顺序改写成relaxed,以提高性能。

#include <iostream>       // std::cout
#include <thread>         // std::thread
int v = 0;
void plus()
{
    for(int i=0; i<10000; i++)
    {
        // ++v; ++使用了默认的内存顺序
        v.fetch_add(1, std::memory_order_relaxed)
    }
}
int main()
{
    std::thread t1(plus);
    std::thread t2(plus);
    t1.join();
    t2.join();
    std::cout << "v: " << v << std::endl;
}

//编译执行指令:g++ -o test test.cpp -lpthread

release 与 acquire

release和acquire通常会配对使用,如果线程1里面写一个原子变量的操作A使用release,线程2里面读该原子变量的操作B使用require。如果线程2的B操作读到了线程1的A操作写入的值,那么线程1里面在A之前发生的所有内存写操作(包括原子写和非原子写,这里的所有内存指的是任意变量或者内存,不仅仅指该原子变量)对线程2都是可见的。这个关系是存在于对同一个原子变量分别使用了release和acquire的两个线程之间。其他线程观察到的线程1和线程2的执行顺序还是可能不一样的。

下面的例子演示了release和acquire的应用。

#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();
}

//编译指令 g++ test.cpp -o test -std=c++11 -lpthread

上面的例子中,ptr的初始值为nullptr,producer生成了一个字符串,给data赋值为42,然后把指针p赋给ptr,ptr是一个原子变量。consumer循环检测ptr的值,如果不为nullptr,就进行下一步。然后断言字符串p2的内容和data的值。这里producer使用了release,cosumer使用了acquire。所以当ptr的新值在consumer中可见时,字符串p和整型data的值对consumer也一定可见。所以两个断言不会失败。如果producer和consumer使用了relaxed,就无法保证字符串p和整型data的值一定为新值,可能读到ptr和data的旧值。

acq_rel

这个顾名思义,就是acquire + release的组合版本,一般用在一段代码被多个线程执行时,在这些线程之间同步。每个线程在读数据的时候要保证能够看到其他线程最新的内存写入,在写数据的时候要保证自己的内存写入能够被其他线程看到。

//获取control block的指针,并把引用计数加1
control_block_ptr = other->control_block_ptr;
control_block_ptr->refs.fetch_add(1, std::memory_order_relaxed);
//把引用计数减1,如果引用计数为0了,就释放control block
control_block_ptr->Write(); 
if(control_block_ptr->refs.fetch_sub(1, std::memory_order_acq_rel) == 0)
    delete control_block_ptr;

上面是一个引用计数的实现,在同一时刻可能有多个线程在获取control_block,多个线程在释放control_block。细心的老铁应该已经发现给内存计数refs加1的时候我们使用了relaxed,而在给refs减1的时候我们使用了acq_rel。

加1用releaxd是因为这里我们只关心refs,refs加1前面没有需要其他线程看到的内存写,所以不需要release,后面没有需要读内存,所以不需要acquire,用relaxed保证计数准确就足够了。

减1操作后判断refs是否为0,如果为0需要释放control block。这里用acq_rel主要是为了同步多个线程同时减1的情况,如果用relaxed,可以保证计数的准确。但是无法保证所有线程在判断语句之前的写操作都对其他线程可见。为了让原子操作前面的内存写操作对所有线程可见,需要使用release发布写的内容,而其他线程需要对应的使用acquire来获取最新的值。所以这里其实既需要release又需要acquire,因此使用acq_rel

release 与 consume

ISO C++标准委员会主席 Herb Sutter 建议不要使用consume,太容易出错,所以本文也不介绍了,弃疗。

memory_order_seq_cst

seq_cst是内存顺序里面最靓的仔,铁王座第一顺位继承人,atomic默认使的内存顺序。使用了seq_cst的效果用最通俗易懂的话来说就是程序将会按照你认为的顺序执行。忘记内存顺序这个概念就可以了。

参考文献

cppreference std::memory_order

Understanding memory reordering

C++ and Beyond 2012: Herb Sutter - atomic Weapons 1 of 2

C++ and Beyond 2012: Herb Sutter - atomic Weapons 2 of 2