一顿操作猛如虎,一看结果还是0,Rust能避免Go的BUG?

全栈 同时被 3 个专栏收录
10 篇文章 0 订阅
10 篇文章 0 订阅
11 篇文章 0 订阅

早些时候我看到这样一条新闻,在谈到Linux内核与Rust的关系时,谷歌曾表示Rust现在已经准备好加入C语言,成为实现内核的实用语言。它可以帮助减少特权代码中潜在的bug和安全漏洞,同时与内核也配合得很好,可以很大程度上保留其性能特性。

虽然Linux的创始人林纳斯,对于汇编和C语言以外的其它编程语言进入内核全部持负面的态度,但是谷歌还是在强推一个Rust编写某些Linux模块的项目。我之前写过一篇文章曾经讨论过各主流编程语言的并发特性,而让Rust进入内核的原因是这个语言安全而且bug少??一个是这也让我特别好奇方向,谷歌自己的GO语言不香吗,为什么非要支持Moliza创造的Rust?

相信如果Rust正式进驻Linux模块,那么以Rust为主开发的Serverless容器必将更加大行其道,而笔者在比较了一下之后认为与目前云原生的主力Go语言相比,Rust的确有它的过人之处,下面为大家分享一下这个经典的案例。

Go Go Go?忙半天结果还是零?

图省事是程序员的天性,所以很多时候像C语言编写的程序就经常会出现内存泄露,我们看到很多安全软件扫描出的漏洞几乎都是OpenSSL带来的,由于这种安全软件攻击收益太高,因此用C语言编写一旦出现所谓的野指针,那么这所带来的影响就非常恶劣了。

而JAVA和GO自带的内存回收机制虽好,但是也会经常让程序员忘记一些如加锁、同步等关键性的工作。

查了半天的bug

以GO语言为例,我们来看看在GC的帮助下,我们程序员到底得到了哪些便利,又要在哪些方面付出代价呢?我们先来看以下这段代码。

package main

import (
	"fmt"
	//"runtime"

	"sync/atomic"
	"time"
)

func main() {
	var x int32
	var y int32
	var z int32

	go func() {
		for {
			x = atomic.AddInt32(&x, 1)
			
			y++//忘加锁了
			z = x + y//同忘加锁
			
		}
	}()
	
	

	time.Sleep(time.Second)
	fmt.Println("x=", x)
	fmt.Println("y=", y)
	fmt.Println("z=", z)
}

这段代码是我们之前所遇BUG的一个变体,也就是说在进行一些并发操作时,只有涉及的一个操作加锁了,另外两个简单操作我们下意识的认为其线程安全也就没在意。结果上述代码跑完之后你会发现本来应该相等x和y不相等了,相差个万分之一左右,而z也不等于x与y之和误差也在万分之一左右。

x= 86209264
y= 86217488
z= 172436022
成功: 进程退出代码 0.

 

但是如果打开日志追踪内部的执行情况,你会发现这个BUG很可能就消失了,虽然这个问题简化之后看起来简单,但是在茫茫多的代码中真正定位这种屎祖级的BUG太难了。

 

x++了半天怎么还是0

上述BUG还是有点复杂,我们先来看以下这段代码。

package main
import (
	"fmt"
	"runtime"

	//"sync/atomic"
	"time"
)

func main() {
	var y int32
	go func() {
			for {
			    //do something
				y++
				
			}
		}()
	time.Sleep(time.Second)
	fmt.Println("y=", y)
	
}

假如我想让GO语言并发的为我去做一些工作,以上述代码为例,我先定义了一个变量y,然后通过启动goroutine,对y变量进行一些操作,但是我最终得到的结果对不起,却是y=0。也就是说忙了半天什么都没做。

threads= 8
x= 0
成功: 进程退出代码 0.

那么有读者可能会说是不是sleep的时间不够长,好我把把休眠时间改为100秒也无济于事。

goroutine到底有没有执行?

其实goroutine肯定是执行了,因为我们稍微把代码改一下就可以看到结果。

package main

import (
	"fmt"
	"runtime"

	//"sync/atomic"
	"time"
)

func main() {
	var y int32
	go func() {
			for {
				
				y++
				
				fmt.Println("goroutine enter once")
			}
		}()

	time.Sleep(time.Second)
	fmt.Println("y=", y)
	
}

只要这样改一下,立刻可以看到如下结果。

goroutine enter once
goroutine enter once
goroutine enter once
goroutine enter once
x= 78686
goroutine enter once
goroutine enter once
goroutine enter once
goroutine enter once
goroutine enter once
goroutine enter once
goroutine enter once
成功: 进程退出代码 0.

也就是说一旦我们加上了一些涉及到IO的耗时操作,就可以让x++这个动作得到一定程度的执行。

++半天,为何结果还是0?

其实想解释这个问题,需要一些CPU工作原理的基础。我在之前的文章曾经提到过目前的CPU都是流水线技术执行的。由于CPU中取指、译码这些模块其实都是独立的,完成可以在同一时刻并行手,那么只要将多条指令的相关步骤放在同一时刻执行,比如指令1取指,指令2译码,指令3取操作数等等依此类推,就跟以达到 大幅提升CPU的执行效果,以5级流水线为例,具体原理详见下图:

那么这就会带来一个问题,由于内存太慢了,因此一般来说计算结果都不会直接放回内存,而是会先暂存到高速缓存当中,最终由调整缓存统一写回到内存。

但是这样的模型在多核的架构下还是会存在一定的问题。因为不同CPU之间会进行竞争

而不同多核数据同步总线使用MESI协议进行数据同步,当然这里的MESI并不是阿根廷的球员,而是四种状态组成的状态机,其中具体解释如下。

M 修改 (Modified):该Cache line有效,数据被修改了,和内存中的数据不一致,数据只存在于本Cache中。

E 独享、互斥 (Exclusive):该Cache line有效,数据和内存中的数据一致,数据只存在于本Cache中。

S 共享 (Shared):该Cache line有效,数据和内存中的数据一致,数据存在于很多Cache中。

I 无效 (Invalid):该Cache line无效。

因此以在下代码中 

go func() {
			for {
			    //do something
				y++
				
			}
		}()

这里简单来描述一下这个问题的原因。

1.初始时各CPU的L1高速缓存都是I也就是无效状态I。

2.从内存读入执行y++的时候,各CPU进行本地写也就是localWrite操作,将自己的L1的高速缓存行置为M.

3.但是回写到内存时,CPU又要执行远程写的操作也就是RemoteWrite,由于没有任何y变量没有任何同步竞争机制,这时所有CPU都会发现有其它CPU拥有该变量,这时RemoteWrite操作会使CPU将自身高速缓存行的状态再次置为I.也就是无效的状态.

4.I状态的数据是不会被回写到内存的。

因此上述代码无论执行多少次都不会让y的值产生一丝丝变化。

不过如果耗时的IO操作,那么第3步中各CPU执行远程写操作时就不会那么集中,也就是说有的CPU是可以在其它CPU忙于IO操作时而没有发送RemoteWrite操作的间隙将自身状态置为S,也就是有效的共享状态。这也就是下列代码能让y++操作体现到,最终结果的原因。

go func() {
			for {
				
				y++
				
				fmt.Println("goroutine enter once")
			}
		}()

只要加锁就不一样

我们再回到最开始BUG中涉及的代码


func main() {
	var x int32
	var y int32
	var z int32

	go func() {
		for {
			x = atomic.AddInt32(&x, 1)

			y++
			z = x + y

		}
	}()
	
	

	time.Sleep(time.Second)
	fmt.Println("x=", x)
	fmt.Println("y=", y)
	fmt.Println("z=", z)
}

只要x++时用了atomic的AddInt32方法就可以避免我们刚刚x执行不到的问题,而且这带来一个意外的情况就是未加锁的y和z也都逃脱升天了,改变了之前全部是0的情况。

纠其根本原因还在于在CPU实现当中,锁是以缓存行为粒度加的,而xyz三个变量在内存中的而已是连续的,因此只要给x加上锁,其y和z应该也不会相差太离谱。当然这也有坏处,如果这几个值要求精确,那这样的运行结果可以非常难定位bug的。

不是一家子的话也没机会

假如我们把刚刚的代码做一下变形。把带锁的部分和没加锁的部分,分别放到两个匿名函数中,


func main() {
	var x int32
	var y int32
	var z int32

	go func() {
		for {
			x = atomic.AddInt32(&x, 1)

		}
	}()

	go func() {
		for {
			y++
			z = x+y
		}
	}()

	time.Sleep(time.Second)
	fmt.Println("x=", x)
	fmt.Println("y=", y)
	fmt.Println("z=", z)
}

这样的话,y和z还是白忙一场,也就是说不在一个goroutine中执行,那x的锁对于同在一行的y和z没有意义。

x= 97888494

y= 0

z= 0

成功: 进程退出代码 0.

Rust是怎么做的呢?

说实话看到Rust中类似的实现代码,笔者真是有点酸了,Rust的类似实现如下:
 

use std::thread;
use std::time::Duration;

fn main() {
    let mut s = 0;

    thread::spawn(move || {
        s +=1;
       
    });
    thread::sleep(Duration::from_millis(1000));
    println!("{}", s);
}

在move关键字等儿科的权限作用域管理的帮助下,上述代码在编译时就会有如下提示:

 --> hello_world.rs:8:9
  |
8 |         s +=1;
  |         ^
  |
  = note: `#[warn(unused_assignments)]` on by default
  = help: maybe it is overwritten before being read?

你没加锁,就提示这个变量在使用中会出现问题,看到这我真是感觉Rust这门语言可就有点强了,用Rust编程犯错都很难。

展开阅读全文
  • 8
    点赞
  • 13
    评论
  • 10
    收藏
  • 一键三连
    一键三连
  • 扫一扫,分享海报

打赏
文章很值,打赏犒劳作者一下
相关推荐
©️2020 CSDN 皮肤主题: 技术黑板 设计师:CSDN官方博客 返回首页

打赏

beyondma

你的鼓励将是我创作的最大动力

¥2 ¥4 ¥6 ¥10 ¥20
输入1-500的整数
余额支付 (余额:-- )
扫码支付
扫码支付:¥2
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、C币套餐、付费专栏及课程。

余额充值