c++11 内存模型解读

c++11 内存模型解读

关于乱序

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

  1. 编译器出于优化的目的,在编译阶段将源码的顺序进行交换。
  2. 程序执行期间,指令流水被 cpu 乱序执行。
  3. inherent cache 的分层及刷新策略使得有时候某些写读操作的从效果上看,顺序被重排。

以上乱序现象虽然来源不同,但从源码的角度,对上层应用程序来说,他们的效果其实相同:写出来的代码与最后被执行的代码是不一致的。这个事实可能会让人很惊讶:有这样严重的问题,还怎么写得出正确的代码?这担忧是多余的了,乱序的现象虽然普遍存在,但它们都有很重要的一个共同点:在单线程执行的情况下,乱序执行与不乱序执行,最后都会得出相同的结果 (both end up with the same observable result), 这是乱序被允许出现所需要遵循的首要原则,也是为什么乱序虽然一直存在但却多数程序员大部分时间都感觉不到的根本原因。

乱序的出现说到底是编译器,CPU 等为了让你程序跑得更快而作出无限努力的结果,程序员们应该为它们的良苦用心抹一把泪。

从乱序的种类来看,乱序主要可以分为如下4种:

  1. 写写乱序(store store), 前面的写操作被放到了后面的操作之后,比如:

    a = 3;
    b = 4;
    被乱序为:
    b = 4;
    a = 3;
  2. 写读乱序(store load),前面的写操作被放到了后面的读操作之后,比如:
    a = 3;
    load(b);
    被乱序为
    load(b);
    a = 3;
  3. 读读乱序(load load), 前面的读操作被放到了后一个读操作之后,比如:
    load(a);
    load(b);
    被乱序为:
    load(b);
    load(a);
  4. 读写乱序(load store), 前面的读操作被放到了后一个写操作之后,比如:
    load(a);
    b = 4;
    被乱序为:
    b = 4;
    load(a);

程序的乱序在单线程的世界里多数时候并没有引起太多引人注意的问题,但在多线程的世界里,这些乱序就制造了特别的麻烦,究其原因,最主要的有2个:

  1. 并发不能保证修改和访问共享变量的操作原子性,使得一些中间状态暴露了出去,因此像 mutex,各种 lock 之类的东西在写多线程时被频繁地使用。
  2. 变量被修改后,该修改未必能被另一个线程及时观察到,因此需要“同步”。

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

内存模型

内存模型所要表达的内容主要是怎么描述一个内存操作的效果,在各个线程间的可见性的问题。修改操作的效果不能及时被别的线程看见的原因有很多,比较明显的一个是,对计算机来说,通常内存的写操作相对于读操作是昂贵很多很多的,因此对写操作的优化是提升性能的关键,而这些对写操作的种种优化,导致了一个很普遍的现象出现:写操作通常会在 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 之前。

而在对它们的使用上,有几点是特别需要注意和强调的:

  1. release 和 acquire 必须配合使用,分开单独使用是没有意义。
  2. release 只对写操作(store) 有效,对读 (load) 是没有意义的。
  3. 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 的修改虽然分别放在两个线程里同时进行,但是这多个动作毕竟是非原子的,因此这些操作地进行在全局上必须要有一个先后顺序:

  1. 先修改a, 后修改 b,或
  2. 先修改b, 把整个a。

而且这个顺序是固定的,必须在其它任意线程看起来都是一样,因此 a == 0 && b == 4 与 a == 3 && b == 0 不允许同时成立。

后话

这篇随笔躺在我的草稿箱里已经半年多时间了,半年多来我不断地整理在这方面的知识,也在不断理清自己的思路,最后还是觉得关于内存模型有太多可以说却不是一下子能说得清楚的东西了,因此这儿只能把想说的东西一减再减,把范围缩小到 c++11 语言层面上作简单介绍,纯粹算是做个总结,有兴趣深入了解更多细节的读者,我强烈推荐去看一下 Herb Sutter 在这方面做的一个 talk, 内存模型方面的知识是很难理解,更难以正确使用的,在大多数情况下使用它而得到的些少性能优势,已经完全不值得为此而带来的代码复杂性及可读性方面的损失,如果你还在犹豫是否要用这些相对底层的东西的时候,就不要用它,犹豫就说明还有其它选择,不到没得选择,都不要亲自实现 lock free 相关的东西。

【引用】

http://bartoszmilewski.com/2008/11/11/who-ordered-sequential-consistency/

http://bartoszmilewski.com/2008/11/05/who-ordered-memory-fences-on-an-x86/

http://bartoszmilewski.com/2008/12/01/c-atomics-and-memory-ordering/

http://en.cppreference.com/w/cpp/atomic/memory_order

http://preshing.com

时间: 2024-11-20 04:55:55

c++11 内存模型解读的相关文章

C++11内存模型的粗略解释

