Skip to content

27.3 免数据竞争

粗看起来,Rust 的多线程 API 很简单。但其实,其表面的简洁之下隐藏着关键的创新设计。正可谓:

胸有激雷而面如平湖者,可拜上将军也!

为了说明 Rust 在多线程方面的威力,我们来做几个实验,看看如果用多个线程读写同一个变量会发生什么情况。

我们创建一个子线程,用它修改一个外部变量:


rust
use std::thread;
fn main() {
    let mut health = 12;

    thread::spawn( || {
        health *= 2;
    });
    println!("{}", health);
}

编译,发生错误。错误信息为:


rust
error: closure may outlive the current function, but it borrows health, which is owned by the current function

根据前面的知识可以知道,spawn 函数接受的参数是一个闭包。我们在闭包里面引用了函数体内的局部变量,而这个闭包是运行在另外一个线程上,编译器无法肯定局部变量 health 的生命周期一定大于闭包的生命周期,于是发生了错误。

那我们对这个程序做一个修改,把闭包加上 move 修饰。再次编译,可见编译错误已经消失。但是执行发现,变量 health 的值并未发生改变。为什么呢?因为 health 是 Copy 类型,在遇到 move 型闭包的时候,闭包内的 health 实际上是一份新的复制,外面的变量并没有被真正修改。

如果我们使用的是非 Copy 类型,又会怎样呢?


rust
use std::thread;
fn main() {
    let mut v : Vec<i32> = vec![];

    thread::spawn( || {
        v.push(1);
    });

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

编译,出现同样的错误。再次尝试给闭包加上 move,还是出现编译错误:


rust
error: use of moved value: v

这个错误也好理解:既然我们已经把 v 移动到了闭包里面,那么它在本函数内就不能再继续使用了,因为其所有权已经移走了。

以上这几个试验全部失败了,那我们究竟怎样做才能让一个变量在不同线程中共享呢?

答案是:我们没有办法在多线程中直接读写普通的共享变量,除非使用 Rust 提供的线程安全相关的设施 。

也就是说,Rust 给我们提供了一个重要的安全保证:

The compiler prevents all data races.

“data race”即数据竞争,意思是在多线程程序中,不同线程在没有使用同步的条件下并行访问同一块数据,且其中至少有一个是写操作的情况。

在笔者看来,这是一项革命性 的进步,非常值得关注。

在许多传统(非函数式)的编程语言中,并行程序设计是非常困难的,原因就在于代码中存在大量的共享状态和很多隐藏的数据依赖。程序员必须非常清楚代码的流程,使用合适的策略正确实现并发控制。而万一某人在某个地方犯了一个小错误,那么这个程序就成了不安全的,而且没有什么静态检查工具可以保证完整无遗漏地将此类问题检查出来。对于一份规模比较大的 C/C++源代码,我们没有什么好办法“证明”一个程序是不是“线程安全”的。况且,人非圣贤,孰能无过,就像墨菲定律说的那样:

Anything that can go wrong,will go wrong.——Murphey’s Law

有很多人尝试过很多办法,来从根源上解决数据竞争(Data race)的问题。根据数据竞争的定义,它的发生需要三个条件:

  • 数据共享——有多个线程同时访问一份数据;

  • 数据修改——至少存在一个线程对数据做修改;

  • 没有同步——至少存在一个线程对数据的访问没有使用同步措施。

我们只要让这三个条件无法同时发生即可:

  • 可以禁止数据共享,比如 actor-based concurrency,多线程之间的通信仅靠发送消息来实现,而不是通过共享数据来实现;

  • 可以禁止数据修改,比如 functional programming,许多函数式编程语言严格限制了数据的可变性,而对共享性没有限制。

然而以上设计在许多情况下都有一些性能上的缺陷,无法达到“零开销抽象”的目的。

Rust 并没有盲目跟随传统语言的脚步设计。Rust 允许存在可变变量,允许存在状态共享,同时也做到了完整无遗漏的 线程安全检查。因为 Rust 设计的一个核心思想就是“共享不可变,可变不共享”,然后再加上类型系统和合理的 API 设计,就可以保证共享数据在访问时一定使用了同步措施。Rust 既可以支持多线程数据共享的风格,也可以支持消息通信的风格。无论选择哪种方案,编译器都能保证没有数据竞争。

请注意这个区别:我们不是说传统 C/C++就无法做到线程安全,而是说,传统 C/C++需要依赖程序员不犯错误来保证线程安全;而 Rust 是由工具自动保证的,这个保证更稳定、更可靠、更有底气。虽然 C/C++里面也有许多静态检查工具,可以辅助我们自动发现一些线程安全问题,但是由于 C/C++灵活度太大、自由度太高,因此可以肯定的是没有任何一款静态检查工具可以保证百分百“无遗漏、无误报”的线程安全检查。现在没有,将来也不可能有。所以不得不依赖程序员的水平。在代码规模大到一定程度之后,这种做法是不可靠的,不论一个人实力有多强,总有马虎的时候、疲惫的时候、情绪不良的时候,偶尔犯错是不可避免的,更何况大规模的团队存在管理配合问题、人员流动交接问题等,一不小心就会埋下一个隐患。作为对比,Rust 对线程的安全检查是稳定的、可靠的,不因时因地而有所波动,不因代码量的多少或复杂程度而懈怠。这个特点,对于某些对安全性要求很高的场景具有非同寻常的意义。

这个区别赋予了 Rust 一种特殊的能力,它使 Rust 的使用者有了更强大的自信,让 Rust 的使用者有胆量使用更激进的并行优化。

Released under the MIT License