关于C++内存模型
2004年,Java 5.0引入了适用于多线程环境的内存模型[2]:JSR-133[3]。但C++直到2011标准才引入了内存模型。
Java内存模型在很大程度上影响了C++内存模型,但后者走得更远。因为它允许开发者打破顺序一致性(Sequential
Consistency,我们会在下文中讲解),以获得更好的控制。
之所以这么做是因为C++是一门系统编程语言,它的设计意图之一就是:不需要另外一个更底层的语言,而是直接提供给开发者以”接近机器“的方式编程。
即便大多数程序员不用在意内存模型,但是当你以“接近机器”的方式工作时,了解这些原理就很重要了。
内存模型是多线程环境能够可靠工作的基础,因为内存模型需要对多线程环境的运作细节进行完备的定义。
简单来讲,可以认为内存模型是一种契约。它定义一套操作手法以及这些操作手法背后的详细含义。开发者利用这套操作完成数据的同步以避免竞争条件,而系统(包括:编译器,操作系统和处理器)保证执行的逻辑符合内存模型对于相关操作的定义
– 即实现契约。 内存模型主要包含了下面三个部分:
元子操作:顾名思义,这类操作一旦执行就不会被打断,你无法看到它的中间状态,它要么是执行完成,要么没有执行。
操作的局部顺序:一系列的操作不能被乱序。
操作的可见性:定义了对于共享变量的操作如何对其他线程可见。
为什么需要内存模型?
在C++11标准出来之前,C++环境没有多线程的概念。编译器和处理器认为系统中只有一个执行流。引入了多线程之后,情况就会变得非常复杂。这是因为:现代计算机系统为了加快执行效率,自动的包含了很多的优化。这些优化虽然保证了在单线程环境下不破坏原来的逻辑。但是一旦到了多线程之后,情况就不一样了。
事实上,开发者编写的代码和最终运行的程序往往会存在较大的差异,而运行结果与开发者预想一致,只是一种“假象”罢了。
之所以会产生差异,原因主要来自下面三个方面:
1. **编译器优化**
2. **CPU out-of-order执行**
3. **CPU Cache不一致性**
下面我们来逐个介绍。
Memory Reorder
以下面这段伪代码为例:
X = 0, Y = 0;
Thread 1:
X = 1; // ①
r1 = Y; // ②
Thread 2:
Y = 1;
r2 = X;
你可能会觉得,在这个程序执行完成之后,r1和r2怎么都不可能同时为0。但事实并非如此[4]。
这是因为“Memory Reorder”的存在,“Memory Reorder”包含了编译器和处理器两种类型的乱序。
这就导致:线程1中事件发生的顺序虽然是先①后②,但是对于线程2来说,它看到结果可能却是先②后①。当然,线程1看线程2也是一样的。
甚至,当今的所有硬件平台,没有任何一个会提供完全的顺序一致(sequentially consistent)内存模型,因为这样做效率太低了。
不同的编译器和处理器对于Memory Reorder有不同的偏好,但它们都遵循一定的原则,那就是:不能修改单线程的行为(Thou shalt not modify the behavior of a single-threaded program.[5])。在这个基础上,它们可以做各种类型的优化。
编译器优化
以gcc为例,该编译器提供了-o参数来控制非常多的优化选项[6]。
以下面这段代码为例:
int A, B;
void foo()
{
A = B + 1;
B = 0;
}
在编译优化后,可能会变成下面这样:
int A, B;
void foo()
{
int temp = B;
B = 0;
A = temp + 1;
}
请注意,编译器只要保证:在单线程环境下,执行的结果和原先一样就可以了。所以,这样做是可以的。
对于编译器来说,它知道的是:当前线程中,数据的读写以及数据之间的依赖关系。但是,编译器并不知道哪些数据是在线程间共享,而且是有可能会被修改的。这就需要开发者在软件层面做好控制。
对于编译器的乱序优化来说,开发者并非完全不能控制。编译器会提供称之为内存栅栏(Memory Barrier)[7]的工具给开发者,让开发者告诉编译器:这部分代码编译的时候不能乱序。
gcc的内存栅栏写法如下:
int A, B;
void foo()
{
A = B + 1;
asm volatile("" ::: "memory");
B = 0;
}
Out-of-order执行
不仅仅是编译器,处理器也可能会乱序执行指令。
Cache Coherency
事情还不只这么简单。现代的主流CPU几乎都会包含多个核以及多级Cache.
每个CPU核在运行的时候,都会优先考虑离自己最近的Cache,一旦命中就直接使用Cache中的数据。这是因为Cache相较于主存(RAM)来说要快很多。但是每个核之间的Cache,每一层之间的Cache,数据常常是不一致的。而同步这些数据是需要消耗时间的。
这就会造成一个问题,那就是:某个CPU核修改了一个数据,没有同步的让其他核知道,于是就存在了数据不一致的情况。
综上这些原因让我们知道,CPU所运行的程序和我们编写的代码可能是不一致的。甚至,对于同一次执行,不同线程感知到其他线程的执行顺序可能都是不一样的。
因此内存模型需要考虑到所有这些细节,以便让开发者可以精确控制。因为所有未定义的行为都可能产生问题。
对象和内存位置
C++内存模型中的基本存储单位是字节。一个字节至少足够大,能够包含基本执行字符集的任何成员以及Unicode UTF-8编码形式的八位代码单元,并且由连续的位序列组成。
C++中所有数据都是由对象组成的。
这里的对象包括了简单基本类型(如int和double),也包括了指针类型(如my_class*)。当然,也包括各种class定义的类的对象。
无论是什么类型,一个对象均包含了一个或多个内存位置。每个内存位置一定是下面两种情况中的一种:
标量类型(Scalar Type)的对象,标量类型包括下面几种:
数字类型:整数或者浮点数
T *指针类型
枚举类型
指向成员的指针
nullptr_t
相邻位域(Bit field)[13]的最大序列
位域
位域声明具有以“位”为单位的明确大小的类数据成员。相邻的位域成员可以打包成共享和跨过各个字节。
例如这样:
struct S {
// 三位的无符号位域,
// 允许值为 0...7
unsigned int b : 3;
};
位域的值必须大于等于0。值0比较特殊,它仅允许使用在无名位域上。并且它具有特殊含义:它指定类定义中的下个位域将始于分配单元的边界。
由此,请看一下下面的例子:
struct S {
char a; // 内存位置 #1
int b : 5;