内存屏障原理

简介

内存屏障在很多地方都看到,本文采用cpp介绍,不懂cpp? 别急,不懂cpp也能看懂。

本文的目的通过介绍cpp代码的执行顺序和cpp内存模型(memory model)阐述内存屏障

复杂的背后,是简单

代码是乱序执行的

有一个残酷的现实 ,你写的代码的顺序,并不是最终被执行的顺序。

比如:

#include <cstdio>
int a() { return std::puts("a"); }
int b() { return std::puts("b"); }
int c() { return std::puts("c"); }
void z(int, int, int) {}
int main() {
    z(a(), b(), c());       // all 6 permutations of output are allowed
    return a() + b() + c(); // all 6 permutations of output are allowed
}

你会发现a, b, c每次执行的顺序都不一样

这是为什么呢?以下因素:

  • 编译器优化。编译器尝试优化更高效的代码
  • cpu并发执行。cpu会同时加载多条指令并发执行
  • cpu缓存。cpu和内存之间,充斥着多种缓存,缓存的更新有延时,可能获取到脏数据,造成乱序的假象

这会有什么问题吗?

显然,优化是出乎程序员意料的,程序员无法知道代码到底优化成了什么样子。但大多数情况下,程序员不需要担心这个问题。

计算机在优化的时候,有一个原则:

  • 优化后代码的执行结果,和未优化是一致的

比如例子一:

int a=3;        (1)
int b=4;        (2)

指令1和指令2谁先执行,这个重要吗?

在单线程环境中, 一点都不重要

又比如例子二:

c = 3;          (1)      
d = c+1;        (2)

计算机会把指令2优化到指令1之前去执行吗?

显然没有那么愚蠢的计算机。计算机能够智能的推测Sequenced-before关系

sequenced-before 关系

程序员书写的代码,每个expression之间,大多数存在执行的先后关系。

expression是什么?详情请看link。简单点,就是一个读写语句

比如:

c = 3;          A      
d = c+1;        B

此时A必须在B之前被执行, 叫做A Sequenced-before B。

但并非每两个expression之间都存在这种关系:

int a=3;        A
int b=4;        B

此时A, B谁先执行无所谓,叫做A not Sequenced-before B。

显然,这种先后关系很好理解。Sequence-before, 仅是无聊的黑话,即指令执行的先后顺序

计算机优化的时候,不会改变Sequenced-before关系,我们大可放心

注意

本文用统一用Sequenced-before表示指令之间的先后关系。如果大家关注过cppreference,会发现上面定义了多种关系,Dependency-ordered before,Inter-thread happens-before,Happens-before等,各种定义仅有细微差别,都用于描述指令执行顺序

本文简化理解,统一采用Sequenced-before表示

sequenced-point

明白了Sequenced-before就是语句执行顺序之后,我们再补充一个黑话,叫做sequenced-point。

在以下例子中,A需要在B之前执行

c = 3;          A      
d = c+1;        B

意味着A需要在B之前某个时间点被执行。A被执行的时间点,叫做sequence-point。具体到我们例子中,分号标示的A指令结束,就是sequence-point

sequence-point是指,A Sequenced-before B关系中,A被执行的时间点

计算机能正确的探测Sequenced-before关系,在Sequenced-point执行应该执行的指令,一切都很美好,可我们就高枕无忧了吗?

好像是的,直到多线程出现

多线程困扰

还是刚才的例子,A与B执行顺序并不重要,乱序我们是接受的:

Thread A:
    int a=3;       A
    int b=4;       B

但很快我们就忘记了这一点,比如我们又写了一个线程,很容易犯这样的错误:

Thread B:

    if(b等于4):
        assert(a一定等于3)

原因在于,我们习惯性的认为计算机的执行顺序就是代码的书写顺序。乐观的认为就算有优化,结果也会没什么不同

但计算机在执行Thread A的时候,完全不知道Thread B的存在,它一如既往对Thread A执行它的优化和并行计算,与你在Thread B中的期待背向而驰

但是话说回来,也不要怪任劳任怨的优化者,你在Thread B中的期待是否有一点唯心主义,计算机怎么能够读懂你的期待呢?

手动建立sequenced-before关系

因而当我们要保证两个not sequenced-before的指令A和B, 要求A必须在B之前执行:

int a=3;       A
int b=4;       B

此时计算机是无能为力的,我们得手动建立A和B之间的顺序关系。

幸运的是,大多数语言都提供了一套指令,用于建立sequenced-before关系,即设置指令被执行的先后顺序。这套指令规则叫做memory fence

memory fence

我们想象一下memory fence应该具备什么功能。以下指令(MM_SEQ, MM_BOTTOM, MM_TOP)纯属假设,仅为解释需要

MM_SEQ(✘)

最简单的,直接指定两条指令间的先后顺序

假设指令是MM_SEQ(A, B),指定A sequenced-before B

所以伪代码是这个样子:

int a=3;       A

MM_SEQ(A,B)

int b=4;       B

但不幸的是,MM_SEQ(A, B)这种方式存在问题:

  • 如果在B之前有很多指令都要执行怎么办?
  • 每条语句怎么标上A, B这样的序号

MM_BOTTOM(✓)

MM_SEQ不是一种合适的思路,一种新的思路是:

int a=3;       A

MM_BOTTOM(之前所有指令都必须执行)    

int b=4;       B 

我们设计一条指令MM_BOTTOM,要求之前的所有指令都必须得到执行。试图保证A,B的顺序关系

但是这还不够,A在MM_BOTTOM之前,必须要被执行。但B没有被限制到,B有很高的自由度,可以被优化到MM_BOTTOM之前。实际效果可能为:

int a=3;       A

int b=4;       B 

MM_BOTTOM(之前所有指令都必须执行)    

或者

int b=4;       B 

int a=3;       A

MM_BOTTOM(之前所有指令都必须执行)    

我们需要补充一个新的指令,防止B随意前移

MM_TOP(✓)

我们把新的指令设计为MM_TOP, MM_TOP之后的所有指令,都不能在MM_TOP之前被执行

int a=3;       A

MM_BOTTOM(之前所有指令都必须执行)    
MM_TOP(之后所有指令都不能执行) 

int b=4;       B 

有了MM_TOP和MM_BOTTOM,就能阻止B被优化到MM_TOP之前了,完美的保障了A sequenced before B关系。

黑话时间

MM_TOP和MM_BOTTOM,是我们假设的指令,在真实的计算机黑话中,他们被叫做 memory barrier, 或者 memory fence, 或者 memory bar。

想象一下,你的代码是一只只在草地上随意溜达的小羊,你为了防止他们乱跑,只好给他们围上篱笆。这些篱笆英文就叫做barrier/fence/bar

cpp memory consistency model

各个语言都有Memory fence指令模型,这里我们介绍一下cpp。

cpp中一共有六种fence指令。统一叫做memory_order, 列举如下:

  • memory_order_relaxed
  • memory_order_consume
  • memory_order_acquire
  • memory_order_release
  • memory_order_acq_rel
  • memory_order_seq_cst

其中的核心思想和上面介绍是一致的,但为什么有六种这么多?是因为cpp把指令和原子操作(atomic)搅合在一起。为了更好的理解这六条指令,我们先不得不介绍下原子操作

啊,原子操作,又是jargon time

原子操作

当我们写一句简单的赋值语句:

x=3

最终在cpu中得以执行时,可不仅仅是一条机器指令,而是一堆指令,尤其在涉及缓存的时候。

比如我们可以简单理解为(当然真实过程远比这复杂):

指令xxx
设置cpu中x的缓存为3
指令xxx
刷新缓存到真实内存

这带来的问题是在线程切换时,由于线程会随时切换,完全有可能在切换那一刻,x并不等于3,而是处于某个中间状态,一个让人无法理解的奇葩值

而很多时候,人们想要避免这种情况出现,人们需要一个事务,保障指令要么整体成功,要么整体失败

    Tx{
        指令 xxx
        设置cpu中x的缓存为3
        指令xxx
        刷新缓存到真实内存
    }

这种完整性要求,就叫原子操作。

在执行原子操作的时候,cpu会阻止在线程切换时中间状态的产生,其他线程看到的x要么是3, 要么是原值,不会是任何意想不到的值

原子操作分为三大类:

  • load。 读相关操作。
  • store。写相关操作。
  • read-modify-write。即读又写的操作

