浅析c程序中的屏障

1 编译屏障

1.1 为什么需要编译屏障

众所周知,从c源文件到可执行程序要经过预处理、编译、汇编、链接这4个步骤,其中编译这一步骤至关重要,负责将c语句翻译成相应的汇编语句,而在做这一步翻译时,编译器往往出于优化提升效率的考虑,对翻译得到的汇编语句的顺序进行调整,也就是说翻译得到的汇编语句可能与c源文件中的语句执行顺序不同。举例来说:
例1

原顺序经编译器进行指令重排后的顺序
x += 1;
y = 2;
x <<= 1;
x += 1;
x <<= 1;
y = 2;

对于这个例子来说,编译器可能出于这样的考虑进而做出上述重排的结果:x += 1x <<= 1都要访问x所在的内存,先读x的值到寄存器,然后修改,再回写到x所在的内存,这样会发生两次读、和两次写。显然,访存出现了重复,效率不高。编译器灵机一动,假如重新排序指令,把上述两条语句排到一起,那么对x的访问可以这么做,先读x的值到寄存器,然后执行加1操作,再执行移位操作,最后回写到x所在的内存,如此只发生了1次读和1次写。

当然,不仅仅是单纯的重排指令,编译器有时会做的更大胆,再看一例:
例2

原来的程序编译器优化后的等价程序
i++;
if (j == 1) x += 3;
i++;
if (j == 1) x += 3;
i+=2;

很明显,上述优化可以节省汇编指令。

编译器通过优化,调整了指令的顺序,甚至减少了指令的数目,这当然能够带来性能的提升,但心细的同学可能发现了问题:优化会不会破坏程序的正确性?比如下面的例子:
例3

优化前优化后
a = 1;
b = a;
b = a;
a = 1;

很显然,如果编译器实施了上述优化,则执行顺序的改变导致前后两者行为改变,程序的正确性就会受到影响。

那么,编译器到底会不会犯这样的错误呢?答案是大多数情况下不会。编译器的优化逻辑不会胡乱对指令进行重排,它既要考虑提升效率,也要确保程序的正确性,当然是尽它所能去确保,而不是绝对保证。例3中,变量a、b之间存在依赖关系——b的值的确定有赖于a,因此编译器不会把对a的赋值调整到对b的赋值之后。对此,再举一例:

例4

有依赖关系的程序编译器不会进行如下的优化
x += 1;
y = x;
x <<= 1;
x += 1;
x <<= 1;
y = x;

然而编译器能力终究是有限的,存在一些编译器认为程序正确,但实际程序并不正确的情况。通常这些特殊的情况出现在多个线程对共享资源的并发访问时,仍旧举例说明:
例5
在这里插入图片描述
倘若没有优化,本例中的两个线程会配合的很好,但如果发生了如下的优化,程序就会出问题:
在这里插入图片描述
为什么编译器不能发现这种问题呢?我的理解是:编译器的视角是局部的。编译器在编译Thread1中的程序时,它发现,仅在Thread1中,x、y之间是没有依赖关系的,进而认为x、y的赋值顺序可以根据优化的需要进行调整。编译器无法全局的分析程序的完整逻辑,因此也就无法发现Thread1和Thread2之间的微妙联系

既然发现了问题,我们就要想办法解决这个问题。如何防止编译器优化带来的问题呢?有人会说,干脆禁止编译器优化好了。这确实是个解决办法(尽管很拙)。但这种解决办法不能被采纳,因为这是因噎废食的做法,不能因为编译器的优化逻辑在某些特殊情况下才会出现的问题就否定它,毕竟优化带来的性能提升是非常可观的,我们确实需要优化的存在。既然不能否定它,那么我们就需要帮它改正。话句话说,我们将所有可能出问题的情形都考虑到位,然后在出现这些特殊情况时,通过某种机制告知编译器在某些位置不能进行指令顺序的调整。所谓的某种机制,其实就是接下来要介绍的编译屏障

1.2 编译屏障会造成哪些影响

在具体介绍编译屏障会在编译过程中带来哪些影响之前,我们先接着上文的例子,介绍如何使用编译屏障来解决例5中出现的问题:
在这里插入图片描述
按上图所示的内容进行修改后,如果不考虑访存优化以及CPU的乱序执行,那么可以说,Thread1和Thread2能够正确的配合工作了。至此,我们对编译屏障就有了一个初步的认识,包括其存在的原因以及大体的作用。下面更加具体的介绍它。

在GCC编译环境下,编译屏障实际上是一条内联汇编——asm volatile ("":::"memory")。可见,这条内联汇编其实不含有任何实际的汇编指令,其含有的元素也就是3个:asmvolatilememory。我们一一分析这三个元素的含义就可以搞清楚编译屏障到底会给编译过程带来哪些影响。(更多关于内联汇编的知识可参考GCC用户手册,如果需要了解ARM GCC 内联汇编则可以参考文献[1])

  • asm
    这是内联汇编使用的关键字,编译器一看到它,就知道后面是一条内联汇编,没什么好说的。

  • volatile
    注意volatile关键字用于内联汇编时,与其用于修饰变量的语义是有区别的,在内联汇编中,volatile关键字用于内联汇编时,表示禁止对这段内联汇编做优化。举例来说,假如有内联汇编asm (“mov r0, r0”),这条语句是没有实际意义的空操作,可能编码者的本意是使用该空操作来实现延时,而编译器会认为这条语句没有作用,出于优化的目的,就把它直接删除。当然,优化内联汇编不一定会出问题,但如果我们不想让编译器对内联汇编执行任何优化,就可以使用volatile关键字,让相应的内联汇编写成什么样就是什么样。

    至于修饰变量的volatile关键字有什么作用,有兴趣的可以看我的另一篇博客:浅析c语言的volatile关键字及数据一致性,其中有所介绍。

  • memory
    memory是实现编译屏障功能的关键,为了保证对其介绍的严谨,我先列出GCC手册中对memory进行介绍的原文:

The “memory” clobber tells the compiler that the assembly code performs memory reads or writes to items other than those listed in the input and output operands (for example, accessing the memory pointed to by one of the input parameters). To ensure memory contains correct values, GCC may need to flush specific register values to memory before executing the asm. Further, the compiler does not assume that any values read from memory before an asm remain unchanged after that asm; it reloads them as needed. Using the “memory” clobber effectively forms a read/write memory barrier for the compiler.

对上文的翻译如下:

(译者补:内联汇编的)clobber列表项的memory告诉编译器,汇编代码(译者补:指内联汇编)对一些内存项进行了读或写操作,而这些内存项没有在输出操作数列表或输入操作数列表中列出(比如,访问了被某个输入参数指向的内存)。为了保证内存中包含是正确的值,GCC需要在执行这段内联汇编之前,将某些寄存器中的值冲刷回内存。进一步的,编译器不能假设在这段内联汇编执行之前从内存中读到的任何值在这段内联汇编执行之后仍然保持不变,因此需要重新加载它们(译者补:指从内存加载到寄存器)。在clobber列表项中使用memory可以有效的为编译器构建一个读/写内存屏障(译者补:个人认为手册上用memory barrier不是太妥,因为memory只能影响编译器的行为,不会生成屏障指令,因此不能影响CPU执行时的指令乱序行为,所以按照习惯,本文将asm volatile ("":::“memory”)称为编译屏障,也有人称为优化屏障。当然,凡事不能过于着相,叫什么不重要,搞清楚是什么更为要紧。)。

上述这段话介绍了memory对编译器造成的影响,概括的说,memory非常含糊的向编译器透露了一个消息:相应内联汇编可能修改了一些内存。这些内存具体是哪些呢?不知道!其实asm volatile ("":::"memory")并没有修改任何内存,只是通过memory虚张声势,吓唬编译器说自己可能修改任何的内存,为了保证程序的正确性,编译器只能小心翼翼的在内联汇编之前和之后做一些同步寄存器和缓存中数据的工作。更具体的:


考虑到内联汇编中会某个内存,而该内存可能就是屏障之前出现的变量,但此时变量由于访存优化暂存在寄存器中,因此需要将其同步到内存(实际先写回缓存,缓存会在合适的时机回写内存),如果不这样做的话,内联汇编中读到的是旧值,进而影响程序的正确性;


考虑到内联汇编中会某个内存,而该内存可能就是屏障之前出现的变量,这些变量由于访存优化暂存在寄存器中,根据①,这些寄存器中的值会在执行屏障之前被同步到内存。也就是说执行屏障之前,寄存器和内存中存有的这些变量的值是一致的,但在执行屏障之后,寄存器中保有的值就是旧值了。因此,如果屏障执行之后需要读这些变量的值,就不能从寄存器中直接读了,而是要从内存重新加载变量的值到寄存器;


考虑到内联汇编中对内存访问(读/写)的无限可能,因此编译器不能把屏障之前的语句优化到屏障之后,因为语句中的变量可能在内联汇编中被访问;同样的,编译器也不能把屏障之后的语句优化到屏障之前。(这一点手册中没有直接的描述,但可以推理出来)


到了这里,memory带来的影响以及asm volatile ("":::"memory")为什么能作为编译屏障就已经解释清楚了。为了证明上述结论所说无误,我们不妨做一个实验。编写源文件barrier.c

/* 请不要在意程序的逻辑,谢谢 */
int main()
{
	int a = 0, b = 0, c = 0;
	scanf("%d %d", &a, &b);
	a++;
	b++;
	asm volatile ("":::"memory");
	c = a + b;
	if (c & 0x8)
		printf("c = %d\n", c);
	
	return 0;
}

执行arm-linux-gnueabi-gcc -O3 -g -o barrier barrier.c以及arm-linux-gnueabi-objdump -d barrier > barrier.dis对该源文件进行优化编译以及反汇编。再编写源文件no_barrier.c,其内容是barrier.c中的程序并且注释掉编译屏障,同样对其进行优化编译和反汇编。

下面比较两者的反汇编程序(只列出能说明问题的关键部分):
在这里插入图片描述
asm volatile ("":::"memory")带来的影响在反汇编程序中显示的非常清楚,不再赘述。

上文中提到了访存优化,之前没有解释过,现在解释一下。CPU无法直接访问内存中的数据,需要先将内存中的数据读到寄存器,然后再访问之,如有修改则写回内存(常说的读、改、写)。也就是说,再加载变量到寄存器之后,写回内存之前,变量暂存在寄存器中。编译器出于优化的考虑,不会每次访问变量都从读/写内存,而是直接访问变量所暂存的寄存器。如果变量是线程本地的变量,则这种优化通常不会有问题,但如果变量是全局的,且被其它线程访问,那么就有可能出现数据不一致的问题。仍旧以例5来说明:
在这里插入图片描述
按上图所示的内容进行修改后,如果不考虑CPU的乱序执行,那么可以说,Thread1和Thread2能够正确的配合工作了。上图中,解决方案1利用编译屏障解决Thread1中赋值顺序的问题,以及Thread2中变量x在访存优化的情况下寄存器与内存数据不一致的问题(x在屏障后,会重新从内存中加载数据,从而完成数据的同步),对于变量y的同步,使用了volatile关键字。解决方案2则仅使用volatile关键字来实现对变量x、y的同步。

然而,不管怎么看,上述用全局变量做同步的做法都十分蹩脚,需要考虑很多编译器、处理器的细节才能实现正确的同步,而这些细节如有可能,是不应该暴露给应用层的。上例中既然有多线程,说明是有操作系统的,此时应该使用操作系统提供的同步原语完成线程间的同步,这样的话,程序的正确性能够得到保证,可读性和可移植性都会更好。

最后,在结束本节的讨论之前,我们来看一种通过手动创建依赖关系来保证指令顺序的做法:

x = 1;
asm volatile("" :: "r"(x), "r"(y));
y = 1;

通过内联汇编的输入操作数列表,我们向编译器传达了内联汇编中可能会读变量x、y,因此x = 1不能优化到asm volatile("" :: "r"(x), "r"(y))的后面,同样的,y = 1不能优化到asm volatile("" :: "r"(x), "r"(y))的前面。实际上,没有任何汇编指令出现在这条内联汇编中,也就是说我们并没有真的读变量x、y,这种依赖是虚拟的,但这并不重要,重要的是这条内联汇编让编译器相信我们会读x、y。很明显,这种方式相较于编译屏障不够通用,变量名一换,该内联汇编就要修改。同时,值得注意的是,编译屏障除了有确定指令相对位置的影响,还会抑制访存优化,但asm volatile("" :: "r"(x), "r"(y))不会对访存优化造成影响(仅有限的影响x、y)。

2 内存屏障

2.1 为什么需要内存屏障

编译器出于效率的考虑,在编译时会对指令进行重新排序,因此我们需要编译屏障。同时,现代处理器出于效率的考虑,也可能出现排在后面的指令先执行的情况,通常这种行为被称为乱序执行(Out-of-Order)。同编译器一样,CPU也不会胡乱的乱序,也会考虑数据依赖关系,但由于并发访问的存在,在CPU看来没有依赖关系的两条指令可能需要严格按照先后顺序执行,然而对此并不知情的CPU会在需要时进行乱序。好在CPU的设计者们意识到了这个问题,并提供了一些指令,来限制CPU的乱序行为。这些指令通常被称为屏障指令,由其构建的屏障通常被称为内存屏障

2.2 内存屏障会造成哪些影响

首先需要交代的是,不同体系结构的CPU提供的屏障指令是不同的,它们的具体影响也不尽相同。而操作系统为了屏蔽这些差异,通常会对这些屏障指令进行一些封装,即对上提供统一的接口,而不同的平台有不同的具体实现。比如linux kernel就做了如下一些封装(摘自参考文献[2]):

  • smp_rmb()
    保证smp_rmb之前的load操作将先于该指令之后的load操作被提交到存储系统。
  • smp_wmb()
    保证smp_wmb之前的store操作将先于该指令之后的store操作被提交到存储系统。
  • smp_mb()
    对load和store都有效的全功能memory barrier,保证smp_mb之前的load和store操作将先于该指令之后的load或者store操作被提交到存储系统。
  • smp_read_barrier_depends()
    如果前后两个memory access有依赖关系(例如前一个的操作是取后一个要操作memory的地址),那么这个memory barrier原语可以用来规定这两个有依赖关系的内存操作顺序(除了Alpha,在其他的CPU上,该原语都是空)。
  • mmiowb()
    主要用于约束被spinlock保护的MMIO write操作顺序。当然,有些平台中,spinlock中的memory barrier操作已经保证了MMIO的写入顺序,那么这个宏是空的。mmiowb是空的CPU architecture包括(但不限于)IA64, FRV, MIPS, and SH。这个原语比较新,因此在比较新的driver会使用它。
    :MMIO指的是内存映射I/O,通俗点说就是内存与I/O统一编址。

值得一提的是,上述封装种也会使用到编译屏障,因为单纯的屏障指令虽然可以限制CPU的乱序行为,但是影响不到编译器的指令重排,因此为了确保上述内存屏障原语的正确性,编译屏障也会使用到,比如这样:asm volatile ("DMB":::"memory"),其中DMB是arm平台提供的屏障指令。

PS
我也看到一些资料说屏障指令也可以限制编译器的指令重排,那么同时使用屏障指令和编译屏障的意义何在呢?这里说一下我个人的理解,根据上文可知,编译屏障有着一些额外的影响,类似于volatile的作用,这有助于操作系统的同步原语的实现,而仅使用屏障指令尽管能约束编译器的指令重排,但没有这些额外的影响。(以上只是我个人查到的资料和个人的理解,如果有误,十分欢迎指正

3 总结

c语言编写的程序无法做到所见即所得,我们看到的c程序的逻辑和CPU实际执行的结果并不一定吻合,这是因为出于效率的考虑,编译器会对指令顺序进行重排,而处理器存在乱序执行的行为。尽管编译器和CPU都尽其所能的考虑了数据的依赖关系,通常不会影响程序的正确性,但智者千虑尚且有一失,在某些特殊情况下,程序的正确性会受到影响。当我们必须要保证指令最终执行的顺序时,可以使用编译屏障+内存屏障来约数编译器和处理器。当然,在应用程序编程中,很少需要我们自己去使用这些屏障,彼时,我们应该尽可能使用操作系统提供的同步原语,至于对屏障的需求,操作系统已经帮我们考虑好了。然而,如果是为linux kernel等底层软件编写程序时,这些问题就要仔细考虑了。

参考文献

[1] Zephyr OS 番外篇: ARM GCC 内联汇编参考手册
[2] Why Memory Barriers中文翻译(下)
[3] GCC用户手册

  • 2
    点赞
  • 10
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值