C++11 内存模型详解

首先需要明确一个普遍存在,但却未必人人都注意到的事实:程序并不总是按照源码中的顺序被执行的,此谓之乱序,乱序产生的原因可能有好几种:

编译器出于优化的目的,在编译阶段将源码的顺序进行交换。

程序执行期间,指令流水被 cpu 乱序执行。

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个:

并发不能保证修改共享变量的原子性,这会导致常说的 race condition,各个线程同时修改某块内存,因此像 mutex,各种 lock 之类的东西在写多线程时被频繁地使用。

变量被修改后,该修改未必能被另一个线程及时观察到,因此需要“同步”。

解决同步问题就需要确定内存模型,也就是需要确定线程间应该怎么通过共享内存来进行交互(查看维基百科).

内存模型

内存模型所要表达的内容主要是怎么描述:一个内存操作的效果,在各个线程间的可见性的问题。我们知道,对计算机来说,通常内存的写操作相对于读操作是昂贵很多很多的,因此对写操作的优化是提升性能的关键,而这些对写操作的种种优化,导致了一个很普遍的现象出现:写操作通常会在 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 则用于进行读操作,它们结合起来表示这样一个约定:

内容版权声明:除非注明,否则皆为本站原创文章。

转载注明出处:https://www.heiqu.com/7323f39e1703e2d46613c3b521f8fd17.html