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

简介: 从传统意义上讲,对齐是指将变量的存储按照计算机的字长进行边界对章,这里字长一般是指一个WORD的位数,也就是现代计算机中一次IO的数据处理长度,通过计算机的字长与CPU的寄存器长度相等。现代的CPU一般都不是按位进行内存访问,而是按照字长来访问内存,当CPU从内存或者磁盘中将读变量载入到寄存器时,每次操作的最小单位一般是取决于CPU的字长。比如8位字是1字节,那么至少由内存载入1字节也就是8位长的数据,再比如32位CPU每次就至少载入4字节数据, 64位系统8字节以此类推。


从传统意义上讲,对齐是指将变量的存储按照计算机的字长进行边界对章,这里字长一般是指一个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读入,详见下图:

image.png



也就是说变量的存储应该按照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共享态到无效态迁移的频繁出现,从而影响效率。


相关文章
|
1月前
|
并行计算 安全 Go
Go语言中的并发编程:掌握goroutines和channels####
本文深入探讨了Go语言中并发编程的核心概念——goroutine和channel。不同于传统的线程模型,Go通过轻量级的goroutine和通信机制channel,实现了高效的并发处理。我们将从基础概念开始,逐步深入到实际应用案例,揭示如何在Go语言中优雅地实现并发控制和数据同步。 ####
|
2月前
|
存储 Go 开发者
Go语言中的并发编程与通道(Channel)的深度探索
本文旨在深入探讨Go语言中并发编程的核心概念和实践,特别是通道(Channel)的使用。通过分析Goroutines和Channels的基本工作原理,我们将了解如何在Go语言中高效地实现并行任务处理。本文不仅介绍了基础语法和用法,还深入讨论了高级特性如缓冲通道、选择性接收以及超时控制等,旨在为读者提供一个全面的并发编程视角。
|
1月前
|
消息中间件 缓存 监控
go高并发之路——消息中间件kafka
本文介绍了高并发业务中的流量高峰应对措施,重点讲解了Kafka消息中间件的使用,包括常用的Go语言库sarama及其版本问题,以及Kafka的版本选择建议。文中还详细解释了Kafka生产者的四种分区策略:轮询、随机、按Key和指定分区,并提供了相应的代码示例。
go高并发之路——消息中间件kafka
|
2月前
|
安全 Serverless Go
Go语言中的并发编程:深入理解与实践####
本文旨在为读者提供一个关于Go语言并发编程的全面指南。我们将从并发的基本概念讲起,逐步深入到Go语言特有的goroutine和channel机制,探讨它们如何简化多线程编程的复杂性。通过实例演示和代码分析,本文将揭示Go语言在处理并发任务时的优势,以及如何在实际项目中高效利用这些特性来提升性能和响应速度。无论你是Go语言的初学者还是有一定经验的开发者,本文都将为你提供有价值的见解和实用的技巧。 ####
|
2月前
|
Go 调度 开发者
Go语言中的并发编程:深入理解goroutines和channels####
本文旨在探讨Go语言中并发编程的核心概念——goroutines和channels。通过分析它们的工作原理、使用场景以及最佳实践,帮助开发者更好地理解和运用这两种强大的工具来构建高效、可扩展的应用程序。文章还将涵盖一些常见的陷阱和解决方案,以确保在实际应用中能够避免潜在的问题。 ####
|
2月前
|
安全 Go 数据处理
Go语言中的并发编程:掌握goroutine和channel的艺术####
本文深入探讨了Go语言在并发编程领域的核心概念——goroutine与channel。不同于传统的单线程执行模式,Go通过轻量级的goroutine实现了高效的并发处理,而channel作为goroutines之间通信的桥梁,确保了数据传递的安全性与高效性。文章首先简述了goroutine的基本特性及其创建方法,随后详细解析了channel的类型、操作以及它们如何协同工作以构建健壮的并发应用。此外,还介绍了select语句在多路复用中的应用,以及如何利用WaitGroup等待一组goroutine完成。最后,通过一个实际案例展示了如何在Go中设计并实现一个简单的并发程序,旨在帮助读者理解并掌
|
2月前
|
安全 Java Go
Go语言中的并发编程:掌握goroutine与通道的艺术####
本文深入探讨了Go语言中的核心特性——并发编程,通过实例解析goroutine和通道的高效使用技巧,旨在帮助开发者提升多线程程序的性能与可靠性。 ####
|
2月前
|
Go 开发者
Go语言中的并发编程:掌握goroutines和channels####
本文深入探讨了Go语言中并发编程的核心概念,重点介绍了goroutines和channels的工作原理及其在实际开发中的应用。文章通过实例演示如何有效地利用这些工具来编写高效、可维护的并发程序,旨在帮助读者理解并掌握Go语言在处理并发任务时的强大能力。 ####
|
2月前
|
算法 安全 程序员
Go语言的并发编程:深入理解与实践####
本文旨在探讨Go语言在并发编程方面的独特优势及其实现机制,通过实例解析关键概念如goroutine和channel,帮助开发者更高效地利用Go进行高性能软件开发。不同于传统的摘要概述,本文将以一个简短的故事开头,引出并发编程的重要性,随后详细阐述Go语言如何简化复杂并发任务的处理,最后通过实际案例展示其强大功能。 --- ###
|
2月前
|
存储 安全 Go
Go 语言以其高效的并发编程能力著称,主要依赖于 goroutines 和 channels 两大核心机制
Go 语言以其高效的并发编程能力著称,主要依赖于 goroutines 和 channels 两大核心机制。本文介绍了这两者的概念、用法及如何结合使用,实现任务的高效并发执行与数据的安全传递,强调了并发编程中的注意事项,旨在帮助开发者更好地掌握 Go 语言的并发编程技巧。
36 2