rust的安全性设计

2019-08-10 fishedee 后端

1 概述

rust的安全性设计,最近有空,花时间看了一下现在大红大紫的rust语言。我主要参考的是这个文档《Rust程序设计语言》,在学习的过程中,我顺道完成了关于这个语言的各种Demo,以了解它的特性和设计。

总的来说,这个语言比golang要复杂很多,因为它的目标是面向系统层面编程,这意味着要涉及到内存管理、数据表示和并发竞争的问题。但是,rust语言保证了,只要你能编译成功,那么就不会出现C/C++中的内存泄漏,内存悬空,内存溢出,数据并发冲突等的问题!这是一个多么让人震惊的安全性保证,即使是一个C/C++的老手,都无法完全自信地说能保证避免这类的问题发生,就像现在的Chrome浏览器总是被人说内存占用太大了,Windows的bug更新了那么多次还是有无数的内存溢出攻击的问题。而rust编译器是如何做到静态检查实现的,仅仅需要看一遍源代码就能杜绝这类问题?!

当我深入到它的类型设计系统以后,只能说叹为观止,实在牛逼大了,可借鉴的地方有很多,这真的是一个天才的设计。当然,编程的世界是没有银弹的,rust在一些极限的场景下依然无法保证内存安全,依然无法保证并发安全。

2 内存安全

2.1 所有权

2.1.1 非Copy Trait的类型

struct User{
    user_id:i32
}
fn main(){
    //变量a获得User的所有权
    let a = User{user_id:123};

    println!("{}",a.user_id);
}

我们可以用let来创建一个变量,获得User结构体的所有权。在rust的设计哲学中,所有变量默认应该只有一个所有者。而当这个所有者离开的时候,就会自动执行RAII,运行它的drop方法。

struct User{
    user_id:i32
}
fn main(){
    let a = User{user_id:123};
    //User结构体的所有权被move到变量b身上。
    let b = a;

    //变量a就无法再使用了,下面这一句会导致编译失败
    println!("{}",a.user_id);
}

当然,所有权是能通过=操作符来转移的,称为move操作。move操作以后,原来的变量就没有所有权了,在编译时就会被标记为悬空的指针,无法再访问了。

2.1.2 Copy Trait的类型

fn main(){
    let a = 123;
    let b = a;

    println!("{}",a);
}

而对于实现了Copy Trait的类型,例如是基本的数据类型时,=操作符的操作不是执行move,而是执行了一次copy,将数据深拷贝了一次,使得两个变量都拥有各自数据的所有权。

2.1.3 Move的工作

#[derive(Debug)]
struct Foo{
    data:i32
}

impl Drop for Foo{
    fn drop(&mut self){
        println!("drop :{:p}",self)
    }

}

fn main(){
    //创建一个Foo类型
    let mut f = Foo{data:123};
    println!("f address:{:p},{:?}",&f,f);

    //执行move操作,move操作就是直接对数据进行浅拷贝,并且在编译器里标注原来的变量已经失效了,原来的变量不再执行drop操作
    //注意,move操作并不是指针指向修改操作,因为进行move操作以后,数据的地址改变了
    let mut f2 = f;
    f2.data = 456;
    println!("f2 address:{:p},{:?}",&f2,f2);
    println!("end");

    //执行drop操作,这个时候的打印的drop地址与f2的地址一致
}

值得注意的是,move操作的实现其实就是一次浅拷贝,move以后两份数据的地址是不同的。它并不像是C++里面的auto_ptr指针。C++里面的auto_ptr指针的赋值的时候,底层指向的数据地址是相同的。

2.1.4 意义

总的来说,rust对于所有权的设计是:

  • 类型默认是非Copy Trait的,对这些类型进行拷贝是没有意义的,例如对File结构体拷贝一份,我们就能获得两个文件了吗?不对,对File结构体拷贝一份,我们只是获得了两个指向相同的File内容的变量而已。所以,rust加入强约定,对于这部分的类型,它的所有权只能存在一份。当它执行=操作符时,应该去掉之前一份变量的所有权,这被称为move操作。
  • 只有标注过的特殊类型是Copy Trait的,部分的特殊类型是值类型的,例如是i32,u32,f32,bool等基本类型,对这些类型进行拷贝是有意义的。因为这些变量没有对应实际的实体,它们拷贝出来的数据是互不关联的。所以,当它执行=操作符时,执行的Copy操作。

rust这样设计阻止了大部分类型没有意义的拷贝操作,而且让释放内存的时机变得可简单可靠。当一个变量的所有权被收回时,就是它释放内存的时候,这在编译时就能确定下来的,不需要运行时GC的介入,完美地解决了内存泄漏的问题。另外,我们进一步地指定类型的drop操作,就能在释放内存的同时帮程序员做,关闭文件,释放互斥锁,提交数据库等常见的收尾操作。

2.2 借用与作用域

2.2.1 引用与作用域

struct User{
    user_id:i32,
    sex:i32,
}

fn check_user_is_man(user:&User)->bool{
    return user.sex == 1
}

fn check_user_id_valid(user:&User)->bool{
    return user.user_id > 10001;
}

fn main(){
    let user = User{user_id:10,sex:2};

    let is_man = check_user_is_man(&user);
    let id_valid = check_user_id_valid(&user);

    println!("is_man:{},id_valid:{}",is_man,id_valid);
}

但是,要读写某个变量,只能通过move所有权的方法就不太方便了,而且这样也不符合语义。例如,我们有check_user_is_man和check_user_id_valid是为了分析user结构体的信息,它们的语义不是为了获取结构体的所有权,而是输入一个结构体的信息来输出结果的。所以,对于它们来说,user结构体是被借来用一下的,而不是用来拥有的。于是,rust使用了C++指针的方法,实现了借用的概念。借用就是获取变量的信息,但是不拥有它的拥有权。

但是,一旦引入了引用的概念以后,就会存在有两个变量同时读写同一份数据的现象,可能会产生以下问题:

  • 引用指向的所有者已经释放了,所有者已经不存在了,但依然对所有权进行读写操作,就会造成悬空指针的问题。
  • 引用和所有者同时操作同一份数据,可能造成数据竞争的问题。
#[derive(Debug)]
struct User{
    user_id:i32,
    sex:i32,
}