我们来看一个原子操作的例子:

    //定义原子操作对象
    std::atomic<int> a = {0};

    //读操作,load
    int x = a.load() + 1;

    //写操作
    a.store(2)

    //即读又写的操作
    flag.compare_exchange_strong(1, 2)

原子操作和fence的结合

明白了原子操作,我们来看原子操作和fence怎么结合的,我们以memory_order_acquire为例:

memory_order_acquire属于读操作相关指令,要求本线程中所有后续操作不能优化到本指令之前(类似MM_TOP)。

//定义原子操作对象
std::atomic<int> a = {0};

//读操作,load
int x = a.load(std::memory_order_acquire) + 1;

//写操作
a.store(2)

在第二条原子操作语句中,就插入了fence指令。

事实上,几乎所有的原子读写操作,都会绑定一个fence指令。比如store的定义:

void store( T desired, std::memory_order order = std::memory_order_seq_cst ) noexcept;

默认的绑定了memory_order_seq_cst指令。

memory_order

我们回过头来看看cpp中的fence指令吧,也就是memory_order

  1. memory_order_relaxed
  • 不提供任何fence功能,仅仅保证原子操作的原子性

2.memory_order_acquire

  • MM_TOP, 禁止后续指令被移动到此指令之前。原子操作中仅能和load类操作结合

3 memory_order_consume

  • 弱化版MM_TOP, 禁止部分后续指令被移动到此指令之前。这里面引入了一个复杂的关系dependecy-ordered-before。感兴趣的读者可自行了解。原子操作中仅能和load类操作结合

4.memory_order_release

  • MM_BOTTOM, 禁止前续指令被移动到此指令之后。原子操作中仅能和store类操作结合

5.memory_order_acq_rel

  • MM_BOTTOM和MM_TOP结合体,禁止前续指令被移动到此指令之后,又禁止后续指令被移动到此指令之前。。原子操作中仅能和read-modify-write(即读又写)类操作结合

6.memory_order_seq_cst

  • 万能性指令。如果和store操作集合,等同于memory_order_release,和load操作结合,等同于memory_order_acquire,和read-modify-write操作集合,等同于memory_order_seq_cst

fence指令能单独使用吗?

上面介绍了fence指令(memory order)总是和atomic结合使用的,但也能单独使用。

比如通过atomic_thread_fence指令

data[v0] = computation(v0);                                         1
data[v1] = computation(v1);                                         2
data[v2] = computation(v2);                                         3

std::atomic_thread_fence(std::memory_order_release);                4

std::atomic_store_explicit(&arr[0], v0, std::memory_order_relaxed); 5
std::atomic_store_explicit(&arr[1], v1, std::memory_order_relaxed); 6
std::atomic_store_explicit(&arr[2], v2, std::memory_order_relaxed); 7
}

这里通过memory_order_release,能够阻止1,2,3指令被移动到指令4之后

结论

本文介绍了代码的执行顺序,以及如何手工建立执行顺序,防止多线程中出现的意外。
当然,很多时候,我们都粗暴的直接采用加锁了事。但事实上,我们能做的很多。

fence在很多语言中都和atomic结合在一起使用,统称为Memory consistency model,或者Memory model。也就是大多数人一谈就头痛的内存模型

但内存模型真相不过如此,保证指令执行顺序而已。

如果本文能对fence和内存模型的理解带来一丝帮助,不胜荣幸

ref

  • https://en.cppreference.com/w/c/language/eval_order
  • https://en.wikipedia.org/wiki/Sequence_point
  • https://en.cppreference.com/w/cpp/atomic/memory_order


《 “内存屏障原理” 》 有 4 条评论

  1. Medical practitioners may recommend Anadrol to women who have already been harmonized buy priligy usa 96 Gy per fraction in a group of 200 patients

  2. Spann Jr, JF Buccino RA Sonnenblick EH et al what is priligy dapoxetine

回复 agodelo 取消回复

您的邮箱地址不会被公开。 必填项已用 * 标注

About Me

一位程序员,会弹吉他,喜欢读诗。
有一颗感恩的心,一位美丽的妻子,两个可爱的女儿
mail: geraldlee0825@gmail.com
github: https://github.com/lisuxiaoqi
medium: https://medium.com/@geraldlee0825