理解C/C++ 11内存模型

现代计算机体系结构上,CPU执行指令的速度远远大于CPU访问内存的速度,于是引入Cache机制来加速内存访问速度。 除了Cache以外,分支预测和指令预取也在很大程度上提升了CPU的执行速度。 随着SMP的出现,多线程编程模型被广泛应用,在多线程模型下对共享变量的访问变成了一个复杂的问题。 于是作为一个程序员,我们有必要了解一下内存模型,这也是多处理器架构下并发编程里必须掌握的一个基础概念。

1. 什么是内存模型?

到底什么是内存模型呢?看到有两种不同的观点:

  • A:内存模型是用来描述编程语言在支持多线程编程中对共享内存访问的顺序。
  • B:内存模型的本质是指在单线程情况下CPU指令在多大程度上发生指令重排(Reorder) [1]。

实际上A,B两种说法都是正确的,只不过是在尝试从不同的角度去说明memory model的概念。不过个人认为,内存模型表达为"内存顺序模型"可能更加贴切一点。

一个良好的memory model定义包含3个方面:

  • Atomic Operations
  • Partial order of operations
  • Visable effects of operations

这里要强调的是

我们这里所说的内存模型和CPU的体系结构、编译器实现和编程语言规范3个层面都有关系。

首先,不同的CPU体系结构内存顺序模型是不一样的,但大致分为两种:

Architecture Memory Model
x86_64 Total Store Order
Sparc Total Store Order
ARMv8 Weakly Ordered
PowerPC Weakly Ordered
MIPS Weakly Ordered

强顺序模型(简称TSO,Total Store Order),这是一种接近程序顺序的顺序模型。 所谓Total,就是说,内存(在写操作上)是有一个全局的顺序的(所有人看到的一样的顺序), 就好像在内存上的每个Store动作必须有一个排队,一个弄完才轮到另一个,这个顺序和你的程序顺序直接相关。 所有的行为组合只会是所有CPU内存程序顺序的交织,不会发生和程序顺序不一致的地方[4]。 TSO模型有利于多线程程序的编写,对程序员更加友好,但对芯片实现者不友好。常见的体系结构,如x86_64Sparc都是强内存模型。在强内存模型下,CPU为了TSO的承诺,会牺牲一些并发上的执行效率。

弱内存模型(简称WMO,Weak Memory Ordering),是把是否要求强制顺序这个要求直接交给程序员的方法。 换句话说,CPU不去保证这个顺序模型(除非他们在一个CPU上就有依赖),程序员要主动插入内存屏障指令来强化这个"可见性"[4]。 ARMv8PowerPCMIPS等体系结构都是弱内存模型。 每种弱内存模型的体系架构都有自己的内存屏障指令,语义也不完全相同。 弱内存模型下,硬件实现起来相对简单,处理器执行的效率也高, 只要没有遇到显式的屏障指令,CPU可以对局部指令进行Reorder以提高执行效率。

对于多线程程序开发来说,对并发的数据访问我们一般到做同步操作, 可以使用mutexsemaphoreconditional等重量级方案对共享数据进行保护。 但为了实现更高的并发,需要使用内存共享变量做通信(Message Passing), 这就对程序员的要求很高了,程序员必须时时刻刻必须很清楚自己在做什么, 否则写出来的程序的执行行为会让人很是迷惑! 值得一提的是,并发虽好,如果能够简单粗暴实现,就不要搞太多投机取巧! 因为要实现lock-free无锁编程真的有点难。

其次,不同的编程语言对内存模型都有自己的规范。例如: C/C++和Java等不同的编程语言都有定义内存模型相关规范。

2011年发布的C11/C++11 ISO Standard为我们带来了memory order的支持, 引用C++11里的一段描述:

css 复制代码
The memory model means that C++ code now has a standardized
library to call regardless of who made the compiler and on
what platform it's running. There's a standard way to control
how different threads talk to the processor's memory.[7]

memory order的问题就是因为指令重排引起的。指令重排会导致原来的内存可见顺序发生了变化, 在单线程执行起来的时候是没有问题的, 但是放到 多核/多线程执行的时候就出现问题了, 为了效率引入的额外复杂逻辑的的弊端就出现了[8]。

