早些时候我看到这样一条新闻,在谈到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太难了。
上述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肯定是执行了,因为我们稍微把代码改一下就可以看到结果。
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的类似实现如下:
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编程犯错都很难。
马超,CSDN博客专家,阿里云MVP、华为云MVP,华为2020年技术社区开发者之星。