C++内存模型初探

269 阅读22分钟

前言

在上一篇文章C++多线程初探中,我介绍了C++11到C++17在并发编程中添加的一些好用的API。通过上篇文章的知识,我们应该可以开发出一个比较完善的C++并发系统了。这已经够满足基本的C++开发需求了,但是在某些情况下,我们需要更好的性能,就要避免对锁的使用。

那有没有一种方式可以使得多线程在并发条件下不用加锁也能保证数据一致性呢?

答案是肯定的,这称之为无锁(lock-free)编程。关于无锁编程,读者也可以阅读Jeff Preshing大牛的这篇文章:《An Introduction to Lock-Free Programming》。不过无锁编程实现起来往往比加锁实现要更为麻烦也更为复杂,最重要的是要对C++内存模型具有深入的理解才能够写出正确的程序。

本文首先将介绍一下有关C++内存模型的基本知识、内存序、内存屏障等概念。最后利用内存顺序实现一个经典的单生产单消费的无锁队列。

现代C++的内存模型

文章将从编译器和CPU的优化开始介绍,然后引出C++的内存模型。此处介绍的内存模型是指多线程编程方面的内存模型,而不是说对象的内存布局对齐之类的。

简单来讲,可以认为内存模型是一种契约。它定义一套操作手法以及这些操作手法背后的详细含义。开发者利用这套操作完成数据的同步以避免竞争条件,而系统(包括:编译器,操作系统和处理器)保证执行的逻辑符合内存模型对于相关操作的定义 – 即实现契约。

内存模型主要包含了下面三个部分:

  • 原子操作:顾名思义,这类操作一旦执行就不会被打断,你无法看到它的中间状态,它要么是执行完成,要么没有执行。
  • 操作的局部顺序:一系列的操作不能被乱序。
  • 操作的可见性:定义了对于共享变量的操作如何对其他线程可见。

为什么需要内存模型

在C++11标准出来之前,C++环境没有多线程的概念。编译器和处理器认为系统中只有一个执行流。引入了多线程之后,情况就会变得非常复杂。这是因为:现代计算机系统为了加快执行效率,自动的包含了很多的优化。这些优化虽然保证了在单线程环境下不破坏原来的逻辑。但是一旦到了多线程之后,情况就不一样了。

事实上,开发者编写的代码和最终运行的程序往往会存在较大的差异,而运行结果与开发者预想一致,只是一种“假象”罢了。

如果你曾经单步调试过一个release版本的程序,你会发现运行过程很怪异,居然没有沿着你的代码顺序执行。因为,要是严格按照你的代码顺序执行,编译器和CPU都会抱怨,说执行效率很低速度很慢。

为什么它们执行了一个不一样的程序,我们竟然不知情?其实,主要原因是编译器和CPU优化都遵循了一个同样的原则(As-if rule),即优化后的代码,若是单线程执行,要与原有代码行为保持一致。再加之多线程环境我们使用的互斥锁,其对编译器和CPU优化做了很多限制,可以让我们对线程间的执行顺序进行同步,进而保证了即使被优化成了另一个程序,仍然有相同的执行结果。

若我们从比互斥锁更为底层地去了解多线程间的同步机制,我们势必会看到CPU平台(X86/IA64/ARMv7/ARMv8/Power…)和编译器(gcc/VC/clang…)的差异,进而可以知道优化的存在。现代C++的内存模型,便是为了屏蔽这些差异,而让你可以不用去了解特定平台特定编译器,也不用依赖互斥锁,就可以完成线程间的同步。C++11开始提供的std::atomic<>类模板,便可以作为更为底层的同步工具,这也是内存模型起作用的地方。

之所以会产生差异,原因主要来自下面三个方面:

  • 编译器优化
  • CPU out-of-order执行
  • CPU Cache不一致性 下面我们来逐个介绍。

Memory Reorder

以下面这段伪代码为例:

X = 0, Y = 0;

Thread 1: 
X = 1; // ①
r1 = Y; // ②

Thread 2: 
Y = 1;
r2 = X;

你可能会觉得,在这个程序执行完成之后,r1r2怎么都不可能同时为0。但事实并非如此

这是因为“Memory Reorder”的存在,“Memory Reorder”包含了编译器和处理器两种类型的乱序。

这就导致:线程1中事件发生的顺序虽然是先①后②,但是对于线程2来说,它看到结果可能却是先②后①。当然,线程1看线程2也是一样的。

甚至,当今的所有硬件平台,没有任何一个会提供完全的顺序一致(sequentially consistent)内存模型,因为这样做效率太低了。

