「C++ 多线程」std::atomic 内存序

文章大图来源:pixiv_id=72873713

1 问题背景

「C++ 多线程」std::atomic 简单操作 中,我们已经能够使用 std::atomic 原子操作来保证对一个原子类型变量操作的可靠性,使得在多线程环境下对这些变量的操作是线程安全的,不会出现数据竞争。

然而,每个 std::atomic 原子操作只能维护当前变量某个操作的原子性。如果一个线程中存在多个 互不依赖 的操作,线程有时会改变这些互不相干的操作顺序来执行。

例如下面这个例子:

1
2
3
4
5
6
int a = 0, b = 0;

void foo(){
a = 1;
b = 2;
}

假设一个线程执行上面这个 foo() 函数,这个函数中有两个赋值操作,分别将 a 赋值为 1,将 b 赋值为 2。我们所期望的线程执行这两个语句的顺序是 先执行 a = 1,然后再执行 b = 2

然而,这两个赋值语句 没有依赖关系,那么可能有时系统 CPU 会因为运行效率等原因对这些语句进行 重排。因此实际上可能先执行 b = 2,然后再执行 a = 1

假设有另一个线程执行如下函数:

1
2
3
4
void foo2(){
while(b != 2); // 等待 b == 2
std::cout << a << "\n";
}

第二个线程希望当 b == 2 的时候,a 此时必然已经赋值为 1。但实际上可能因为语句重排,第一个线程会先执行 b = 2,而此时还没进行 a = 1 的操作时,第二个线程已经开始执行了,那么很有可能最终输出的 a 并不是所期望的 1,而是 0

这就是为什么需要内存序的原因。通过 std::atomic 中的内存序,我们可以有效 限制 CPU 对语句执行顺序的重排程度,防止单线程指令的合理重排在多线程的环境下出现顺序上的错误。

2 std::memory_order_relaxed

在 C++ std::atomic 的原子操作中,std::memory_order_relaxed 是一种最宽松的内存序,故简称为 宽松内存序。它只保证原子操作本身的原子性,也就是保证对同一个原子变量的操作在多线程环境下不会出现数据竞争,是原子的、不可分割的。

std::memory_order_relaxed 不会保证操作的执行顺序相对于其他线程中的操作顺序,也不会保证一个线程中不同变量的操作顺序。因此,std::memory_order_relaxed 其实和不增加内存序的限制一样,不会限制 CPU 对操作语句的合理重排程度。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
#include <atomic>
#include <thread>
#include <iostream>
#include <chrono>

std::atomic<int> atm1(0), atm2(0);
int res1 = 0, res2 = 0;

void foo1() {
res1 = atm2.load(std::memory_order_relaxed);
atm1.store(1, std::memory_order_relaxed);
}

void foo2() {
res2 = atm1.load(std::memory_order_relaxed);
atm2.store(1, std::memory_order_relaxed);
}

int main(){
std::thread t1(foo1);
std::thread t2(foo2);

t1.join();
t2.join();

std::cout << "r1: " << res1 << ", r2: " << res2 << "\n";
// (0, 0), (0, 1), (1, 0), (1, 1)

return 0;
}

在上面的代码中,有两个线程 t1t2,它们分别对两个原子变量 atm1atm2 进行读写操作,并且在读写时都使用了 memory_order_relaxed 内存序。

  • 输出结果为 0, 0

    两个线程同时开始执行,恰巧线程 t1 执行读取 atm2 的值(此时为 0),线程 t2 同时执行读取 atm1 的值(此时也为 0)。

    之后,线程 t1t2 分别执行对 atm1atm2 值的存储。

    故此情况的结果为 0, 0

  • 输出结果为 0, 1

    可能的一种执行顺序如下:

    线程 t1 先执行 res1 = atm2.load(std::memory_order_relaxed);,读到初始值 0,紧接着执行对 atm1 的写入操作;

    线程 t2 后来才执行,先执行对 atm1 的数据读取操作,读取到 res2 = 1。然后执行对 atm2 的写入操作。

    故此情况的结果为 0, 1

  • 输出结果为 1, 0

    与上面情况类似,只是顺序稍有不同。可能的执行顺序如下:

    线程 t2 先执行 res2 = atm1.load(std::memory_order_relaxed);,读到初始值 0,紧接着执行对 atm2 的写入操作;

    线程 t1 后来才执行,先执行对 atm2 的数据读取操作,读取到 res1 = 1。然后执行对 atm1 的写入操作。

    故此情况的结果为 1, 0

  • 输出结果为 1, 1

    可能的一种执行顺序如下:

    t1 先执行 atm1.store(1, std::memory_order_relaxed);,然后 t2 执行 atm2.store(1, std::memory_order_relaxed);,之后 t1 执行 res1 = atm2.load(std::memory_order_relaxed);,就能读到 1,而 t2 执行 res2 = atm1.load(std::memory_order_relaxed); 也能读到 1

    故此情况结果为 1, 1

