专栏首页Rust学习专栏远看像乱序执行,近看是内存屏障的BUG是如何被解决的
原创

远看像乱序执行,近看是内存屏障的BUG是如何被解决的

前几天我发布了《Serverless时代Rust将迎春天》后,针对热心读者的回复针对他所提出的问题我又总结了一些文章,其中我对于多并发操作,结果却还是0的情况给出了多核竞争冲突的解释,结果一石击起千层浪,再次收到很多热心读者的反馈,其中有几个回复特别值得一说。

单核环境y也是0:其中一位非常细心的读者针对这个多核竞争造成问题的结论进行了验证,亲身在单核的环境ECS上实验,结果发现结果照样y=0。

后发先至:另外一位读者则给出了一个更奇怪的现象,两个变量中后执行的代码看起来却先被调用了。

加个if问题竟然解了:最后一个反馈留言最令人崩溃,在代码中随便加上个判断语句,不但解决了y=0的问题,性能还非常好。

难道这就是传说中的乱序执行?

先来看以下读者回复的代码:

package main
import (
 "fmt"
 "sync/atomic"
 "time"
)
func main() {
 var x int32
 var y int32
 
 go func() {
 for {
			x = atomic.AddInt32(&x, 1)
			y = atomic.AddInt32(&y, 1)
		}
	}()
 
	time.Sleep(time.Second)
	fmt.Println("x=", x)
	fmt.Println("y=", y)
}

在这部分内容中,两个变量x和y都是由原子操作Automic.Add来保证并发安全的,但是结果输出出来我们可以发现y竟然比x还大?而且每次运行的情况基本都是y更大,只是大多少有所区别。

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

看到这个输出结果,我第一反应感觉这是乱序执行的衍生现象,因为x和y的加1操作彼此是独立的,虽然编译器不会优化执行顺序,但是在CPU的执行层面有可能会对于前后无依赖的操作打乱顺序执行。这样一来就的确有可能出现后面的操作先执行的情况。

但是仔细一想这样的说法应该并不合理,如果是乱序执行的原因,那么上面这段代码的执行结果肯定不会每次结果都是y更大一些,每次执行都是y比x更大只能说明代码是按照一定顺序执行的,而且目前的CPU指令流水线的预测功能肯定还没有牛到能够完全知晓x与y的值不按照顺序提交是没有作何影响的地步。

仔细一看还是多并发竞争问题

再来看以下代码,

package main
import (
 "fmt"
 "sync/atomic"
 "time"
)
func main() {
 var x int32
 var y int32
 
 go func() {
 for {
			x = atomic.AddInt32(&x, 1)
			y = atomic.AddInt32(&y, 1)
		}
	}()
 
	time.Sleep(time.Second)
	x1 := x
	y1 := y
	fmt.Println("x=", x1)
	fmt.Println("y=", y1)
}

只要把fmt.println之前先把x和y的值拷贝出来到x1与y1,再打印x1与y1的值就基本没有这个误差了。

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

这也就是说,fmt.println在执行中间,go func中的子gorouine又被调度了。所以y比x的值大,本质又是一个多并发的竞争问题。而不是乱序执行的原因,只是这个问题在Go的开发模式下也是非常隐蔽。

崩溃了,单核怎么也是0

再说第二个令人崩溃的读者反馈,他在单核的云ECS尝试运行以下代码,

package main
import (
 "fmt"
 //"sync/atomic"
 "time"
)
func main() {
 var x int32
 var y int32
 
 go func() {
 for {
			x++
			y++
		}
	}()
 
	time.Sleep(time.Second)
	fmt.Println("x=", x)
	fmt.Println("y=", y)
}

结果也是0。刚开始我觉得这个读者反馈有误,因此我也立刻在阿里云的X86集群与华为云的鲲鹏集群分别申请了一台单核ECS,不过结果令人崩溃,无论是ARM还是X86单核平台运行上述代表的结果也还是0,不过这还没完。

