C++11 内存模型(简明版)

参考

英文资料1
英文资料2
中文资料1
中文资料2

为什么要写这篇文章

项目需求,需要实现 lock-free 的并行写文件。
深入理解内存模型是实现高性能并行程序的基本,因此需要将 C++11 的 atomic 相关内容细细研读。
网上看了不少相关资料,大部分给我的感觉是,偏深偏难不实用。不适合我这种菜鸡看。于是只好自己动手写一篇。

为什么需要内存模型

先进一段代码

#include <thread>
#include <vector>

int main(int argc, char const *argv[]) {
  int a = 1;
  int b = 100;
  std::vector<std::thread> threads;
  threads.emplace_back([&](){a = 2; printf("b = %d\n", b);});
  threads.emplace_back([&](){b = 200; printf("a = %d\n", a);});
  for (auto& t : threads) {
    t.join();
  }
  return 0;
}

猜猜这段代码的输出是什么?有可能存在 a = 1, b = 100 的输出吗?

乱序执行

首先需要推翻的一个观点是,单个 CPU 只能串行执行指令。
现代cpu都采用流水线结构,流水线的各级可以同时执行不同的指令,也只有用多条指令将流水线填满以后,cpu的能力才能得到充分发挥。
编译器和 CPU 都会对你的代码进行优化,为了实现更好的性能。例如,有如下操作:

B = func(3)  // 1
A = B+1  // 2 
C = 7  // 3

注意到,语句 2 依赖于语句 1 的结果,而语句 3 是独立的。
假设目前有 1 个 CPU 正在处理这段代码,那么它在等待获取 B 的值的时候其实空闲流水线可以先处理语句 3 的代码。这就是为什么需要乱序优化。
乱序优化也是有原则的,那就是保证在单核情况下运行的效果是不变的

乱序导致的问题

但是,现在已经进入了多核时代,于是乱序就会导致问题。例如:
伪代码如下所示,能否预测 Q 和 D 最终结果是多少?

A := 1
B := 2
C := 3
P := &A
Q := &C

// CPU 1
B = 4 
P = &B 

// CPU 2
Q = P 
D = *Q 

这个例子中,CPU2 要执行的指令有明显的依赖关系,所以顺序不会改变。因为 D=*Q 依赖于 Q 指针。所以需要先执行 Q=P
CPU1 要执行的指令看起来似乎也有依赖关系,但实际是没有。因为改变 B 的值不会改变 B 的地址。也就是说,倒序执行,最终 B 和 P 中的值是一样的。所以 CPU1 在这里不一定会按照顺序执行。
可能出现以下情况,第三种情况比较特殊。

  1. CPU1 还未执行 P=&B,CPU 2 执行结束
    此时应该有 Q = &A, D = 1
  2. CPU1 执行了 P=&B,CPU2 执行结束
    这时候必定已经执行了B=4,因此有 Q = &B, D = 4
  3. CPU1 乱序执行,先执行了 P=&B,CPU2 执行结束,但是还未执行 B=4
    此时会有 Q=&B, D = 2

如何解决

如上面例子所述,在多核系统上,如果不施加任何限制,当多个线程同时读写共享的变量的时候,一个线程可能观察到值的变化于另一个线程写的顺序不同。
要解决这个问题,我们需要定义内存的访问顺序。

四种常用内存模型

我们将重点介绍 release-acquire 模型,它是实现 lock-free 编程的重点。

std::memory_order_relaxed

最宽松的内存模型,效率也最高。实际上它不属于同步操作,因为它不对内存访问做出任何顺序限制。仅仅保证操作的原子性。

一般用于多线程计数器。例如 std::shared_ptr 的引用计数就是利用这个实现的。

std::memory_order_release 和 std::memory_order_acquire

这两者是需要搭配使用的。构成一种 release-acquire 模型。
std::memory_order_acquire
这是读操作 (load) 时可以指定的内存顺序。对作用的内存区域产生效果:

  1. 在这次 load 之前当前线程的读写不允许乱序。
  2. release 同一原子量的线程中的写操作在当前线程可见。