C++11引入memory order的意义在于:在语言层提供了一个与运行平台无关和编译器无关的标准库, 让我们可以在high level languange层面实现对多处理器对共享内存的交互式控制。 我们的多线程终于可以跨平台啦!我们可以借助内存模型写出更好更安全的并发代码。 真棒,简直不要太优秀~

C11/C++11使用memory order来描述memory model, 而用来联系memory order的是atomic变量, atomic操作可以用load()release()语义来描述。 一个简单的atomic变量赋值可描述为:

c 复制代码
 atomic_var1.store (atomic_var2.load()); // atomic variables
     vs
 var1 = var2;                            // regular variables

为了更好地描述内存模型,有4种关系术语需要了解一下。

sequenced-before

同一个线程之内,语句A的执行顺序在语句B前面,那么就成为A sequenced-before B。 它不仅仅表示两个操作之间的先后顺序,还表示了操作结果之间的可见性关系。 两个操作A和操作B,如果有A sequenced-before B,除了表示操作A的顺序在B之前,还表示了操作A的结果操作B可见。 例如:语句A是sequenced-before语句B的。

c 复制代码
r2 = x.load(std::memory_order_relaxed); // A 
y.store(42, std::memory_order_relaxed); // B

happens-before

happens-before关系表示的不同线程之间的操作先后顺序。 如果A happens-before B,则A的内存状态将在B操作执行之前就可见。 happends-before关系满足传递性、非自反性和非对称性。 happens before包含了inter-thread happens beforesynchronizes-with两种关系。

synchronizes-with

synchronizes-with关系强调的是变量被修改之后的传播关系(propagate), 即如果一个线程修改某变量的之后的结果能被其它线程可见,那么就是满足synchronizes-with关系的[9]。 另外synchronizes-with可以被认为是跨线程间的happends-before关系。 显然,满足synchronizes-with关系的操作一定满足happens-before关系了。

Carries dependency

同一个线程内,表达式A sequenced-before 表达式B,并且表达式B的值是受表达式A的影响的一种关系, 称之为Carries dependency。这个很好理解,例如:

c 复制代码
int *a = &var1;     // A
int *b = &var2;     // B
c = *a + *b;        // C

执行语句A和B与语句C之间就存在Carries dependency关系, 因为c的值直接依赖于*a*b的值。

了解了上面一些基本概念,下面我们来一起学习一下内存模型吧。

2. C11/C++11内存模型

C/C++11标准中提供了6种memory order,来描述内存模型[6]:

c++ 复制代码
enum memory_order {
    memory_order_relaxed,
    memory_order_consume,
    memory_order_acquire,
    memory_order_release,
    memory_order_acq_rel,
    memory_order_seq_cst
};

每种memory order的规则可以简要描述为:

枚举值 定义规则
memory_order_relaxed 不对执行顺序做任何保证
memory_order_consume 本线程中,所有后续的有关本原子类型的操作,必须在本条原子操作完成之后执行
memory_order_acquire 本线程中,所有后续的操作必须在本条原子操作完成后执行
memory_order_release 本线程中,所有之前的操作完成后才能执行本条原子操作
memory_order_acq_rel 同时包含memory_order_acquirememory_order_release标记
memory_order_seq_cst 全部存取都按顺序执行

下面我们来举例一一说明,扒开内存模型的神秘面纱。

2.1 memory order releaxed

relaxed表示一种最为宽松的内存操作约定,Relaxed ordering 仅仅保证load()和store()是原子操作, 除此之外,不提供任何跨线程的同步[5]。

c 复制代码
                   std::atomic<int> x = 0;     // global variable
                   std::atomic<int> y = 0;     // global variable
		  
Thread-1:                                  Thread-2:
r1 = y.load(memory_order_relaxed); // A    r2 = x.load(memory_order_relaxed); // C
x.store(r1, memory_order_relaxed); // B    y.store(42, memory_order_relaxed); // D

上面的多线程模型执行的时候,可能出现r2 == r1 == 42。 要理解这一点并不难,因为CPU在执行的时候允许局部指令重排reorder,D可能在C前执行。 如果程序的执行顺序是 D -> A -> B -> C,那么就会出现r1 == r2 == 42。

如果某个操作只要求是原子操作,除此之外,不需要其它同步的保障,那么就可以使用 relaxed ordering。 程序计数器是一种典型的应用场景:

c++ 复制代码
#include <cassert>
#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();
    }
    assert(cnt == 10000);    // never failed
    return 0;
}

