0c(远看像乱序执行,近看是内存屏障的BUG是如何解决的?)

作者|马超编辑|欧阳姝黎

制作CSDN博客

我前几天发的《几种主流语言的高并发实现的比较,Serverless时代Rust即将迎来春天》后来,为了回应热情读者的反应和他提出的问题,我总结了另一篇文章《一顿操作猛如虎,一看结果却是0》,对于多个并发操作,但结果仍然为0的情况,给出了多核心竞争冲突的解释。结果一石二鸟,千波三折,又收到了许多热心读者的反馈。其中有几份答复特别值得一提。

单核环境y也是0:一位非常细心的读者证实了多核心竞争导致的问题的结论。他亲自在单核环境中对ECs进行了实验,发现结果仍然是y=0。

后进先出:另一位读者给出了一个更奇怪的现象。这两个变量中后来执行的代码似乎是第一个被调用的。

添加一个if问题并解决它:最后一条反馈信息最令人震惊。在代码中添加判断语句不仅解决了y=0的问题,而且具有很好的性能。

这就是传说中的无序处决吗?

让我们看看下面的读者回答的代码:

包干管进口(“fmt”“同步/原子”“时间”)funcmain{varxint32变量yint32Gofunc{为了{x=原子。附加32(&;x,1)y=原子。附加32(y&y,1)}}时间睡眠(时间秒)fmt。Println(“x=,x)fmt。Println(“y=,y)}

在这一部分中,atomAdd对两个变量X和y进行操作,以确保并发安全性,但当结果输出时,我们可以发现y大于X?而且,每一次行动的情况基本上都比以前大,但规模不同。

x=49418397y=49425282成功:进程退出代码0

看到这个输出结果,我的第一反应是,它是无序执行的派生,因为X和y的+1运算彼此独立。虽然编译器不会优化执行顺序,但是中央处理器的执行级别可能会中断前后没有依赖关系的操作的顺序执行。通过这种方式,可以首先执行以下操作。

然而,仔细考虑这一说法是不合理的。如果是无序执行的原因,则上述代码的执行结果不会每次都大于y,每次都大于x,这只意味着代码是按一定的顺序执行的,而且,当前CPU指令管道的预测功能肯定不够好,无法完全知道X和y的值没有按顺序提交。

仔细看看多重并行竞争的问题

让我们看看下面的代码,

包干管进口(“fmt”“同步/原子”“时间”)funcmain{varxint32变量yint32gofunc{为了{x=原子。附加32(&;x,1)y=原子。附加32(y&y,1)}}时间睡眠(时间秒)x1:=xy1:=yfmt。Println(“x=,x1)fmt。Println(“y=,y1)}

只需将FMT放在println之前,将X和y的值复制到X1和Y1,然后打印X1和Y1的值。基本上没有这种错误。

x=51061072y=51061071成功:进程退出代码0

也就是说,在执行println期间,子gorouine在gofunc的FMT再次被安排。因此,Y的值大于X的值,这本质上是一个多并发竞争问题。这并不是执行混乱的原因,但在Go的发展模式下,这个问题也非常隐蔽。

崩溃了,一个核心怎么可能是0

除了第二次崩溃的读者反馈,他还尝试在单核云ECs上运行以下代码:,

