前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >Go和Rust的高并发编程中,为什么要特别注意对齐?

Go和Rust的高并发编程中,为什么要特别注意对齐?

原创
作者头像
beyondma
修改2021-08-21 21:50:37
4520
修改2021-08-21 21:50:37
举报
文章被收录于专栏:Rust学习专栏Rust学习专栏

从传统意义上讲,对齐是指将变量的存储按照计算机的字长进行边界对章,这里字长一般是指一个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共享态到无效态迁移的频繁出现,从而影响效率。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 对齐详解
  • 按照缓存行对齐
  • MESI协议简介
相关产品与服务
对象存储
对象存储(Cloud Object Storage,COS)是由腾讯云推出的无目录层次结构、无数据格式限制,可容纳海量数据且支持 HTTP/HTTPS 协议访问的分布式存储服务。腾讯云 COS 的存储桶空间无容量上限,无需分区管理,适用于 CDN 数据分发、数据万象处理或大数据计算与分析的数据湖等多种场景。
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档