std::memory_order_release
这是写操作 (store) 时可以指定的内存顺序。对作用的内存区域产生如下效果:

  1. 在这次 store 之后当前线程的读写不允许乱序。
  2. acquire 同一原子量的线程可以看到当前线程的所有写操作。

用人话来讲就是,在两个线程中建立了同步关系(synchronize-with),在 release 之前发生的所有事,在 acquire 之后都是可见的。

release-acquire

下面是一个利用原子操作来解决 Double-Checked Locking 线程不安全问题的例子。

std::atomic<Singleton*> Singleton::m_instance;
std::mutex Singleton::m_mutex;

Singleton* Singleton::getInstance() {
    Singleton* tmp = m_instance.load(std::memory_order_acquire);
    if (tmp == nullptr) {
        std::lock_guard<std::mutex> lock(m_mutex);
        tmp = m_instance.load(std::memory_order_relaxed);
        if (tmp == nullptr) {
            tmp = new Singleton;
            m_instance.store(tmp, std::memory_order_release);
        }
    }
    return tmp;
}

如果不增加这个原子操作,会出现以下问题:

  1. A 线程调用,发现还未构造,于是获取锁开始进行构造。
  2. tmp = new Singleton 其实是分为两步,第一步分配内存,第二步构造对象。分配内存后 tmp 指针就已经不是空了。但是还没执行构造函数。
  3. B 线程刚好此时插入,检查 tmp 非空,于是直接返回了一个没有构造完成的对象。

因此必须要使用原子操作来同步。在新建实例成功后再 release,则 acquire 的操作时必然可见的是一个构造好了的对象。

mutex 和 spinlock 都是它的典型应用。

典型的 spinlock 实现:

std::atomic_flag spinlock = ATOMIC_FLAG_INIT;
// lock
while (spinlock.test_and_set(std::memory_order_acquire)) {
}
// critical area
// unlock
spinlock.clear(std::memory_order_release);

由于在上锁之前, acquire 特性保证了不可乱序,而解锁之后,release 特性又保证了不可乱序,中间则只能有一个线程执行,因为只有一个线程能获得锁,因此乱序也无妨。所以一定是安全的。

std::memory_order_release 和 std:: memory_order_consume

不建议使用。

std::memory_order_seq_cst

顺序一致模型(sequence-consistent)。任何操作都同时是 acquire 操作和 release 操作。在所有线程上都观察到改变是同一顺序的(与作出修改的线程一致)。这是默认的模型,如果不为原子操作指定参数,则就采用这个模型。性能最差,但是符合逻辑。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,425评论 4 361
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,058评论 1 291
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,186评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,848评论 0 204
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,249评论 3 286
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,554评论 1 216
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,830评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,536评论 0 197
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,239评论 1 241
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,505评论 2 244
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,004评论 1 258
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,346评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 32,999评论 3 235
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,060评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,821评论 0 194
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,574评论 2 271
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,480评论 2 267

推荐阅读更多精彩内容

  • 接着上节 mutex,本节主要介绍atomic的内容,练习代码地址。本文参考http://www.cplusplu...
    jorion阅读 73,091评论 1 14
  • C++ 11 atomic 简介 Atomic类型是c++11里面引入的一种类型,它规定了当程序的多个线程同时访问...
    EFlql阅读 7,624评论 1 0
  • 不讲语言特性,只从工程角度出发,个人觉得C++标准委员会在C++11中对多线程库的引入是有史以来做得最人道的一件事...
    stidio阅读 13,057评论 0 11
  • 前天上班的时候,老大问一个跟我一样的新职员女孩,问了她一些之前有关于去跟班的地方的一系列问题,很多都答不上,要不然...
    疯华绝代的四喜阅读 259评论 2 2
  • 感觉到压死骆驼的最后一根稻草已经出现,只是自己不断地放低姿态,突破底线。没想到这段感情让我这么累,仅仅因为我比对方...
    HG曼殊斐儿阅读 173评论 1 0