可见性与乱序

在说到内存模型相关的东西时,我们常常会说到两个名词:乱序与可见性,且两者经常交错着使用,容易给人错觉仿佛是两个不同的东西,其实不是这样,他们只是从不同的角度来描述一个事情,本质是相同的。比如说,我们有如下代码:

atomic<int> g_payLoad = {0};
atomic<int> g_guard = {0}; // thread 0
void foo1()
{
g_payLoad.store(42, memory_order_relaxed);
g_guard.store(1, memory_order_relaxed);
} // thread 1
void foo2()
{
int r = g_guard.load(memory_order_relaxed);
if (r)
{
r = g_payLoad.load(memory_order_relaxed);
}
}

因为 g_guard 与 g_payLoad 的读写都是以 relaxed 方式进行,因此我们会认为 foo2() 中 g_guard.load() 与 g_payLoad.load() 有可能会乱序(从不同的地址 load, cpu 可能会 speculate & prefetch,etc),但从另一个角度来看,g_payLoad 被 reorder 到 g_guard 之前(其它类型乱序同理),其实就相当于 g_payLoad 在 foo1 中的修改没有被 foo2 所看到,同一件事情的两个不同角度,如此而已。

但在处理 c++11 的内存模型时,语言标准上的措词却基本上是基于“可见性”来描述的(和 Java 的内存模型描述基本一致),看得多了,我也渐渐发现从“可见性”来理解问题有时真会容易很多,不信请继续看。

Happen-before

Happen-before 指的是程序指令间的一种关系,且是一种运行时的关系,是动态的,如果指令 A happen-before B, 则 A 的副作用能够被 B 所看到,容易理解对吧?且暂时可简单认为 happen-before 具有传递性(transitive)(准确地说是在不考虑 consume ordering的情况下), 如果 A happen-before B, B happen-before C,则我们可以认为 A happen-before C。

两条指令 A 与 B 如要满足 happen-before 关系,必须满足如下条件之一:

  1. A sequence-before B: 这个指的是在同一个线程里两条指令的关系,从程序源码上看如果指令 A 在 B 之前,则 A sequence before B, 比如前面代码中 g_payLoad.store() 这一行代码就是 sequence-before g_guard.store().
  2. A synchronize-before B: 这个指的是两个线程间的指令的关系,接下来一节再介绍。

因此当我们在理解代码时,如果想弄清楚当前线程里某些代码是否能看到公共变量在别的线程里被修改后的效果,我们应该第一时间这样来考虑,当前使用这些公共变量的代码能与别的线程里修改这些公共变量的代码建立一个 happen-before 的关系吗?如果有 happen-before 关系,则可以看到,如果没有则不一定,还是使用前面的例子:

// thread 0
g_payLoad.store(42, memory_order_relaxed); // 1
g_guard.store(1, memory_order_relaxed); // 2 // thread 1
int r = g_guard.load(memory_order_relaxed); // 3
if (r) r = g_payLoad.load(memory_order_relaxed); // 4

如果 g_payLoad.load() 要 load 到 #1 中所写入的值,则 #1 和 #4 这两者之间必须要有 happen-before 关系,而他们之间要有 happen before 关系,只需满足如下两个条件之一:

  1. 它们真的有 happen-before 关系,即上述代码中, #1 happen-before #4, 不过这个没法只根据 1 和 4 这两行代码来证明, 原因下面一节会具体解释。
  2. g_guard.store() 与 g_guard.load() 之间有 happen-before 关系, 即 #2 和 #3 之间有 happen-before 关系,则根据传递性,我们可以知道 #1 happen-before #4.

Synchronize-with

Sychronize-with 指的是两个线程间的指令在运行时的一种特殊关系,这种关系是通过 acquire/release 语义来达成的,具体来说,如果:

  1. 线程 A 中对某个变量 m 以 release 的方式进行修改,m.store(42, memory_order_release).
  2. 线程 B 中对变量 m 以 acquire 的方式进行读取,并且读到线程 A 所写的值(或以 A 为开始的一个 release sequence 所写入的值),则线程 B 中读 m 变量的那条指令与线程 A 中修改 m 的指令存在 synchronize-with 的关系。