不同的编译器和处理器对于Memory Reorder有不同的偏好,但它们都遵循一定的原则,那就是:不能修改单线程的行为Thou shalt not modify the behavior of a single-threaded program.)。在这个基础上,它们可以做各种类型的优化。

编译器优化

以gcc为例,该编译器提供了-o参数来控制非常多的优化选项

以下面这段代码为例:

int A, B;

void foo()
{
    A = B + 1;
    B = 0;
}

在编译优化后,可能会变成下面这样:

int A, B;

void foo()
{
    int temp = B;
    B = 0;
    A = temp + 1;
}

请注意,编译器只要保证:在单线程环境下,执行的结果和原先一样就可以了。所以,这样做是可以的。

对于编译器来说,它知道的是:当前线程中,数据的读写以及数据之间的依赖关系。但是,编译器并不知道哪些数据是在线程间共享,而且是有可能会被修改的。这就需要开发者在软件层面做好控制。

对于编译器的乱序优化来说,开发者并非完全不能控制。编译器会提供称之为内存栅栏(Memory Barrier)的工具给开发者,让开发者告诉编译器:这部分代码编译的时候不能乱序。

gcc的内存栅栏写法如下:

int A, B;

void foo()
{
    A = B + 1;
    asm volatile("" ::: "memory");
    B = 0;
}

Out-of-order执行

不仅仅是编译器,处理器也可能会乱序执行指令。

下面是维基上给出的一张表格,列出了不同类型的CPU可能会执行的乱序类别。

从这个表格中可以看出,不同架构的CPU会有不同类型的Memory Reorder偏好。

我们使用的台式机和笔记本电脑基本上都是x86架构的CPU,而手机或者平板之类的移动设备一般用的是ARM架构的CPU。相较而言,前者的乱序类型要比后者少很多。

x86的内存模型叫做x86-TSO(Total Store Order),这可能是目前处理器中最强的内存模型之一。

下面这幅图是Preshing on Programming一篇文章中给出的对比关系图。

由此我们可以推算,在多线程环境下,假设我们写的代码包含了未定义行为,那么这些问题在手机上将比在电脑上更容易暴露出来。

关于硬件的的内存模型,有兴趣的可以继续看下面几个链接:

类似的,处理器也会提供指令给开发者进行避免乱序的控制。例如,x86,x86-64上的fence指令:

lfence (asm), void _mm_lfence(void)
sfence (asm), void _mm_sfence(void)
mfence (asm), void _mm_mfence(void)

由此提醒我们:如果我们只以单线程的思维来开发并发系统,一旦引入了Memory Reorder之后就可能会发生问题。例如:以上面的AB两个变量为例,在编译器将其乱序后,虽然对于当前线程是没问题的。但是如果在此时刚好有另外一个线程使用这两个变量,并且依赖于它们的更新顺序,那么就会出现问题。

Cache Coherency

事情还不只这么简单。现代的主流CPU几乎都会包含多个核以及多级Cache,下图是MacBook Pro上的CPU Cache信息。

如果画成结构图,结构大概会像下面这样:

每个CPU核在运行的时候,都会优先考虑离自己最近的Cache,一旦命中就直接使用Cache中的数据。这是因为Cache相较于主存(RAM)来说要快很多。但是每个核之间的Cache,每一层之间的Cache,数据常常是不一致的。而同步这些数据是需要消耗时间的。

这就会造成一个问题,那就是:某个CPU核修改了一个数据,没有同步的让其他核知道,于是就存在了数据不一致的情况。

综上这些原因让我们知道,CPU所运行的程序和我们编写的代码可能是不一致的。甚至,对于同一次执行,不同线程感知到其他线程的执行顺序可能都是不一样的。

因此内存模型需要考虑到所有这些细节,以便让开发者可以精确控制。因为所有未定义的行为都可能产生问题。

修改顺序

我们知道,C++中的数据都是由对象组成,一个对象包含了若干个内存位置。

每个对象从初始化开始,直到最终销毁,在其生命周期的范围内,对它进行的访问必须有一个确定的修改顺序,这个顺序包含了所有线程的访问操作。

虽然程序的每一次运行,这个顺序可能是不一样的,但是针对具体的某一次来说,必须有一个“一致的顺序”,这个顺序要被所有的线程认可,并且可见。

并发编程的难点之一就在于:识别出系统中哪些在线程共享且可能会被修改的数据,并对它们做“合理”的保护。之所以强调这一点,是因为对于共享数据的保护本质上是在对抗编译器和处理器的优化,所以保护不能过度(锁的粒度)。

