简介
内存屏障在很多地方都看到,本文采用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
- 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
回复 agodelo 取消回复