前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >一顿操作猛如虎,一看结果还是 0,Rust 能避免 Go 的 Bug?

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

作者头像
MikeLoveRust
发布2021-07-16 16:07:13
5290
发布2021-07-16 16:07:13
举报
文章被收录于专栏:Rust语言学习交流

早些时候我看到这样一条新闻,在谈到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的帮助下,我们程序员到底得到了哪些便利,又要在哪些方面付出代价呢?我们先来看以下这段代码。

代码语言:javascript
复制
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之和误差也在万分之一左右。

代码语言:javascript
复制
x= 86209264
y= 86217488
z= 172436022
成功: 进程退出代码 0.

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

x++了半天怎么还是0

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

代码语言:javascript
复制
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。也就是说忙了半天什么都没做。

代码语言:javascript
复制
threads= 8
x= 0
成功: 进程退出代码 0.

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

goroutine到底有没有执行?

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

代码语言:javascript
复制
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)
  
}

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

代码语言:javascript
复制
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无效。

因此以在下代码中

代码语言:javascript
复制
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++操作体现到,最终结果的原因。

代码语言:javascript
复制
go func() {
      for {
        
        y++
        
        fmt.Println("goroutine enter once")
      }
    }()

只要加锁就不一样

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

代码语言:javascript
复制
 
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的。

不是一家子的话也没机会

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

代码语言:javascript
复制
 
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没有意义。

代码语言:javascript
复制
x= 97888494
 
y= 0
 
z= 0
 
成功: 进程退出代码 0.

Rust是怎么做的呢?

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

代码语言:javascript
复制
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关键字等儿科的权限作用域管理的帮助下,上述代码在编译时就会有如下提示:

代码语言:javascript
复制
 --> 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编程犯错都很难。

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

本文参与 腾讯云自媒体同步曝光计划,分享自微信公众号。
原始发表:2021-06-21,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 Rust语言学习交流 微信公众号,前往查看

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

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • x++了半天怎么还是0
  • goroutine到底有没有执行?
  • 只要加锁就不一样
  • Rust是怎么做的呢?
相关产品与服务
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档