由于使用了宽松的 memory_order_relaxed 内存序,线程之间对原子变量的读写操作顺序具有不确定性,导致了上述多种可能的输出结果。

3 std::memory_order_seq_cst

std::memory_order_seq_cst 是 C++ 中最严格的内存顺序。它不仅保证原子操作的原子性,还保证所有使用 std::memory_order_seq_cst 的原子操作在所有线程中以相同的顺序执行,是一种全局约束的内存序。

在多线程环境中,std::memory_order_seq_cst 确保所有的读 - 改 - 写操作都是原子的,并且所有线程对这些操作的观察顺序是一致的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
#include <atomic>
#include <thread>
#include <iostream>
#include <chrono>

std::atomic<int> atm1(0), atm2(0);
int res1 = 0, res2 = 0;

void foo1() {
res1 = atm2.load(std::memory_order_seq_cst);
atm1.store(1, std::memory_order_seq_cst);
}

void foo2() {
res2 = atm1.load(std::memory_order_seq_cst);
atm2.store(1, std::memory_order_seq_cst);
}

int main(){
std::thread t1(foo1);
std::thread t2(foo2);

t1.join();
t2.join();

std::cout << "r1: " << res1 << ", r2: " << res2 << std::endl;
// 结果只能可能为 (0, 1), (1, 0)

return 0;
}

在上面的代码中,每个操作都使用的是 std::memory_order_seq_cst 内存序。在每个线程中,两个语句之间的顺序不会被重排,因此,t1 线程会严格遵循先执行 res1 = atm2.load(std::memory_order_seq_cst); 再执行 atm1.store(1, std::memory_order_seq_cst); 的顺序。同理 t2 线程也是类似的会严格遵循类似的顺序。

只不过两个线程执行的先后顺序就不能保证了。可能先执行 t1,再执行 t2;也有可能反过来。因此,最终的结果比较显而易见,只有两种可能的情况:

  • 结果为 (0, 1)

    t1 先执行,t2 再执行。单个线程内部的执行顺序不会被重排,且两个线程之间的操作也不会被重排。

  • 结果为 (1, 0)

    t2 先执行,t1 再执行。

4 memory_order_acquire 和 memory_order_release

  • memory_order_acquire

    当一个原子操作使用 memory_order_acquire 内存序进行加载(load)操作时,它会确保在这个加载操作之后的所有读写操作都能看到这个加载操作所获取到的内存状态。也就是说,此操作 不会被重排到后续操作的后面

  • memory_order_release

    当一个原子操作使用 memory_order_release 内存序进行存储(store)操作时,它会确保在这个存储操作之前的所有读写操作都完成。也就是说,此操作 不会被重排到前面操作的前面

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
#include <atomic>
#include <thread>
#include <iostream>
#include <chrono>

std::atomic<int> atm1(0), atm2(0);
int res1 = 0, res2 = 0;

void foo1() {
res1 = atm2.load(std::memory_order_acquire);
atm1.store(1, std::memory_order_release);
}

void foo2() {
res2 = atm1.load(std::memory_order_acquire);
atm2.store(1, std::memory_order_release);
}

int main(){
std::thread t1(foo1);
std::thread t2(foo2);

t1.join();
t2.join();

std::cout << "r1: " << res1 << ", r2: " << res2 << "\n";
// (0, 0), (0, 1), (1, 0)
// acq acq re re
// acq re acq re

return 0;
}

t1 线程和 t2 线程内部的语句,采用了 memory_order_acquire 和 memory_order_release 的获取-释放内存序,因此,内部是不会重排的。也就是说,t1 线程中 atm1.store(1, std::memory_order_release); 不会在 res1 = atm2.load(std::memory_order_acquire); 前面执行;t2 线程中,atm2.store(1, std::memory_order_release); 不会在 res2 = atm1.load(std::memory_order_acquire); 的前面执行。

因此,上面的代码运行结果是不会存在 (1, 1) 这种同时为 1 的情况的。


「C++ 多线程」std::atomic 内存序
https://marisamagic.github.io/2025/01/08/20250108/
作者
MarisaMagic
发布于
2025年1月8日
许可协议