对优化说不 - Linux中的Barrier

原文引用自 [https://zhuanlan.zhihu.com/p/96001570]
感谢原作者

对优化说不 - Linux中的Barrier

我们编写的源代码需要经过编译器转换成机器指令,最后由CPU执行这些指令。编译器作为一个“翻译官”,并不会老老实实地逐条翻译,而是会对我们的代码按照自己的“想法”进行调整和重组。CPU作为一个执行部件,对交给它的指令也不会规规矩矩的逐条执行,而是会重新排序之后执行。
这么“不尊重”我们的代码顺序,它们有权利这么做吗?它们有,因为它们高举的是“为效率优化”的大旗,一切都是为了你编写的代码能更快地执行,就像小时候某些家长常说的“都是为了你好”。
为了我好我就一定能好么,那可不一定,因为他们虽然经验丰富,但毕竟没有我自己对自己的了解那么深。编译器和CPU固然厉害,但它们都有个局限:无法理解代码执行的上下文联系。它们都只能假定一段代码是在单线程的环境下运行,因此它们做出的优化在面对多核多线程的环境时,就可能是不妥当的。最懂代码本身逻辑的,还是编写这段程序的人。
然而不可否认的是,编译器和CPU所做出的优化确实对性能提升明显,我们不可能完全弃之不用,所以需要在必要的时候suppress一下这些优化。

Compiler Barriers

对编译器的优化我们可以使用compiler barrier,比如大家熟知的"volatile",就可以让编译器生成的代码,每次都从内存重新读取变量的值,而不是用寄存器中暂存的值。因为在多线程环境中,不会被当前线程修改的变量,可能会被其他的线程修改,从内存读才可靠。
这就部分解释了上文留的那个问题,即为什么要用READ_ONCE()和WRITE_ONCE()这两个宏,因为atomic_read()和atomic_set()所操作的这个变量,可能会被多核/多线程同时修改,需要避免编译器把它当成一个普通的变量,做出错误的优化。还有一部分原因是,这两个宏可以作为标记,提醒编程人员这里面是一个多核/多线程共享的变量,必要的时候应该加互斥锁来保护。
Linux中设置compiler barrier的函数是barrier(),它对应gcc的实现是这样的(定义在include/linux/compiler-gcc.h):

/* The "volatile" is due to gcc bugs */
#define barrier() __asm__ __volatile__("": : :"memory")

这是一个内嵌汇编,里是一个空的指令,空的指令怎么发挥作用?
它其实利用了末尾clobber list里的"memory",clober list是gcc和gas(GNU Assembler)的接口,用于gas通知gcc它对寄存器和memory的修改情况。
这里的"memory"就是告知gcc,在汇编代码中,我修改了内存中的内容,之前的C代码块和之后的C代码块看到的内存是不一样的,对内存的访问不能依赖于嵌入汇编之前的C代码块中寄存器的内容,所以乖乖地重新从内存读数据吧。
也不知道编译器能不能识别这种伎俩,反正最后它是欣然的被骗了。需要注意的是,barrier()只会对编译器的行为产生约束,它不会生成真正的指令,因此对最终CPU的指令执行没有影响。

Memory Order

对编译器优化的控制看起来还比较简单,但对CPU的reorder优化的控制就需要考虑更多的因素了。先来思考一个问题:既然CPU会reorder指令的执行顺序,那为什么没有造成混乱?
因为这里有个前提,对于前后有数据上的依赖(dependency)的指令,CPU一般是不会去做reorder的(Alpha架构除外),这可以算是CPU优化的“底线”吧。比如前一条指令将1赋值给变量x,后一条指令将变量x的值赋给变量y,那么这两条指令的执行顺序是不会被CPU颠倒的。
在这里插入图片描述
而对于那些没有这种依赖关系的指令,CPU就有发挥的空间了,对此,不同架构的处理器的“尺度”还不一样,这就是一个CPU的memory-ordering model要考虑的问题。Memory Order指的是CPU通过总线读写内存的顺序,因此主要涉及"load"和"store"两种操作。
在ARM中,只要没有依赖关系,对指令的执行顺序没有要求,load指令(以"L"表示)和store指令(以"S"表示)可任意交换,属于relaxed model,俗称weak order。
在这里插入图片描述

而在x86中,对于同一CPU执行的load指令后接load指令(L-L),store指令后接store指令(S-S),load指令后接store指令(L-S),都是不能交换指令的执行顺序的,只有store指令后接load指令(S-L)时才可以[注1]。这种memory order被称为TSO(Total Store Order),俗称strong order。
在这里插入图片描述
为什么唯独store指令后接load指令这种情况被CPU给开了“后门”?因为如果写一个数据没完成,一般没多大影响,而如果读一个数据没完成,就可能对后面依赖这个数据的指令的继续执行造成影响,形成"stall",所以CPU会优先保证读操作的完成。
这可以理解为是“读”的优先级比“写”高,所以"load"可以跑到"store"前面去执行,而其他的三种情况,要么优先级相同,要么后面的一条指令的优先级更低。
一般来说,memory order的强度越弱(不严格),硬件成本越低,速度也越快。你也别以为x86就一根筋坚持strong order不动摇,它也支持对order强度的动态调整,可加强,可减弱。比如IO设备对order比较敏感,因此执行IO指令的时候就需要加强,其他时候则可以适当地减弱,以获得更好的性能。

Memory Barriers

CPU的reorder优化会导致指令的内存访问顺序与我们程序设定的顺序(program order)不一致,如果我们不希望CPU对一段指令进行reorder,就需要使用memory barrier。
以ARM为例,加上memory barrier的效果是这样的:
在这里插入图片描述
正如它的名字所言,“barrier"就像一个栅栏一样,隔开了在它前面和后面的指令。对于在它前面的指令,只要没有违反当前CPU的memory order的规则,该可以调换还是可以调换,对于在它后面的指令,同样如此。但是,“barrier"后面的指令无论如何也别想跑到"barrier"前面的指令那里去“掺和一脚”。
Memory barriers在约束CPU行为的同时,也约束了编译器的行为,CPU都不敢reorder,编译器就更别想了,可以理解为memory barrier里隐含了compiler barrier的语义。
看起来ARM这种“天马行空”的风格是很需要memory barrier来约束一下的,ARM中设置memory barrier的指令是DMB/DSB/ISB。
以Inner Shareable(IS)为例,使用"DSB"指令加上"ISH”,可防止所有load指令和store指令的reorder(full memory reorder)。如果只想防止"L-L"和"L-S"之间的reorder(write memory barrier),应该使用"ISHLD”。如果只想防止"S-S"之间的乱序(read memory barrier),则应该使用"ISHST"。ARM用上"ISHLD+ISHST"的效果,就等同于x86默认的order强度。

x86中用于设置"full memory barrier"的指令是fence,根据x86默认的order强度,按理只需要防止"S-L"这种情况的reorder才需要。但前面讲过,x86的order强度是可以调整的,所以它还是提供了设置"write memory barrier"的sfence指令和设置"read memory barrier"的lfence指令。如果我们不确定现在的order强度,为了保险起见,应该都假定是weaker一些的。
看起来ARM用于设置memory barrier的指令体系比x86还复杂一些,要知道,x86可是CISC架构的,而ARM是RISC架构(虽然不像MIPS那么纯粹),但这里ARM比x86还"complex"。因为ARM是weak order嘛,它需要加的memory barrier自然比strong order的x86要多。order强度不够,只能靠barrier来补偿。
Linux中提供的设置full/write/read memory barrier的API分别是mb(), wmb()和rwb(),其基于ARM64的实现位于/arch/arm64/include/asm/barrier.h:

#define mb()		dsb(sy)
#define rmb()		dsb(ld)
#define wmb()	        dsb(st)
#define dsb(opt)	asm volatile("dsb " #opt : : : "memory")

Memory Barrier只能提供对一段代码执行顺序的保证,但它不能提供SMP系统中,不同CPU对同一内存变量访问顺序的保证。
假设3个CPU分别依次写三个变量A, B, C,那么使用memory barrier后,每个CPU写这三个变量的顺序都是A->B->C,但是最终A, B, C被这3个CPU写入的顺序是不确定的。
在这里插入图片描述
Linux还提供了一个函数叫smp_mb(),看起来好像是专门用于SMP系统的memory barrier,那它能提供SMP系统中,不同CPU对内存访问顺序的保证吗?不能,SMP系统中,它就等同于mb(),在UP系统中,它会退化为compiler barrier。

#ifdef CONFIG_SMP
#define smp_mb()	mb()
#else	
#define smp_mb()	barrier()
#endif

smp_mb()并不像很多人理解的那样,是mb()的超集(superset),相反,它只能算mb()的子集(subset)。能用smp_mb()的地方可以用mb()代替,但能用mb()的地方不一定能用smp_mb()代替。

注1:根据AMD的手册AMD64 ArchitectureProgrammer’s Manual Volume 2 第7.1节,在AMD64中,部分out-of-order reads("L-L"形式的reorder)是被允许的,这和memory type有关,详情参看该手册第7.4.2节Memory Barrier Interaction with Memory Types。

参考:

https://github.com/google/ktsan/wiki/READ_ONCE-and-WRITE_ONCE
Memory access ordering part 2: Barriers and the Linux kernel
Intel SDM Volume 3 - 8.2 MEMORY ORDERING

  • 1
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值