C++11内存模型

C++11内存模型

内存模型(memory model),作用就是规定了各种不同访问共享内存的方式,不同的内存模型,既需要编译器的支持,也需要CPU硬件的支持。

1 多核CPU结构

image

 特点:

  • 有多个CPU处理器,每个CPU处理器内部又有多个内核。
  • 存在只能被一个CPU内核访问的一级缓存L1 Cache。
  • 存在只能被一个CPU处理器多个内核访问的二级缓存L2 cache。
  • 存在被所有CPU处理器都能访问的三级缓存L3 cache以及内存。
  • L1 cache、L2 cache、L3 cache的容量依次变大,但是访问速度依次减慢。

2 顺序一致性

Sequential Consistency(顺序一致性,简称SC)对程序执行结果有两个要求:

  • 每个处理器的执行顺序和代码中的顺序(program order)一致。
  • 所有处理器都只能看到一个单一的操作执行顺序。

3 顺序一致性的缺点

从顺序一致性的定义可以看出,顺序一致性实际上是一种强一致性,可以想象成整个程序过程中由一个开关开选择执行的线程,这样才能同时保证顺序一致性的两个条件。这种情况实际上还是相当于同一时间只有一个现场在工作,这种保证导致了程序是低效,且无法利用上多核的优点。

4 全存储排序

Total Store Ordering,简称TSO。有些CPU架构,在处理核心中增加写缓存,一个操作只要写入到本核心的写缓存就可以返回。此时就面临问题,比如两个线程如下:

线程1 线程2
1. A=1 3. B=2
2. print(B) 4. print(A)

 

在多核CPU架构下,写一个值写到本内核的缓冲区就可以返回,接着执行下一条指令,因此可能出现如下情况:

image

  •  执行操作1,core1写入A的新值1到core1的缓冲区中后马上就返回了,此时并没有将A的新值更新到所有CPU都能访问的内存中。
  • 执行操作3,core2写入B的新值2到core2的缓冲区之后马上就返回了,此时也并没有将B的新值更新到所有CPU都能访问的内存中。
  • 执行操作2,由于操作2访问到的本core缓冲区中存储的B值还是原来的0,因此输出0。
  • 执行操作4,由于操作4访问到本core缓冲区中存储的A值还是原来的0,因此输出0。

可以看到,在引入了只能由每个core才能访问到的写缓冲区之后,之前SC中不允许出现的输出(0,0)的情况就在这样的条件下可能出现了。

 5 松弛型内存模型

Relaxed memory models,前面已经介绍了两种内存模型,SC是最简单直白的内存模型,TSO是在SC的基础上,加入了写缓存,写缓存导致了一些在SC条件下不可能出现的情况也成为了可能。

然后,即便如此,以上两种内存模型都没有改变单线程执行一个程序时的执行顺序。

在松散的内存模型中,编译器可以在满足程序单线程执行结果的情况下进行指令重排(reorder),来看下面的程序:

int A, B;

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

int main() {
  foo();
  return 0;
}

如果在不使用优化的情况下,gcc foo.c -,foo函数针对A和B操作的汇编代码如下:

    movl    B(%rip), %eax
    addl    $1, %eax
    movl    %eax, A(%rip)
    movl    $0, B(%rip)

 即,先把变量B的值赋给累加器eax,将寄存器eax加1后的结果赋值给变量A,最后将变量B置为0。

如果使用O2优化编译,g c c foo.c -S -O2则得到如下汇编代码:

    movl    B(%rip), %eax
    movl    $0, B(%rip)
    addl    $1, %eax
    movl    %eax, A(%rip)

即,先把变量B值赋值给寄存器eax,然后变量B置0,再将寄存器eax加1的结果赋值给变量A。

其原因在于,foo函数中,只要将变量B的值暂存下来,那么对变量B的赋值操作可以被打乱而并不影响程序的执行结果,这就是编译器可以做的重新排序优化。

回到前面的例子中,在松弛型内存模型中,程序的执行顺序就不见得和代码中编写的一样了,这是这种内存模型和SC、TSO模型最大的差异。

 5 内存栅栏

说完了三种内存模型,这里还需了解一下内存栅栏(memory barrier)的概念。

由于有了缓存的出现,导致一些操作不到内存就可以返回继续执行后面的指令,为了保证这些操作必须是写入到内存之后才执行,于是引入了内存栅栏(memory barrier,又称为mmemory fence)操作。内存栅栏指令保证了在这条指令之前所有的内存操作结果都在这条指令之后的内存操作指令没执行之前写入到内存中。也可以换另外一个角度来理解内存栅栏指令的作用:显式的在程序的某个执行点上保证SC。

再以前面的例子来说明这个指令,在X64下面,内存屏障指令使用汇编指令 asm volatile("pause":::"memory");来实现,如果将这个指令放到两个赋值语句之间:

int A, B;

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

int main() {
  foo();
  return 0;
}