cnt是共享的全局变量,多个线程并发地对cnt执行RMW(Read Modify Write)原子操作。 这里只保证cnt的原子性,其他有依赖cnt的地方不保证任何的同步。

2.2 memory order consume

consume要搭配release一起使用。很多时候,线程间只想针对有依赖关系的操作进行同步 , 除此之外线程中其他操作顺序如何不关心,这时候就适合用consume来完成这个操作。 例如:

c 复制代码
b = *a;
c = *b

第二行的变量c依赖于第一行的执行结果,因此这两行代码是Carries dependency关系。 显然,由于consume是针对有明确依赖关系的语句来限定其执行顺序的一种内存顺序, 而releaxed不提供任何顺序保证, 所以consume order要比releaxed order要更加地Strong。

c++ 复制代码
#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");  // never fires: *p2 carries dependency from ptr
    assert(data == 42);      // may or may not fire: data does not carry dependency from ptr
}

int main()
{
    std::thread t1(producer);
    std::thread t2(consumer);
    t1.join();
    t2.join();
}

assert(*p2 == "Hello")永远不会失败,但assert(data == 42)可能会。 原因是:

  • p2和ptr直接有依赖关系,但data和ptr没有直接依赖关系,
  • 尽管线程1中data赋值在ptr.store()之前,线程2看到的data的值还是不确定的。

2.3 memory order acquire

acquirerelease也必须放到一起使用。 releaseacquire构成了synchronize-with关系,也就是同步关系。 在这个关系下:线程A中所有发生在release x之前的值的写操作, 对线程B的acquire x之后的任何操作都可见

c++ 复制代码
#include <thread>
#include <atomic>
#include <cassert>
#include <string>
#include <iostream>

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

void sender()
{
    data = 42;                                              // A
    var.store(100, std::memory_order_relaxed);              // B
    ready.store(true, std::memory_order_release);           // C
}
void receiver()
{
    while (!ready.load(std::memory_order_acquire))          // D
        ;
    assert(data == 42);  // never failed                    // E
    assert(var == 100);  // never failed                    // F
}

int main()
{
    std::thread t1(sender);
    std::thread t2(receiver);
    t1.join();
    t2.join();
}
r 复制代码
c++

上面的例子中:

  • sender线程中data = 42sequence before原子变量ready的
  • sender和receiver在C和D处发生了同步
  • 线程sender中C之前的所有读写对线程receiver都是可见的 显然, releaseacquire组合在一起比releaseconsume组合更加Strong!

2.4 memory order release

release order一般不单独使用,它和acquireconsume组成2种独立的内存顺序搭配。 这里就不用展开啰里啰嗦了。

2.5 memory order acq_rel

acq_relacquirerelease的叠加。中文不知道该咋描述好:

sql 复制代码
A read-modify-write operation with this memory order is both an acquire operation and a release operation. No memory reads or writes in the current thread can be reordered before or after this store. All writes in other threads that release the same atomic variable are visible before the modification and the modification is visible in other threads that acquire the same atomic variable.

大致意思是:

memory_order_acq_rel适用于read-modify-write operation, 对于采用此内存序的read-modify-write operation,我们可以称为acq_rel operation, 既属于acquire operation 也是release operation. 设有一个原子变量M上的acq_rel operation: 自然的,该acq_rel operation之前的内存读写都不能重排到该acq_rel operation之后, 该acq_rel operation之后的内存读写都不能重排到该acq_rel operation之前. 其他线程中所有对M的release operation及其之前的写入都对当前线程从该acq_rel operation开始的操作可见, 并且截止到该acq_rel operation的所有内存写入都对另外线程对M的acquire operation以及之后的内存操作可见[13]。

这里是一个例子,关于为什么要有acq_rel可以参考一下:

cpp 复制代码
#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); // will never fire
}
 
int main()
{
    std::thread a(thread_1);
    std::thread b(thread_2);
    std::thread c(thread_3);
    a.join(); b.join(); c.join();
}

2.6 memory order seq_cst

seq_cst表示顺序一致性内存模型,在这个模型约束下不仅同一个线程内的执行结果是和程序顺序一致的, 每个线程间互相看到的执行结果和程序顺序也保持顺序一致 。 显然,seq_cst的约束是最强的,这意味着要牺牲性能为代价。

