可见性与乱序

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

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. Atitti.java exp ast java表达式语法ast构造器

    Atitti.java exp ast java表达式语法ast构造器 /atiplat_cms/src/com/attilax/lang/AstParser.java 原理 分割tokens_sli ...

  2. Atitit.研发管理---TOGAF架构跟 (ADM开发方法)总结

    Atitit.研发管理---TOGAF架构跟 (ADM开发方法)总结 1. TOGAF是在过去二十年间出现的企业架构框架 1 2. TOGAF内容结构 1 3. TOGAF 实现过程 2 4. 参考 ...

  3. Windows 10 Java环境变量配置

    Win10下 Java环境变量配置 首先,你应该已经安装了 Java 的 JDK 了(如果没有安装JDK,请跳转到此网址:http://www.oracle.com/technetwork/java/ ...

  4. Android图片处理-相机、相处简单调用

    安卓开发中,常常需要使用到手机相机拍照.或者相册上传头像等等.通过使用Intent,我们很方便地获得相机.相册里面的图片: 1.相机调用,通过设置File文件路径和文件名,可以将拍照得到的图片保存下来 ...

  5. loadlibrary DLL 0失败 的一个原因。

    加载DLL失败,因为 dll 中有一个全局类变量,由于在构造函数 没有初始化变量,导致 初始化 失败 异常. C++ 变量没有初始值,全部都要 手动赋值 为 0 的这个特性 还真是烦人呢...

  6. 【Cocos2d-x】VS2012开发2dx无法解析的外部符号解决记录(第一篇)【转】

    come from http://acoder.me/cocos2d-unresolved-external-symbol.html 看到cocos2d-x带了扩展包,心动的想尝试下,以下分享下我使用 ...

  7. THEOS makefile

    转自https://www.h4ck.org.cn/2013/07/theos-makefile/ theos的makefile写法与其他linux/unix环境下的makefile写法大同小异,但是 ...

  8. MongoDB副本集配置系列十一:MongoDB 数据同步原理和自动故障转移的原理

    1:数据同步的原理: 当Primary节点完成数据操作后,Secondary会做出一系列的动作保证数据的同步: 1:检查自己local库的oplog.rs集合找出最近的时间戳. 2:检查Primary ...

  9. 如何在使用MAMP环境下安装MySQLdb

    我的电脑上没有安装XAMPP,而是安装了MAMP PRO,其实两者都差不多,都是PHP+MySQL+Apache的集成环境,只是MAMP的GUI界面更华丽一些,但是也更复杂一些. 好了不说这些,说说问 ...

  10. 使用jQuery页面加载函数启动定时任务

    var timer; $(document).ready(function(){ if (timer == undefined) timer = window.setInterval(showTime ...