基本解释 C++11引入了多线程,同时也引入了一套内存模型.从而提供了比较完善的一套多线程体系.在单线程时代,一切都很简单.没有共享数据,没有乱序执行,所有的指令的执行都是按照预定的时间线.但是也正是因为这个强的同步关系,给CPU提供的优化程度也就相对低了很多.无法体现当今多核CPU的性能.因此需要弱化这个强的同步关系,来增加CPU的性能优化. C++11提供了6种内存模型: 1 enum memory_order{ 2 memory_order_relaxed, 3 memory_order_

Cocos2d-x v3.11 中的新内存模型

Cocso2d-x v3.11 一项重点改进就是 JSB 新内存模型.这篇文章将专门介绍这项改进所带来的新研发体验和一些技术细节. 1. 成果 在 Cocos2d-x v3.11 之前的版本中,使用 JS 语言发布原生版本的用户可能多少都会遇到一个经典的问题:Invalid Native Object,或者遇到一些莫名其妙的 JS 对象失效的崩溃.而解决这些问题,我们给出的解决方案基本是使用 retain / release 来显式声明持有或释放对象,或者是在脚本层更合理得持有对象索引.而在 v

Redis内存模型及应用解读 读后随笔

文章出处: Redis内存模型及应用解读 https://dbaplus.cn/news-158-2127-1.html 第一部分:Redis内存统计 随笔:这一部分略显枯燥,是通过redis-cli连接redis后对于info命令的结果字段解读,属于较底层的部分,熟悉redis在操作系统中的实现会更容易理解这部分. 这段对于我的帮助 1.redis进程运行本身会需要内存和内存碎片,同时redis中还存在虚拟内存 2.mem_fragmentation_ratio表示内存碎片比率,mem_fra

Java内存模型深度解读

Java内存模型规范了Java虚拟机与计算机内存是如何协同工作的.Java虚拟机是一个完整的计算机的一个模型,因此这个模型自然也包含一个内存模型——又称为Java内存模型. 如果你想设计表现良好的并发程序,理解Java内存模型是非常重要的.Java内存模型规定了如何和何时可以看到由其他线程修改过后的共享变量的值,以及在必须时如何同步的访问共享变量. 原始的Java内存模型存在一些不足,因此Java内存模型在Java1.5时被重新修订.这个版本的Java内存模型在Java8中人在使用. Java内

对java内存模型的认识

浅谈java内存模型        不同的平台,内存模型是不一样的,但是jvm的内存模型规范是统一的.其实java的多线程并发问题最终都会反映在java的内存模型上,所谓线程安全无非是要控制多个线程对某个资源的有序访问或修改.总结java的内存模型,要解决两个主要的问题:可见性和有序性.我们都知道计算机有高速缓存的存在,处理器并不是每次处理数据都是取内存的.JVM定义了自己的内存模型,屏蔽了底层平台内存管理细节,对于java开发人员,要清楚在jvm内存模型的基础上,如果解决多线程的可见性和有序性

java内存模型详解

内存模型 (memory model) 内存模型描述的是程序中各变量(实例域.静态域和数组元素)之间的关系,以及在实际计算机系统中将变量存储到内存和从内存取出变量这样的低层细节. 不同平台间的处理器架构将直接影响内存模型的结构. 在C或C++中, 可以利用不同操作平台下的内存模型来编写并发程序. 但是, 这带给开发人员的是, 更高的学习成本.相比之下, java利用了自身虚拟机的优势, 使内存模型不束缚于具体的处理器架构, 真正实现了跨平台.(针对hotspot jvm, jrockit等不同的

jvm的stack和heap,JVM内存模型,垃圾回收策略,分代收集,增量收集(转)

深入Java虚拟机:JVM中的Stack和Heap(转自:http://www.cnblogs.com/laoyangHJ/archive/2011/08/17/gc-Stack.html) 在JVM中,内存分为两个部分,Stack(栈)和Heap(堆),这里,我们从JVM的内存管理原理的角度来认识Stack和Heap,并通过这些原理认清Java中静态方法和静态属性的问题. 一般,JVM的内存分为两部分:Stack和Heap. Stack(栈)是JVM的内存指令区.Stack管理很简单,push

JAVA并发编程2_线程安全&内存模型

"你永远都不知道一个线程何时在运行!" 在上一篇博客JAVA并发编程1_多线程的实现方式中后面看到多线程中程序运行结果往往不确定,和我们预期结果不一致.这就是线程的不安全.线程的安全性是非常复杂的,没有任何同步的情况下,多线程的执行顺序是不可预测的.当多个线程访问同一个资源时就会出现线程安全问题.例如有一个银行账户,一个线程往里面打钱,一个线程取钱,要是得到不确定的结果那是多么可怕的事情. 引入: 例如下面的程序,在单线程下,执行两次i++理论上i的最终值是12,但是在多线程环境下则不

C++ primer plus读书笔记——第9章 内存模型和名称空间

第9章 内存模型和名称空间 1. 头文件常包含的内容: 函数原型. 使用#define或const定义的符号常量. 结构声明. 类声明. 模板声明. 内联函数. 2. 如果文件名被包含在尖括号中,则C++编译器将在存储标准头文件的主机系统的文件系统中查找.但如果头文件名包含在双引号中,则编译器将首先查找当前的工作目录或源代码目录(或其他目录,这取决于编译器).如果没有在那里找到头文件,则将在标准位置中查找.因此在包含自己的头文件时,应使用引号而不是尖括号. 3. 链接程序将目标文件代码.库代码和