C++ Memory Order

前言

原子变量是C++解决多线程中数据竞争、线程同步的一种方式,而内存序(Memory Order)则是使用原子变量前的一个重要知识。

Luyu Huang’s Blog的文章中,已经对C++的内存序做出了比较清晰易懂的解释,本文的目的旨在提炼其文章中的核心思想,补充其文章中所提到的不足和发表一点作者自己的看法与感想。

原子变量

在使用原子变量时,有load、store、rmw三种基础操作,每次操作原子变量前,我们都需要指定其使用的内存序(某些函数有默认的内存序,比如load和store),在C++中一共规定了六种内存序,分别对应三种内存模型:
Sequencial Consistent模型

  • memory_order_seq_cst
    Relaxed模型
  • memory_order_relaxed
    Acquire-release模型
  • memory_order_acquire
  • memory_order_release
  • memory_order_acq_rel
    Acuqire-release.Comsume-release模型
  • memory_order_consume

要了解这六种内存序,我们先从一些基本概念开始说起。

修改顺序一致性

同一原子变量能保证修改顺序在所有线程中的一致性,这意味着:

  • 两个修改操作之间一定存在先后顺序
  • 修改后的值立即可见且各线程所看见的值相同

Happens-before关系

Happens-before关系描述的是操作A、B之间,如果操作A happens-before 操作B,那么操作A的结果对操作B可见。

单线程中的happens-before关系

在单线程中,指令总是顺序执行的,前面的操作总是比后面的操作先执行,这称为sequenced-before关系[0]。由这一特点我们可知,如果操作A、B之间具有sequenced-before关系,则A、B之间构成happens-before关系。

sequenced-before关系同样具有传递性:如果a sequenced-before k,k sequenced-before b,则a sequenced-before b。

多线程中的happens-before关系

在多线程中,为了构成happens-before关系,我们先引入新的两种关系:synchronizes-with关系和inter-thread happens-before关系。

首先,如果a inter-thread happens-before b,那么a happens-before b

并且synchronizes-with关系可以用来构建inter-thread happens-before关系。
要构建A inter-thread happens before B,则:

  • A synchronizes-with B 或者
  • A inter-thread happens-before k, k synchronizes-with B 或者
  • A sequenced-before k, k inter-thread happens-before B 或者
  • A inter-thread happens-before k, k inter-thread happens-before B

而synchronizes-with关系,则由下文所述的内存顺序模型提供。

Happens-before不代表指令实际的执行顺序

happens-before是 C++ 语义层面的概念, 它并不代表指令在 CPU 中实际的执行顺序。 编译器可能会在不破坏语义的前提下对指令重排。

六种内存序与四种内存顺序模型

顺序一致性(Sequencial Consistent)模型

这种模型所对应的内存序为memory_order_seq_cst,可以用于load, store和RMW操作。seq_cst这一内存序保证了对所有在顺序一致性模型下的原子变量,其修改顺序具有一致性。

例如,对于变量x的操作A和对变量y的操作B在这一模型下不可能同时发生,并且A、B的修改顺序能被所有线程看到,而且所看到的顺序一致[1]

顺序一致性模型可以构建synchronizes-with关系:在seq_cst下的load操作读到seq_cst下的store操作写入的值,则这一load操作 synchronizes-with 这一store操作。

宽松(Relaxed)模型

这一模型所对应的内存序为memory_order_relaxed,可以用于load, store和RMW操作。relaxed这一内存序对原子变量的行为约束最少,其只能保证对原子变量操作的原子性和修改顺序的一致性,无法用于构建synchronizes-with关系。

Acquire-release模型

这一模型对应着三种内存序:memory_order_acquire, memory_order_releasememory_order_acq_rel

  • memory_order_acquire用于load操作,称为acquire操作
  • memory_order_release用于store操作,称为release操作
  • memory_order_acq_rel用于RMW操作。同时,如果RMW操作使用memory_order_acquire,则作为acquire操作;使用memory_order_release,则作为release操作

acquire-release模型可以用于构建synchronizes-with关系:

  • 如果一个acquire操作读取到了一个release操作写入的值,则这个release操作 synchronizes-with 这个acquire操作。
  • 如果一个acquire操作读取到了一个以某一release操作为首的release sequence[2]写入的值,则这个release操作 synchronizes-with 这个acquire操作。

acquire-release模型所保证的顺序一致性是对于单一原子变量而言的,相较于sequencial-consistent模型而言,其缺少了对所有原子变量的约束,这意味着对于不同的两个原子变量而言,它们的行为可以同时发生。

Consume-release模型

comsume-release模型是acquire-release的一种特殊形式,其所对应的内存序为:memory_order_consume,用于load操作。为了理解这一模型,我们要引入两个新的概念:Carries dependency和Dependency-ordered before。

