内存屏障和锁 从X86往ARM平台移植
1.内存屏障
ARM架构下,不需要使用内存屏障的几个典型场景:
1)存在地址依赖时,不需要显示使用内存屏障,也可以保证内存一致性
LDR X1,[X2]
AND X1, X1, XZR
LDR X4, [X3, X1]
第三行的汇编语句从内存地址 [X3, X1]读数据到寄存器X4时,需要依赖X1从地址[X2]中读到的值,所以这种情况下LDR
X1,[X2]先于LDR X4, [X3, X1]执行。
2)存在控制依赖时,不需要显示使用内存屏障,也可以保证内存一致性
r1 = x;
if (r1 == 0) nop();
y = 1;
如果一个条件分支依赖于一个加载操作,那么在条件分支后面的存储操作都在加载操作后执行,所以“r1
= x;”先于“y = 1;”执行
3)存在寄存器数据依赖时,不需要显示使用内存屏障,也可以保证内存一致性
LDR X1,[X2]
AND X3, X3, X1
SUB X3, X3, X1
STR X4, [X3]
执行最后的STR指令时依赖于X3寄存器中存放的内存地址,而X3寄存器的值又依赖于从X2内存地址中获取到数据X1寄存器的值;这些寄存器之间存在数据依赖,所以“LDR
X1,[X2]”先于“STR X4, [X3]”执行。
x86和arm架构之间的内存序差异
内存乱序行为 x86 arm
读-读乱序 不允许 允许
读-写乱序 不允许 允许
写-读乱序 允许 允许
写-写乱序 不允许 允许
原子操作-读写乱序 不允许 允许
说明:该表内容中主要指大部分情况下的单词内存访问行为,如x86中的字符串处理指令、非易失内存编程指令等批量内存访问行为不在讨论范围内。
以写-读乱序为例,假设在程序中连续访问2个全局变量a和b
初始的内存操作为:
cpu0:
x = 1;
r1 = y;
cpu1:
y = 1;
r2 = x;
初始值:x = y = 0;
由于x86和arm架构都存在写-读乱序行为。故其实际执行序列可能如下所示:
cpu0:
r1 = y;
x = 1;
cpu1:
r2 = x;
y = 1;
所以最终的执行结果可能是r1 = r2 = 0;
总的来说,x86架构下的内存序要比arm下严格,大部分情况下的内存访问都是定序的,如果不加修改的将多线程程序移植到arm架构下,就可能出现功能问题。
1.3 代码移植注意事项:
1.3.1 cpu内存屏障指令移植:
略
ARM架构的几种屏障指令的区别:
isb -- 流水线全部刷掉,让后面的指令,重新去取指令
dsb -- 保证dsb指令之前的指令全部执行完了之后,后面的指令执行;没有刷流水线
dmb -- 为了保序
1.3.2 编译器屏障移植:
程序编译时,特别是加入了优化选项-O2/O3后,编译器可能将代码打乱顺序执行,即编译生成后的汇编代码的执行顺序可能与原始的高级语言代码中的执行顺序不一致。所以编译器提供了编译阶段的内存屏障,用于指导编译器及时刷新寄存器的值到内存中,保证该编译器屏障前后的内存访问指令在编译后是定序排布的。
常见的编译型屏障定义如下:
#define barrier() __asm__ __volatile__("":::"memory")
x86属于强内存序架构,大部分情况使用编译型屏障就可以保证多线程内存访问的一致性。但是在arm下无法做到这一点。
在x86架构下,读屏障和写屏障的宏smp_rmb()和smp_wmb()都设置成了编译器内存屏障,而在arm架构下则都设置成了cpu指令级内存屏障,从这里可以看出2个架构之间的差异。所以我们需要将源代码中的编译型屏障改写成cpu级内存屏障。
1.3.3 尽可能使用acquire和release语义进行同步
armv8增加了load-acquire(LDLARB,LDLARH和LDLAR)和store-release(STLLRB,STLLRH和STLLR)指令,这可以直接支持C++原子库中的相关语义。这些指令可以理解成半屏障。这些半屏障指令的执行效率要比全屏障更高,所以在能够使用这些类型的屏障时,我们尽可能acquire和release语义来做线程间的同步。
read-acquire用于修饰内存读取指令,一条read-acquire读指令会禁止它后面的内存操作指令被提前执行,即后续内存操作执行重排时无法向上越过屏障。
write-release用于修饰内存写指令,一条write-release的写指令会禁止它上面的内存操作指令被滞后到写操作完成后才执行,即写指令之前的内存操作指令重排时不会向下越过屏障。
2. 锁的移植
2.1 架构差异
2.1.1 指令集差异
2.1.2 内存序差异
2.2 注意处理器差异
除了架构差异外,在锁的移植过程中还需要注意处理器的差异。
x86架构下大部分处理器的l3 cacheline大小一般为64byte,而kp920芯片的l3 cache line大小为128byte,所以在设计锁时要格外注意,尽量避免多线程相互独立修改的变量共享同一个cacheline的情况出现,即通常所说的“伪共享”现象--这对性能有较大影响。
什么是伪共享?
缓存系统是以cache line为单位存储的。最长见的缓存大小是64个字节。当多线程修改互相独立的变量时,如果这些变量共享同一个缓存行,就会无意中影响彼此的性能,这就是伪共享。cache line的写竞争是运行在SMP系统中并行线程实现可伸缩性最重要的限制因素。
2个线程写同一个变量可以在代码中发现。为了确定互相独立的变量是否共享了同一个cache line,就需要了解内存布局,可以使用intel VTune分析工具。
分析工具