fn main(){//'a作用域
   
    let user_ref:&User;
    {//'b作用域
         let user = User{user_id:10,sex:2};
         user_ref = &user;
         //正确,可以运行,解引用的地方
         //所有者是user,作用域在'b,解引用的地方是'b,所有者的作用域'b大于等于解引用的作用域'b。
         println!("{:?}",user_ref)
    }

    //错误,引用所指向的所有权已经丢失了,无法读取
    //所有者是user,作用域在'b,解引用的地方是'a,所有者的作用域少于解引用的作用域'a
    println!("{:?}",user_ref)
}

为了解决第一个问题,rust引入作用域的概念,每个{}符号就会产生一个作用域。在每一个解引用的地方,检查所有者的作用域是不是大于或等于引用的作用域。也就是说,引用执行解引用的操作的时候,要保证所有者必须是仍在生存的,仍未被释放的。

use std::io;

#[derive(Debug)]
struct User{
    user_id:i32,
    sex:i32,
}

fn main(){//作用域'a
   
    let user_ref:&User;
    let user1 = User{user_id:10,sex:2};
    {//作用域'b
        let mut guess = String::new();
        io::stdin().read_line(&mut guess).expect("Failed to read line");
        let guess: u32 = guess.trim().parse().expect("Please type a number!");

        let user2 = User{user_id:2,sex:3};
        if guess < 10 {
            user_ref = &user1;
        }else{
            user_ref = &user2;
        }
        //正确,可以运行
        //所有者是user1或者user2,在解引用的作用域'b中,所有者user1或user2都肯定存在
        println!("{:?}",user_ref);
    }
        
    //错误,编译错误
    //所有者是user1或者user2,在解引用的作用域'a中,当所有者是user2时,其作用域为'b,所有者已经不存在了
    //println!("{:?}",user_ref); 
}

要注意的是,rust的这种检查方式是保守性的。因为引用所指向的所有者并不都是在编译时就能完全确定的,绝大部分的时候都是运行时才能确定。例如,在以上的例子,user_ref的所有者可能在两个不同的作用域中,只要在一次解引用中,任意一个可能的所有者不满足规则时,就会拒绝编译通过。这样做虽然可能误伤正确的代码,但能完全保证没有悬空指针的问题。

2.2.2 返回值的作用域

fn get_max_id_user(a:&User,b:&User)->&User

当然,我们在编写代码的时候,不可能将所有代码都写到一个main函数里面,我们会用函数作为划分模块,再组合起来。当函数的参数或返回值包含引用的时候,编译器是如何检查引用的合法性呢?很明显,用的还是引用的作用域必须少于等于所有者的作用域的规则了。例如,在上述这个函数签名中,返回值是一个&User参数,它返回的是一个引用,那引用的所有者是哪个呢?很明显,不是函数内部创建的一个新对象。因为新对象在函数返回的时候就已经释放了,怎么能返回出它的引用出来呢。

fn get_max_id_user<'a>(a:&'a User,b:&User)->&'a User
//返回值的引用的作用域,与参数a引用的作用域一致

fn get_max_id_user<'a>(a:& User,b:&'a User)->&'a User
//返回值的引用的作用域,与参数b引用的作用域一致

fn get_max_id_user<'a>(a:&'a User,b:&'a User)->&'a User
//返回值的引用的作用域,与参数a和参数b引用的共同作用域一致

那么,引用的所有者只有一种可能,来自于输入参数。但是输入参数有两个呀,所以我们有以上的三种可能。

#[derive(Debug)]
struct User{
    user_id:i32,
    sex:i32,
}

//获取a和b参数的共同作用域,作为返回值引用的作用域
fn get_max_id_user<'s>(a:&'s User,b:&'s User)->&'s User{
    if a.user_id > b.user_id{
        return a;
    }else{
        return b;
    }
}

fn main(){//作用域'a
   
    let user_ref:&User;
    let user1 = User{user_id:10,sex:2};
    {//作用域'b

        let user2 = User{user_id:2,sex:3};

        //user1的作用域是'a,user2的作用域是'b,根据函数签名,我们可以推导出返回值的作用域是'b
        user_ref = get_max_id_user(&user1,&user2);

        //正确,编译通过
        //在解引用的作用域'b中,当所有者的作用域为'b,编译通过
        println!("{:?}",user_ref)
    }
        
    //错误,编译不通过
    //在解引用的作用域'a中,当所有者的作用域为'b,编译失败
    //println!("{:?}",user_ref); 
}

根据get_max_id_user的语义,我们应该选择的是第三种可能,因为返回值可能是来自于第一个参数,也可能是来自于第二个参数。

#[derive(Debug)]
struct User{
    user_id:i32,
    age:i32,
}

//获取a的作用域,作为返回值引用的作用域
fn add_user_age<'s>(a:&'s mut User,b:&User)->&'s User{
    a.age += b.age;
    return a;
}

fn main(){//作用域'a
   
    let user_ref:&User;
    let mut user1 = User{user_id:10,age:2};
    {//作用域'b

        let user2 = User{user_id:2,age:3};

        //user1的作用域是'a,user2的作用域是'b,根据函数签名,我们可以推导出返回值的作用域是'a
        user_ref = add_user_age(& mut user1,&user2);

        //正确,编译通过
        //在解引用的作用域'b中,当所有者的作用域为'a,编译通过
        println!("{:?}",user_ref)
    }
        
    //正确,编译通过
    //在解引用的作用域'a中,当所有者的作用域为'a,编译通过
    println!("{:?}",user_ref); 
}

在另外一个例子add_user_age中,返回值的引用仅仅来自于第一个参数,所以,外层的println!是没有问题的,能编译通过的。

