C++11内存模型详解

    xiaoxiao2025-07-30  13

        C++内存模型可以被看作是C++程序和计算机系统(包括编译器,多核CPU等可能对程序进行乱序优化的软硬件)之间的契约,它规定了多个线程访问同一个内存地址时的语义,以及某个线程对内存地址的更新何时能被其它线程看见.

    关于乱序

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

    1. 编译器出于优化的目的,在编译阶段将源码的顺序进行交换。 2. 程序执行期间,指令流水被CPU乱序执行。 3. 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个:

    1. 共享变量被修改时,并发不能保证修改共享变量的原子性,会导致常说的 race condition。因此像 mutex,各种 lock 等在写多线程时被频繁地使用。 2. 共享变量被修改后,该修改未必能被另一个线程及时观察到。因此需要“同步”。

    内存模型

       解决共享变量被修改后的“同步”问题,就需要确定内存模型,即确定线程间怎么通过共享内存进行交互(查看维基百科).

       内存模型所要表达的内容主要是这么描述: 一个内存操作的效果,在其他线程中的可见性问题。我们知道,对计算机来说,通常内存的写操作相对于读操作是昂贵很多很多的,因此对写操作的优化是提升性能的关键,而这些对写操作的种种优化,导致了一个很普遍的现象出现:写操作通常会在 CPU 内部的 cache 中缓存起来。这就导致了在一个 CPU 里执行一个写操作之后,该操作导致的内存变化却不一定会马上就被另一个 CPU 所看到。

    // cpu1 执行如下: a = 0; // cpu2 执行如下: load(a);

       对如上代码,假设 a 的初始值是 0, 然后 cpu1 先执行,之后 cpu2 再执行,假设其中读写都是原子的,那么最后 cpu2 如果读到 a = 0 也其实不是什么奇怪的事情。很显然,这种在某个线程里成功修改了共享变量,却在另一个线程里看不到修改效果的后果是很严重的。

       因此必须要有必要的手段对这种修改共享变量的行为进行同步。

        C++11 中的 atomic library 中定义了以下6种语义来对内存操作的行为进行约定,这些语义分别规定了不同的内存操作在其它线程中的可见性问题

    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语义则用于读操作,它们结合起来表示这样一个约定:如果一个线程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.

       以上死板的描述事实上还传达了额外的不那么明显的信息:

    1. release 和 acquire 是相对两个线程来说的,它约定的是这两个线程间的相对行为,它保证了这两个线程之间对共享变量操作后的可见性问题。此时如果有第三个线程已非acquire的方式读取共享变量,它并不保证这种可见性。 2. release 和 acquire 一定程度阻止了乱序的发生,因为其要求 release 操作之前的所有操作都在另一个线程 acquire 之后可见,那么: - release 操作之前的所有内存操作不允许被乱序到 release 之后。 - acquire 操作之后的所有内存操作不允许被乱序到 acquire 之前。

       在对release-acquire 语义的使用上,有几点是特别需要注意和强调的:

    1. release 和 acquire 必须配合使用,分开单独使用是没有意义。 2. release 只对写操作(store) 有效,对读 (load) 是没有意义的。 3. acquire 则只对读(load)操作有效,对写(store)操作是没有意义的。

    memory_order_seq_cst语义

       现代的处理器通常都支持一些 read-modify-write 之类的指令,对这种指令,有时我们可能既想对该操作执行 release 又要对该操作执行 acquire,因此 C++11 中还定义了 memory_order_acq_rel,该类型的操作就是 release 与 acquire 的结合,除前面提到的作用外,还起到了 memory barrier 的功能。

       简单来说就是,对所有以 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 不允许同时成立。

    转载请注明原文地址: https://ju.6miu.com/read-1301212.html
    最新回复(0)