我们必须在保证正确性的基础上尽可能少的干扰编译器和处理器的优化:对于那些没有访问共享数据,或者对于所有线程来说都是只读的数据来说,这部分代码就任由编译器和处理器优化好了。

另外还有一点需要说明的是,这里说的是:对于每一个变量来说,要有明确的修改顺序。但是这并不要求所有的变量存在一个全局的一致顺序。这意味着,当将多个变量的访问顺序放在一起看的时候,不同线程看得顺序可能是不一样的。

关系术语

谈内存模型之前,先谈谈如下几个概念,以便于后续分析多线程的同步过程。

顺序一致(Sequential consistency)

编译器和CPU需要严格按照代码顺序进行生成和执行。对于多线程程序,每个线程的指令执行顺序由各自的CPU来控制,因而都有独立的执行顺序,但因为它们共享同一个内存,而内存修改的顺序是全局的(global memory order),我们可以以该全局顺序为参照,对所有线程进行同步。其实,我们用互斥锁来推导线程间同步顺序时,也是基于类似这种顺序一致的前提。但顺序一致更为严格,对于内存读写来说,读写顺序需要严格按照代码顺序,即要求如下(符号”<p”表示程序代码顺序,符号”<m”表示内存的读写顺序):

// 顺序一致的要求
/* Load→Load */
/*若按代码顺序,a变量的读取先于b变量,
则内存顺序也需要先读a再读b
后面的规则同理。*/
if L(a) <p L(b)L(a) <m L(b) 

/* Load→Store */
if L(a) <p S(b)L(a) <m S(b) 

/* Store→Store */	
if S(a) <p S(b)S(a) <m S(b) 

 /* Store→Load */
if S(a) <p L(b)S(a) <m L(b)	

顺序一致性是一种最强的内存一致性模型,它要求所有线程观察到的操作顺序与程序中的顺序一致。在顺序一致性模型下,所有线程都遵循相同的happens-before(下面介绍)规则,并且所有线程看到的操作顺序是一致的。

既然顺序一致这么严格,其显然会限制编译器和CPU的优化,所以业界提出了很多宽松的模型,例如在X86中使用的TSO(Total Store Order)便允许某些条件下的重排。

从程序员(a programmer-centric approach)的角度,抛开编译器和硬件,也即从语言层面,我们也可以提出合适的模型。现代C++(包括Java)都是使用了SC-DRF(Sequential consistency for data race free)。在SC-DRF模型下,程序员只要不写出Race Condition的代码,编译器和CPU便能保证程序的执行结果与顺序一致相同。因而,内存模型就如同程序员与编译器/CPU之间的契约,需要彼此遵守承诺。C++的内存模型默认为SC-DRF,此外还支持更宽松的非SC-DRF的模型。

Happens-before

如果一个操作Ahappens-beforeB,那么可以保证:

  • A的效果对B可见
  • B不会在A之前执行

Synchronizes-with

"synchronizes-with" 是一个重要的概念,它与 "happens-before" 关系紧密相关。"synchronizes-with" 描述了两个原子操作之间的一种特殊的因果关系,其中一个操作是另一个操作的释放点(release),而另一个操作是它的获取点(acquire)。

Happens-before和Synchronizes-with的区别与联系

看到这里读者可能觉得有点懵,觉得Happens-beforeSynchronizes-with关系好像是一样的。

"Happens-before" 和 "Synchronizes-with" 是C++内存模型中描述操作间关系的两个关键概念,它们都用于确保多线程程序中的操作顺序性和数据一致性。尽管它们的目的相似,但它们的含义和用途有所不同。

Happens-before 关系

"Happens-before" 关系是一种偏序关系,它定义了一组规则,根据这些规则,一个操作的效果对另一个操作可见。如果操作 A "happens-before" 操作 B,那么:

  1. 操作 A 对操作 B 是可见的。
  2. 操作 B 不能在 A 之前执行。

"Happens-before" 关系可以通过多种方式建立,包括程序顺序规则、原子操作、锁机制等。

Synchronizes-with 关系

"Synchronizes-with" 是一种更具体的 "happens-before" 关系,它专门用于描述两个原子操作之间的因果关系。这种关系通常在涉及原子操作的内存顺序(如 std::memory_order_acquirestd::memory_order_release)时出现。如果操作 A "synchronizes-with" 操作 B,那么:

  1. 操作 A 是一个 std::memory_order_release 或更高级别的原子写操作。
  2. 操作 B 是一个 std::memory_order_acquire 或更高级别的原子读操作。
  3. 操作 B 能够观察到 A 对共享数据所做的更改。

