C++内存模型
关于乱序
说到内存模型,首先需要明确一个普遍存在,但却未必人人都注意到的事实:程序通常并不是总按着照中的顺序一一执行,此谓之乱序,乱序产生的原因可能有好几种:
- 编译器出于优化的目的,在编译阶段将源码的顺序进行交换。
- 程序执行期间,指令流水被 cpu 乱序执行。
- inherent cache 的分层及刷新策略使得有时候某些写读操作的从效果上看,顺序被重排。
以上乱序现象虽然来源不同,但从源码的角度,对上层应用程序来说,他们的效果其实相同:写出来的代码与最后被执行的代码是不一致的。这个事实可能会让人很惊讶:有这样严重的问题,还怎么写得出正确的代码?这担忧是多余的了,乱序的现象虽然普遍存在,但它们都有很重要的一个共同点:在单线程执行的情况下,乱序执行与不乱序执行,最后都会得出相同的结果 (both end up with the same observable result), 这是乱序被允许出现所需要遵循的首要原则,也是为什么乱序虽然一直存在但却多数程序员大部分时间都感觉不到的根本原因。
乱序的出现说到底是编译器,cpu 等为了让你程序跑得更快而作出无限努力的结果,程序员们应该为它们的良苦用心抹一把泪。
从乱序的种类来看,乱序主要可以分为如下4种:
写写乱序(store store), 前面的写操作被放到了后面的操作之后,比如:
a = 3; b = 4; 被乱序为: b = 4; a = 3;
写读乱序(store load),前面的写操作被放到了后面的读操作之后,比如:
a = 3; load(b); 被乱序为 load(b); a = 3;
读读乱序(load load), 前面的读操作被放到了后一个读操作之后,比如:
load(a); load(b); 被乱序为: load(b); load(a);
读写乱序(load store), 前面的读操作被放到了后一个写操作之后,比如:
load(a); b = 4; 被乱序为: b = 4; load(a);
程序的乱序在单线程的世界里多数时候并没有引起太多引人注意的问题,但在多线程的世界里,这些乱序就制造了特别的麻烦,究其原因,最主要的有2个:
- 并发不能保证修改和访问共享变量的操作原子性,使得一些中间状态暴露了出去,因此像 mutex,各种 lock 之类的东西在写多线程时被频繁地使用。
- 变量被修改后,该修改未必能被另一个线程及时观察到,因此需要“同步”。
解决同步问题就需要确定内存模型,也就是需要确定线程间应该怎么通过共享内存来进行交互(查看*).
内存模型
1. 顺序一致性模型(sequential consistency)
在介绍c++多线程模型之前,让我们先介绍一下最基本的顺序一致性模型。对多线程程序来说,最直观,最容易被理解的执行方式就是顺序一致性模型。顺序一致性的提出者lamport给出的定义是:
“… the result of any execution is the same as if the operations of all the processors were executed in some sequential order, and the operations of each inpidual processor appear in this sequence in the order specified by its program.”
从这个定义中我们可以看出,顺序一致性主要约定了两件事情:
(1)从单个线程的角度来看,每个线程内部的指令都是按照程序规定的顺序(program order)来执行的;
(2)从整个多线程程序的角度来看,整个多线程程序的执行顺序是按照某种交错顺序来执行的,且是全局一致的;
下面我们通过一个例子来理解顺序一致性。假设我们有两个线程(线程1和线程2),它们分别运行在两个cpu核上,有两个初始值为0的全局共享变量x和y,两个线程分别执行下面两条指令:
初始条件: x = y = 0;
线程 1 | 线程 2 |
x = 1; | y=1; |
r1 = y; | r2 = x; |
因为多线程程序交错执行的顺序是不确定的,所以该程序可能有如下几种执行顺序:
顺序 1 | 顺序 2 | 顺序 3 |
x = 1; r1 = y; y = 1; r2 = x; 结果:r1==0 and r2 == 1 |
y = 1; r2 = x; x = 1; r1 = y; 结果: r1 == 1 and r2 == 0 |
x = 1; y = 1; r1 = y; r2 = x; 结果: r1 == 1 and r2 == 1 |
顺序一致性模型的第一个约定要求每个线程内部的语句都是按照程序规定的顺序执行,例如,线程1里面的两条语句在该线程中一定是x=1先执行,r1=y后执行。顺序一致性的第二个约定要求多线程程序按照某种顺序执行,且所有线程看见的整体执行顺序必须一致,即该多线程程序可以按照顺序1、顺序2或者顺序3(以及其他可能的执行顺序)执行,且线程1和线程2所观察到的整个程序的执行顺序是一致的(例如,如果线程1“看见”整个程序的执行顺序是顺序 1,那么线程2“看见”的整个程序的执行顺序也必须是顺序1,而不能是顺序2或者顺序3)。依照顺序一致性模型,虽然这个程序还可能按其他的交错顺序执行,但是r1和r2的值却只可能出现上面三种结果,而不可能出现r1和r2同时为0的情况。
然而,尽管顺序一致性模型非常易于理解,但是它却对cpu和编译器的性能优化做出了很大的限制,所以常见的多核cpu和编译器大都没有实现顺序一致性模型。例如,编译器可能会为了隐藏一部分读操作的延迟而做如下优化,把线程1中对y的读操作(即r1=y)调换到x=1之前执行:
初始条件:x=y=0;
线程 1 | 线程 2 |
r1 = y; | y=1; |
x = 1; | r2 = x; |
在这种情况下,该程序如果按下面的顺序执行就可能就会出现r1和r2都为0这样的违反顺序一致性的结果:
顺序 4 |
r1 = y; y = 1; r2 = x; x = 1; |
那么为什么编译器会做这样的乱序优化呢?因为读一个在内存中而不是在cache中的共享变量需要较长的时钟周期,所以编译器就“自作聪明”的让读操作先执行,从而隐藏掉一些指令执行的延迟,从而提高程序的性能。实际上,这种优化是串行时代非常普遍的,因为它对单线程程序的语义是没有影响的。但是在进入多核时代后,编译器缺少语言级的内存模型的约束,导致其可能做出违法顺序一致性规定的多线程语义的错误优化。同样的,多核cpu中的写缓冲区(store buffer)也可能实施乱序优化:它会把要写入内存的值先在缓冲区中缓存起来,以便让该写操作之后的指令先执行,进而出现违反顺序一致性的执行顺序。
因为现有的多核cpu和编译器都没有遵守顺序一致模型,而且c/c++的现有标准中都没有把多线程考虑在内,所以给编写多线程程序带来了一些问题。例如,为了正确地用c++实现double-checked locking,我们需要使用非常底层的内存栅栏(memory barrier)指令来显式地规定代码的内存顺序性(memory ordering)[5]。然而,这种方案依赖于具体的硬件,因此可移植性很差;而且它过于底层,不方便使用。
2. c++多线程内存模型
为了更容易的进行多线程,程序员希望程序能按照顺序一致性模型执行;但是顺序一致性对性能的损失太大了,cpu和编译器为了提高性能就必须要做优化。为了在易编程性和性能间取得一个平衡,一个新的模型出炉了:sequential consistency for data race free programs,它就是即将到来的c++1x标准中多线程内存模型的基础。对c++程序员来说,随着c++1x标准的到来,我们终于可以依赖高级语言内建的多线程内存模型来编写正确的、高性能的多线程程序。
c++内存模型可以被看作是c++程序和计算机(包括编译器,多核cpu等可能对程序进行乱序优化的软硬件)之间的契约,它规定了多个线程访问同一个内存地址时的语义,以及某个线程对内存地址的更新何时能被其它线程看见。这个模型约定:没有数据竞跑的程序是遵循顺序一致性的。该模型的核心思想就是由程序员用同步原语(例如锁或者c++1x中新引入的atomic类型的共享变量)来保证你程序是没有数据竞跑的,这样cpu和编译器就会保证程序是按程序员所想的那样执行的(即顺序一致性)。换句话说,程序员只需要恰当地使用具有同步语义的指令来标记那些真正需要同步的变量和操作,就相当于告诉cpu和编译器不要对这些标记好的同步操作和变量做违反顺序一致性的优化,而其它未被标记的地方可以做原有的优化。编译器和cpu的大部分优化手段都可以继续实施,只是在同步原语处需要对优化做出相应的限制;而且程序员只需要保证正确地使用同步原语即可,因为它们最终表现出来的执行效果与顺序一致性模型一致。由此,c++多线程内存模型帮助我们在易编程性和性能之间取得了一个平衡。
在c++1x标准之前,c++是在建立在单线程语义上的。为了进行多线程编程,c++程序员通过使用诸如pthreads,windows thread等c++语言标准之外的线程库来完成代码设计。以pthreads为例,它提供了类似pthread_mutex_lock这样的函数来保证对共享变量的互斥访问,以防止数据竞跑。人们不禁会问,pthreads这样的线程库我用的好好的,干嘛需要c++引入的多线程,这不是多此一举么?其实,以线程库的形式进行多线程编程在绝大多数应用场景下都是没有问题的。然而,线程库的解决方案也有其先天缺陷。第一,如果没有在编程语言中定义内存模型的话,我们就不能清楚的定义到底什么样的编译器/cpu优化是合法的,而程序员也不能确定程序到底会怎么样被优化执行。例如,pthreads标准中并未对什么是数据竞跑(data race)做出精确定义,因此c++编译器可能会进行一些错误优化从而导致数据竞跑[3]。第二,绝大多数情况下线程库能正确的完成任务,而在极少数对性能有更高要求的情况下(尤其是需要利用底层的硬件特性来实现高性能lock free算法时)需要更精确的内存模型以规定好程序的行为。简而言之,把内存模型集成到编程语言中去是比线程库更好的选择。
内存模型所要表达的内容主要是怎么描述一个内存操作的效果,在各个线程间的可见性的问题。修改操作的效果不能及时被别的线程看见的原因有很多,比较明显的一个是,对计算机来说,通常内存的写操作相对于读操作是昂贵很多很多的,因此对写操作的优化是提升性能的关键,而这些对写操作的种种优化,导致了一个很普遍的现象出现:写操作通常会在 cpu 内部的 cache 中缓存起来。这就导致了在一个 cpu 里执行一个写操作之后,该操作导致的内存变化却不一定会马上就被另一个 cpu 所看到,这从另一个角度讲,效果上其实就是读写乱序了。
cpu1 执行如下: a = 3; cpu2 执行如下: load(a);
对如上代码,假设 a 的初始值是 0, 然后 cpu1 先执行,之后 cpu2 再执行,假设其中读写都是原子的,那么最后 cpu2 如果读到 a = 0 也其实不是什么奇怪事情。很显然,这种在某个线程里成功修改了全局变量,居然在另一个线程里看不到效果的后果是很严重的。
因此必须要有必要的手段对这种修改公共变量的行为进行同步。
c++11 中的 atomic library 中定义了以下6种语义来对内存操作的行为进行约定,这些语义分别规定了不同的内存操作在其它线程中的可见性问题:
enum memory_order { memory_order_relaxed, memory_order_consume, memory_order_acquire, memory_order_release, memory_order_acq_rel, memory_order_seq_cst };
我们主要讨论其中的几个:relaxed, acquire, release, seq_cst(sequential consistency).
relaxed 语义
首先是 relaxed 语义,这表示一种最宽松的内存操作约定,该约定其实就是不进行约定,以这种方式修改内存时,不需要保证该修改会不会及时被其它线程看到,也不对乱序做任何要求,因此当对公共变量以 relaxed 方式进行读写时,编译器,cpu 等是被允许按照任意它们认为合适的方式来加以优化处理的。
release-acquire 语义
如果你曾经去看过别的介绍内存模型相关的文章,你一定会发现 release 总是和 acquire 放到一起来讲,这并不是偶然。事实上,release 和 acquire 是相辅相承的,它们必须配合起来使用,这俩是一个 “package deal”, 分开使用则完全没有意义。具体到其中, release 用于进行写操作,acquire 则用于进行读操作,它们结合起来表示这样一个约定:
如果一个线程a对一块内存 m 以 release 的方式进行修改,那么在线程 a 中,所有在该 release 操作之前进行的内存操作,都在另一个线程 b 对内存 m 以 acquire 的方式进行读取之后,变得可见。
举个粟子,假设线程 a 执行如下指令:
a.store(3); b.store(4); m.store(5, release);
线程 b 执行如下:
e.load(); f.load(); m.load(acquire); g.load(); h.load();
如上,假设线程 a 先执行,线程 b 后执行, 因为线程 a 中对 m 以 release 的方式进行修改, 而线程 b 中以 acquire 的方式对 m 进行读取,所以当线程 b 执行完m.load(acquire)
之后, 线程 b 必须已经能看到a == 3, b == 4
. 以上死板的描述事实上还传达了额外的不那么明显的信息:
-
release 和 acquire 是相对两个线程来说的,它约定的是两个线程间的相对行为:如果其中一个线程 a 以 release 的方式修改公共变量 m, 另一个线程 b 以 acquire 的方式时读取该 m 时,要有什么样的后果,但它并不保证,此时如果还有另一个线程 c 以非 acquire 的方式来读取 m 时,会有什么后果。
- 一定程度阻止了乱序的发生,因为要求 release 操作之前的所有操作都在另一个线程 acquire 之后可见,那么:
- release 操作之前的所有内存操作不允许被乱序到 release 之后。
- acquire 操作之后的所有内存操作不允许被乱序到 acquire 之前。
而在对它们的使用上,有几点是特别需要注意和强调的:
- release 和 acquire 必须配合使用,分开单独使用是没有意义。
- release 只对写操作(store) 有效,对读 (load) 是没有意义的。
- acquire 则只对读操作有效,对写操作是没有意义的。
现代的处理器通常都支持一些 read-modify-write 之类的指令,对这种指令,有时我们可能既想对该操作 执行 release 又要对该操作执行 acquire,因此 c++11 中还定义了 memory_order_acq_rel,该类型的操作就是 release 与 acquire 的结合,除前面提到的作用外,还起到了 memory barrier 的功能。
sequential consistency
sequential consistency 相当于 release + acquire 之外,还加上了一个对该操作加上全局顺序的要求,这是什么意思呢?
简单来说就是,对所有以 memory_order_seq_cst 方式进行的内存操作,不管它们是不是分散在不同的 cpu 中同时进行,这些操作所产生的效果最终都要求有一个全局的顺序,而且这个顺序在各个相关的线程看起来是一致的。
举个粟子,假设 a, b 的初始值都是0:
线程 a 执行:
a.store(3, seq_cst);
线程 b 执行:
b.store(4, seq_cst);
如上对 a 与 b 的修改虽然分别放在两个线程里同时进行,但是这多个动作毕竟是非原子的,因此这些操作地进行在全局上必须要有一个先后顺序:
- 先修改a, 后修改 b,或
- 先修改b, 把整个a。
而且这个顺序是固定的,必须在其它任意线程看起来都是一样,因此 a == 0 && b == 4 与 a == 3 && b == 0 不允许同时成立。