carries dependency描述的是在两个具有sequenced-before的关系之间(A sequenced-before B)的关系,如果:

  • A 是 B的一个操作数;或者
  • A写入了某个标量M,而B要从这个标量M中读取数据;或者
  • X依赖A, B依赖X

那么我们称B依赖A,或者说A carries a dependency into B

如果一个comsume load读到了一个release操作或者release sequence headed by这个release操作写入的值,那么这个comsume load dependency-ordered before这个release操作。

与inter-thread happens before关系类似,dependency-ordered before关系可以通过carries dependency关系来进行“桥接”:

  • 如果A dependency-ordered before k, k carries a dependency into B,则A dependency-ordered before B

需要注意的是,memory_order_consume这一内存序比较复杂,某些编译器上没有给出实现,这个时候memory_order_consume等同于memory_order_acquire

这里对上文进行补充:comsume load与release操作之间构成的dependency-ordered before关系可以构成inter-thread happens before关系,进而构成happens-before关系。

happens-before关系构成方式的总结

  • 单独的sequenced-before关系、inter-thread happens before关系和dependency-ordered before关系可以构成happens-before关系,且前两种关系具有传递性。
  • 对于一组synchronizes-with关系,可以后接sequenced-before关系构成inter-thread happens before关系,进而构成happens-before关系。
  • 对于一组dependency-ordered before关系,可以后接carries dependency关系扩展dependency-ordered before关系。
  • 而inter-thread happens before关系又可以前接sequenced-before关系,或者后接sychronizes-with关系进行扩展。

图源:Luyu Huang’s Blog——谈谈 C++ 中的内存顺序 (Memory Order)

不同内存序分析与应用举例

Relaxed内存序

看下面这段代码:

// Thread 1:
v1 = y.load(std::memory_order_relaxed); // 1
x.store(v1, std::memory_order_relaxed); // 2
// Thread 2:
v2 = x.load(std::memory_order_relaxed); // 3
y.store(1, std::memory_order_relaxed); // 4

由于这段代码的所有操作均使用memory_order_relaxed这一内存序,所以对于原子变量的操作只能保证原子性和修改顺序的一致性。

由于Relaxed内存序仅保证原子性,所以程序执行是无序的;而修改顺序一致性则确保了1和4,2和3不可能同时执行。

在这种内存序下,v1==v2==1是被允许的(例如程序按照3412的顺序执行)。

Acquire-release内存序

先看一段程序:

std::atomic<bool> x{false}, y{false};

void thread1() {
    x.store(true, std::memory_order_relaxed); // 1
    y.store(true, std::memory_order_release); // 2
}

void thread2() {
    while (!y.load(std::memory_order_acquire)); // 3
    assert(x.load(std::memory_order_relaxed)); // 4
}

现在我们思考:断言4会不会失败?

我们先分析一下:在这段程序中,3能够读取到2所写入的数据,4能够读取到1所写入的数据,所以:

  • 2 synchronizes-with 3
  • 1 synchronizes-with 4

并且由于1 sequenced before 2,3 sequenced before 4
所以2 inter-thread happens before 4,可以推出1 inter-thread happens before 4,即1 happens before 4

所以在1的结果一定对4可见,即x开始读取时,已经被设置为true,故断言不会失败。

Consume-release内存序

std::atomic<std::string*> ptr;
int data;

void thread1() {
    std::string* p  = new std::string("Hello"); // (1)
    data = 42; // (2)
    ptr.store(p, std::memory_order_release); // (3)
}

void thread2() {
    std::string* p2;
    while (!(p2 = ptr.load(std::memory_order_consume))); // (4)
    assert(*p2 == "Hello"); // (5)
    assert(data == 42); // (6)
}

分析程序可知:

  • consume load (4) 读取到 release (3),所以(3) dependency-ordered before (4)
  • p2是(5)的操作数,则 (4) carries a dependency into (5)
  • 所以(3) inter-thread happens before (5)
  • 又因为(1) sequenced-before (3),所以 (1) inter-thread happens before (5)
    所以(5)一定能读到(1)写入的值,(5)不会失败;但(3)和(6)之间没有happens before关系(虽然(3) happens before【dependency-ordered before】 (4),(4) happens before 【sequenced before】 (6),但happens before没有传递性,且(3)、(4)、(6)之间的关系无法组合成一个心得关系构成happens before关系),所以断言(6) 不一定成功。

Sequencial Consistent内存序

示例程序如下:

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); // 1
}

void write_y()
{
    y.store(true, std::memory_order_seq_cst); // 2
}

void read_x_then_y()
{
    while (!x.load(std::memory_order_seq_cst)); // 3
    if (y.load(std::memory_order_seq_cst)) ++z; // 4

}

void read_y_then_x()
{
    while (!y.load(std::memory_order_seq_cst)); // 5
    if (x.load(std::memory_order_seq_cst)) ++z; // 6

}

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); // 7
}

现在我们同样思考一个问题:断言7会不会失败?

要想断言7失败,那么我们必须满足使得z=0,而要使得z=0,必须同时满足:

  • read_x_then_y()获取到x=true,y=false;
  • read_y_then_x()获取到y=true,x=false。

并且由于seq_cst模型的约束,1、2、3、4、5、6之间也存在一个顺序。如果read_x_then_y()先获取到x=true,那么必然意味着1已经被执行,当read_y_then_x()获取到y=true时,由于这一顺序的存在,此时x必定为true,z++执行,反之同理。

所以上述断言失败的条件不成立,所以断言不会失败。

同时我们分析不难得到:

  • 1 sychronizes-with 3,6
  • 2 sychronizes-with 4,5
  • 3 sequenced before 4
  • 5 sequenced before 6。

所以可以得到:

  • 1 inter-thread happens before 4
  • 2 inter-thread happens before 6

内存序对读/写顺序的作用

  • 宽松内存序对同一原子变量的读写几乎无约束作用,仅保证读写操作的原子性和修改顺序的一致性
  • Acquire-release内存序会在读取数据时禁止其前的写操作排到该读操作的后面,写入数据时禁止其前的读操作排到该写操作的后面,在RMW时则同时禁止。
  • 而Seq_cst内存序则是在Acquire-release内存序的基础上,对所有原子变量进行了约束,对某一原子变量操作后的其他操作,包括对其他原子变量的操作不能在这一操作前,其前的操作不能在这一操作后。

补充概念的严谨定义

Sequenced before关系

1. 定义
sequenced before关系描述的是同一线程中的求值之间的非对称的、传递的对偶关系,具体为:

  • A sequenced before B,则A的求值在B之前完成
  • A 不sequenced before B,B sequenced before A,则B的求值在A之前完成
  • 如果A既不sequenced before B,B也不sequenced before A,那么A、B之间的求值顺序无序或者顺序不定。

注:无序,意味着操作可以同时发生,且顺序可以随意互换;顺序不定,仅意味着操作之间的顺序可以互换,但不能同时发生

2. 关系的确立规则
确定sequenced before的规则多达20条,这里就以截图的形式展示了,读者可以自行查阅cppreference.com(见参考)。

规则

Happens-before与可见性

写A的结果要被读B看见则必须满足下面的条件:

  • A happens before B;或
  • A happens before X,X happens before B,并且X对A要写入的值无副作用

注:函数的除产生返回值以外对调用方的其他作用即为副作用,例如修改全局变量、IO操作等。

Modification Order

1. 定义
对于同一个原子变量的所有修改操作,均在特定于这个原子变量的全序上发生。

2. 性质
这一定义保证原子变量在多个线程中,对于操作A happens before 操作B:

  • A不可能读到B写入的值;或者
  • A一定在B写入之前写入;或者
  • A写入的值有可能被B读到,否则B读取的值一定是A之后的操作写入的值;或者
  • A读取到的值X可能也被B读取,否则B读取的值一定比X更新

Release sequence

1. 定义
原子变量M上的release sequence headed by A指的是M的修改顺序上的一个最大连续子序列,以写操作A开始的,当前线程对M的写操作(C++20后被废弃),其他线程对M的RMW操作构成的。

2. 解释
意思就是:操作A和其后的所有连续写和RMW操作构成一个release sequence

比如序列(R表示读,W表示写,RMW表示读-修改-写操作,t表示线程):R R W1 R WA t2-RMW1 W2 R RMW2 R

release headed by A指的就是WA t2-RMW1 W2这一序列(C++20前)或WA t2-RMW1这一序列(C++20及以后)

内存序与底层执行

需要注意的是,内存序是C++语言层面上的概念,它制约着编译器的优化和行为,保障程序执行的语义与使用内存序所期待的语义一致。换言之,我们在C++中所要求的内存序与底层真正执行的指令的顺序是可能不相同的,只是指令执行所展现出来的效果与我们所想要达到的效果一致。

总结

从约束能力上来说,memory_order_seq_cst最严格,约束能力最强,但性能损失也是最大的,memory_order_relax最宽松,约束能力最弱,性能损失最小;而memory_order_acquirememory_order_releasememory_order_acq_relmemory_order_consume的性能损失和约束能力则介于这两者之间。
从作用范围来说,只有memory_order_seq_cst对全局有作用,剩余的内存序均只作用于某一个原子变量。

相关阅读

一起来学C++ 55.内存模型与顺序

参考

谈谈 C++ 中的内存顺序 (Memory Order)

求值顺序 cppreference.com

C++ memory order循序渐进(二)—— C++ memory order基本定义和形式化描述所需术语关系详解

C++ memory order循序渐进(三)—— 原子变量上组合应用memory order实现不同的内存序

暂无评论

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇
下一篇
隐藏
变装