顺便说一下 release sequence,操作 A 对变量 m 的 release sequence 指的是满足如下两个条件且作用于 m 上的操作的序列:

  1. 该操作与 A 在同一个线程内,且跟在 A 操作之后。
  2. 该操作在其它线程内,但是是以 Read-Modify-Write(RMW) 的方式对 m 进行修改。

其中, A 操作以 store release 的方式修改变量 m,而 RMW 操作是一种很特殊的操作,它的 read 部分要求永远能读到该变量最新的值。[参看 c++ 标准 29.3.12]

下图展示了一个经典的 synchonize-with 的例子:

这里有一点需要明确,操作 A 与 操作 B 是否存在 synchronize-with 关系,关键在于 B 是否读到了 A 所写入的内容(或 release sequence 写入的内容), 而 B 是否能读到 A 写入的内容与 A 这个操作是否已经进行了无关,c++ 标准的规定是,假如读到了,则 A synchronize-with B, 否则则不,因此对于某些关键变量,如果你想保证当你去读它时,总能读到它在别的线程里写入的最新的值,一般来说,你需要额外再设置一个 flag 用于进行同步,一个标准的模式就是前面例子中的 g_payLoad 与 g_guard。 g_payLoad 是你关注的关键信息或者说想要发布到别的线程的关键信息,而 g_guard 则是一个 flag,用于进行同步或者说建立 happen-before 的关系,只有建立了 happen-before 关系,你去读 g_payLoad 时,才能保证读到最新的内容。

sequential consistency

sequential consistency 这种模型实在是太美好了,它让编码变得这样地简单直接,一切都是和谐有序的,社会主义般地美好,而这种美好又是那么地触手可及,只要你完全不要使用其它模型,SC 就是你的了!而你所需付出的代价只是在某些平台上一点点效率的损失,就那么一点点!但不幸 c++ 程序员里面处女座的太多,因此我们得处理 acquire/release,甚至是 relaxed。而当 SC 与其它模型混合在了一起时,一定不要想当然以为有 SC 出现的地方就都是曾经的美好乐园,不一定了。

  1. 以 sequential consistency 方式进行的 load() 操作含有 acquire 语义。
  2. 以 sequential consistency 方式进行的 store() 操作含有 release 语义。
  3. 所有 sequential consistency 操作在全局范围内有一个一致的顺序,但这个顺序与 happen-before/synchronize-with 没有直接联系,sequential consistency Load 不一定会 Load() 到最新的值,sequential consistency write() 也并不一定就能马上被其它非 sequential consistency load() 所能 load() 到。

除此,需要注意的是 sequential consistency 类型的 fence,它是个例外,和纯粹的 SC load 和 SC store 不同,SC fence 能建立“类似” happen-before 的关系,参看 c++ 标准 29.3.6:

假如存在两个作用于变量 m 的操作 A 和操作 B,A 修改 m,而 B 读取 m,如果存在两个 memory_order_seq_cst 类型的 fence X 与 Y,使得:

1. A sequence-before X,且 Y sequence-before B.

2. 且 X 在全局顺序上处于 Y 之前(因为 X 和 Y 是 memory_order_seq_cst 类型的,因此肯定有一个全局顺序)。

则 B 会读到 A 写入的数据。

Dekker and Petterson's Algo

现在让我们尝试用前面介绍的知识来解决两个问题,如下是一个简化版的 Dekker's Algo,假设所有数据的初始值都是 0,则显然,如果所有内存操作都是以 relaxed 方式进行的话,则 r1 == r2 == 0 是可能的,因为 thread 0 里对 g_a 的读取不一定能看到 thread 1 对 g_a 的修改,对 g_b 的读取同理,现在的问题是,怎么才能阻止同时读到 r1 == r2 == 0?

// thread 0
g_a.store(42, memory_order_relaxed); // 1
r1 = g_b.load(memory_order_relaxed); // 2 // thread 1
g_b.store(24, memory_order_relaxed); // 3
r2 = g_a.load(memory_order_relaxed); // 4.

直接机械地套用 acquire/release 是不行的,#1 和 #4 不一定能建立 synchronize-with 关系,且 g_a 本身是关键变量,我们需要保证的是能读到它的最新值,直接用它来建立 synchonize-with 显然不能保证这点,#3 和 #2 同理。一个解法是分别在两个 thread 里分别加入一个 SC fence:

// thread 0
g_a.store(42, memory_order_relaxed);
atomic_thread_fence(memory_order_seq_cst); // fence 1
r1 = g_b.load(memory_order_relaxed); // thread 1
g_b.store(24, memory_order_relaxed);
atomic_thread_fence(memory_order_seq_cst); // fence 2
r2 = g_a.load(memory_order_relaxed); // 4

原理很简单,因为 fence 1 和 fence 2 是 sequential consistency 类型的, 因此它们的副作用在全局上有一个固定顺序,要么 fence 1 先于 fence 2,要么 fence 2 先于 fence 1,根据前一节的介绍,我们知道要么 g_a 读到 42, 要么 g_b 读到 24, 因此肯定不会出现 r1 == r2 == 0.

现在是第二个问题,如下是 Peterson's Algo 的简化写法, 用于实现互斥锁,问题的关键是怎么保证 flag0 在线程 1 里能读到线程 0 对它的修改?等价问题是怎么阻止 #3 被 reorder 到 #1 之前,#6 被 reorder 到 #4 之前?

// Thread 0
flag0.store(true, memory_order_relaxed); // 1
r0 = turn.store(0, memory_order_relaxed); // 2
r1 = flag1.load(memory_order_relaxed); // 3 // Thread 1
flag1.store(true, memory_order_relaxed); // 4
r0 = turn.exchange(1, memory_order_relaxed); // 5
r1 = flag0.load(memory_order_relaxed); // 6

现在我们尝试用 acquire/release 语义来解决它,假设 thread 0 先执行并进入了临界区,然后 thread 1 后执行,当 thread 1 执行到 #6 时,怎么保证能看到 thread 0 对 flag0 的修改呢?根据前面第二节的介绍,我们知道关键在于要保证 #1 happen-before #6,又由于 #1 和 #6 分别在不同的线程,因此其实就是要保证 #1 synchronize-with #6,因此我们需要在 thread 0 中以 release 的方式写一个变量 A,然后在 thread 1 中以 acquire 的方式读取该变量 A,那么我们应该选取哪个变量作为这个关键变量呢?

  1. flag0 不行,原因与前面第一节的例子相同,flag0 是我们要读取的关键变量,我们要保证的是能读取到它的最新值,而不是通过它来实现 synchronize-with.
  2. flag1 也不行,flag1 在 thread 0 只有一个 load 操作,没有 release 语义(但如果用 flag1.fetech_add(0, memory_order_acq_rel) 呢?应该也是行的,只是不是最好,多了一次无谓的写操作)。
  3. 最优选择应该是 turn 变量。

因此得到如下解法如下:

// Thread 0
flag0.store(true, memory_order_relaxed); // 1
r0 = turn.exchange(1, memory_order_acq_rel); // 2
r1 = flag1.load(memory_order_acquire); // 3 // Thread 1
flag1.store(true, memory_order_relaxed); // 4
r0 = turn.exchange(2, memory_order_acq_rel); // 5
r1 = flag0.load(memory_order_acquire); // 6

让我啰嗦点指出其中一个关键,#2 和 #5 能建立 synchronize-with 关系的关键在于 exchange 是一个 RMW 操作,它的读操作总能够读到变量最新的值(c++ 标准 29.3.12),因此当 thread 0 先执行时,turn 会被以 release 的方式写入一个值,再然后后面 thread 1 执行 #3 ,会以 acquire 的方式对 turn 进行读取,因为 RMW 保证它的 load 会 load 到最新的值,因此此时 #2 synchronize-with #5,皆大欢喜。

