5.8.选择你的保证

优质
小牛编辑
127浏览
2023-12-01

Rust 的一个重要特性是允许我们控制一个程序的开销和(安全)保证。

Rust 标准库中有多种“wrapper 类型”的抽象,他们代表了大量在开销,工程学和安全保证之间的权衡。很多让你在运行时和编译时增强之间选择。这一部分将会详细解释一些特定的抽象。

在开始之前,强烈建议你阅读 Rust 的所有权借用

基础指针类型

Box<T>

Box\是一个“自我拥有的”,或者“装箱”的指针。因为它可以维持引用和包含的数据,它是数据的唯一的拥有者。特别的,当执行类似如下代码时:

let x = Box::new(1);
let y = x;
// `x` is no longer accessible here.

这里,装箱被移动进了y。因为x不再拥有它,此后编译器不再允许程序猿使用x。相似的一个函数可以通过返回装箱来移出函数。

当一个装箱(还没有被移动的)离开了作用域,析构函数将会运行。这个析构函数负责释放内部的数据。

这是一个动态分配的零开销抽象。如果你想要在堆上分配一些内存并安全的传递这些内存的指针,这是理想的情况。注意你将只能通过正常的借用规则来共享引用,这些在编译时被检查。

&T&mut T

这分别是不可变和可变引用。他们遵循“读写锁”的模式,也就是你只可能拥有一个数据的可变引用,或者任意数量的不可变引用,但不是两者都有。这个保证在编译时执行,并且没有明显的运行时开销。在大部分情况这两个指针类型有能力在代码块之间廉价的共享引用。

这些指针不能在超出他们的生命周期的情况下被拷贝。

*const T*mut T

这些是C风格的指针,并没附加生命周期或所有权。他们只是指向一些内存位置,没有其他的限制。他们能提供的唯一的保证是除非在标记为unsafe的代码中他们不会被解引用。

他们在构建像Vec<T>这样的安全,低开销抽象时是有用的,不过应该避免在安全代码中使用。

Rc<T>

这是第一个我们将会介绍到的有运行时开销的包装类型。

Rc\是一个引用计数指针。换句话说,这让我们拥有相同数据的多个“有所有权”的指针,并且数据在所有指针离开作用域后将被释放(析构函数将会执行)。

在内部,它包含一个共享的“引用计数”(也叫做“refcount”),每次Rc被拷贝时递增,而每次Rc离开作用域时递减。Rc<T>的主要职责是确保共享的数据的析构函数被调用。

这里内部的数据是不可变的,并且如果创建了一个循环引用,数据将会泄露。如果我们想要数据在存在循环引用时不被泄漏,我们需要一个垃圾回收器。

保证

这里(Rc<T>)提供的主要保证是,直到所有引用离开作用域后,相关数据才会被销毁。

当我们想要动态分配并在程序的不同部分共享一些(只读)数据,且不确定哪部分程序会最后使用这个指针时,我们应该用Rc<T>。当&T不可能静态地检查正确性,或者程序员不想浪费开发时间编写反人类的代码时,它可以作为&T的可行的替代。

这个指针并不是线程安全的,并且Rust也不会允许它被传递或共享给别的线程。这允许你避免在不必要的情况下的原子性开销。

Rc<T>有个姐妹版智能指针类型——Weak<T>。它是一个既没有所有权、也不能被借用的智能指针。它也比较像&T,但并没有生命周期的限制--一个Weak<T>可以一直存活。然而,尝试对其内部数据进行访问可能失败并返回None,因为它可以比有所有权的Rc存活更久。这对循环数据结构和一些其他类型是有用的。

开销

随着内存使用增加,Rc<T>是一次性的分配,虽然相比一个常规Box<T>它会多分配额外两个字(也就是说,两个usize值)。(“强”引用计数相比“弱”引用计数)。

Rc<T>分别在拷贝和离开作用域时会产生递增/递减引用计数的计算型开销。注意拷贝将不会进行一次深度复制,相反它会简单的递增内部引用计数并返回一个Rc<T>的拷贝。

Cell 类型

Cell提供内部可变性。换句话说,他们包含的数据可以被修改,即便是这个类型并不能以可变形式获取(例如,当他们位于一个&指针或Rc<T>之后时)。

对此cell模块的文档有一个非常好的解释

这些类型经常在结构体字段中出现,不过他们也可能在其他一些地方找到。

Cell<T>

Cell\是一个提供了零开销内部可变性的类型,不过只用于Copy类型。因为编译器知道它包含的值对应的所有数据都位于栈上,所以并没有通过简单的替换数据而导致任何位于引用之后的数据泄露(或者更糟!)的担心。

