现代C++内存模型

CPU有很多优化技术如流水线(Pipeline)动态分支预测(Dynamic Branch Prediction),推断执行(Speculative Execution)寄存器重命名(Register Renaming),各种深奥看不懂的东西,都会使指令的乱序执行。

为了解决底层这个混乱的世界,硬件处理器会提供一些内存屏障(memory barrier)指令,简单地理解就是在指令之间插入一道栅栏,栅栏前后的指令不会跨过栅栏去执行,同时也会确保存储操作同步到其他核。当然如果再细化的话可以分成读内存屏障和写内存屏障,但这些太底层了,如果上层的服务器程序要天天面对这些可能会疯掉。

幸运的是一些高级语言或编译器会定义一套统一的内存模型,比如C++11的原子操作可以指定下面的内存顺序:

  1. memory_order_seq_cst 默认的顺序,该原子操作前后的读写不能跨过该操作乱序;该原子操作之前的写操作都能被所有线程观察到,比如这个例子,如果指定为这个模式(C++里不指定就是这个模式):
-Thread 1-                         
y.store(1)
x.store(2, memory_order_seq_cst);

-Thread 2-
if (x.load(memory_order_seq_cst) == 2)
assert (y == 1);

assert总是会成功,因为线程1将x写为2的时候,y=1这个写操作的结果已经刷新到所有线程,所以线程2加x出来判断等于2的时候,线程2一定已经观察到y的最新值了。

  1. memory_order_relaxed 这是最宽松的顺序,除了保证操作的原子性之外,没有限定前后指令的顺序,其他线程看到数据的变化顺序也可能不一样,上面两个例子断言可能会失败。这种模式一般用于变量的store和load,且这个变量比较独立,没有和其他变量有相关性,这样就没有顺序问题。

  2. memory_order_release/memory_order_acquire 这两个模式通常成对使用,memory_order_release用于写操作(store),memory_order_acquire用于读操作(load):

    • memory_order_release 原子操作之前的读写不能往后乱序;并且之前的写操作,会被使用acquire/consume的线程观察到,这里要注意它和seq_cst不同的是只有相关的线程才能观察到写变化,所谓相关线程就是使用acquire或consume模式加载同一个共享变量的线程;而seq_cst是所有线程都观察到了。
    • memory_order_acquire 原子操作之后的读写不能往前乱序;它能看到release线程在调用load之前的那些写操作。

    从描述看release/acquire的效率要比seq_cst高,常用于spinlock和读写锁的实现。

    看下面这个例子:

    -Thread 1-     
    y.store(20,memory_order_release);
    x.store(10, memory_order_release);

    -Thread 2-
    if (x.load(memory_order_acquire) == 10) {
    assert (y.load() == 20)
    y.store (10, memory_order_release)
    }

    -Thread 3-
    if (y.load() == 10)
    assert (x.load() == 10)

    线程2的assert会成功,因为x.load如果等于10,线程2必然观察到线程1对y的修改;但线程3不一定会成功,因为y.load没有使用acquire,它是不相关的线程,它未必能观察到x的修改(也可以反过来这样想,使用release的线程,只会把修改同步给使用acquire/consume的线程)。

  3. memory_order_consume 和acquire比较接近,也是和release一起使用的;和acquire不一样的地方是加了一个限定条件:依赖于该读操作的后续读写不能往前乱序;它可以看到release线程在调用load之前那些依赖的写操作,依赖于的意思是和该共享变量有关的写操作,举个例子可能会容易明白一点:

    -Thread 1-
    n = 1
    m = 1
    p.store (&n, memory_order_release)

    -Thread 2-
    t = p.load (memory_order_acquire);
    if (*t == 1)
    assert(m == 1);

    -Thread 3-
    t = p.load (memory_order_consume);
    if (*t == 1)
    assert(m == 1);

    线程2的断言会成功,因为线程1对 n 和 m 在store之前修改;线程2 load之后,可以观察到m的修改。

    但线程3的断言不一定会成功,因为m是和load/store操作不相关的变量,线程3不一定能观察看到。

  4. memory_order_acq_rel是memory_order_release/memory_order_acquire的合并,前后的读写都是不能跨过这个原子操作,但仅相关的线程能看到前面写的变化。

这些内存模型非常的细化,在使用的时候总是先用memory_order_seq_cst模式,然后再根据情影慢慢优化。

上面在描述原子操作的内存顺序时,是从两个方面来说的:

  • 原子操作前后的乱序,是否能跨过该原子操作,这相当于内存屏障的作用;会阻止CPU和编译器进行乱序优化。
  • 线程之间的对共享变量修改的可见性顺序。

这两个维度决定了内存模式的区别,当然了,如果我们使用锁来同步共享内存时,内部已经自己实现了这些设施,写起来也会轻松很多;如果你想对多线程进行极致的优化,想写一些Lockless代码,那么就必须像Kernel代码那样,准确地使用这些内存模式。