从传统意义上讲,对齐是指将变量的存储按照计算机的字长进行边界对章,这里字长一般是指一个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);
}
并发环境要按照缓存行对齐
在没有并发竞争的情况下,按照CPU字长进行对齐就完全可以了,但是如果在并发的情况下,即使没有共享变量,也可能会造成伪共享的问题,我们来看下面的代码,代码示例一中四个个goroutine分别操作slicea中的前四个元素,
package main
import (
"fmt"
"time"
)
func main() {
s1icea := []int64{0, 1, 2, 3, 4, 5, 6, 7}
//s1iceb := []int64{0, 1, 2, 3, 4, 5, 6, 7}
//s1icec := []int64{0, 1, 2, 3, 4, 5, 6, 7}
//s1iced := []int64{0, 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 := []int64{0, 1, 2, 3, 4, 5, 6, 7}
s1iceb := []int64{0, 1, 2, 3, 4, 5, 6, 7}
s1icec := []int64{0, 1, 2, 3, 4, 5, 6, 7}
s1iced := []int64{0, 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]
这两段代码在我四核的机器上测试,性能差距至少相差近一倍。这个问题本质是由于多核竞争造成的,虽然每个虽然在例程一中每个goroutine都在操作不同的对象,但是这些对象处于同一个内存缓存行上,这就会造成本来没有并发竞争的程序,也产生了并发竞争问题。
MESI协议简介
现代的CPU除了多内核之外,还给每个内核都配备了独享的高速缓存,按照多核高速缓存同步的MESI协议约定,每个缓存行都有四个状态,分别是E(exclusive)、M(modified)、S(shared)、I(invalid),其中:
M:代表该缓存行中的内容被修改,并且该缓存行只被缓存在该CPU中。这个状态代表缓存行的数据和内存中的数据不同。
E:代表该缓存行对应内存中的内容只被该CPU缓存,其他CPU没有缓存该缓存对应内存行中的内容。这个状态的缓存行中的数据与内存的数据一致。
I:代表该缓存行中的内容无效。
S:该状态意味着数据不止存在本地CPU缓存中,还存在其它CPU的缓存中。这个状态的数据和内存中的数据也是一致的。不过只要有CPU修改该缓存行都会使该行状态变成 I 。
但是在上面的例程一当中,四个goroutine操作的对象本质上处于同一个内存缓存行上,这也会造成S共享态到无效态迁移的频繁出现,从而影响效率。