包干管进口(“fmt”//“同步/原子”“时间”)funcmain{varxint32变量yint32gofunc{为了{x++y++}}时间睡眠(时间秒)fmt。Println(“x=,x)fmt。Println(“y=,y)}

结果也是0。起初,我认为读者的反馈是错误的,所以我立即在阿里云的x86集群和华为云的坤鹏集群中申请了单核ECS,但结果是崩溃了,无论是arm还是X86在单核心平台上运行上述代表的结果仍然是0,但还没有结束。

更是崩溃了,随便加上一个if,甚至杀疯了

directx 9 0c怎么安装

接下来是最令人崩溃的时刻。让我们看看下面的代码:

包干管进口(“fmt”//“同步/原子”“时间”)funcmain{varxint32变量yint32z:=0gofunc{为了{X++//一些不需要注意并发安全的计算问题y++ifZ>;0{fmt。Println(“Zis”,Z)//这行代码不会执行到}}}时间Sleep(time.Second)//定期执行,1秒后停止。无需注意并发安全性fmt。Println(“x=,x)fmt。Println(“y=,y)}

这段代码解决了y=0的问题,而不需要进行任何锁定或互斥。更重要的是,这段代码的执行效率是惊人的,它至少比以前的automic方法快一个数量级。如果是这样,这个编码方案非常适合不需要并发控制且需要定期结束的计算场景,如果我只能给一个计算任务一秒钟的时间,即使我能计算,如果我不能计算,我可以解决下一个问题,那么if的方案非常适合。

x=407698730y=407745938成功:进程退出代码0

在解释if分支的非主流方案之前,让我们来看看互斥的主流并发同步方案。

互斥锁的实现如下:

包干管进口(“fmt”“同步”//“同步/原子”“时间”)funcmain{varxint32变量yint32var互斥同步。互斥gofunc{为了{互斥。锁x++y++互斥。解锁}}时间睡眠(时间秒)x1:=xy1:=yfmt。Println(“x=,x1)fmt。Println(“y=,y1)}

操作结果如下:

x=50889322y=50889322成功:进程退出代码0

我们可以看到,互斥、原子运算等方法的最终运算结果基本上在一个数量级内上下浮动,范围不超过10%。相比之下,if的计划是疯狂的,这直接比上述安全写作表现好一个数量级!随机加入if的一个分支可以解y=0,而且仍然有效。为什么?

关键时刻的汇编令人安心,伟大的上帝用一个词打破了它

当我的知识无法解释上述现象时,我只能求助于希望objdump,通过查看gobuild生成的可执行文件,对其进行反编译汇编语言找到解释问题的线索的代码。我不知道。真是个惊喜。添加Marx语句与锁定相同。它们都将添加一个内存写屏障写载体。详情如下:

没有if的编辑结果

0000000000499400<;主要的主要的功能1>;:499400:eb00jmp499402<;主要的主要的func1+0x2>;499402:eb00jmp499404<;主要的主要的func1+0x4>;499404:eb00jmp499406<;主要的主要的func1+0x6>;499406:ebfajmp499402<;主要的主要的func1+0x2>;499408:ccint3499409:ccint349940a:ccint349940b:ccint349940c:ccint349940d:ccint3...省略0000000000499420<;类型等式[2]接口{}>;:499420:64488b0c25f8ffmov%fs:0xFFFFFFFFFFF8,%rcx499427:ff499429:483b6110cmp0x10(%rcx),%rsp49942d:0f86cf000000jbe499502<;类型等式[2]接口{}+0xe2>;499433:4883欧共体50分$0x50,%rsp

使用Marx或lock的汇编结果

NicholasTse有点类似于文件操作中的刷新功能,它将强制数据从缓存同步到内存。因此,我前面提到的两个变量中的一个可以被锁定,而另一个结果不能为0,因为它们位于同一缓存线中。这种解释也是错误的。X和y不同步回内存,因为它们在同一个缓存线中,这是由wirteBarrier作为屏障引入的。让我们看看下面的代码。

包干管进口(“fmt”//“同步/原子”“时间”)funcmain{varxint32变量yint32切片:=make([]整数,10,10)z:=0gofunc{为了{x++y++对于索引,value:=范围切片{切片[索引]=value+1}ifZ>;0{fmt。Println(“z是”,z)}}}时间睡眠(时间秒)fmt。Println(“x=,x)fmt。Println(“y=,y)fmt。Println(“slice=,slice)}

他的手术结果是:

x=86961625y=86972610切片=[86978588869790758697910186979417869794358697945286979464869797718697979386979807]成功:进程退出代码0

我做了一个长度为10的塑形切片。通常,缓存线只有64字节,因此该片上的数据不能在同一缓存线上。通过这段代码的执行结果,我们可以看到所有的切换值都被更新了,因此,我们可以理解写载波的功能是强制所有以前的数据返回内存。

此外,鉴于原始代码中的单核环境可以重现问题,我咨询了新程序员的封面人物熊大——操作系统的大神(图中右边第二位是熊大)

我有一定的理解,单核ECS运行的结果也是y=0的结果。因为ECS虚拟机的主体也是物理机物理机器它肯定不是一个单核,因此如果不执行writebarrier语句writebarrier,就无法将数据刷新回内存,尽管该程序在单核虚拟机上运行虚拟机汇编指令没有重新打包,这使得实际执行与多核环境中的执行没有区别。

if为什么这样安排

事实上,if不仅实现了内存同步的效果,而且具有更高的效率。它似乎非常适合这种没有强制同步的使用场景。然而,我们不禁要问,当Marx语句出现时,编译器为什么显式地调用内存屏障。我想有两个原因,

在if的判断中使用真实价值是一个隐含的前提:首先,在做出判断时,使用隐藏物中的数据可能会带来明显的问题:在进行判断时,程序员通常要求使用当前变量的实际值,而不是缓存的值,这是一个隐含的前提,编译器在优化时可能会考虑到这一点。

使用指令管道的原因:我们知道CPU的每一个动作都需要由晶体振荡触发。以add指令为例。为了完成这条执行指令,我们需要几个步骤,比如获取、解码、获取操作数、执行和获取操作结果,每个步骤都需要一个晶体振荡来推进,因此,在流水线技术出现之前,执行一条指令至少需要5到6个晶体振荡周期。如下所示:

为了缩短指令执行的晶体振荡周期,芯片设计者参考了工厂流水线机制,提出了指令流水线的思想。由于取指和解码模块在芯片中实际上是独立的,因此只要同时执行多条指令的不同步骤,例如指令1取指、指令2解码、指令3取指操作数,等可以大大提高CPU的执行效率:

以上图中的管道为例,在T5之前,指令管道以每周期一条的速度连续建立。T5之后,可以有一条指令记录每个振荡周期的结果。平均而言,每条指令只需一个振荡周期即可完成。这种流水线设计大大提高了CPU的运行速度。但是,Marx分支会导致管道暂停,即指令管道系统无法确定指令1执行时指令7的具体情况。事实上,在if耗时的操作中添加写载体是可以理解的。无论如何,Marx也会降低执行速度,因此编译器此时不关心添加其他耗时的操作。

为什么Rust令人羡慕

《一顿操作猛如虎,一看结果却是0》在这篇文章发表后,许多伟大的人物回答说,每种语言都有自己的生活方式,像Java的rxjava这样的高并发性框架可以获得良好的性能。作者非常同意这一观点。

然而,在看了一段时间Rust之后,我觉得Rust的优势在于它可以避免程序员犯很多错误。虽然所谓的错误看起来很低级,但如果它们隐藏在数千万行代码中,检查起来相当耗时费力,因为所有权已经转移,因此,变量的使用不太可能有Go那样的错误。我们在前一篇文章中讨论了这一点,让我们看看以下代码:

使用STD::thread;使用std::sync::mpsc;使用std::time::Duration;fnmain{let(TX,Rx)=MPSC::频道;letTX1=MPSC::sender::Clone(&;TX)//添加发送方TX1需要克隆letTX2=MPSC::sender::Clone(&;TX)//添加发送方TX2需要克隆thread:繁殖(move){letVAL=VEC![String::from(“我”),String::from(“from”),String::from(“the”),String::from(“txitself”),];对于val中的val{发送(val)。打开…的包装}});thread:繁殖(move){letVAL=VEC![String::from(“我”),String::from(“from”),String::from(“the”),String::from(“tx1”),];对于val中的val{tx1。发送(val)。打开…的包装}});thread:繁殖(move){letVAL=VEC![String::from(“我”),String::from(“from”),String::from(“the”),字符串::from(“tx2”),];对于val中的val{tx2。发送(val)。打开…的包装}});对于Rx中接收的{//一个通道,一个接收器,从多个发送者接收消息普林顿!(“GoT:{}”,收到);}}

可以看出,Rust对管道多通道并发的管理和使用应该通过克隆安全地传输信息。我无法想象如何使用Rust来编程上面例子中由Go引起的错误,所以Rust的学习曲线尽管难度很大,但与Python和Java不同,Rust的软件包似乎只有掌握本机框架才能做得很好。除了母语知识,他们还需要学会熟练使用各种第三方软件包。

马超,CSDN博客专家,阿里云MVP,华为云MVP,华为2020技术社区开发者之星。

您可以还会对下面的文章感兴趣

最新评论

◎欢迎参与讨论,请在这里发表您的看法、交流您的观点。

使用微信扫描二维码后

点击右上角发送给好友