fn get_max_id_user(a:& User,b:& User)->User{

当返回值不是一个引用类型的时候,是不需要注明作用域的。因为所有者是全新的,它是没有指针去指向它自己的,它总是有效的,不会造成任何内存安全问题的,rust是不需要规则需要去约束这样的一个返回值类型的。

所以,rust能仅通过检查函数签名的方式,不需要检查函数的内部实现,就能跨模块地检查悬空指针的问题,这个问题的解决方法仅仅是通过在函数签名上加入一个作用域的参数就可以了,这设计真的是鼓掌66666。当然了,对于每个函数的实现,rust也会去检查它的内部实现是否真的满足它的函数签名的规则。例如,get_max_id_user函数如果只注明返回值只来源于参数a,但是实现的时候即可能来自于参数a,又可能来自于参数b,rust的编译器就会向你抱怨的,不给你编译通过的,因为你的承诺(函数签名)和你做的(函数实现)根本不能逻辑自洽。

2.2.3 复合类型的作用域

#[derive(Debug)]
struct Class{
    class_id:i32,
    name:String,
}

#[derive(Debug)]
struct User{
    user_id:i32,
    age:i32,
    class:& Class,
}

impl User{
    fn get_class<'b>(&'b self)->&'b Class{
        return self.class;
    }
}

fn main(){
    let class_one = Class{class_id:789,name:"md".to_string()};
    let class_ref:&Class;
    {   
        
        let user = User{user_id:123,age:123,class:&class_one};
        class_ref = user.get_class(); 
    }

    println!("{:?}",class_ref);
}

对于复合类型(struct,enum和tunple)中,如果类型中包含有引用类型,rust就要求我们在声明类型时就显式地写明引用类型的作用域。例如,在以上的代码中,User的class字段是引用类型,没有写明它的作用域,就会编译不给通过。

#[derive(Debug)]
struct Class{
    class_id:i32,
    name:String,
}

#[derive(Debug)]
struct User<'a>{
    user_id:i32,
    age:i32,
    class:&'a Class,
}

impl<'a> User<'a>{
    fn get_class<'b>(&'b self)->&'b Class{
        return self.class;
    }
}

fn main(){//作用域'x
    let class_one = Class{class_id:789,name:"md".to_string()};
    let class_ref:&Class;
    {//作用域'y    
        //class引用字段的作用域的形参为'a,实参为'x
        //而user结构体自身的作用域显然只是在'y。
        let user = User{user_id:123,age:123,class:&class_one};

        //但是,根据get_class的函数的签名,返回值Class引用类型的作用域是与输入参数&self相同的,也就是'y作用域。
        //那么,显然将一个'y作用域的返回值,赋值给一个'x作用域的class_ref肯定会失败呀,因为class_ref引用的作用域'x比所有权的作用域'y的要更大。
        class_ref = user.get_class(); 
    }

    println!("{:?}",class_ref);
}

这个时候,我们不明白为什么rust要这样要求,难道字段class引用的作用域就不能由编译器自己推导出来吗,为什么要我们显式地指定?没事,我们先按照rust的要求,补上User的作用域试试看。声明作用域的语法,就是如同给User给一个范型参数,然后将这个参数指定给class字段就可以了。但是,这个时候,编译依然不给通过。因为get_class的返回值的作用域太小了,无法赋值给class_ref引用。但是,我们都知道,语义上是没有问题的呀,因为class字段的来源就是从class_one出来的。

#[derive(Debug)]
struct Class{
    class_id:i32,
    name:String,
}

#[derive(Debug)]
struct User<'a>{
    user_id:i32,
    age:i32,
    class:&'a Class,
}

impl<'a> User<'a>{
    fn get_class(& self)->&'a Class{
        return self.class;
    }
}

fn main(){
    let class_one = Class{class_id:789,name:"md".to_string()};
    let class_ref:&Class;
    {   
        
        let user = User{user_id:123,age:123,class:&class_one};
        class_ref = user.get_class(); 
    }

    println!("{:?}",class_ref);
}

理解了问题的来源以后,我们就知道,get_class的返回值的作用域,不应该来自于self,而是应该来自于self.class。所以,我们就去掉get_class中的’b参数,直接将声明User的’a作用域赋值到返回值上就可以了。这个时候,编译通过了。

经过这一番测试,我们就明白了,rust要求我们每个引用字段都必须声明它的作用域。是为了提醒我们,self的作用域与self里面的各个引用字段的作用域是不同的。我们需要显式地标注出来,以告诉编译器struct方法中返回值的准确的作用域,和告诉编译器struct各个引用字段准确的作用域,编译器才能准确地根据rust中的借用规则进行检查。

2.2.4 复合类型的作用域检查原则

#[derive(Debug)]
struct Class{
    class_id:i32,
    name:String,
}

#[derive(Debug)]
struct User<'a>{
    user_id:i32,
    age:i32,
    class:&'a Class,
}

fn main(){//作用域'x
    
    let class_one = Class{class_id:789,name:"md".to_string()};
    let mut class_ref:&Class;
    {//作用域'y
        
        let class_two = Class{class_id:123,name:"cc".to_string()};
        class_ref = &class_two;

        println!("{:?}",class_ref);

        class_ref = &class_one;
    }

    println!("{:?}",class_ref);
}

仔细看一下以上的代码,class_ref首先使用作用域为’x的class_one初始化,然后再用作用域为’y的class_two赋值,在class_ref离开作用域’y的时候用class_one再次赋值。整个过程在语义上是没有问题,不可能产生内存悬空的问题,rust也是编译通过的。

#[derive(Debug)]
struct Class{
    class_id:i32,
    name:String,
}

#[derive(Debug)]
struct User<'a>{
    user_id:i32,
    age:i32,
    class:&'a Class,
}

fn main(){//作用域'x
    
    let class_one = Class{class_id:789,name:"md".to_string()};
    let mut user = User{user_id:123,age:456,class:&class_one};
    {//作用域'y
        
        let class_two = Class{class_id:123,name:"cc".to_string()};
        user.class = &class_two;

        println!("{:?}",user);

        user.class = &class_one;
    }

    println!("{:?}",user);
}

但是,我们将class_ref改为user的class引用字段就会报错,rust编译器抱怨class_two的作用域不够大。但是,从语义上说,是没有问题的,没有可能产生内存悬空的问题呀。那问题是什么呢?

//user在初始化时就定下来了'a的参数,因为初始化时用class_one赋值,所以,User的实际类型是User<'x>
let mut user = User{user_id:123,age:456,class:&class_one};

//但是,后来在赋值的时候,我们却用作用域更小的class_two(作用域为'y)的去赋值引用字段class(作用域为'x),所以就报错了
user.class = &class_two;

这里因为,复合类型的引用字段的作用域在初始化符合类型的时候就已经确定下来了,后续是无法改变的。这点和基础类型的引用类型是不同的。复合类型的检查规则,就是在赋值引用字段的时候,去检查新的引用作用域是否大于引用字段的作用域。这种检查方法,可以说是rust牺牲了复杂的借用场景的合法性,而保证了所有场景下的无内存悬空的问题,这算是一个功能上的缺陷吧,毕竟这段代码从语义上是没问题的。

#[derive(Debug)]
struct Class{
    class_id:i32,
    name:String,
}

#[derive(Debug)]
struct User<'a>{
    user_id:i32,
    age:i32,
    class:&'a Class,
}

fn main(){//作用域'x
    
    let class_one = Class{class_id:789,name:"md".to_string()};
    let class_ref:&Class;
    {//作用域'y
        
        let class_two = Class{class_id:123,name:"cc".to_string()};
        let mut user = User{user_id:123,age:456,class:&class_two};

        println!("{:?}",user);

        user.class = &class_one;

        println!("{:?}",user);

        class_ref = &user.class;
    }

    println!("{:?}",class_ref);
}

同理,以上的这段代码,从语义上是没有内存悬空的问题的。但是,rust依然会编译不通过。

#[derive(Debug)]
struct Class{
    class_id:i32,
    name:String,
}

#[derive(Debug)]
struct User<'a>{
    user_id:i32,
    age:i32,
    class:&'a Class,
}

fn main(){//作用域'x
    
    let class_one = Class{class_id:789,name:"md".to_string()};
    let class_ref:&Class;
    {//作用域'y
        
        let class_two = Class{class_id:123,name:"cc".to_string()};
        let user = User{user_id:123,age:456,class:&class_two};

        println!("{:?}",user);

        let user = User{
            class:&class_one,
            ..user
        };

        println!("{:?}",user);

        class_ref = &user.class;
    }

    println!("{:?}",class_ref);
}

我们要编译通过的话,就要在赋值class_one到class的时候,通过新建一个user来实现。因为新建的user,它的作用域就是全新的’x了,而不是一定要沿用原来的作用域’y。

2.2.5 复合类型的多引用作用域

#[derive(Debug)]
struct Class{
    class_id:i32,
    name:String,
}

#[derive(Debug)]
struct User<'a>{
    user_id:i32,
    age:i32,
    class1:&'a Class,
    class2:&'a Class,
}

fn main(){//作用域'x
   
    let class_one = Class{class_id:123,name:"mc".to_string()};
    let class_ref:&Class;
    {   //作用域'y
        let class_two = Class{class_id:789,name:"md".to_string()};
        let user = User{user_id:234,age:789,class1:&class_one,class2:&class_two};

        class_ref = user.class1;
        println!("{:?}",class_ref);
    }

    println!("{:?}",class_ref);
}

当复合类型中有两个字段是引用类型的时候,我们会下意识地声明一个作用域,然后指定这两个引用类型都是这个作用域下的。但是,这样写代码会出问题的,因为两个引用的作用域有可能是不同的。例如,在上面的代码中,语义是没问题的,但是编译时就不能通过,是由于开发者只指定了一个作用域导致的。

#[derive(Debug)]
struct Class{
    class_id:i32,
    name:String,
}

#[derive(Debug)]
struct User<'a,'b>{
    user_id:i32,
    age:i32,
    class1:&'a Class,
    class2:&'b Class,
}

fn main(){//作用域'x
   
    let class_one = Class{class_id:123,name:"mc".to_string()};
    let class_ref:&Class;
    {   //作用域'y
        let class_two = Class{class_id:789,name:"md".to_string()};
        let user = User{user_id:234,age:789,class1:&class_one,class2:&class_two};

        class_ref = user.class1;
        println!("{:?}",class_ref);
    }

    println!("{:?}",class_ref);
}

解决办法,很简单,只需要让两个引用字段有各自的作用域’a和’b,而不是共用一个作用域’a就可以了。

#[derive(Debug)]
struct Class{
    class_id:i32,
    name:String,
}

#[derive(Debug)]
struct User<'a,'b>{
    user_id:i32,
    age:i32,
    class1:&'a Class,
    class2:&'b Class,
}

impl<'a,'b> User<'a,'b>{
    fn get_class(&self,isFirst:i32)->& Class{
        if isFirst == 1{
            return self.class1;
        }else{
            return self.class2;
        }
    }
}

fn main(){//作用域'x

    let class_one = Class{class_id:123,name:"mc".to_string()};
    {   //作用域'y
        let class_two = Class{class_id:789,name:"md".to_string()};
        let class_ref:&Class;

        {
            let user = User{user_id:234,age:789,class1:&class_one,class2:&class_two};
            let real_class = user.get_class(2);
            class_ref = real_class;
        }

        println!("{:?}",class_ref);
    }
}

但是,以上代码中,即使使用了’a和’b两个生命周期,也会导致编译不通过,因为get_class的参数返回的是生命周期是与self一致,而不是class_one与class_two共同的生命周期,所以报错。

#[derive(Debug)]
struct Class{
    class_id:i32,
    name:String,
}

#[derive(Debug)]
struct User<'a>{
    user_id:i32,
    age:i32,
    class1:&'a Class,
    class2:&'a Class,
}

impl<'a> User<'a>{
    fn get_class(&self,isFirst:i32)->&'a Class{
        if isFirst == 1{
            return self.class1;
        }else{
            return self.class2;
        }
    }
}

fn main(){//作用域'x

    let class_one = Class{class_id:123,name:"mc".to_string()};
    {   //作用域'y
        let class_two = Class{class_id:789,name:"md".to_string()};
        let class_ref:&Class;

        {
            let user = User{user_id:234,age:789,class1:&class_one,class2:&class_two};
            let real_class = user.get_class(2);
            class_ref = real_class;
        }

        println!("{:?}",class_ref);
    }
}

解决办法是让class1与class2共用一个作用域’a,同时指定get_class返回值的作用域为’a,就能编译通过了。

看,结构体的多字段引用作用域,是应该分开指定,还是共同指定,是与你的业务有关的,rust并不能自动帮你隐式指定的,这和函数中需要开发者自己指定生命周期是一样的道理。

2.2.6 引用的引用

fn main(){
    let mut a1:String = "adf".to_string();
    let mut a2:String = "cde".to_string();

    {
        //创建一级指针,指针指向的内容是可变的,而指针自身也是可变的
        let mut b:& mut String = & mut a1;
        {
            //创建二级指针,指针指向的内容是可变的,而指针自身是不可变的。注意,二级指针的指向内容是一级指针。
            let c:&mut &mut String = &mut b;

            *c = &mut a2;
            (*c).push_str("_mj");
        }
        b.push_str("_fu");
    }

    println!("a1:{},a2:{}",a1,a2)
}

rust中的引用和C++中的引用不是一个意思,rust的引用更像是C++中的指针。rust中的引用,是可以修改指向哪个对象,也可以进行引用的引用的递归操作的。要注意,引用的mut修饰放在不同的位置上的意思。

2.3 可变性

2.3.1 不可变引用

fn main(){
    let mut a1:String = "adf".to_string();
    let mut a2:String = "cde".to_string();

    {
        let str_ref:&String = &a1;

        println!("{}",str_ref);

        //错误,不可变引用无法修改数据
        //str_ref.push_str("uc");

        //错误,不可变引用无法覆盖数据
        //*str_ref = "cde".to_string();

        //错误,str_ref自身不可变,不能改变指向哪个对象
        //str_ref = &a2;
    }

    {
        let mut str_ref2:&String = &a1;

        println!("{}",str_ref2);

        //错误,不可变引用无法修改数据
        //str_ref2.push_str("uc");

        //错误,不可变引用无法覆盖数据
        //*str_ref2 = "cde".to_string();

        //正确,str_ref自身不可变,但可以改变指向哪个对象
        str_ref2 = &a2;
    }
}

首先,我们要理解不可变性。rust默认变量的创建都是immutable的。当一个引用在声明时,有四种可能:

  • &String,指向不可变对象,引用指向不可变
  • & mut String,指向可变对象,引用指向不可变
  • mut &String,指向不可变对象,引用指向可变
  • mut &mut String,指向可变对象,引用指向可变
fn main(){
    let mut a1:String = "adf".to_string();

    {
        let str_ref:&String = &a1;

        println!("{}",str_ref);

        //错误,所有者已经借出不可变引用,不能再修改所有者
        //a1.push_str("mu");

        //错误,所有者已经借出不可变引用,不能再借出可变引用
        //let str_mut_ref:& mut String = &mut a1;

        println!("{}",str_ref);
    }
}

另外,要注意rust的不可变的严谨性,一旦借出不可变引用,那么对象是无法以任何方式进行修改的。不能通过所有权修改,也不能通过借出可变引用来修改。这样的设定,大大提高了不可变契约的可靠性,也让编译器有更大的优化余地。

2.3.2 可变引用

fn main(){
    let mut a1:String = "adf".to_string();

    {
        let str_mut_ref:& mut String = & mut a1;

        println!("{}",str_mut_ref);

        str_mut_ref.push_str("ccd");

        //错误,所有者已经借出可变引用,不能再修改所有者
        //a1.push_str("mu");

        //错误,所有者已经借出可变引用,不能再借出不可变引用
        //let str_ref:& String = & a1;

        println!("{}",str_mut_ref);
    }
}

rust的可变引用也是相当严谨,一旦借出可变引用,就无法通过所有权来修改对象,这避免了数据冲突。也不允许再借出不可变引用,避免破坏不可变引用的契约。

2.4 突破单一所有权

2.4.1 引用计数

use std::rc::Rc;

#[derive(Debug)]
struct File{
    filename:String,
}

impl File{
    fn print_address(&self){
        println!("file struct address {:p}",&self);
    }
}

impl Drop for File{
    fn drop(&mut self){
        println!("close file {}",self.filename)
    }
}


fn main(){//作用域'a
    let a:Rc<File>;
    let b:Rc<File>;

    {   
        //用带有所有权的变量来初始化Rc指针,那么该Rc指针就拥有了该变量的所有权
        let c = Rc::new(File{filename:"fish.txt".to_string()});

        c.print_address();

        //其他变量通过clone来复制一份所有权,也就是可以实现多个所有权的变量。
        //要注意的是,这种同时拥有多个所有权的代价,是变量是只读的
        a = Rc::clone(&c);

        a.print_address();
        println!("strong count {}",Rc::strong_count(&a));
    }

    println!("strong count {}",Rc::strong_count(&a));

    b = Rc::clone(&a);
    b.print_address();

    println!("strong count {}",Rc::strong_count(&b));

    //当引用计数为0时,就清空所有权,触发File的drop trait。
}

rust中默认的所有权规则是单一所有权,就是只有一个变量能拥有这个对象,其他变量要么以move的方式来获取这个对象,要么就是以引用的方式来访问这个对象。但是,在写链表,或二叉树等容器的时候,各个节点的所有权的位置是动态决定的,可能在根上,也可能是在父节点上。我们就需要突破单一所有权这个限制了。我们用到的工具称为引用计数Rc,它能让单个对象的所有权分布在多个变量上,当没有任何一个变量拥有这个对象时,就会立即释放这个对象的内存。值得注意的是,这个对象的所有权是真实地被多个变量中共享的,而不是从一个变量move到另外一个变量的,因为例子输出的address都是一致的,这多个变量操作的的确是同一块内存上的同一个对象。

use std::rc::Rc;

#[derive(Debug)]
struct File{
    filename:String,
}

impl Drop for File{
    fn drop(&mut self){
        println!("close file {}",self.filename)
    }
}


fn main(){//作用域'a
    let a:Rc<File>;
    let b:Rc<File>;

    {   
        let mut origin = File{filename:"fish.txt".to_string()};

        a = Rc::new(origin);

        b = Rc::clone(&a);

        //错误,无法修改origin,因为origin被move到a里面了
        //origin.filename = "aa".to_string();
        
        //错误,Rc返回的指针都是immutable的
        //a.filename = "aa".to_string();

        //错误,Rc返回的指针都是immutable的
        //b.filename = "bb".to_string();
    }
}

Rc能突破单一所有权,让多个变量拥有单个对象的所有权。但是,这样的付出的代价是,作为智能指针,它只能作为immutable来使用,不能修改它指向的内容。这是因为,当多个变量能共享所有权的时候,如果它们是immutable的,就能修改它们指向的数据,其中一个指针就能在它未知的情况,发现它指向的数据被修改了,这会违反rust的可变性原则的。

2.4.2 内部可变性

use std::rc::Rc;
use std::cell::RefCell;

#[derive(Debug)]
struct File{
    filename:String,
}

impl Drop for File{
    fn drop(&mut self){
        println!("close file {}",self.filename)
    }
}


fn main(){
    let origin = File{filename:"fish.txt".to_string()};

    let a:Rc<RefCell<File>>;
    let b:Rc<RefCell<File>>;

    a = Rc::new(RefCell::new(origin));
    b = Rc::clone(&a);

    println!("{:?}",b);

    a.borrow_mut().filename = "cat.txt".to_string();

    println!("{:?}",b);
}

为了解决Rc里面无法使用mutable的问题,rust引入了RefCell类型,对外保持不可变性,但是对内是可变的,就如上面的例子所说。注意,RefCell没有破坏rust的可变性原则,它只是将可变性的检查从编译时的检查改为运行时的检查。

use std::rc::Rc;
use std::cell::RefCell;

#[derive(Debug)]
struct File{
    filename:String,
}

impl Drop for File{
    fn drop(&mut self){
        println!("close file {}",self.filename)
    }
}


fn main(){
    let origin = File{filename:"fish.txt".to_string()};

    let origin2 = File{filename:"cat.txt".to_string()};

    let a:RefCell<Rc<File>>;

    a = RefCell::new(Rc::new(origin));

    println!("{:?}",a);

    *a.borrow_mut() = Rc::new(origin2);

    println!("{:?}",a);
}

注意,在RefCell与Rc也可以以RefCell<Rc<File>>组合在一起,而不是Rc<RefCell<File>>,它代表的含义是引用自身可以改变,但Rc里面的内容不可改变。这相当于,mut &与&mut类型的区别。

2.4.3 循环引用

use std::rc::Rc;
use std::cell::RefCell;

#[derive(Debug)]
enum Node{
    Have{
        content:String,
        next:RefCell<Rc<Node>>,
    },
    Nil,
}

impl Drop for Node{
    fn drop(&mut self){
        if let Node::Have{content,..} = self{
            println!("close file {}",content)
        }
    }
}

fn main(){
   {
        let node = Rc::new(RefCell::new(Node::Have{
            content:"node.txt".to_string(),
            next:RefCell::new(Rc::new(Node::Nil)),
        }));
        //node 正常释放内存,drop正常执行
   }

   {
        let node1 = Rc::new((Node::Have{
            content:"node1.txt".to_string(),
            next:RefCell::new(Rc::new(Node::Nil))
        }));
        let node2 = Rc::new((Node::Have{
            content:"node2.txt".to_string(),
            next:RefCell::new(Rc::clone(&node1))
        }));

        {
            let node1_ref:&Node = &node1;
            if let Node::Have{content, next} = node1_ref{
                *(next.borrow_mut()) = Rc::clone(&node2);
            }
        }

        //node1与node2无法释放内存,drop没有执行,注释上面的代码块泽可以正常执行drop操作
   }
}

但是,使用引用计数突破单一所有权以后,还会有一个问题,就是循环引用的时候,无法释放内存,也就是说内存是不安全。如同代码中的node1与node2,没有使用unsafe代码,但是最终node1与node2由于互相持有对方的所有权,导致无法释放它们自身的内存。解决办法是引入Weak指针,但是复杂度会更高了。

2.5 小结

我们探讨了rust中对内存安全的保证和实现,以及它的局限性:

  • 单一所有权,使用copy和move语义来保证内存能被准确和及时地被清理,这避免了内存泄漏的问题。
  • 引用与作用域,使用引用语义来使用对象,而不会获得它的所有权。同时引用作用域必须比所有权的作用域更大的原则,来避免内存悬空的问题。但是,rust目前对作用域的推导都是在编译时进行的,这意味着会损失一些灵活性。特别是对复合类型的作用域计算时,复合类型的作用域在初始化对象时就已经确定下来,无法变更,这导致了在语义上没有内存悬空的问题,都会在rust看作是有内存悬空风险的问题代码。
  • 可变性,rust对可变性的保证非常好,一个不可变的引用,就保证了无法通过其他可变引用,或所有权本身来修改它。
  • 引用计数与内部可变性,对于复杂的数据结构,rust提供了Rc和RefCell容器来实现它,但是不变性保证被移动了运行时来检查。同时,当遇上循环引用时,Rc会导致内存泄漏的问题。

3 并发安全

3.1 多线程检查原则

pub fn spawn<F, T>(f: F) -> JoinHandle<T> 
where
    F: FnOnce() -> T,
    F: Send + 'static,
    T: Send + 'static, 

这是std::thread的spawn方法签名,rust在并发安全的保证是以依赖编译器检查来实现的。而神奇的是,编译器没有对thread的spawn方法进行特别对待。它仅仅是通过检查传入的函数参数是否满足上面的条件来确保并发安全的。

use std::thread;
use std::rc::Rc;

fn main(){
    
    let data = Rc::new(10);
    
    thread::spawn(move||{
        println!("data is {}",data);
    });
}

为了实践这个说法是不是对的,我们首先构造一个因为并发不安全,而被rust编译器拒绝的例子。以上的代码,编译以后,编译器会提示data没有满足Send Trait,所以它不能安全地在多线程环境中传递。

use std::rc::Rc;

fn do_nothing<F, T>(closure:F)
    where F: FnOnce() -> T,
        F: Send + 'static,
        T: Send + 'static{
}


fn main(){
    
    let data = Rc::new(10);
    
    do_nothing(move||{
        println!("data is {}",data);
    });
}

然后我们建立了一个std::thread的spawn方法相同签名的do_nothing方法,注意,这个方法里面并没有启动多线程,也没有调用任何的thread的方法。所以,按照语义来说,这个代码是没有并发冲突的问题。但是,编译器提示,这段代码依然有问题,原因和上一个例子一样,data没有满足Send Trait,所以它不能安全地在多线程环境传递。

由此可见,rust检查多线程的方法,是通过根据函数签名来检查的,而没有根据语义来检查,也没有对thread::spawn方法进行特别检查。那么,这个函数签名是有什么特别的地方,为什么满足这个函数签名的闭包就能保证肯定能在多线程环境中安全地使用呢?

3.2 FnOnce

首先,函数签名中的clouse参数要求,对应的是F类型,而F类型必须满足FnOnce的Trait。FnOnce是闭包的Trait,对于一个闭包来说,它有如下的三种类型:

  • FnOnce: 当指定这个Trait时, 匿名函数内访问的外部变量必须拥有所有权.
  • FnMut: 当指定这个Trait时, 匿名函数可以改变(borrow value immutabe)外部变量的值.
  • Fn: 当指定这个Trait时, 匿名函数可以读取(borrow value immutably)变量值.
use std::thread;

fn main(){
    
    let mut data = "123".to_string();
    
    thread::spawn(||{
        println!("data is {}",data);
        data.push_str("cg");
    });
}

这就是说,对于闭包内的外部变量。如果要想在多线程环境中使用,就必须被move到闭包里面。例如,上面的闭包就因为没有加move关键字,默认用了borrow value mutably了,所以闭包的Trait仅仅是Fn,而不是FnOnce。所以,没有函数签名,因此而失败了。

use std::thread;

fn main(){
    
    let mut data = "123".to_string();
    
    thread::spawn(move||{
        println!("data is {}",data);
        data.push_str("cg");
    });
}

在闭包前面加上move就成功了,编译成功。

use std::thread;

fn main(){
    
    let mut data = "123".to_string();
    
    thread::spawn(||{
        //持有data的可变引用,FnMut Trait
        println!("data is {}",data);
        data.push_str("cd");
    });

    thread::spawn(||{
        //持有data的可变引用,FnMut Trait
        println!("data is {}",data);
        data.push_str("ma");
    });
}

从语义上看,如果闭包没有FnOnce的要求,多线程的闭包就可以以borrow value mutably来使用外部变量的话,就会可能产生数据冲突的问题。例如,一个线程获得data的可变引用,另外一个线程也获得data的可变引用,它们并发地修改同一个地址的内存,就会出现数据竞争的问题。

3.3 ’static作用域

另外,在spawn的签名,我们也看出,F函数也必须满足’static的作用域。也就是闭包里面的引用类型的变量,它的作用域必须是’static的,而不是普通的’a和’b。

use std::thread;

fn main(){
    
    let data = "123".to_string();
    let data_ref:&str = &data[..];
    
    thread::spawn(move ||{
        println!("data is {}",data_ref);
    });
}

例如,在上面这段代码了,即使加上了move关键字,依然无法编译通过,因为闭包不满足’static作用域的要求。data_ref的作用域仅仅在main函数中是有效的,一旦在main结束时就会无效,而’static作用域要求,data_ref必须指向全局变量。

use std::thread;

static data:&str = "123";

fn main(){
    let data_ref:&str = &data;
    
    thread::spawn(move ||{
        println!("data is {}",data_ref);
    });
}

我们将data移动到全局静态变量就可以了

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

fn main(){
    
    let data = "123".to_string();
    let data_ref:&str = &data[..];
    
    thread::spawn(move ||{
        //等待5秒
        thread::sleep(time::Duration::new(5, 0));

        //尝试读取悬空指针data_ref
        println!("data is {}",data_ref);
    });

    //data_ref的所有者已经结束了,释放内存了
}

从语义来看,要求多线程内的外部变量的引用类型必须’static作用域,是因为外部变量的作用域的生存期与多线程的生存期是很难分析,它们可能是重叠的,可能是不重叠的,所以是无法比较大小的。例如,在上面的例子中,如果没有’static的约束,由于两个作用域互不重叠,就会造成读取悬空指针的问题。但是,有’static约束,就能拒绝以上的代码。同时,’static的作用域保证在全局任何一个时候都是可读的,所以在多线程环境中不可能造成悬空指针的问题。

3.4 Send与Sync

好了,到了最烧脑的地方,也是rust让人拍案叫绝的时候了。在thread::spawn的函数签名中,闭包不仅需要FnOnce和’static的要求,还有要满足Send Trait的要求。Send Trait是什么,为什么要满足这个要求?不满足这个要求为什么会被rust编译拒绝,为什么可能会产生并发冲突的问题。

use std::rc::Rc;
use std::sync::{Mutex, Arc,RwLock};
use std::thread;

fn main(){
    let data:String = "hello".to_string();

    let rc1 = Rc::new(data);

    let rc2 = Rc::clone(&rc1);

    thread::spawn(move||{
        println!("{}",rc1);
    });

    thread::spawn(move||{
        println!("{}",rc2);
    });
}

在rust中,Send Trait的意义是说可以安全地move到多线程环境中的类型。显然,绝大部分的基础类型由于只有单一所有权的原则,一旦move以后,就只有一个线程拥有这个变量,所以是完全不可能出现数据冲突的问题的。除了一种可能,某个对象被Rc包装了,这个对象就被多个变量拥有了,这个对象具有了多个所有权。一旦这个对象move到多线程以后,每个线程都有所有权去操作这个对象,就有可能产生数据冲突的问题。但是,rust中阻止了这种做法,它要求发送到闭包的外部变量必须满足Send Trait,而Rc类型就恰好不满足这个Send Trait,因为Rc类型的实现不是原子计数的,在多线程环境下,无法正确地计算引用计数的数值,因此以上的代码会被编译器拒绝。

use std::rc::Rc;
use std::sync::{Mutex, Arc,RwLock};
use std::thread;

fn main(){
    let data:String = "hello".to_string();

    let rc1 = Arc::new(data);

    let rc2 = Arc::clone(&rc1);

    thread::spawn(move||{
        println!("{}",rc1);
    });

    thread::spawn(move||{
        println!("{}",rc2);
    });
}

因此,我们将Rc类型改为Arc类型,编译就通过了。由于Arc类型的原子计数的引用计数指针,所以他能在多线程环境中使用。与此同时,在rust的标准库中,Arc是实现了Send Trait的类型。从语义来说,Arc包装的类型都是不可变的,所以能在多线程环境下安全使用。

use std::sync::{Mutex, Arc,RwLock};
use std::thread;
use std::cell::RefCell;