更崩溃了,随随便便加个if竟然杀疯了….

接下来是最令人崩溃的时刻,我们来看以下代码:

package main
import (
 "fmt"
 //"sync/atomic"
 "time"
)
func main() {
 var x int32
 var y int32
	z := 0
 
 go func() {
 for {
			x++//一些无需关注并发安全的计算问题
			y++
 if z > 0 {
				fmt.Println("z is", z)//这一行代码不会执行到
			}
		}
	}()
 
	time.Sleep(time.Second)//定时执行,超过1秒钟就停止了,无需关注并发安全
	fmt.Println("x=", x)
	fmt.Println("y=", y)
}

这段代码在没有作何锁或者互斥体的基础上竟然解决了y=0的问题,而且令人崩溃的是,这段代码的执行效率竟然还非常惊人,比之前Automic的方式至少快一个数量级,如果是这样的话那么这种代码方案就非常适合于不需要并发控制,并且定时需要结束的计算场景,假如我一个计算任务只能给1秒钟,能算得出来就算,算不出来就解下一题了,那么if的方案就非常适合了。

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

在解释if分支这个非主流的方案之前,我们再来看一下互斥体这种主流并发同步方案。

互斥体实现如下:

package main
import (
 "fmt"
 "sync"
 
 //"sync/atomic"
 "time"
)
func main() {
 var x int32
 var y int32
 var mutex sync.Mutex
 
 go func() {
 for {
			mutex.Lock()
			x++
			y++
			mutex.Unlock()
 
		}
	}()
 
	time.Sleep(time.Second)
	x1 := x
	y1 := y
	fmt.Println("x=", x1)
	fmt.Println("y=", y1)
}

运行结果如下:

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

我们可以看到互斥、原子操作等方法最终运行结果基本都在一个数量级以内上下浮动,幅度不超过10%,对比之下if的方案实在是杀疯了,直接比上述这种安全的写法性能好出一个数量级!随便加入个if分支,竟然也能解决y=0,而且还是高效解决这到底是为什么?

关键时刻汇编令人心安,大神一语道破

在我的知识储备实在无法解释以上现象的时候,我只能将希望诉诸objdump,将gobuild生成的可执行文件来进行反编译,通过查看汇编语言代码来寻找问题解释的蛛丝马迹。不看不知道一看还真是有惊喜,加了if语句和加锁等方式一样全部会加上内存写屏障writeBarrier。具体如下:

未加if的汇编结果

0000000000499400 <main.main.func1>:
  499400:       eb 00                   jmp    499402 <main.main.func1+0x2>
  499402:       eb 00                   jmp    499404 <main.main.func1+0x4>
  499404:       eb 00                   jmp    499406 <main.main.func1+0x6>
  499406:       eb fa                   jmp    499402 <main.main.func1+0x2>
  499408:       cc                      int3
  499409:       cc                      int3
  49940a:       cc                      int3                                         49940b:       cc                      int3
  49940c:       cc                      int3
  49940d:       cc                      int3
...省略
0000000000499420 <type..eq.[2]interface {}>:
  499420:       64 48 8b 0c 25 f8 ff    mov    %fs:0xfffffffffffffff8,%rcx
  499427:       ff ff
  499429:       48 3b 61 10             cmp    0x10(%rcx),%rsp                       49942d:       0f 86 cf 00 00 00       jbe    499502 <type..eq.[2]interface {}+0xe2>
  499433:       48 83 ec 50             sub    $0x50,%rsp

加了if或者锁的汇编结果

wirteBarrier有点类似于文件操作中flush的作用,会强制把数据由缓存同步到内存当中去,因此我前文中所说两个变量其中一个加锁,另一个结果也能不为0是因为他们在同一缓存行原因解释也不对,x和y并不是因为在同一个缓存行所以才被一起同步回内存的,而是由于wirteBarrier这个屏障所引入的。我们来看下面的代码。