然而使用这个封装仍有可能违反你自己的不可变性,所以谨慎的使用它。它是一个很好的标识,表明一些数据块是可变的并且可能在你第一次读取它和当你想要使用它时的值并不一样。

use std::cell::Cell;

let x = Cell::new(1);
let y = &x;
let z = &x;
x.set(2);
y.set(3);
z.set(4);
println!("{}", x.get());

注意这里我们可以通过多个不可变的引用改变相同的值。

这与如下代码有相同的运行时开销:

let mut x = 1;
let y = &mut x;
let z = &mut x;
x = 2;
*y = 3;
*z = 4;
println!("{}", x);

不过它有额外的优势,它确实能够编译成功。(高级黑?)

保证

这个类型放宽了当没有必要时“没有因可变性导致的混淆”的限制。然而,这也放宽了这个限制提供的保证;所以当你的不可变量依赖存储在Cell中的数据,你应该多加小心。

这对改变基本类型和其他Copy类型非常有用,当通过&&mut的静态规则并没有其他简单合适的方法改变他们的值时。

Cell并不让你获取数据的内部引用,它让我们可以自由改变值。

开销

使用Cell<T>并没有运行时开销,不过你使用它来封装一个很大的(Copy)结构体,可能更适合封装单独的字段为Cell<T>因为每次写入都会是一个结构体的完整拷贝。

RefCell<T>

RefCell\也提供了内部可变性,不过并不限制为Copy类型。

相对的,它有运行时开销。RefCell<T>在运行时使用了读写锁模式,不像&T/&mut T那样在编译时执行。这通过borrow()borrow_mut()函数来实现,它修改一个内部引用计数并分别返回可以不可变的和可变的解引用的智能指针。当智能指针离开作用域引用计数将被恢复。通过这个系统,我们可以动态的确保当有一个有效的可变借用时绝不会有任何其他有效的借用。如果程序猿尝试创建一个这样的借用,线程将会恐慌。

use std::cell::RefCell;

let x = RefCell::new(vec![1,2,3,4]);
{
    println!("{:?}", *x.borrow())
}

{
    let mut my_ref = x.borrow_mut();
    my_ref.push(1);
}

Cell相似,它主要用于难以或不可能满足借用检查的情况。大体上我们知道这样的改变不会发生在一个嵌套的形式中,不过检查一下是有好处的。

对于大型的,复杂的程序,把一些东西放入RefCell来将事情变简单是有用的。例如,Rust 编译器内部的ctxt结构体中的很多 map 都在这个封装中。他们只会在创建时被修改一次(但并不是正好在初始化后),或者在明显分开的地方多次多次修改。然而,因为这个结构体被广泛的用于各个地方,有效的组织可变和不可变的指针将会是困难的(也许是不可能的),并且可能产生大量的难以扩展的&指针。换句话说,RefCell提供了一个廉价(并不是零开销)的方式来访问它。之后,如果有人增加一些代码来尝试修改一个已经被借用的 cell 时,这将会产生(通常是决定性的)一个恐慌,并会被追溯到那个可恶的借用上。

相似的,在 Servo 的 DOM 中有很多可变量,大部分对于一个 DOM 类型都是本地的,不过有一些交错在 DOM 中并修改了很多内容。使用RefCellCell来保护所有的变化可以让我们免于担心到处都是的可变性,并且同时也表明了何处正在发生变化。

注意如果是一个能用&指针的非常简单的情形应该避免使用RefCell

保证

RefCell放宽了避免混淆的改变的静态限制,并代之以一个动态限制。保证本身并没有改变。

开销

RefCell并不分配空间,不过它连同数据还包含一个额外的“借用状态”指示器(一个字的大小)。

在运行时每次借用产生一次引用计数的修改/检查。

同步类型(Synchronous types)

上面的很多类型不能以一种线程安全的方式使用。特别是Rc<T>RefCell<T>,他们都使用非原子的引用计数(原子引用计数可以在不引起数据竞争的情况下在多个线程中递增),不能在多线程中使用。这让他们使用起来更廉价,不过我们也需要这两个类型的线程安全版本。他们以Arc<T>Mutex<T>/RWLock<T>的形式存在。

注意非线程安全的类型不能在线程间传递,并且这是在编译时检查的。

Arc<T>

Arc\就是一个使用原子引用计数版本的Rc<T>Atomic reference count,因此是“Arc”)。它可以在线程间自由的传递。