fn main(){
    let data:String = "hello".to_string();

    let rc1 = Arc::new(RefCell::new(data));

    let rc2 = Arc::clone(&rc1);

    thread::spawn(move||{
        rc1.borrow_mut().push_str("mg");
        println!("{}",rc1.borrow());
    });

    thread::spawn(move||{
        rc2.borrow_mut().push_str("mk");
        println!("{}",rc2.borrow());
    });
}

好了,我们企图通过引入RefCell来实现内部可变性,从而试图在多线程环境中并发修改同一块内存的数据。但是,编译器拒绝了我们,它指出RefCell<String>类型无法在多线程环境下安全地共享。注意,是共享,不是发送。为什么,Arc类型满足了Send Trait,却依然会被编译器拒绝。

impl<T> Send for Arc<T>
where
    T: Send + Sync + ?Sized, 

我们翻开Arc类型的签名,我们就能看到,只有满足Arc包装的类型T同时满足了Send Trait和Sync Trait的时候,Arc<T>才会实现Send Trait。所以,在上一个例子编译不通过的原因是,RefCell类型没有满足Sync Trait,所以Arc<RefCell<T>>就没有满足Send Trait,因此rust拒绝了我们。在这里,我们可以看出Sync Trait的意义,它是协助Arc类型恰当地表达Send Trait的。也正因为如此,Sync Trait才被称为可以安全地share到多线程环境中的类型。

use std::sync::{Mutex, Arc,RwLock};
use std::thread;
use std::cell::RefCell;

fn main(){
    let data:String = "hello".to_string();

    let rc1 = Arc::new(Mutex::new(data));

    let rc2 = Arc::clone(&rc1);

    thread::spawn(move||{
        let mut data_ref = rc1.lock().unwrap();
        data_ref.push_str("mg");
        println!("{}",data_ref);
    });

    thread::spawn(move||{
        let mut data_ref = rc2.lock().unwrap();
        data_ref.push_str("mg");
        println!("{}",data_ref);
    });
}

这一次,我们老老实实地使用Mutex代替RefCell,这个时候编译终于通过了。因为Mutex实现了Sync Trait,所以Arc<Mutex<String>>就会实现Send Trait,因此编译通过。从语义上来说,因为Mutex能对同一块内存的数据加锁,所以才能安全地多线程环境中share同一个对象。

这是一个多么漂亮的设计,只用Send Trait和Sync Trait就能在编译时强迫开发者必须写出同一对象必须加锁(Mutex或者RWLock)的代码。

3.5 死锁

use std::sync::{Mutex, Arc,RwLock};
use std::thread;
use std::cell::RefCell;
use std::time;

fn main(){
    let data:String = "hello".to_string();

    let data2:String = "hello2".to_string();

    let rc1 = Arc::new(Mutex::new(data));
    let rc2 = Arc::clone(&rc1);

    let mc1 = Arc::new(Mutex::new(data2));
    let mc2 = Arc::clone(&mc1);


    let thread1 = thread::spawn(move||{
        let mut data_ref = rc1.lock().unwrap();
        //加入强制等待1秒,造成死锁
        thread::sleep(time::Duration::new(1, 0));

        let mut data_ref2 = mc1.lock().unwrap();
        data_ref.push_str("_a");
        data_ref2.push_str("_b");
        println!("exit one! data:{},data2:{}",data_ref,data_ref2);
    });

    let thread2 = thread::spawn(move||{
        let mut data_ref2 = mc2.lock().unwrap();
        //加入强制等待1秒,造成死锁
        thread::sleep(time::Duration::new(1, 0));

        let mut data_ref = rc2.lock().unwrap();
        data_ref.push_str("_a");
        data_ref2.push_str("_b");
        println!("exit two! data:{},data2:{}",data_ref,data_ref2);
    });

    thread1.join().unwrap();
    thread2.join().unwrap();
}

在上面的代码中,两个线程就无法退出,都在互相等待对方释放mutex锁,这就是死锁的问题。而且,上面这段代码可是能编译通过的哦。这意味着,rust并不保证编译器能检查出死锁的问题,它保证的并发安全仅仅是无数据冲突,并不包括无死锁!

use std::sync::{Mutex, Arc,RwLock};
use std::thread;
use std::cell::RefCell;
use std::time;

fn main(){
    let data:String = "hello".to_string();

    let data2:String = "hello2".to_string();

    let rc1 = Arc::new(Mutex::new(data));
    let rc2 = Arc::clone(&rc1);

    let mc1 = Arc::new(Mutex::new(data2));
    let mc2 = Arc::clone(&mc1);


    let thread1 = thread::spawn(move||{
        let mut data_ref = rc1.lock().unwrap();
        //加入强制等待1秒
        thread::sleep(time::Duration::new(1, 0));

        let mut data_ref2 = mc1.lock().unwrap();
        data_ref.push_str("_a");
        data_ref2.push_str("_b");
        println!("exit one! data:{},data2:{}",data_ref,data_ref2);
    });

    let thread2 = thread::spawn(move||{
        let mut data_ref = rc2.lock().unwrap();
        //加入强制等待1秒
        thread::sleep(time::Duration::new(1, 0));

        let mut data_ref2 = mc2.lock().unwrap();
        data_ref.push_str("_a");
        data_ref2.push_str("_b");
        println!("exit two! data:{},data2:{}",data_ref,data_ref2);
    });

    thread1.join().unwrap();
    thread2.join().unwrap();
}

解除死锁的方法很简单,将thread2的上锁顺序交换一下,就可以了。

3.6 小结

rust仅仅通过类型系统就能保证无数据冲突的问题,完全不需要加入其他任何的语言特性,这设计实在是很漂亮。但是,我们也需要知道,rust仅保证无并发数据冲突,并不保证无死锁等待的问题。所以,还是没有银弹呀,不要天真地以为编译通过了就是万事大吉。

4 总结

rust站在巨人的肩膀上,吸收了C++的模板问题,使用trait来约束模板参数。同时,引入了所有权和作用域的概念,来保证了内存安全和并发安全,这在以前任何一个编程语言都是未曾实现过的,这的确是一个壮举。当然,我们也探讨了,在一些极端的场景下,rust编译通过了,即使没有使用unsafe代码,却依然还是有内存泄漏的问题(循环引用),却依然还是有并发安全的问题(死锁)。

瑕不掩瑜,rust的总体设计依然非常漂亮,我非常看好它的前景,

相关文章