package main
import (
 "fmt"
 //"sync/atomic"
 "time"
)
func main() {
 var x int32
 var y int32
	slice := make([]int, 10, 10)
	z := 0
 
 go func() {
 for {
			x++
			y++
 for index, value := range slice {
				slice[index] = value + 1
			}
 if z > 0 {
				fmt.Println("z is", z)
			}
		}
	}()
 
	time.Sleep(time.Second)
	fmt.Println("x=", x)
	fmt.Println("y=", y)
	fmt.Println("slice=", slice)
}

他的运行结果是:

x= 86961625
y= 86972610
slice= [86978588 86979075 86979101 86979417 86979435 86979452 86979464 86979771 86979793 86979807]
成功: 进程退出代码 0.

我造出来长度为10整形切片,缓存行一般只有64BYTE,那么这个切片上面的数据是不可能在同一缓存行上的,通过这段代码的执行结果可以看到所有切换的值全部被更新了,因此我们可以了解writeBarrier这个内存写屏障的功能是将之前所有的数据全部强制回写到内存当中。

另外针对最初代码中单核环境也能重现问题的情况,在请教了操作系统大神熊大之后,我对于单核ECS中运行的结果也是y=0的结果有了一定的认识,由于ECS虚拟机运行的主体也是物理机,而物理机肯定不是单核的,因此不执行writeBarrier这个写屏障语句,数据也无法刷回内存,虽然程序运行在单核虚拟机上,而虚拟机并不会把汇编指令再做包装,这也就造成实际的执行与多核环境没有什么差别。

if为什么会被如此安排

实在中If不但实际达到了内存同步的效果,而且还效率更高,看起来非常适合这种没有强制同步需要的使用场景。不过我们不禁要问为什么编译器要在出现if语句时显式调用内存屏障。个人猜测原因有两个,

if判断使用真实值是隐含的前提:首先在进行判断时,使用缓存中的数据可能会带来显而易见的问题:因为在做判断时程序员一般是要求用目前变量的实际值而不是缓存值来进行的,这是一个隐含的前提,可能编译器在优化时考虑到了这一点。

指令流水线的原因:我们知道CPU的每个动作都需要用晶体震荡而触发,以加法ADD指令为例,想完成这个执行指令需要取指、译码、取操作数、执行以及取操作结果等若干步骤,而每个步骤都需要一次晶体震荡才能推进,因此在流水线技术出现之前执行一条指令至少需要5到6次晶体震荡周期才能完成。如下图:

指令/时刻

T1

T2

T3

T4

T5

ADD

取指

译码

取操作数

执行

取结果

为了缩短指令执行的晶体震荡周期,芯片设计人员参考了工厂流水线机制的提出了指令流水线的想法,由于取指、译码这些模块其实在芯片内部都是独立的,完成可以在同一时刻并发执行,那么只要将多条指令的不同步骤放在同一时刻执行,比如指令1取指,指令2译码,指令3取操作数等等,就可以大幅提高CPU执行效率:

指令/时刻

T1

T2

T3

T4

T5

T6

T7

T8

指令1

取指

译码

取操作数

执行

取结果

指令2

取指

译码

取操作数

执行

取结果

指令3

取指

译码

取操作数

执行

取结果

指令4

取指

译码

取操作数

执行

取结果

指令5

取指

译码

取操作数

执行

指令6

取指

译码

取操作数

指令7

取指

译码

指令8

取指

以上图流水线为例 ,在T5时刻之前指令流水线以每周期一条的速度不断建立,在T5时代以后每个震荡周期,都可以有一条指令取结果,平均每条指令就只需要一个震荡周期就可以完成。这种流水线设计也就大幅提升了CPU的运算速度。但是if分支会造成流水线的停顿,也就是说指令流水线系统无法确定在指令1执行时确定指令7的具体情况。那么在if时加上writeBarrier这种耗时操作其实也就可以理解了,反正if也造拖慢执行速度,那编译器也就不在乎在此时加上另外的耗时操作了。

