前言
C++11标准引入了全新的多线程内存模型,如果没有它准确地定义基本构建块(building blocks)的工作方式,那么前文所提到的一切并发工具都将成为空谈。
C++内存模型基本可以分为2部分:内存结构(structs,与对象在内存中的布局有关)与并发性,一般认为前者比后者重要,尤其是需要深入了解底层原子操作时。
object && memory location
C++中任何数据均由object构成(并非是狭义上的“对象”)。C++标准对object的定义是“一块内存区域”。当然,object并不能一概而论,有些object较为简单,例如POD类型,而另一些则以用户自定义类型的形式出现。有些object存在子object(例如数组,派生类的实例,以及具有non-static data member的类实例),有些则并不具备。
无论object是何种类型,其必然被存储于一个或多个内存位置(memory location)上。内存位置要么是标量类型的对象(如unsigned int
或my class*
)或子对象,要么是一块连续的位域(bit fields)。需要注意,在位域中,即使相邻的位域内存储着不同的对象,他们依旧被视作具有相同的内存位置。下图清晰地展示了一个struct的objects 与 memory locations。
首先,整个struct是一个由几个子对象组成的对象,每个子对象对应一个数据成员。bf1和bf2位字段共享一个内存位置,std::string对象s内部由几个内存位置组成。除此之外,每个成员都有自己的内存位置。注意零长度位字段 bf3(bf3这个名字是用来注释的,C++中零长度位字段必须未命名)如何将bf4分隔到它自己的内存位置,但它本身没有内存位置。(C++中零长度的未命名位域有特殊含义:让下一个位域从分配单元的边界开始。C++中位域的内存布局是与机器相关的,取地址操作符&不能作用于位域)。
抛开细节,从上图中可以得出四点结论:
- 任何变量都是一个对象,即使它本身是其他对象的data member。
- 每一个对象至少占据一个内存位置。
- 基本类型(如int或char)无论大小必然占据一个内存位置,即使他们毗邻或是是数组的一部分。
- 相邻的位域共享同一个内存位置。
object && memory location && concurrency
首先需要阐明:在C++中,一切事物均与其内存位置挂钩。如果两个线程各自访问不同的内存位置,那么岁月静好,反之,若两个线程访问同一个内存位置,则必须提高警惕。此外,如果不存在线程更新内存位置,此时同样无需担忧——只读数据不需要保护与同步,但如果某个线程需要修正数据,则意味着引入数据竞争的可能性。
为了规避数据竞争,我们必须强制指定线程访问数据的先后顺序。方案一已经在第三章提过:使用互斥锁。如果在两次访问前锁定了相同的mutex,则可以确保同一时刻仅有一个线程访问数据,因此线程访问数据的顺序必然是有序的(尽管无法知道线程们谁先谁后)。另一种方案是在内存位置上使用原子操作(定义具体可见下一节)的同步属性,强制定义线程访问数据的顺序。若在多线程下试图对同一内存位置写入数据而不使用原子操作,则将引起数据竞争,从而导致未定义行为。
这里需要明确指出的是,使用原子操作并不能规避数据竞争(仍未指定各原子操作访问内存位置的先后性),但原子操作可以规避未定义行为。
modification orders
C++程序中的任意对象从都具备明确的修改顺序(modification order),由所有线程对该对象的写入操作组成,其开端为对象初始化。在大多数情况下,每一次程序时的修改顺序并不完全相同,但系统中所有线程都必须对该顺序达成一致。使用原子操作后,编译器将采取必要的同步措施保证线程按照修改顺序写入数据。如果对象类型并非原子类型,开发者应当保证采取足够的同步措施以确保每一个线程严格遵守修改顺序。
这一要求意味着某些投机执行(speculative execution)将不再被允许(禁止优化部分数据的读写顺序)。使用原子操作后,线程已经明确了解对象的修改顺序,那么读操作后的写操作必须等待读操作完成后执行,而写操作后的读操作也是同理,不可提前读取或写入。相关细节我们将在后续章节进一步讨论。