那么,再次使用O2编译出来的汇编代码就变成了:

.LFB1:
  .cfi_startproc
  movl  B(%rip), %eax
  addl  $1, %eax
  movl  %eax, A(%rip)
#APP
# 6 "foo.c" 1
  pause
# 0 "" 2
#NO_APP
  movl  $0, B(%rip)

可以看到,插入内存屏障指令之后,生成的汇编代码顺序就不会乱序了。

 6 C++11内存模型

6.1 几种关系术语

sequenced-before

sequenced-before用于表示单线程之间,两个操作上的先后顺序,这个顺序上非对称、可以进行传递的关系。

它不仅表示连个操作之间的先后顺序,还表示操作结果之间的可见性关系。两个操作A和B,如果有A sequenced-before B,除了表示操作A的顺序在B之前,还表示操作A的结果操作B可见。

happens-before

与sequeneced-before不同的是,happens-before关系表示的不同线程之间的操作先后顺序,同样也是非对称、可传递的关系。

如果A happens-before B,则A的内存状态将在B操作执行之前就可见。在前文中,某些情况下一个写操作只是简单的写入缓存就返回了,其他核心上的操作不一定能马上见到操作结果,这种关系上不满足happens-before的。

synchronizes-with

 synchronizes-with关系强调的变量被修改之后的传播关系(propagate),即如果一个线程修改某变量之后的结果能被其它线程可见,那么就说满足synchronized-with的关系的。显然,满足synchronizes-with关系的操作一定满足happens-before关系。

6.2 C++11支持的内存模型

 从C++11开始,就支持以下内存模型:

enum memory_order {
    memory_order_relaxed,
    memory_order_consume,
    memory_order_acquire,
    memory_order_release,
    memory_order_acq_rel,
    memory_order_seq_cst
};

与内存模型相关的枚举类型有以上6种,但是其实分为4类,如下图所示,其中对一致性的要求逐渐减弱:

image

 memory_order_seq_cst

这是默认的内存模型,即前文分析过的顺序一致性内存模型。示例代码如下:

#include <atomic>
#include <thread>
#include <assert.h>

std::atomic<bool> x,y;
std::atomic<int> z;

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()
{
    x=false;
    y=false;
    z=0;
    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);
}

由于采用了顺序一致性模型,因此最后的断言不可能发生,即在程序结束时不可能出现z为0的情况。

memory_order_relaxed

这种类型对应的松散内存模型,这种内存模型的特点是:

  • 针对一个变量的读写操作是原子操作;
  • 不同线程之间针对该变量的访问操作先后顺序不能得到保证,即有可能乱序。

来看示例代码:

#include <atomic>
#include <thread>
#include <assert.h>

std::atomic<bool> x,y;
std::atomic<int> z;

void write_x_then_y()
{
    x.store(true,std::memory_order_relaxed);
    y.store(true,std::memory_order_relaxed);
}

void read_y_then_x()
{
    while(!y.load(std::memory_order_relaxed));
    if(x.load(std::memory_order_relaxed))
        ++z;
}

int main()
{
    x=false;
    y=false;
    z=0;
    std::thread a(write_x_then_y);
    std::thread b(read_y_then_x);
    a.join();
    b.join();
    assert(z.load()!=0);
}

在上面的代码中,对原子变量分访问都使用memory_order_relaxed操作,导致了最后的断言可能失败,即在程序结束时z可能为0。

Acquire-Release

  • memory_order_acquire:用来修饰一个读操作,表示在本线程中,所有后续的关于此变量的内存操作都必须在本条原子操作完成后执行。

image

  •  memory_order_release:用来修饰一个写操作,表示在本线程中,所有之前的针对该变量的内存操作完成之后才能执行本条原子操作。

image

memory_order_acq_rel:同时包含memory_order_acquire和memory_order_release标志。

来看示例代码:

// 5.7.cpp
#include <atomic>
#include <thread>
#include <assert.h>

std::atomic<bool> x,y;
std::atomic<int> z;

void write_x()
{
    x.store(true,std::memory_order_release);
}

void write_y()
{
    y.store(true,std::memory_order_release);
}

void read_x_then_y()
{
    while(!x.load(std::memory_order_acquire));
    if(y.load(std::memory_order_acquire))
        ++z;
}

void read_y_then_x()
{
    while(!y.load(std::memory_order_acquire));
    if(x.load(std::memory_order_acquire))
        ++z;
}

int main()
{
    x=false;
    y=false;
    z=0;
    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);
}

 在这个例子中,并不能保证程序最后的断言,即Z!=0位真,其原因在于:在不同的线程中分别针对x、y两个变量进行了同步操作并不能保证x、y变量的读取操作。

线程write_x针对变量x使用了wire-release模型,这样就保证了read_x_then_y函数中,在load变量y之前x为true;同理,线程wirte_y针对y使用了write_release模型,这样就保证了read_y_then_x函数中,在load变量x之前y为true。然而即便是这样,仍然可能出现以下类似的情况:

image

 如上图所示:

  • 初始条件为x=y=false。
  • 由于在read_x_and_y线程中,对x的load操作使用了acquire模型,因此保证了是先执行write_x函数才到这一步的;同理,先执行wtrite_y才到read_y_and_x中针对y的load操作。
  • 然后即便如此,也可以能出现read_x_then_y中针对y的load操作在y的store操作之前就完成,因为y.store操作与此之间没有先后顺序关系;同理也不能保证x一定读到true值,因此到程序结束时就出现了z=0的情况。

从上面的分析可以看到,即便使用了release_acquire模型,仍然没有保证z=0,其原因在于:最开始针对x、y两个变量的写操作分别在write_x和write_y线程中进行的,不能保证两者执行的顺序导致。因此修改如下:

// 5.8.cpp
#include <atomic>
#include <thread>
#include <assert.h>

std::atomic<bool> x,y;
std::atomic<int> z;

void write_x_then_y()
{
    x.store(true,std::memory_order_relaxed);
    y.store(true,std::memory_order_release);
}

void read_y_then_x()
{
    while(!y.load(std::memory_order_acquire));
    if(x.load(std::memory_order_relaxed))
        ++z;
}

int main()
{
    x=false;
    y=false;
    z=0;
    std::thread a(write_x_then_y);
    std::thread b(read_y_then_x);
    a.join();
    b.join();
    assert(z.load()!=0);
}

 

image

 如上图所示:

  • 初始条件为x=y=false;
  • 在write_x_then_y线程中,先执行对x的写操作,再zhix对y对写操作,由于两者在同一个线程中,所以即便针对x对修改操作使用relaxed模型,修改x也一定在修改y之前执行。
  • 在read_y_then_x线程中,对y的load操作使用了acquire模型,而在线程write_x_then_y中针对变量y的读操作使用release模型,因此保证了是先执行write_x_then_y函数才到read_y_then_x的针对变量y的load操作。
  • 因此最终的执行顺序如上图所示,此时不可能出现z=0的情况。

以上分析可以看出,针对同一个变量的release-acquire操作,更多时候扮演了一种“线程间使用某一个变量的同步”作用,由于有了这种语义的保证,做到了线程间操作的先后顺序保证(inter-thread happens-before)。

Release-Consume

从上面对Aquire-Release模型的分析可以知道,虽然可以使用这个模型做到两个线程之间的某些操作的synchronizes-with关系,然后这个粒度有些过大了。

在很多时候,线程间只想针对有依赖关系的操作进行同步,除此之外线程中的其他操作顺序如何无所谓。比如下面的代码中:

b = *a;
c = *b;

其中第二行代码的执行结果依赖第一行代码执行结果,此时称者两个代码之间的关系为“carry-a-dependency”。C++中引入的memory_order_consume内存模型针对这类代码间有明确的依赖关系语句限制其先后顺序。

// 5.10
#include <string>
#include <thread>
#include <atomic>
#include <assert.h>
struct X
{
    int i;
    std::string s;
};

std::atomic<X*> p;
std::atomic<int> a;

void create_x()
{
    X* x=new X;
    x->i=42;
    x->s="hello";
    a.store(99,std::memory_order_relaxed);
    p.store(x,std::memory_order_release);
}

void use_x()
{
    X* x;
    while(!(x=p.load(std::memory_order_consume)))
        std::this_thread::sleep_for(std::chrono::microseconds(1));
    assert(x->i==42);
    assert(x->s=="hello");
    assert(a.load(std::memory_order_relaxed)==99);
}
int main()
{
    std::thread t1(create_x);
    std::thread t2(use_x);
    t1.join();
    t2.join();
}

以上代码中:

  • create_x线程中的store(x)操作使用memory_order_relase,而在use_x线程中,有针对x的使用memory_order_consume内存模型的load操作,两者之间由于有carry-a-dependency关系,因此能保证两者的先后执行顺序。所以x->i==42以及x->s=="hello"这两个断言不会失败。
  • 然后,create_x中针对变量a的使用relaxed内存模型的store操作,use_x线程中也针对变量a使用relaxed内存模型的load操作。这两者的先后执行顺序并不受前面的memory_order_consume内存模型的影响,所以并不能保证前后顺序,因此断言a.load(std::memory_order_relaxed)==99真假都有可能。

以上可以对比Acquire-Relase以及Release-Consume两个内存模型,可以知道:

  • Acquire-Relase能保证不同线程之间的Synchronize-With关系,这同时也约束同一个线程中前后语句的执行顺序。
  • 而Release-Consume只能约束有明确carry-a-dependency关系的语句的执行顺序,同一个线程中其他语句的执行先后顺序并不受这份内存模型的影响。

 

posted @ 2025-12-19 14:10  钟齐峰  阅读(9)  评论(0)    收藏  举报