C++内存模型与并发编程 #
内存模型是C++并发编程的基石,它定义了多线程程序中内存访问的行为规则。理解内存模型对于编写正确的并发程序至关重要。 直接看这段代码:
#include <iostream>
#include <thread>
int x = 0;
int y = 0;
void func1() {
x = 100;
y = 2;
}
void func2() {
while (y == 2) {
std::cout << x << std::endl;
break;
}
}
int main() {
std::thread t1(func1);
std::thread t2(func2);
t1.join();
t2.join();
}
大家猜一猜这段代码会输出什么?100吗?绝大数时候会是100,但是也有极小概率会是0,理论上有输出0的可能。这里涉及到内存序(memory order)的知识点,在这之前,需要先了解下什么是改动序列。在一个C++程序中,每个对象都具有一个改动队列,它由所有线程在对象上的全部写操作构成,变量的值会随着时间推移形成一个序列,不同线程观察同一个变量的序列,正常情况下是一致的,如果出现不一致,就说明出现了数据竞争线程不安全的问题。
看图,随着时间的推移,一个变量可能做了图中的改动,产生了一个改动序列,即(1,2,3,4,5,6,7,8,9,10),然而理论上来说,不同线程不能保证他们看见的是最新的值,比如同一时刻,线程a可能看见的是5,线程b可能看见的是4,线程c可能看见的是3,d是4,e是2。然后过了一段时间,可能变成了a(10),b(4),c(8),d(6),e(3)。每个线程看到的只会是序列中上一次看到的之后的值,不可能是之前的,时光不能倒流。
同理,如图:
如果有两个变量,它们的改动序列如图,然而同一时刻,理论上可能不同线程看到的值不同。再回到上面那段代码:
#include <iostream>
#include <thread>
int x = 0;
int y = 0;
void func1() {
x = 100;
y = 2;
}
void func2() {
while (y == 2) {
std::cout << x << std::endl;
break;
}
}
int main() {
std::thread t1(func1);
std::thread t2(func2);
t1.join();
t2.join();
}
t1和t2线程看到的x和y值可能有(0,0)(0,2)(100,0)(100,2),所以上面的代码运行时,正常会输出100,但是也有可能会输出0。你可能会说,可得了吧,我测试了几十万次,输出的都是100,从没出现过0。是的,大概率都是100,这种现象只有理论上会出现,而且估计意外只会在内存序相对宽松的Arm机器上会出现,正常的X86应该不会出现这种问题。但我们写代码还是要往标准了写。
为什么会出现这种现象?
因为编译器有指令乱序的优化。还是上面那段代码:
int x = 0;
int y = 0;
void func() {
x = 100;
y = 2;
}
函数func()中,按顺序来看可能是先执行x = 100再执行y = 2,但实际情况可能不同,有可能编译器会做一些指令重排序的优化,真正优化后的结果可能会是y = 2,再x = 100,重排序后再运行,结果和顺序执行完全相同。(你可能会问,为什么要做这种优化,先执行谁后执行谁都需要执行,有意义吗?文中我只是举一个比较简单的例子,可能这里没有意义,但遇到真正复杂的代码时指令重排序还是很有效的优化策略,其实如果你学过计算机体系结构就会知道,这种没有任何依赖关系的指令是可以做并行优化的,具体是什么术语我也记不起来了,好像是SIMD。)
编译器只会保证单线程环境下,优化执行的最终结果是一致的,所以这种优化就会导致多线程情况下的数据冲突问题,比如上面的代码:
void func1() {
x = 100;
y = 2;
}
void func2() {
while (y == 2) {
std::cout << x << std::endl;
break;
}
}
int main() {
std::thread t1(func1);
std::thread t2(func2);
t1.join();
t2.join();
}
由于执行重排序的原因,无法保证另一个线程在执行func2的时候,x和y的赋值顺序,所以上面x的输出,有可能是0,也有可能是100。这也就是为什么会出现上面介绍的改动序列的原因。
那怎么解决这种问题?肯定是要在某些情况下,禁止这种指令重排序。可以引入原子操作,我们可以把上面的x和y的定义改为:
std::atomic<int> x = 0;
std::atomic<int> y = 0;
结果自然而然就会变得正常。为什么?因为C++的atomic不仅仅是原子操作,它很重要的一点是可以禁止这种指令重排序。
我们平时使用atomic可能都是这样使用:
int value = x.load();
x.store(100);
但其实atomic的多数函数都是重载函数,它可以配置一些参数,这些参数就是内存序的类型参数:
x.store(100, std::memory_order_relaxed);
C++里关于一共引入了 6种内存序 的类型:
- memory_order_relaxexd:只有普通的原子性,没有任何内存次序的要求。
- memory_order_seq_cst:与代码顺序严格一致。
- memory_order_acquire:载入语义,当前线程,load操作之后的读写操作不能被重排序到当前指令前面。如果其它线程对此变量使用release的store操作,在当前线程是可见的。
- memory_order_release:存储语义,当前线程,store操作之前的读写操作不能重排序到当前指令后面,如果其它线程对此变量使用了acquire的load操作,当前线程store之前的任何读写操作都对其它线程可见。
- memory_order_acq_rel:它等于acquire + release
- memory_order_consume:C++17中明确建议我们不使用此次序,以后会被废弃掉,咱也就不纠结它了。
尽管有 6种内存序,但其实可简单划分为3种模式:
- 先后一致次序(Sequential Consistency Ordering):这就是atomic默认的内存次序,它是最直观、最符合直觉的内存次序,所有关于此次序的实例,都严格保持先后顺序,这种内存模型无法重新编排次序,它要求在所有线程间进行全局同步,因此也是代价最高的内存次序。
- 宽松次序(Relaxed Ordering):你可以理解为使用搭配这种次序的atomic,只有原子性,而对内存次序没有任何要求,指令重排序之类的优化还是正常进行。
- 获取-释放次序(Acquire-Release Ordering):它比宽松次序严格一些,却没有先后一致次序那样特别严格。在此次序模型中,载入(load)操作可以使用memory_order_acquire语义,存储(store)可以使用memory_order_release语义,而读-改-写(fetch_add、exchange)可以使用memory_order_acq_rel语义。
所以,我们看下这段使用relaxed模型的代码会不会触发assert:
#include <assert.h>
#include <atomic>
#include <thread>
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);
}
再看下使用acquire-release模型的代码会不会触发assert:
#include <assert.h>
#include <atomic>
#include <thread>
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);
}
使用先后一致次序模型的代码这里就不过多介绍了,atomic的默认次序,肯定没问题的。所以在我们平时开发过程中,普通开发者不用管那么多,使用默认的atomic次序就行,资深程序员可以自由选用,充分利用更加细分的次序关系来提升性能,比如写一个高性能的无锁队列。一般使用默认的atomic足以,我估计大多数人写的代码,性能瓶颈一般都在业务逻辑上,而不是这种内存模型上。
这里还有个memory fence的概念,大体作用和上面介绍的类似,感兴趣的可以自己了解一下哈。写到这里,推荐大家看看这段无锁队列的代码 https://github.com/taskflow/taskflow/blob/master/taskflow/core/tsq.hpp ,有助于理解C++的内存模型。
参考资料: