Rust性能对比与内存安全性

一  性能对比

不同的语言使用不同的内存管理方式,一些语言使用垃圾回收机制在运行时寻找不再被使用的内存并释放,典型的如Java、Golang。在另一些语言中,程序员必须亲自分配和释放内存,比如C/C++。Rust 则选择了第三种方式:内存被一个所有权系统管理,它拥有一系列的规则使编译器在编译时进行检查,任何所有权系统的功能都不会导致运行时开销。Rust 速度惊人且内存利用率极高,标准Rust性能与标准C++性能不相上下,某些场景下效率甚至高于C++。由于没有运行时和垃圾回收,它能够胜任对性能要求特别高的服务。网上已经有了很多关于Rust性能分析对比的文章,不过为了获得一手的资料,还是自己动手来的更加真实。我选择了Python,C++,Golang这3种语言来和Rust做性能对比。

性能测试场景设计

同样的算法用4种语言分别实现,对比在规定的时间内完成任务的次数。本次测试选择的算法是找出10000000以内的所有素数,比较在一分钟内完成找出所有素数任务的次数。

源代码链接见[1]。

静态编译(或者打包)后生成的二进制大小对比

image.png

结论:(二进制大小)python > golang > rust > c++

运行速度对比

本场景下比较1分钟内找出1000000以内所有素数的次数。

image.png

结论:(运行效率)rust > c++ > golang > python

重点来了,在3台不同的机器上测试四次的结果显示:Rust效率居然高于C++!!!

内存消耗对比(粗略计算)

image.png

结论:(内存消耗) python > golang > rust > c++

CPU消耗对比(粗略计算)

image.png

结论:(CPU消耗)golang > python > rust  = c++

以上便是我的测试结果,测试代码、二进制和测试结果参考附件bin.zip,第一次测试后看到结果,有些吃惊,rust的性能居然超过了c++,不可思议,于是又在网上搜索,找到了别人已经完成的rust性能测试,网上的结果更让人吃惊,先看第一篇,原始链接见[2]。

我直接截图看结论:

image.pngimage.png

以上为Rust vs Golang。

image.pngimage.png

以上为Rust vs C++。

结论:以上截图显示,Rust在性能和资源消耗上不仅大幅度优于Golang,并且和C++性能不相上下,某些场景下效率甚至优于C++。

以上两种测试场景只是测试一些简单的算法,接下来我们看一下在实际使用中的性能资源占用对比,依然是在网上找到了一篇测试报告[3],该测试报告用Python、PyPy、Go、Rust四种语言实现了一个web后端,接下来使用wrk分别对四个http服务器进行压测,该测试场景比较贴近实际,直接截图看结论:

image.pngimage.png

结论(性能):在实际作为后端服务使用的场景下,Rust比Golang依然有明显性能优势。

image.png

结论(资源占用):在内存占用上Rust的优势更加明显,只用了Golang的1/3。

综合以上3个测试,Rust在运行效率和资源消耗上的优势十分明显,和C++同一个级别,远远优于Golang !

二  内存安全性

Rust 最重要的特点就是可以提供内存安全保证,而且没有额外的性能损失。在传统的系统级编程语言( C/C++) 的开发过程中,经常出现因各种内存错误引起的崩溃或bug ,比如空指针、野指针、内存泄漏、内存越界、段错误、数据竞争、迭代器失效等,血泪斑斑,数不胜数;内存问题是影响程序稳定性和安全性的重大隐患,并且是影响开发效率的重大因素;根据google和微软 两大巨头的说法,旗下重要产品程序安全问题70%由内存问题引发[4], 并且两个巨头都用利用Rust语言来解决内存安全问题的想法。Rust语言从设计之初就把解决内存安全作为一个重要目标,通过一系列手段保证内存安全,让不安全的潜在风险在编译阶段就暴露出来。接下来根据自己粗浅的理解,简单介绍Rust解决内存安全的手段有哪些。

1  所有权规则

1)Rust 中每一个值或者对象都有一个称之为其 所有者(owner)的变量。

例如:

let obj = String::from("hello");

obj是String对象的所有权变量。

2)值或对象有且只能有一个所有者。

3)当所有者离开作用域,所有者所代表的对象或者值会被立即销毁。

4)赋值语句、函数调用、函数返回等会导致所有权转移,原有变量会失效。

例如:

fn main() {
    let s = String::from("hello");
    let s1 = s; //所有权发生了转移,由s转移给s1
    print!("{}",s); //s无效,不能访问,此句编译会报错
}
fn test(s1:String){
    print!("{}",s1);
}

fn main() {
    let s = String::from("hello");
    test(s); //传参,所有权发生了转移
    print!("{}",s); //此处s无效,编译报错
}

Rust的所有权规则保证了同一时刻永远只有一个变量持有一个对象的所有权,避免数据竞争。

2  借用规则

可能大家都发现了问题,什么鬼,为什么我传了个参数s给test函数,这参数s后面还不能用了呢?如果我接下来要使用变量s怎么办?这时候就要用到Rust的借用特性。在Rust中,你拥有一个变量的所有权,如果想让其它变量或者函数访问,你可以把它“借”给其它变量或者你所调用的函数,供它们访问。Rust会在编译时检查所有借出的值,确保它们的寿命不会超过值本身的寿命。

例如,以下的写法就没有问题:

fn test(s1:&String){
    print!("{}",s1);
}

fn main() {
    let s = String::from("hello");
    test(&s); //传参,注意只是传递了引用,所有权还归属于s
    print!("{}",s); //此处s依然有效,可以访问
}
fn main() {
    let s = String::from("hello");
    let s1 = &s; //s1借用s,所有权还归属于s
    print!("{}",s); //此处s依然有效,可以访问
    print!("{}",s1); //此处s1和s指向同一个对象
}

如果我们尝试修改借用的变量呢?

fn main() {
    let s = String::from("hello");
    change(&s);

}

fn change(some_string: &String) {
    some_string.push_str(", world");
}

借用默认是不可变的,上面的代码编译时会报错:

error[E0596]: cannot borrow immutable borrowed content `*some_string` as mutable
 --> error.rs:8:5
  |
7 | fn change(some_string: &String) {
  |                        ------- use `&mut String` here to make mutable
8 |     some_string.push_str(", world");
  |     ^^^^^^^^^^^ cannot borrow as mutable

根据编译错误的提示,通过mut关键字将默认借用修改为可变借用就OK,如下代码可以编译通过:

fn main() {
    let mut s = String::from("hello");
    change(&mut s);

}

fn change(some_string: &mut String) {
    some_string.push_str(", world");
}

不过可变引用有一个很大的限制:在特定作用域中的特定数据有且只能有一个可变引用,这个限制的好处是 Rust 可以在编译时就避免数据竞争,这些代码会失败:

let mut s = String::from("hello");
let r1 = &mut s;
let r2 = &mut s;

报错如下:

error[E0499]: cannot borrow `s` as mutable more than once at a time
 --> borrow_twice.rs:5:19
  |
4 |     let r1 = &mut s;
  |                   - first mutable borrow occurs here
5 |     let r2 = &mut s;
  |                   ^ second mutable borrow occurs here
6 | }
  | - first borrow ends here

在存在指针的语言中,容易通过释放内存时保留指向它的指针而错误地生成一个 悬垂指针(dangling pointer),所谓悬垂指针是其指向的内存可能已经被分配给其它持有者或者已经被释放。相比之下,在 Rust 中编译器确保引用永远也不会变成悬垂状态:当我们拥有一些数据的引用,编译器确保数据不会在其引用之前离开作用域。

让我们尝试创建一个悬垂引用,Rust 会通过一个编译时错误来避免:

fn main() {
    let reference_to_nothing = dangle();

}

fn dangle() -> &String {
    let s = String::from("hello");
    &s
}

这里是编译错误:

error[E0106]: missing lifetime specifier
 --> dangle.rs:5:16
  |
5 | fn dangle() -> &String {
  |                ^ expected lifetime parameter
  |
  = help: this function's return type contains a borrowed value, but there is
  no value for it to be borrowed from
  = help: consider giving it a 'static lifetime

让我们简要的概括一下之前对引用的讨论,以下3条规则在编译时就会检查,违反任何一条,编译报错并给出提示。

1)在任意给定时间,只能 拥有如下中的一个:

  • 一个可变引用。
  • 任意数量的不可变引用。

2)引用必须总是有效的。

3)引用的寿命不会超过值本身的寿命。

3  变量生命周期规则

生命周期检查的主要目标是避免悬垂引用,考虑以下示例 中的程序,它有一个外部作用域和一个内部作用域,外部作用域声明了一个没有初值的变量 r,而内部作用域声明了一个初值为 5 的变量 x。在内部作用域中,我们尝试将 r 的值设置为一个 x 的引用。接着在内部作用域结束后,尝试打印出 r 的值:

error[E0106]: missing lifetime specifier
 --> dangle.rs:5:16
  |
5 | fn dangle() -> &String {
  |                ^ expected lifetime parameter
  |
  = help: this function's return type contains a borrowed value, but there is
  no value for it to be borrowed from
  = help: consider giving it a 'static lifetime

当编译这段代码时会得到一个错误:

error: `x` does not live long enough
   |
6  |         r = &x;
   |              - borrow occurs here
7  |     }
   |     ^ `x` dropped here while still borrowed
...
10 | }
   | - borrowed value needs to live until here

编译错误显示:变量 x 并没有 “活的足够久”,那么Rust是如何判断的呢?

编译器的这一部分叫做 借用检查器(borrow checker),它比较作用域来确保所有的借用都是有效的。如下:r 和 x 的生命周期注解,分别叫做 'a 和 'b:

{
    let r;                // -------+-- 'a
                          //        |
    {                     //        |
        let x = 5;        // -+-----+-- 'b
        r = &x;           //  |     |
    }                     // -+     |
                          //        |
    println!("r: {}", r); //        |
}                         // -------+

我们将 r 的生命周期标记为 'a 并将 x 的生命周期标记为 'b。如你所见,内部的 'b 块要比外部的生命周期 'a 小得多。在编译时,Rust 比较这两个生命周期的大小,并发现 r 拥有生命周期 'a,不过它引用了一个拥有生命周期 'b 的对象。程序被拒绝编译,因为生命周期 'b 比生命周期 'a 要小:被引用的对象比它的引用者存在的时间更短。

关于借用生命周期检查,Rust还有一套复杂的生命周期标记规则,使Rust能在编译时就能发现可能存在的悬垂引用,具体链接见[5]。

4  多线程安全保证

内存破坏很多情况下是由数据竞争(data race)所引起,它可由这三个行为造成:

  • 两个或更多指针同时访问同一数据。
  • 至少有一个这样的指针被用来写入数据。
  • 不存在同步数据访问的机制。

那么在多线程环境下,Rust是如何避免数据竞争的?

先从一个简单的例子说起,尝试在另一个线程使用主线程创建的 vector:

use std::thread;
fn main() {
    let v = vec![1, 2, 3];
    let handle = thread::spawn(|| {
        println!("Here's a vector: {:?}", v);
    });
    handle.join().unwrap();
}

闭包使用了 v,所以闭包会捕获 v 并使其成为闭包环境的一部分。因为 thread::spawn 在一个新线程中运行这个闭包,所以可以在新线程中访问 v。然而当编译这个例子时,会得到如下错误:

error[E0373]: closure may outlive the current function, but it borrows `v`,
which is owned by the current function
 --> src/main.rs:6:32
  |
6 |     let handle = thread::spawn(|| {
  |                                ^^ may outlive borrowed value `v`
7 |         println!("Here's a vector: {:?}", v);
  |                                           - `v` is borrowed here
  |
help: to force the closure to take ownership of `v` (and any other referenced
variables), use the `move` keyword
  |
6 |     let handle = thread::spawn(move || {
  |                                ^^^^^^^

Rust 会“推断”如何捕获 v,因为 println! 只需要 v 的引用,闭包尝试借用 v。然而这有一个问题:Rust 不知道这个新建线程会执行多久,所以无法知晓 v 的引用是否一直有效。所以编译器提示:
closure may outlive the current function, but it borrows v

下面展示了一个 v 的引用很有可能不再有效的场景:

use std::thread;
fn main() {
    let v = vec![1, 2, 3];
    let handle = thread::spawn(|| {
        println!("Here's a vector: {:?}", v);
    });
    drop(v); // 强制释放变量v
    handle.join().unwrap();
}

为了修复示上面的编译错误,我们可以听取编译器的建议:

help: to force the closure to take ownership of `v` (and any other referenced
variables), use the `move` keyword
  |
6 |     let handle = thread::spawn(move || {

接下来是正确的写法:

use std::thread;
fn main() {
    let v = vec![1, 2, 3];
    let handle = thread::spawn(move || {  //使用 move 关键字强制获取它使用的值的所有权,接下来就可以正常使用v了
        println!("Here's a vector: {:?}", v);
    });
    handle.join().unwrap();
}

从上面简单例子中可以看出多线程间参数传递时,编译器会严格检查参数的生命周期,确保参数的有效性和可能存在的数据竞争。

大家注意到没有,上面的例子虽然能正确编译通过,但是有个问题,变量v的所有权已经转移到子线程中,main函数已经无法访问v,如何让main再次拥有v呢?如果用C++或者Golang等语言,你可以有很多种选择,比如全局变量,指针,引用之类的,但是Rust没有给你过多的选择,在Rust中,为了安全性考虑,全局变量为只读不允许修改,并且引用不能直接在多线程间传递。Rust 中一个实现消息传递并发的主要工具是 通道(channel),这种做法时借鉴了Golang的通道,用法类似。

示例:

use std::thread;
use std::sync::mpsc;
fn main() {
    let (tx, rx) = mpsc::channel();
    thread::spawn(move || {
        let val = String::from("hi");
        tx.send(val).unwrap();
    });
    let received = rx.recv().unwrap();
    println!("Got: {}", received);
}

上例中,我们可以在main函数中通过channel得到了子线程中的对象val。

注意,tx.send(val).unwrap(); 之后,val的所有权已经发生了变化,接下来在子线程中不能再对val进行操作,否则会有编译错误,如下代码:

use std::thread;
use std::sync::mpsc;
fn main() {
    let (tx, rx) = mpsc::channel();
    thread::spawn(move || {
        let val = String::from("hi");
        tx.send(val).unwrap();
        println!("val is {}", val);//在这里会发生编译错误
    });
    let received = rx.recv().unwrap();
    println!("Got: {}", received);
}

这里尝试在通过 tx.send 发送 val 到通道中之后将其打印出来。允许这么做是一个坏主意:一旦将值发送到另一个线程后,那个线程可能会在我们再次使用它之前就将其修改或者丢弃。这会由于不一致或不存在的数据而导致错误或意外的结果。对于上面的代码,编译器给出错误:

error[E0382]: use of moved value: `val`
  --> src/main.rs:10:31
   |
9  |         tx.send(val).unwrap();
   |                 --- value moved here
10 |         println!("val is {}", val);
   |                               ^^^ value used here after move
   |
   = note: move occurs because `val` has type `std::string::String`, which does
not implement the `Copy` trait

我们通过channel能够实现多线程发送共享数据,但是依然有个问题:通道一旦将一个值或者对象send出去之后,我们将无法再使用这个值;如果面对这样一个需求:将一个计数器counter传给10条线程,每条线程对counter加1,最后在main函数中汇总打印出counter的值,这样一个简单的需求如果使用C++或者Golang或者其它非Rust语言实现,非常容易,一个全局变量,一把锁,几行代码轻松搞定,但是Rust语言可就没那么简单,如果你是一个新手,你可能会经历如下“艰难历程”:

首先很自然写出第一版:

use std::sync::Mutex;
use std::thread;
fn main() {
    let counter = Mutex::new(0);
    let mut handles = vec![];
    for _ in 0..10 {
        let handle = thread::spawn(move || {
            let mut num = counter.lock().unwrap();
            *num += 1;
        });
        handles.push(handle);
    }
    for handle in handles {
        handle.join().unwrap();
    }
    println!("Result: {}", *counter.lock().unwrap());
}

多线程有了,Mutex锁也有了,能保证每一次加一都是原子操作,代码看起来没什么问题,但是编译器会无情报错:

error[E0382]: capture of moved value: `counter`
  --> src/main.rs:10:27
   |
9  |         let handle = thread::spawn(move || {
   |                                    ------- value moved (into closure) here
10 |             let mut num = counter.lock().unwrap();
   |                           ^^^^^^^ value captured here after move
   |
   = note: move occurs because `counter` has type `std::sync::Mutex<i32>`,
   which does not implement the `Copy` trait
error[E0382]: use of moved value: `counter`
  --> src/main.rs:21:29
   |
9  |         let handle = thread::spawn(move || {
   |                                    ------- value moved (into closure) here
...
21 |     println!("Result: {}", *counter.lock().unwrap());
   |                             ^^^^^^^ value used here after move
   |
   = note: move occurs because `counter` has type `std::sync::Mutex<i32>`,
   which does not implement the `Copy` trait
error: aborting due to 2 previous errors

错误信息表明 counter 值的所有权被move了,但是我们又去引用了,根据所有权规则,所有权转移之后不允许访问,但是为什么会发生?

让我们简化程序来进行分析。不同于在 for 循环中创建 10 个线程,仅仅创建两个线程来观察发生了什么。将示例中第一个 for 循环替换为如下代码:

let handle = thread::spawn(move || {
    let mut num = counter.lock().unwrap();
    *num += 1;
});
handles.push(handle);
let handle2 = thread::spawn(move || {
    let mut num2 = counter.lock().unwrap();
    *num2 += 1;
});
handles.push(handle2);

这里创建了两个线程并将用于第二个线程的变量名改为 handle2 和 num2,编译会给出如下错误:

error[E0382]: capture of moved value: `counter`
  --> src/main.rs:16:24
   |
8  |     let handle = thread::spawn(move || {
   |                                ------- value moved (into closure) here
...
16 |         let mut num2 = counter.lock().unwrap();
   |                        ^^^^^^^ value captured here after move
   |
   = note: move occurs because `counter` has type `std::sync::Mutex<i32>`,
   which does not implement the `Copy` trait
error[E0382]: use of moved value: `counter`
  --> src/main.rs:26:29
   |
8  |     let handle = thread::spawn(move || {
   |                                ------- value moved (into closure) here
...
26 |     println!("Result: {}", *counter.lock().unwrap());
   |                             ^^^^^^^ value used here after move
   |
   = note: move occurs because `counter` has type `std::sync::Mutex<i32>`,
   which does not implement the `Copy` trait
error: aborting due to 2 previous errors

啊哈!第一个错误信息中说,counter 所有权被移动进了 handle 所代表线程的闭包中。因此我们无法在第二个线程中再次捕获 counter , Rust 告诉我们不能将 counter 的所有权移动到多个线程中。所以错误原因明朗了,因为我们在循环中创建了多个线程,第一条线程获取了 counter 所有权后,后面的线程再也拿不到 counter 的所有权。如何让多条线程同时间接(注意,只能是间接)拥有一个对象的所有权,哦,对了,引用计数!

通过使用智能指针 Rc 来创建引用计数的值,尝试使用 Rc 来允许多个线程拥有 Mutex 于是写了第二版:

展开阅读全文

本文系作者在时代Java发表,未经许可,不得转载。

如有侵权,请联系nowjava@qq.com删除。

编辑于

关注时代Java

关注时代Java