Rust为什么令人羡慕

《一顿操作猛如虎,一看结果却是0》一文刊发后,也有很多大神人物回复说每种语言都有自己的生存方式,像Java的RxJava等高并发框架都可以做出很好的性能,笔者非常认同这一观点。

不过在看了一段时间的Rust后,我感觉Rust的优势是可以避免程序员犯很多错误,而这其中所谓的错误虽然看起来低级,但是如果他们被隐藏在千万行代码之中,那么排查起来真是相当费时费力,中由于已经是所有权转移了,因此变量的使用不太会出现像Go一样的错误情况,这点我们在上一篇文章中已经有所论述了,而且我们来看以下代码:

use std::thread;
use std::sync::mpsc;
use std::time::Duration;
 
fn main() {
let (tx, rx) = mpsc::channel();
let tx1 = mpsc::Sender::clone(&tx);  //增加一个发送者tx1,需要clone
let tx2 = mpsc::Sender::clone(&tx);  //增加一个发送者tx2,需要clone
 
thread::spawn(move || {
    let vals = vec![
        String::from("I'm"),
        String::from("from"),
        String::from("the"),
        String::from("tx it self"),
    ];
 
 for val in vals {
        tx.send(val).unwrap();
 
    }
});
 
thread::spawn(move || {
    let vals = vec![
        String::from("I'm"),
        String::from("from"),
        String::from("the"),
        String::from("tx1"),
    ];
 
 for val in vals {
        tx1.send(val).unwrap();
 
    }
});
 
thread::spawn(move || {
    let vals = vec![
        String::from("I'm"),
        String::from("from"),
        String::from("the"),
        String::from("tx2"),
    ];
 
 for val in vals {
        tx2.send(val).unwrap();
 
    }
});
for received in rx {  //一个通道一个接收者,接收若干个发送者的信息
 println!("Got: {}", received);
}
 
}

可见Rust中连管道的多路并发的管理使用都要通过clone的方式来安全传递信息,个人根本想不到用Rust编程怎么能出现像上面例子中Go造成的Bug,因此Rust的学习曲线虽然陡峭,但是感觉Rust程序包往往只掌握原生的框架就可以做得很好了,而不像Python、Java除了原生语言知识以外,还需要学习熟练运用各种第三方的包。

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

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

我来说两句

0 条评论
登录 后参与评论

