C++11标准化内存模型的含义及影响

C++11标准化内存模型的含义及影响

技术背景

在C++98/C++03规范中,抽象机器本质上是单线程的。因此,无法编写相对于该规范“完全可移植”的多线程C++代码。该规范甚至没有提及内存加载和存储的原子性,也没有说明加载和存储可能发生的顺序,更不用说互斥锁之类的东西了。而在实际中,开发者可以为特定的具体系统(如pthreads或Windows)编写多线程代码,但C++98/C++03没有标准的多线程编程方法。

实现步骤

1. 了解C++11抽象机器与内存模型

C++11的抽象机器从设计上就是多线程的,并且有一个定义良好的内存模型,即规定了编译器在访问内存时可以做什么和不可以做什么。

2. 对比不同情况下的代码实现

非原子变量的并发访问

1
2
3
4
5
6
7
8
9
10
// 全局变量
int x, y;

// 线程1
x = 17;
y = 37;

// 线程2
std::cout << y << " ";
std::cout << x << std::endl;

在C++98/C++03中,由于标准未考虑“线程”的概念,这个问题本身毫无意义;在C++11中,由于加载和存储通常不是原子的,结果是未定义行为。

原子变量的并发访问

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
#include <iostream>
#include <atomic>
#include <thread>

std::atomic<int> x, y;

void thread1() {
x.store(17);
y.store(37);
}

void thread2() {
std::cout << y.load() << " ";
std::cout << x.load() << std::endl;
}

int main() {
std::thread t1(thread1);
std::thread t2(thread2);

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

return 0;
}

在C++11中,使用std::atomic类型,行为是定义好的。线程2可能输出0 0(如果它在线程1之前运行)、37 17(如果它在线程1之后运行)或0 17(如果它在线程1给x赋值之后但给y赋值之前运行),但不能输出37 0,因为C++11中原子加载/存储的默认模式是强制顺序一致性。

放松顺序的原子操作

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
#include <iostream>
#include <atomic>
#include <thread>

std::atomic<int> x, y;

void thread1() {
x.store(17, std::memory_order_relaxed);
y.store(37, std::memory_order_relaxed);
}

void thread2() {
std::cout << y.load(std::memory_order_relaxed) << " ";
std::cout << x.load(std::memory_order_relaxed) << std::endl;
}

int main() {
std::thread t1(thread1);
std::thread t2(thread2);

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

return 0;
}

如果算法可以容忍无序的加载和存储,即只需要原子性而不需要顺序性,可以使用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
#include <iostream>
#include <atomic>
#include <thread>

std::atomic<int> x, y;

void thread1() {
x.store(17, std::memory_order_release);
y.store(37, std::memory_order_release);
}

void thread2() {
std::cout << y.load(std::memory_order_acquire) << " ";
std::cout << x.load(std::memory_order_acquire) << std::endl;
}

int main() {
std::thread t1(thread1);
std::thread t2(thread2);

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

return 0;
}

如果只需要保持特定的加载和存储顺序,可以使用std::memory_order_releasestd::memory_order_acquire,这样可以以最小的开销实现有序的加载和存储。

核心代码

序列锁实现示例(存在问题的代码)

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
33
34
35
#include <atomic>

std::atomic<uint64_t> seq; // seqlock representation
int data1, data2; // this data will be protected by seq

template<typename T>
T reader() {
int r1, r2;
unsigned seq0, seq1;
while (true) {
seq0 = seq;
r1 = data1; // INCORRECT! Data Race!
r2 = data2; // INCORRECT!
seq1 = seq;

// if the lock didn't change while I was reading, and
// the lock wasn't held while I was reading, then my
// reads should be valid
if (seq0 == seq1 && !(seq0 & 1))
break;
}
// use(r1, r2);
return T();
}

void writer(int new_data1, int new_data2) {
unsigned seq0 = seq;
while (true) {
if ((!(seq0 & 1)) && seq.compare_exchange_weak(seq0, seq0 + 1))
break; // atomically moving the lock from even to odd is an acquire
}
data1 = new_data1;
data2 = new_data2;
seq = seq0 + 2; // release the lock by increasing its value to even
}

问题分析与解决方案

在上述序列锁实现中,data1data2需要是std::atomic类型,否则会存在数据竞争问题。并且不能仅仅将它们设为std::atomic并使用std::memory_order_relaxed访问,因为reader()中对seq的读取只有获取语义,编译器可能会对操作进行重排序,从而破坏锁的实现。论文中给出的一种较好的解决方案是在第二次读取seqlock之前使用std::atomic_thread_fencestd::memory_order_relaxed

最佳实践

1. 使用互斥锁

如果只是简单地保护数据,使用互斥锁是一个不错的选择,因为互斥锁一直提供了足够的顺序和可见性保证。

2. 合理使用原子操作

在需要高性能的并发场景中,可以使用原子操作,但要根据具体需求选择合适的内存顺序。

3. 参考专业资料

对于复杂的并发编程,如序列锁的实现,建议参考专业的技术报告和资料,避免自己实现时出现错误。

常见问题

1. 数据竞争问题

在多线程程序中,如果多个线程同时访问同一内存位置,并且至少有一个线程进行写操作,就会发生数据竞争。可以使用原子类型或互斥锁来避免数据竞争。

2. 内存顺序选择不当

如果选择的内存顺序不恰当,可能会导致程序出现意外的行为。需要根据具体的算法和性能需求来选择合适的内存顺序。

3. 序列锁实现错误

在实现序列锁时,很容易出现错误,如未将受保护的数据设为原子类型、未正确处理内存顺序等。在实现序列锁时,要仔细考虑内存模型的要求,并参考专业的实现方案。


C++11标准化内存模型的含义及影响
https://119291.xyz/posts/c11-standardized-memory-model-meaning-and-impact/
作者
ww
发布于
2025年5月19日
许可协议