区别

  1. 关系范围:"Happens-before" 是一种更广泛的概念,涵盖了所有类型的操作和同步机制。而 "Synchronizes-with" 仅用于描述原子操作之间的特定类型的 "happens-before" 关系。
  2. 内存顺序要求:"Synchronizes-with" 关系要求特定的内存顺序,即写操作必须是std::memory_order_release 或更高级别,而读操作必须是 std::memory_order_acquire 或更高级别。
  3. 目的:"Happens-before" 关系用于确保程序中所有相关操作的顺序性和可见性,而 "Synchronizes-with" 关系主要用于确保原子操作之间的数据一致性和顺序性。

联系

  1. 包含关系:"Synchronizes-with" 是 "happens-before" 的一种特例。如果操作 A "synchronizes-with" 操作 B,那么 A 必然 "happens-before" B。
  2. 数据一致性:两者都旨在确保多线程程序中的数据一致性和操作顺序性。
  3. 同步机制:它们都是C++内存模型中用于描述同步机制和操作顺序的概念。

C++内存顺序模型

在多线程编程中,临界区是一个很重要的概念,我们对此再做进一步的认识。

对于临界区内的语句,我们不能将其移出临界区,但是我们可以把临界区外的代码移进临界区,如下图的三种情况,第一种可能会被优化成第二种。但是第二种情况不会被优化成第三种:

image.png

从上图中我们可以看出,lock和unlock可以看作两个单方向的屏障,只允许代码往下方向移动,而unlock则指允许向上方向移动。

C++中的内存模型借鉴lock/unlock,引入了两个等效的概念,Acquire(类似lock)和Release(类似unlock),这两个都是单方向的屏障(One-way Barriers: acquire barrier, release barrier)。

现代C++的内存模型,其实主要表现在对原子变量进行操作时,通过指定memory order(内存顺序)的参数,来控制线程间同步。memory order在C++11到C++20前是一个枚举:

// C++11 到C++20前
typedef 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;

// C++20起
enum class memory_order : /* 未指明 */ {  


    relaxed, consume, acquire, release, acq_rel, seq_cst  
};  
inline constexpr memory_order memory_order_relaxed = memory_order::relaxed;  
inline constexpr memory_order memory_order_consume = memory_order::consume;  
inline constexpr memory_order memory_order_acquire = memory_order::acquire;  
inline constexpr memory_order memory_order_release = memory_order::release;  
inline constexpr memory_order memory_order_acq_rel = memory_order::acq_rel;  
inline constexpr memory_order memory_order_seq_cst = memory_order::seq_cst;

以上6个枚举可以细分为三种内存顺序模型,如下表:

内存顺序模型Memory order枚举值
顺序一致(sequentially consistent ordering)memory_order_seq_cst,只有该值满足SC-DRF,其它都是比SC-DRF更宽松的模型,原子操作默认使用该值。
获取发布 (acquire-release ordering)memory_order_consume, memory_order_acquire, memory_order_release, memory_order_acq_rel
松散(relaxed ordering )memory_order_relaxed
解释
memory_order_relaxed宽松操作:没有同步或定序约束,仅对此操作要求原子性(见下方宽松定序)。
memory_order_consume有此内存定序的加载操作,在其影响的内存位置进行消费操作:当前线程中依赖于当前加载的值的读或写不能被重排到此加载之前。其他线程中对有数据依赖的变量进行的释放同一原子变量的写入,能为当前线程所见。在大多数平台上,这只影响到编译器优化(见下方释放-消费定序)。
memory_order_acquire有此内存定序的加载操作,在其影响的内存位置进行获得操作:当前线程中读或写不能被重排到此加载之前。其他线程的所有释放同一原子变量的写入,能为当前线程所见(见下方释放-获得定序)。
memory_order_release有此内存定序的存储操作进行释放操作:当前线程中的读或写不能被重排到此存储之后。当前线程的所有写入,可见于获得该同一原子变量的其他线程(见下方释放-获得定序),并且对该原子变量的带依赖写入变得对于其他消费同一原子对象的线程可见(见下方释放-消费定序)。
memory_order_acq_rel带此内存定序的读修改写操作既是获得操作又是释放操作。当前线程的读或写内存不能被重排到此存储之前或之后。所有释放同一原子变量的线程的写入可见于修改之前,而且修改可见于其他获得同一原子变量的线程。
memory_order_seq_cst有此内存定序的加载操作进行获得操作,存储操作进行释放操作,而读修改写操作进行获得操作释放操作,再加上存在一个单独全序,其中所有线程以同一顺序观测到所有修改(见下方序列一致定序)。