再说 c++11 内存模型的更多相关文章

  1. C++11 并发指南七(C++11 内存模型一:介绍)

    第六章主要介绍了 C++11 中的原子类型及其相关的API,原子类型的大多数 API 都需要程序员提供一个 std::memory_order(可译为内存序,访存顺序) 的枚举类型值作为参数,比如:a ...

  2. c++11 内存模型解读

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

  3. C++11内存模型的一些补充阅读材料

    <Intel Threading Building Block> O'REILLY Chapter 7 Mutual Exclusion - Atomic Operation - Memo ...

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

    基本解释 C++11引入了多线程,同时也引入了一套内存模型.从而提供了比较完善的一套多线程体系.在单线程时代,一切都很简单.没有共享数据,没有乱序执行,所有的指令的执行都是按照预定的时间线.但是也正是 ...

  5. C++11并发内存模型学习

    C++11标准已发布多年,编译器支持也逐渐完善,例如ms平台上从vc2008 tr1到vc2013.新标准对C++改进体现在三方面:1.语言特性(auto,右值,lambda,foreach):2.标 ...

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

    Cocso2d-x v3.11 一项重点改进就是 JSB 新内存模型.这篇文章将专门介绍这项改进所带来的新研发体验和一些技术细节. 1. 成果 在 Cocos2d-x v3.11 之前的版本中,使用 ...

  7. c++11 standardized memory model 内存模型

    C++11 标准中引入了内存模型,其目的是为了解决多线程中可见性和顺序(order).这是c++11最重要的新特征,标准忽略了平台的差异,从语义层面规定了6种内存模型来实现跨平台代码的兼容性.多线程代 ...

  8. 【JVM.11】Java内存模型与线程

    鲁迅曾经说过“并发处理的广泛应用是使得Amdahl定律代替摩尔定律成为计算机性能发展源动力的根本原因,也是人类‘压榨‘ 计算机运行能力的最有力武器.” 一.概述 多任务处理在现代计算机操作系统中几乎已 ...

  9. 11、Java并发性和多线程-Java内存模型

    以下内容转自http://ifeve.com/java-memory-model-6/: Java内存模型规范了Java虚拟机与计算机内存是如何协同工作的.Java虚拟机是一个完整的计算机的一个模型, ...

随机推荐

  1. JNI技术基础(1)——从零开始编写JNI代码

    众所周知,Java程序的最大特点就是其跨平台的特性,编写的上层应用程序可以不加任何修改甚至不用重新编译而运行于不同的平台上,然而,Java本身也存着这一个弊端,那就是性能上相对要差一些,在对性能要求比 ...

  2. 分析system_call中断处理过程

    分析system_call中断处理过程 上周我们使用gcc内嵌汇编调用系统调用,这次我们具体分析下过程. 将getpid嵌入menuos 代码从github下载,步骤如下: 1. 增加一个函数,get ...

  3. Axios、Lodash、TweenJS

    Axios是一个基于promise的HTTP库 http://chuansong.me/n/394228451820 Lodash是一个JavaScript的函数工具集 http://www.css8 ...

  4. Java并发包中Lock的实现原理

    1. Lock 的简介及使用 Lock是java 1.5中引入的线程同步工具,它主要用于多线程下共享资源的控制.本质上Lock仅仅是一个接口(位于源码包中的java\util\concurrent\l ...

  5. Enterprise Solution 2.2 开发帮助文档集合

    首先是一个PPT文档,从宏观层面展示Enterprise Soltion的几个功能特色. Enterprise Solution解决方案安装与配置 将源代码解决方案和演示程序在电脑中进行配置,作为了解 ...

  6. 在线测试 ssl 安全性

    记录下, https://www.ssllabs.com/index.html

  7. 高端PCB设计相关知识整理

    PCB的设计布局布线实际上是一门很复杂而且大部分靠经验来做的学问,很多东西也有点玄乎,但有很多经验性的结论和公式还是可以参考的 保证原创,一天不一定写的完 CH.1 更加严重的电磁干扰 首先基本上微电 ...

  8. [GO编程] GO入门语法基础

    学习一门语言,首先肯定是要熟悉他的语法,然后才可以进行编程开发,虽然本人使用过C++,.net等语言,不过对于GO的一些新特性还是需要多多熟悉,否则即使看得懂也写不出程序来.今天我们就开始我们的GO ...

  9. linux环境内存分配原理 mallocinfo

    Linux的虚拟内存管理有几个关键概念: Linux 虚拟地址空间如何分布?malloc和free是如何分配和释放内存?如何查看堆内内存的碎片情况?既然堆内内存brk和sbrk不能直接释放,为什么不全 ...

  10. php加密解密功能类

    这两天突发奇想想要用php写一个对日常项目加密以及解密的功能,经过努力简单的封装了一个对php代码进行加密解密的类,一些思想也是来自于网络,初步测试用着还行,可以实现对指定项目的加密以及解密(只针对本 ...