相关文章

  • 不会Java内存模型,就先别扯什么熟悉并发编程

    前两天看到同学和我显摆他们公司配的电脑多好多好,我默默打开了自己的电脑,酷睿 i7-4770,也不是不够用嘛,4 核 8 线程的 CPU,也是杠杠的。

    乔戈里
  • 浅墨: 聊聊原子变量、锁、内存屏障那点事(2)

    编译器优化乱序和CPU执行乱序的问题可以分别使用优化屏障 (Optimization Barrier)和内存屏障 (Memory Barrier)这两个机制来解...

    Linux阅码场
  • 谈乱序执行和内存屏障【转】

    10多年前的程序员对处理器乱序执行和内存屏障应该是很熟悉的,但随着计算机技术突飞猛进的发展,我们离底层原理越来越远,这并不是一件坏事,但在有些情况下了解一些底层...

    233333
  • Tencent JDK 国产化CPU架构支持分享

    导语 GIAC(全称:GLOBAL INTERNET ARCHITECTURE CONFERENCE)是长期关注互联网技术与架构的高可用架构技术社区和msup...

    腾讯大数据
  • Tencent JDK 国产化CPU架构支持分享

    ? GIAC(GLOBAL INTERNET ARCHITECTURE CONFERENCE)是长期关注互联网技术与架构的高可用架构技术社区和msup推出的,...

    腾讯技术工程官方号
  • 操作系统篇-cpu

    计算机通电 -> CPU读取内存中程序(电信号输入)->时钟发生器不断震荡通断电 ->推动CPU内部一步一步执行(执行多少步取决于指令需要的时钟周期)->计算完...

    lovelife110
  • BUF大事件丨StrandHogg 2.0漏洞影响10亿设备;泰国移动运营商泄露83亿记录

    本周BUF大事件还是为大家带来了新鲜有趣的安全新闻,三星手机因锁屏APP闰月bug无限重启;StrandHogg 2.0安卓漏洞影响超过10亿台设备;泰国移动运...

    FB客服
  • 为什么程序员下班后只关显示器从不关电脑?

    崔庆才:看到这个话题,我不禁回想了下,我上次关电脑是什么时候?好像就是搬工位关过一次,公司停电被迫关了一次,其他时候好像就再也没关过了。

    崔庆才
  • 创造了程序语言的女学霸,生前定义程序bug,死后引发千年虫危机

    如果你觉得好的话,不妨分享到朋友圈。 来源:科学网 作者:张磊 计算机刚出现的1947年,一位女程序员所用的电脑发生了故障。经排查后发现,原来是某个继电器内...

    IT派
  • 谢宝友:深入理解 Linux RCU 从硬件说起之内存屏障

    本文从硬件的角度引申出内存屏障,这不是内存屏障的详尽手册,但是相关知识对于理解RCU有所帮助。

    Linuxer
  • APP测试类型—App自动化测试与框架实战(2)

      以下内容没有覆盖到功能测试的所有方面,读者都很熟悉的常规内容就不再讲述了。在App功能测试中,有一些传统软件测试里不太常见的关注点,以下权当抛砖引玉,启发一...

    小老鼠
  • 【玩转腾讯云】Windows云服务器排障思路

    Windows排障对我来说很简单,毕竟是鼠标操作,而且有那么多小工具辅助排障,非常轻松。本文更偏重通用能力,不仅适用腾讯云,其他云也适用。专对腾讯云的话,推荐我...

    shawyang
  • Java内存模型

      重排序是指编译器或处理器为了提高程序性能而对指令序列进行重新排序的一种手段。重排序可以导致操作延时或程序看似乱序执行,给程序运行的结果带来一定的不确定性。

    在周末
  • Java 内存模型

    物理机遇到的并发问题与虚拟机中的情况有不少相似之处,物理机对并发的处理方案对于虚拟机的实现也有相当大的参考意义。

    静默虚空
  • 一篇文章让你明白CPU缓存一致性协议MESI

    CPU在摩尔定律的指导下以每18个月翻一番的速度在发展,然而内存和硬盘的发展速度远远不及CPU。这就造成了高性能能的内存和硬盘价格及其昂贵。然而CPU的高度运算...

    程序员追风
  • 这些解决 Bug 的套路,你都会了不?

    这些信息,都很重要。如果可以的话,最好还能拿到用户详细的报错原因、请求和响应,信息多了,才能帮助我们更精准地定位和分析问题。

    程序员鱼皮
  • CPU缓存一致性协议MESI

    CPU 在摩尔定律的指导下以每 18 个月翻一番的速度在发展,然而内存和硬盘的发展速度远远不及 CPU。这就造成了高性能能的内存和硬盘价格及其昂贵。然而 CPU...

    入门小站
  • 【天天开铺子】BUG修改记

    修改一个bug耗时几个小时,确实解决了调试中发现的另一个隐藏问题,但实际上并未解决该bug本身。而是经过几个小时后,看过一个复现视频才知道走偏了。于是有感!(有...

    张晓衡
  • C和C++中的volatile、内存屏障和CPU缓存一致性协议MESI

    然后看看标准C++基金会(https://isocpp.org)怎么说的(官方链接):

    一见

扫码关注云+社区

领取腾讯云代金券