acquire/release:读写顺序的保证

memory_order_acquire保证后续的内存读写指令都不能被优化到这条指令之前;memory_order_release保证之前的内存读写指令都不能被优化到本条指令之后。

有如下测试代码:

// CODE 1  
atomic<int> flag{5};  
void test_release(int * a) {  
    int b = 100// {1}  
    *a = 6// {2}  
    flag.store(10, memory_order_release); // {3}  
}

release保证{1,2}一定在{3}之前执行,但是对{1}和{2}的执行顺序不做规定。

// CODE 2  
atomic<bool> flag{false};  
int * ptr = nullptr;  
  
// thread 1  
void test_release() {  
    if (!ptr)  
        ptr = new int(1); // {1}  
    flag.store(true, memory_order_release); // {2}  
}  
  
// thread 2  
void test_acquire() {  
    while (!flag.load(memory_order_acquire)) {  
    } // {3}  
  
    int x = *ptr; // {4}  
}

thread 1中,release保证{1}一定在{2}之前执行;thread 2中acquire保证{3}一定在{4}之前执行;如果{3}处用的不是acquire而是relaxed,那么{4}完全有可能被优化到{3}之前,进而导致x的值不达预期,造成逻辑错误。

由此可见,一个线程呈现给别人看的顺序,不仅需要自己做好相应的memory_order,连看的一边的人,也需要做好相应的顺序约定。从以上代码我们可以体会到:memory order是针对单线程重排序规则的概念,概念本身可以不讲多线程。多线程只是利用了memory order的规则加内存一致性来做线程同步。

consume:读写粒度的削弱

从acquire/release中可以看到其作用范围有时候会"波及无辜"。在CODE 3中,{3}必须在{4,5}之前执行,但是显然只有{3}和{5}存在依赖关系,而{4}被误伤了。memory_order_consume就是为了解决这种情况出现的。memory_order_consume保证和本指令存在依赖关系的读写指令必须在本指令之后执行。

下面是一个测试代码:

// CODE 3
atomic<bool> flag{false};
void test_consume() {    
    while (!flag.load(memory_order_consume)) { } // {3}    
    int x = *ptr; // {4}    
    int y = flag.load() + 1// {5}
}

使用memory_order_consume序后,只会限制{3}在{5}之前执行,对{4}的执行顺序不做要求,降低了同步的粒度,这样就留给了编译器更大的优化空间。

acq_rel: 读写粒度的增强

memory_order_acq_rel是acquire/release同时起作用,其保证当前指令之前的所有读写操作都不能被优化到当前指令之后;当前指令之后的所有读写操作都不能被优化到当前指令之前;

relaxed

memory_order_relaxed是最简单的一种内存序,它只保证原子性,不作任何的同步。

seq_cst

表示顺序一致性,这是最强的约束。有如下关系(其中<==>表示等价):seq_cst的load <==> acquire的load;seq_cst的store <==> release的store;seq_cst的read-modify-write <==> acquire的load 且 release的store 且 Single Total Order(这里的Single Total Order指的是所有的线程都以相同的顺序观察到所有修改);显然,除了seq_cst以外的其他几种序都不具备Single Total Order,也就是在多线程读写一个变量的时候,A线程观察到这个变量的值的变化顺序和B线程的不一样。

memory_order和volatile的联系

  1. 易失性限定符volatile 关键字用于指示一个对象的值在程序的控制之外可能会改变,例如,它可能由硬件或操作系统直接修改。
  2. 优化和副作用:编译器在优化代码时,通常会重新排序或省略掉看似没有“可见”效果的操作。然而,对于 volatile 类型的对象,编译器不能这么做。即使在单线程程序中,对 volatile 对象的访问也不能被优化掉或重新排序。
  3. 线程间通信volatile 关键字不适用于线程间的同步,因为它不提供任何内存顺序或原子性保证,这些是多线程同步所需的。
  4. 未定义行为:如果尝试通过非 volatile 类型的引用或指针来访问 volatile 对象,将会导致未定义行为。这意味着程序可能会崩溃,或者表现出不可预测的行为。
  5. std::memory_order:这是C++11中引入的原子操作的一部分,它定义了原子操作的内存顺序,用于多线程同步。

总结

本文介绍了C++内存模型中有关内存序的相关知识,但是限于篇幅本文并没有给出根据C++内存模型进行无锁编程的示例。下一篇文章将利用C++内存序实现一个无锁的单生产者单消费者的队列。