为什么并发编程中,不小心翼翼的对齐会使程序慢一倍?
Posted beyondma
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了为什么并发编程中,不小心翼翼的对齐会使程序慢一倍?相关的知识,希望对你有一定的参考价值。
从传统意义上讲,对齐是指将变量的存储按照计算机的字长进行边界对齐,在没有并发竞争的情况下,按照CPU字长进行对齐就完全可以了,但是如果在并发竞争的情况下,不考虑缓存行的对齐往往会带来很大的伪共享问题,而伪共享则会引发程序的性能呈倍数级别的降低,为了简要说明这个问题我们来看下面的代码,
代码示例一中四个goroutine分别操作slicea中的前四个元素,
package main
import (
"fmt"
"time"
)
func main()
s1icea := []int640, 1, 2, 3, 4, 5, 6, 7
//s1iceb := []int640, 1, 2, 3, 4, 5, 6, 7
//s1icec := []int640, 1, 2, 3, 4, 5, 6, 7
//s1iced := []int640, 1, 2, 3, 4, 5, 6, 7
go func()
for
s1icea[0]++
()
go func()
for
s1icea[1]++
()
go func()
for
s1icea[2]++
()
go func()
for
s1icea[3]++
()
time.Sleep(time.Second)
fmt.Println(s1icea)
运行结果如下:
[269164771 265021684 258089104 267919418 4 5 6 7]
而代码示例二中两个goroutine分别操作slicea和sliceb,
package main
import (
"fmt"
"time"
)
func main()
s1icea := []int640, 1, 2, 3, 4, 5, 6, 7
s1iceb := []int640, 1, 2, 3, 4, 5, 6, 7
s1icec := []int640, 1, 2, 3, 4, 5, 6, 7
s1iced := []int640, 1, 2, 3, 4, 5, 6, 7
go func()
for
s1icea[0]++
()
go func()
for
s1iceb[1]++
()
go func()
for
s1icec[2]++
()
go func()
for
s1iced[3]++
()
time.Sleep(time.Second)
fmt.Println(s1icea, s1iceb, s1icec, s1iced)
运行结果如下:
[399287607 1 2 3 4 5 6 7] [0 406576583 2 3 4 5 6 7] [0 1 403888391 3 4 5 6 7] [0 1 2 396400686 4 5 6 7]
通过比较也可以看出来,程序一的执行效率比程序二高40%,如果并发数再增加,这个效率差距还会更加明显。那么我们也不禁要问,编译器不是已经帮助我们对齐了吗,为什么并发场景下还会有对齐的问题?
字长对齐详解
传统意义上对齐一般是将变量按照CPU的字长进行存住进边界的对齐。这里字长一般是指一个WORD的位数,也就是现代计算机中一次IO的数据处理长度,通过计算机的字长与CPU的寄存器长度相等。现代的CPU一般都不是按位进行内存访问,而是按照字长来访问内存,当CPU从内存或者磁盘中将读变量载入到寄存器时,每次操作的最小单位一般是取决于CPU的字长。比如8位字是1字节,那么至少由内存载入1字节也就是8位长的数据,再比如32位CPU每次就至少载入4字节数据, 64位系统8字节以此类推。
那么以8位机为例咱们来看一下这个问题。假如变量1是个bool类型的变量,它占用1位空间,而变量2为byte类型占用8位空间,假如程序目前要访问变量2那么,第一次读取CPU会从开始的0x00位置读取8位,也就是将bool型的变量1与byte型变量2的高7位全部读入内存,但是byte变量的最低位却没有被读进来,还需要第二次的读取才能把完整的变量2读入,详见下图:
也就是说变量的存储应该按照CPU的字长进行对齐,当访问的变量长度不足CPU字长的整数倍时,需要对变量的长度进行补齐。这样才能提升CPU与内存间的访问效率,避免额外的内存读取操作。
一般来说只要保证变量存储的首地址恰好是CPU字长的整数倍就能做到按照字长对齐了。这方面绝大多数编译器都做得很好,在缺省情况下,C编译器为每一个变量或是数据单元按其自然对界条件分配空间边界。也可以通过pragma pack(n)调用来改变缺省的对界条件指令,调用后C编译器将按照pack(n)中指定的n来进行n个字节的对齐,这其实也对应着汇编语言中的.align。以上这些工作现代的编译器都做得很好了。
我们可以来比较下面两段代码,由于我测试的平台是64位的机器,因此我选择的占位变量1是bool类型,变量2为int64类型,如果没有做对齐的话那么变量2在实际中需要读取两次,不过这些优化编译器和CPU都会帮你做好,以下两段代码的执行效率并没有明显不同。
代码段一有占位变量,
fn main()
let j=true;
let mut i:u64=0;
while i < 100000000
i += 1
println!("", j);
println!("", i);
代码段二,无占位变量
fn main()
//let j=true;
let mut i:u64=0;
while i < 100000000
i += 1
//println!("", j);
println!("", i);
并发环境要按高速缓存行大小对齐
本文开始的两段代码在我四核的机器上测试,性能差距至少相差近一倍。这个问题本质是由于多核竞争造成的,虽然每个虽然在例程一中每个goroutine都在操作不同的对象,但是这些对象处于同一个高速缓存行上,这就会造成本来没有并发竞争的程序,也产生了并发竞争问题,这里值得关注的是按照高速缓存行去对齐不是编译器会帮助程序员去考虑的,无论是Rust还是Go都无法在编译时就知道你的变量会不会引发并争竞争问题,用Rust实现文初代码的效果也完全一样。
use std::thread;
fn main()
let mut i=0;
let mut j=0;
let handle = thread::spawn(move ||
let mut k = 0;
while k < 100000000
i += 1;
k += 1;
println!("", i)
);
let handle1 = thread::spawn(move ||
let mut k = 0;
while k < 100000000
j += 1;
k += 1;
println!("",j)
);
handle.join().unwrap();
handle1.join().unwrap();
要解释这个现代还要从现代CPU的高速缓存同步协议入手,自从CPU进入多核时代以后,处理器为了解决主内存速度与CPU不匹配的问题,不但有统一的高速缓存,还给每个内核都配备了独享的高速缓存,MESI协议就是各个独享的高速缓存之间同步数据的协议,按照MESI协议约定,每个缓存行都有四个状态,分别是E(exclusive)、M(modified)、S(shared)、I(invalid),其中:
M:代表该缓存行中的内容被修改,并且该缓存行只被缓存在该CPU中。这个状态代表缓存行的数据和内存中的数据不同。
E:代表该缓存行对应内存中的内容只被该CPU缓存,其他CPU没有缓存该缓存对应内存行中的内容。这个状态的缓存行中的数据与内存的数据一致。
I:代表该缓存行中的内容无效。
S:该状态意味着数据不止存在本地CPU缓存中,还存在其它CPU的缓存中。这个状态的数据和内存中的数据也是一致的。不过只要有CPU修改该缓存行都会使该行状态变成 I 。
但是在本节的例程一当中,变量i和j处于同一个高速缓存行上,根据刚刚的解释,这个缓存行会在多个核心上共享也就是理想情况下会处于S状态,但是只要有一个核心发起了修改的请求,这个缓存行的状态就会变为I,这也会造成S共享态到无效态迁移的频繁出现,从而影响了高速缓存的效率,即便i和是完全独立的两个变量,也不会同时被多个核心操作,但只要他们处在同一缓存行上,就会产生并发竞争。
所以总结一下,在单线程的环境下,编译器就能解决绝大多数对齐问题,几乎不需要程序员人工干预,但是在并发环境下,程序员必须考虑将变量针对高速缓存行的大小进行对齐,以避免并发竞争矛盾。
以上是关于为什么并发编程中,不小心翼翼的对齐会使程序慢一倍?的主要内容,如果未能解决你的问题,请参考以下文章