Appearance
29.10 总结
从前面的分析可见,Rust 的设计一方面禁止了我们在线程之间随意共享变量,另一方面提供了一些工具类型供我们使用,使我们可以安全地在线程之间共享变量。这既提供了完整的功能,又避免了数据竞争一类的 bug。Rust 之所以这么设计,是因为设计者观察到了发生“数据竞争”的根源是什么。简单总结就是:
Alias+Mutation+No ordering
实际上我们可以看到,Rust 保证内存安全的思路和线程安全的思路是一致的。在多线程中,我们要保证没有数据竞争,一般是通过下面的方式:
(1)多个线程可以同时读共享变量;
(2)只要存在一个线程在写共享变量,则不允许其他线程读/写共享变量。
这是不是与第二部分讲的“内存安全”的模型一模一样?这两个设计实际上是一脉相承的。如果没有“默认内存安全”打下的良好基础,Rust 就没办法做到“线程安全”;正因为在“内存安全”问题上的一系列基础性设计,才导致了“线程安全”基本就是水到渠成的结果。我们甚至可以观察到一些“线程安全类型”和“非线程安全类型”之间有趣的对应关系,比如:
(1)Rc 是非线程安全的,Arc 则是与它对应的线程安全版本。当然还有弱指针 Weak 也是一一对应的。Rc 无须考虑多线程场景下的问题,因此它内部只需普通整数做引用计数即可。Arc 要用在多线程场景,因此它内部必须使用“原子整数”来做引用计数。
(2)RefCell 是非线程安全的,它不能在跨线程场景使用。Mutex/RwLock 则是与它相对应的线程安全版本。它们都提供了“内部可变性”,RefCell 无须考虑多线程问题,所以它内部只需一个普通整数做借用计数即可。Mutex/RwLock 可以用在多线程环境,所以它们内部需要使用操作系统提供的原语来完成“锁”功能。它们有相似之处,也有不同之处。Mutex/RwLock 在加锁的时候返回的是 Result 类型,是因为它们需要考虑“异常安全”问题——在多线程环境下,很可能出现一个线程发生了 panic,导致 Mutex 内部的数据已经被破坏,而在另外一个线程中依然有可能观察到这个被破坏的数据结构。RefCell 则相对简单,只需考虑 AssertUnwindSafe 即可。
(3)Cell 是非线程安全的,Atomic*系列类型则是与它对应的线程安全版本。它们之间的相似之处在于,都提供了“内部可变性”,而且都不提供指向内部数据的方法。它们对内部数据的读写,都是整体读出、整体写入,不可能制造出直接指向内部数据的指针。它们的不同之处在于,Cell 的条件更宽松。而标准库提供的 Atomic*系列类型则受限于 CPU 提供的原子指令,内部存储的数据类型是有限的,无法推广到所有类型。其实我们完全可以仿造 Cell 类型,设计一个可以应用于所有类型的通用型Atomic<T>
类型——内部用 Mutex 实现,提供 get/set 方法作为对外 API。这个尝试已经在第三方开源库中实现,如需了解,上 GitHub 搜索“atomic-rs”即可。
Rust 的这套线程安全设计有以下好处:
免疫一切数据竞争;
无额外性能损耗;
无须与编译器紧耦合。
我们可以观察到一个有趣的现象:Rust 语言实际上并不知晓“线程”这个概念,相关类型都是写在标准库中的,与其他类型并无二致。Rust 语言提供的仅仅只是 Sync、Send 这样的一般性概念,以及生命周期分析、“borrow check”分析这样的机制。Rust 编译器本身并未与“线程安全”“数据竞争”等概念深度绑定,也不需要一个 runtime 来辅助完成功能。然而,通过这些基本概念和机制,它却实现了完全通过编译阶段静态检查实现“免除数据竞争”这样的目标。这样的设计正是 Rust 的魅力所在。
正因为解耦合如此彻底,Rust 语言才会如此精简,它只提供了非常基本的并行开发相关的基本抽象。而且标准库中实现的这些基本功能,其实都可以完全由第三方来实现。理论上来讲,其他语言中出现了的更高级的并行程序开发的抽象机制,一般都可以通过第三方库的方式来提供,没必要与 Rust 编译器深度绑定。在下一章中,我们再来介绍一些更高级的并行开发模式,它们都由第三方库来实现,无须编译器特殊支持。