C++的shared_ptrArc类似,然而C++的情况中它的内部数据总是可以改变的。为了语义上与C++的形式相似,我们应该使用Arc<Mutex<T>>Arc<RwLock<T>>,或者Arc<UnsafeCell<T>>1。最后一个应该只被用在我们能确定使用它并不会造成内存不安全性的情况下。记住写入一个结构体不是一个原子操作,并且很多像vec.push()这样的函数可以在内部重新分配内存并产生不安全的行为,所以即便是单一环境也不足以证明UnsafeCell是安全的。

保证

类似Rc,它提供了当最后的Arc离开作用域时(不包含任何的循环引用)其内部数据的析构函数将被执行的(线程安全的)保证。

开销

使用原子引用计数有额外的开销(无论是被拷贝或者离开作用域时都会发生)。当在一个单独的线程中通过一个Arc共享数据时,任何时候都更倾向于使用&指针。

Mutex<T>RwLock<T>

Mutex\RwLock\通过 RAII guard(guard 是一类直到析构函数被调用时能保持一些状态的对象)提供了互斥功能。对于这两个类型,mutex 直到我们调用lock()之前它都是无效的,此时直到我们获取锁这个线程都会被阻塞,同时它会返回一个 guard。这个 guard 可以被用来访问它的内部数据(可变的),而当 guard 离开作用域锁将被释放。

{
    let guard = mutex.lock();
    // `guard` dereferences mutably to the inner type.
    *guard += 1;
} // Lock is released when destructor runs.

RwLock对多线程读有额外的效率优势。只要没有 writer,对于共享的数据总是可以安全的拥有多个 reader;同时RwLock让reader们获取一个“读取锁”。这样的锁可以并发的获取并通过引用计数记录。writer 必须获取一个“写入锁”,它只有在所有 reader 都离开作用域时才能获取。

保证

这两个类型都提供了线程间安全的共享可变性,然而他们易于产生死锁。一些额外的协议层次的安全性可以通过类型系统获取。

开销

他们在内部使用类原子类型来维持锁,这样的开销非常大(他们可以阻塞处理器所有的内存读取知道他们执行完毕)。而当有很多并发访问时等待这些锁也将是很慢的。

组合(Composition)

阅读 Rust 代码时的一个常见的痛苦之处是遇到形如Rc<RefCell<Vec<T>>>这样的类型(或者诸如此类的更复杂的组合)。这些组合式干什么的,和为什么作者会选这么一个类型(以及何时你应该在自己的代码中使用这样一个类型)的理由并不总是显而易见的。

通常,将你需要的保证组合到一起是一个例子,而不为无关紧要的东西产生开销。

例如,Rc<RefCell<T>>就是一个这样的组合。Rc<T>自身并不能可变的解引用;因为Rc<T>可以共享,而共享的可变性可以导致不安全的行为,所以我们在其中放入RefCell<T>来获得可以动态验证的共享可变性。现在我们有了共享的可变数据,不过它只能以只有一个 writer(没有 reader)或多个 reader 的方式共享。

现在,我们可以更进一步,并拥有Rc<RefCell<Vec<T>>>Rc<Vec<RefCell<T>>>,他们都是可共享可改变的vector,不过他们并不一样。

前者,RefCell<T>封装了Vec<T>,所以Vec<T>整体是可变的。与此同时,同一时刻只能有一个整个Vec的可变借用。这意味着你的代码不能同时通过不同的Rc句柄来操作 vector 的不同元素。然而,我们可以随意的从Vec<T>中加入或取出元素。这类似于一个有运行时借用检查的&mut Vec<T>

后者,借用作用于单独的元素,不过vector整体是不可变的。因此,我们可以独立的借用不同的元素,不过我们对vector加入或取出元素。这类似于&mut [T]2,不过同样会在运行时做借用检查。

在并发程序中,我们有一个使用Arc<Mutex<T>>的类似场景,它提供了共享可变性和所有权。

当阅读使用这些类型的代码时,一步步的阅读并关注他们提供的保证/开销。

当选择一个组合类型的时候,我们必须反过来思考;搞清楚我们需要何种保证,以及在组合中的何处我们需要他们。例如,如果面对一个Vec<RefCell<T>>RefCell<Vec<T>>之间的选择,我们需要明确像上面讲到的那样的权衡并选择其一。


1. Arc<UnsafeCell<T>>实际上并不能编译因为UnsafeCell<T>并不是SendSync的,不过我们可以把它 wrap 进一个类型并且手动为其实现Send/Sync来获得Arc<Wrapper<T>>,它的Wrapperstruct Wrapper<T>(UnsafeCell<T>)
2. &[T]&mut [T]切片(slice);他们包含一个指针和一个长度并可以引用一个vector或数组的一部分。&mut [T]能够改变它的元素,不过长度不能改变。