c 复制代码
        atomic int x (0);               atomic int y (0);
    x. store (1, seq cst );     ||      y. store (1, seq cst );
    int r1 = y.load( seq cst ); ||      int r2 = x.load( seq cst );
                assert (r1 == 1 || r2 == 1);

下面是一个seq_cst的实例:

c 复制代码
#include <thread>
#include <atomic>
#include <cassert>
 
std::atomic<bool> x = {false};
std::atomic<bool> y = {false};
std::atomic<int> z = {0};
 
void write_x()
{
    x.store(true, std::memory_order_seq_cst);
}
 
void write_y()
{
    y.store(true, std::memory_order_seq_cst);
}
 
void read_x_then_y()
{
    while (!x.load(std::memory_order_seq_cst))
        ;
    if (y.load(std::memory_order_seq_cst)) {
        ++z;
    }
}
 
void read_y_then_x()
{
    while (!y.load(std::memory_order_seq_cst))
        ;
    if (x.load(std::memory_order_seq_cst)) {
        ++z;
    }
}
 
int main()
{
    std::thread a(write_x);
    std::thread b(write_y);
    std::thread c(read_x_then_y);
    std::thread d(read_y_then_x);
    a.join(); b.join(); c.join(); d.join();
    assert(z.load() != 0);  // will never happen
}

2.7 Relationship with volatile

人的一生总是充满了疑惑

可能你会思考,volatile关键字能够防止指令被编译器优化,那它能提供线程间(inter-thread)同步语义吗? 答案是:不能!!!

  • 尽管volatile能够防止单个线程内对volatile变量进行reorder,但多个线程同时访问同一个volatile变量,线程间是完全不提供同步保证。
  • 而且,volatile不提供原子性!
  • 并发的读写volatile变量是会产生数据竞争的,同时non volatile操作可以在volatile操作附近自由地reorder

看一个例子,执行下面的并发程序,不出意外的话,你不会得到一个为0的结果。

cpp 复制代码
#include <thread>
#include <iostream>

volatile int count = 0;

void increase() {
    for (int i = 0; i < 1000000; i++) {
        count++;
    }
}

void decrease() {
    for (int i = 0; i < 1000000; i++) {
        count--;
    }
}

int main() {
    std::thread t1(increase);
    std::thread t2(decrease);
    t1.join();
    t2.join();
    std::cout << count << std::endl;
}

如果要进一步理解这个topic,建议可以听一下油管上的大拿视频,可以更好地理解memory model的含义。

3. Reference

  1. The C/C++ Memory Model: Overview and Formalization
  2. 知乎专栏:如何理解C++的6种memory order
  3. 理解 C++ 的 Memory Order
  4. 理解弱内存顺序模型
  5. 当我们在谈论 memory order 的时候,我们在谈论什么
  6. en.cppreference.com/w/cpp/atomi...
  7. Youtube: Atomic's memory orders, what for? - Frank Birbacher [ACCU 2017]
  8. C++11中的内存模型下篇 - C++11支持的几种内存模型
  9. memory ordering, Gavin's blog
  10. c++11 内存模型解读
  11. memory barriers in c, MariaDB FOUNDATION, pdf
  12. C++ memory order循序渐进
  13. Memory Models for C/C++ Programers
  14. Memory Consistency Models: A Tutorial
相关推荐
ROC_bird..31 分钟前
STL - vector的使用和模拟实现
开发语言·c++
机器视觉知识推荐、就业指导31 分钟前
C++中的栈(Stack)和堆(Heap)
c++
Mr_Xuhhh3 小时前
递归搜索与回溯算法
c语言·开发语言·c++·算法·github
无敌岩雀3 小时前
C++设计模式行为模式———命令模式
c++·设计模式·命令模式
爱吃生蚝的于勒5 小时前
C语言内存函数
c语言·开发语言·数据结构·c++·学习·算法
小白学大数据7 小时前
Python爬虫开发中的分析与方案制定
开发语言·c++·爬虫·python
versatile_zpc9 小时前
C++初阶:类和对象(上)
开发语言·c++
小鱼仙官9 小时前
MFC IDC_STATIC控件嵌入一个DIALOG界面
c++·mfc
神仙别闹9 小时前
基本MFC类框架的俄罗斯方块游戏
c++·游戏·mfc
娅娅梨10 小时前
C++ 错题本--not found for architecture x86_64 问题
开发语言·c++