Rust 能帮你捕获什么,又不能捕获什么

3 阅读11分钟

本内容是对 Some mistakes Rust doesn't catch的翻译与整理,有适当删减。


换一个角度看编程语言

作者开篇说了一句耐人寻味的话:

我对编程语言依然感到兴奋,但现在吸引我的,不再是它们让我做什么,而是它们不让我做什么

从理论上说,所有图灵完备的语言都能表达同样的计算。C 能做的事 JavaScript 也能做,Rust 能做的事 Go 也能做,最终都是同一台机器在跑同样的电。

但语言之间有一个真实的差异:合法程序的集合大小不同。把"编程"这件事理解为"在一个巨大的程序空间里找到那一个正确的程序"——一门语言越严格,它允许的"合法"程序组合就越少,搜索空间越小,找到正确程序的路径越短,犯错的机会也越少。

这篇文章用 JavaScript、Go、Rust 三门语言横向对比,看对同一类错误,三者分别是什么态度。


一、不可达代码

先来看一个简单例子:main 函数里在 return 之后还写了一句 bar() 调用。

JavaScript:

function foo(i) {
  console.log("foo", i);
}

function bar() {
  console.log("bar!");
}

function main() {
  for (i = 0; i < 3; i++) {
    foo(i);
  }
  return;
  bar();
}

main();
$ node sample.js
foo 0
foo 1
foo 2

安静地运行,没有任何警告。

Go 的 go build:

package main

import "log"

func foo(i int) {
  log.Printf("foo %d", i)
}

func bar() {
  log.Printf("bar!")
}

func main() {
  for i := 0; i < 3; i++ {
    foo(i)
  }
  return
  bar()
}
$ go build ./sample.go
$ ./sample
2022/02/06 17:35:55 foo 0
2022/02/06 17:35:55 foo 1
2022/02/06 17:35:55 foo 2

go build 静默通过,但 Go 自带的静态分析工具 go vet 会提示:

$ go vet ./sample.go
# command-line-arguments
./sample.go:18:2: unreachable code

它知道这段代码可疑,不过 go vet 本身不阻止编译。

Rust:

fn foo(i: usize) {
    println!("foo {}", i);
}

fn bar() {
    println!("bar!");
}

fn main() {
    for i in 0..=2 {
        foo(i)
    }
    return;
    bar()
}
$ cargo run
   Compiling lox v0.1.0 (/home/amos/bearcove/lox)
warning: unreachable expression
  --> src/main.rs:14:5
   |
13 |     return;
   |     ------ any code following this expression is unreachable
14 |     bar()
   |     ^^^^^ unreachable expression
   |
   = note: `#[warn(unreachable_code)]` on by default

warning: `lox` (bin "lox") generated 1 warning
    Finished dev [unoptimized + debuginfo] target(s) in 0.15s
     Running `target/debug/lox`
foo 0
foo 1
foo 2

Rust 不只告诉你哪里不可达,还告诉你为什么不可达(因为前面有 return)。

这仍然是警告,不阻止编译。如果想让它变成编译错误,在 main.rs 顶部加上:

#![deny(unreachable_code)]

相当于 gcc/clang 的 -Werror=unreachable-code


二、未定义符号:报错是现在,还是等运行时

现在把 bar定义删掉,但调用还留着。

JavaScript:

function foo(i) {
  console.log("foo", i);
}

function main() {
  for (i = 0; i < 3; i++) {
    foo(i);
  }
  return;
  bar();
}

main();
$ node sample.js
foo 0
foo 1
foo 2

由于 return 在前,bar() 从未执行到,JavaScript 完全不在乎 bar 存不存在。

如果去掉 return,才会在运行时报错:

function main() {
  for (i = 0; i < 3; i++) {
    foo(i);
  }
  // 去掉了 return
  bar();
}
$ node sample.js
foo 0
foo 1
foo 2
/home/amos/bearcove/lox/sample.js:10
  bar();
  ^

ReferenceError: bar is not defined

node.js 本质上是解释器,符号查找是运行时的事,用不到 bar 就不关心它存不存在。

Go:

func main() {
  for i := 0; i < 3; i++ {
    foo(i)
  }
  return
  bar()
}
$ go run ./sample.go
# command-line-arguments
./sample.go:14:2: undefined: bar

编译阶段直接报错,拒绝生成可执行文件。

Rust:

fn main() {
    for i in 0..=2 {
        foo(i)
    }
    return;
    bar()
}
$ cargo run
error[E0425]: cannot find function `bar` in this scope
  --> src/main.rs:10:5
   |
10 |     bar()
   |     ^^^ not found in this scope

warning: unreachable expression
  --> src/main.rs:10:5
   |
9  |     return;
   |     ------ any code following this expression is unreachable
10 |     bar()
   |     ^^^^^ unreachable expression

error: could not compile `lox` due to previous error; 1 warning emitted

Rust 不只报"找不到 bar",还额外提醒:即便 bar 存在,那行代码也永远不会被执行——两个问题都说清楚了。


三、悬空函数指针:Go 与 Rust 的分岔口

如果想用函数指针来模拟"这个函数可能存在也可能不存在"的情况,三门语言的差异更加鲜明。

Go 允许声明一个 nil 函数指针,但运行时会 panic:

package main

import "log"

func foo(i int) {
  log.Printf("foo %d", i)
}

type Bar func()

var bar Bar  // bar 是 nil

func main() {
  for i := 0; i < 3; i++ {
    foo(i)
  }
  bar()
}
$ go build ./sample.go
$ ./sample
2022/02/06 18:08:06 foo 0
2022/02/06 18:08:06 foo 1
2022/02/06 18:08:06 foo 2
panic: runtime error: invalid memory address or nil pointer dereference
[signal SIGSEGV: segmentation violation code=0x1 addr=0x0 pc=0x48756e]

bar 赋一个有效实现就不会崩溃:

func init() {
  bar = func() {
    log.Printf("bar!")
  }
}

Rust 不允许声明没有初始值的静态变量:

static BAR: fn();  // 编译错误
error: free static item without body
 --> src/main.rs:5:1
  |
5 | static BAR: fn();
  | ^^^^^^^^^^^^^^^^-
  |                 |
  |                 help: provide a definition for the static: `= <expr>;`

如果想表达"函数可能存在,也可能不存在",必须用 Option<fn()>,而且必须赋初始值:

static BAR: Option<fn()>;  // 还是编译错误,必须赋值
error: free static item without body
 --> src/main.rs:5:1

None 之后编译通过,但直接调用会报新的错误:

static BAR: Option<fn()> = None;

fn main() {
    for i in 0..=2 {
        foo(i)
    }
    BAR()  // 编译错误
}
error[E0618]: expected function, found enum variant `BAR`
   |
5  | static BAR: Option<fn()> = None;
   | -------------------------------- `BAR` defined here
...
11 |     BAR()
   |     ^^^--
   |     call expression requires function
   |
help: `BAR` is a unit variant, you need to write it without the parentheses

因为 Option<fn()> 不是函数,不能直接调用。Rust 强制你处理两种情况:

static BAR: Option<fn()> = None;

fn main() {
    for i in 0..=2 {
        foo(i)
    }
    match BAR {
        Some(f) => f(),
        None => println!("(no bar implementation found)"),
    }
}
$ cargo run
foo 0
foo 1
foo 2
(no bar implementation found)

BAR 换成 Some 变体,甚至可以在 Some 里直接定义函数:

static BAR: Option<fn()> = Some({
    fn bar_impl() {
        println!("bar!");
    }
    // 块的最后一个表达式就是块的求值结果
    bar_impl
});

fn main() {
    for i in 0..=2 {
        foo(i)
    }
    match BAR {
        Some(f) => f(),
        None => println!("(no bar implementation found)"),
    }
}
$ cargo run
foo 0
foo 1
foo 2
bar!

JavaScript 的松散不是疏漏,是设计

JavaScript 允许运行时向全局作用域注入符号。下面这段代码(不建议模仿)就是证明:

function foo(i) {
  console.log("foo", i);
}

eval(
  `mruhgr4hgx&C&./&CD&iutyurk4rum.(hgx'(/A`
    .split("")
    .map((c) => String.fromCharCode(c.charCodeAt(0) - 6))
    .join(""),
);

function main() {
  for (i = 0; i < 3; i++) {
    foo(i);
  }
  bar();
}

main();
$ node sample.js
foo 0
foo 1
foo 2
bar!

通过 eval 把混淆的字符串解码执行,bar 就注入进来了。这就是为什么 node.js 在编译阶段不检查符号:它根本不知道运行时会冒出什么。


安全 Rust 与 unsafe Rust

说"Rust 不让你创建悬空函数指针",这个说法需要加限定词:是安全 Rust

尝试用 unsafe 构造一个垃圾函数指针:

static BAR: fn() = unsafe { std::mem::transmute(&()) };
error[E0080]: it is undefined behavior to use this value
 --> src/main.rs:5:1
  |
5 | static BAR: fn() = unsafe { std::mem::transmute(&()) };
  | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ type validation failed: encountered pointer to alloc4, but expected a function pointer

编译期就被抓住了。

绕过去的方法是用裸指针:

const BAR: *const () = std::ptr::null();

fn main() {
    for i in 0..=2 {
        foo(i)
    }
    let bar: fn() = unsafe { std::mem::transmute(BAR) };
    bar();
}
$ cargo run
foo 0
foo 1
foo 2
zsh: segmentation fault (core dumped)  cargo run

能做到,但需要主动绕过安全检查,写 unsafe,明确承担责任。这不是意外发生的,是刻意为之的。

三门语言立场对比:

  • JavaScript:不关心符号是否存在,到运行时才查
  • Go:关心直接函数调用,但允许声明空函数指针,运行时可能 panic
  • Rust(安全代码):悬空函数指针在结构上不可能存在

四、类型系统与泛型

写一个对两个值做"加法"的泛型函数,三门语言展示了非常不同的思路。

JavaScript: 无类型约束,什么都能加:

function add(a, b) {
  return a + b;
}

function main() {
  console.log(add(1, 2));
  console.log(add("foo", "bar"));
}
$ node sample.js
3
foobar

Go 早期: 必须选一个具体类型,要同时支持数字和字符串,早期写法是 interface{}

func add(a interface{}, b interface{}) interface{} {
  if a, ok := a.(int); ok {
    if b, ok := b.(int); ok {
      return a + b
    }
  }
  if a, ok := a.(string); ok {
    if b, ok := b.(string); ok {
      return a + b
    }
  }
  panic("incompatible types")
}

add(1, "foo") 可以编译,但运行时 panic。

Go 1.18 泛型: 第一次尝试:

func add[T int64 | string](a T, b T "T int64 | string") T {
  return a + b
}
$ go run ./main.go
./main.go:10:22: int does not implement int64|string

intint64 不是同一个类型,需要参考官方类型参数提案,把所有支持 + 的类型全部列出来:

type Addable interface {
  ~int | ~int8 | ~int16 | ~int32 | ~int64 |
    ~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 | ~uintptr |
    ~float32 | ~float64 | ~complex64 | ~complex128 |
    ~string
}

func add[T Addable](a T, b T "T Addable") T {
  return a + b
}
$ go run ./main.go
2022/02/06 19:12:11 3
2022/02/06 19:12:11 foobar

能用,但表达的是"这些具体类型的列表",而不是"能做加法"这个性质本身。如果 Go 将来加了 int128,这里就要手动更新;如果用户想为自己的类型实现 +,也无法放进这个列表。

Rust: 只描述"能做加法"这个 trait bound:

use std::ops::Add;

fn add<T>(a: T, b: T) -> T::Output
where
    T: Add<T>,
{
    a + b
}

fn main() {
    dbg!(add(1, 2));
    dbg!(add("foo", "bar"));
}
$ cargo run
error[E0277]: cannot add `&str` to `&str`
  --> src/main.rs:12:10
   |
12 |     dbg!(add("foo", "bar"));
   |          ^^^ no implementation for `&str + &str`

意外:&str + &str 不允许。

这里值得解释清楚。&str 是字符串切片,只是一个指向某段数据的引用,数据本身存储在别处(比如可执行文件的 .rodata 段)。把 "foo""bar" 拼在一起,需要分配新内存来存放结果。Rust 不允许这个分配隐藏在 + 运算符背后。

可以用 objdump 确认 "foo""bar" 确实在可执行文件里:

$ objdump -s -j .rodata ./target/debug/lox | grep -B 3 -A 3 -E 'foo|bar'
 3c100 03000000 00000000 62617266 6f6f6164  ........barfooad

"foo""bar" 的有效期是整个程序运行期间,是 &'static str

允许的是 String + &str,而不是 &str + &str

标准库文档说:

String + &str 会消耗左边的 String(获取所有权),复用它的缓冲区。右边的 &str 只是被借用,内容被拷贝到结果里。这样避免了每次都分配新内存,构建长字符串时不会产生 O(n²) 的时间复杂度。

所以 &str + &str 没有实现,只有 String + &str 有。

把两个参数改成不同类型来绕过:

use std::ops::Add;

fn add<A, B>(a: A, b: B) -> A::Output
where
    A: Add<B>,
{
    a + b
}

fn main() {
    dbg!(add(1, 2));
    dbg!(add("foo".to_string(), "bar"));
}
$ cargo run
[src/main.rs:11] add(1, 2) = 3
[src/main.rs:12] add("foo".to_string(), "bar") = "foobar"

字符串所有权的一系列例子

这部分直接展示 Rust 在字符串操作上的所有权规则:

fn main() {
    // to_string() 分配内存,这不是隐藏的。+ 可能会重新分配(扩容缓冲区)
    let foobar = "foo".to_string() + "bar";
    dbg!(&foobar);
}
fn main() {
    let foo: String = "foo".into();
    let bar: String = "bar".into();

    // 不能编译:右边不能是 String,必须是 &str
    let foobar = foo + bar;
}
fn main() {
    let foo: String = "foo".into();
    let bar: String = "bar".into();

    // 可以编译:右边用引用 &bar
    let foobar = foo + &bar;
    dbg!(&foobar);
}
fn main() {
    let foo: String = "foo".into();
    let bar: String = "bar".into();

    let foobar = foo + &bar;
    dbg!(&foobar);

    // 不能编译!foo 在上面的 + 里被 move 了(所有权转移)
    let foobar = foo + &bar;
}
fn main() {
    let foo: String = "foo".into();
    let bar: String = "bar".into();

    // clone 分配新内存,这同样不是隐藏的
    let foobar = foo.clone() + &bar;
    dbg!(&foobar);

    // foo 还在,clone 保留了它
    let foobar = foo + &bar;
    dbg!(&foobar);
}

这个设计让一些人觉得烦。有人说 Rust 里有一个"更高层次的语言"藏在里面,等着被发掘——让你不用这么操心分配的版本。不过目前还在等待中。


五、并发:线程与 Mutex

进入文章最核心的部分。

场景: 两个线程同时对一个计数器做递增,各增 10 万次,期望结果是 20 万。


Go:无同步时的数据竞争

最直接的写法:

package main

import (
  "log"
  "sync"
)

func doWork(counter *int64, wg *sync.WaitGroup) {
  defer wg.Done()
  for i := 0; i < 100000; i++ {
    *counter += 1
  }
}

func main() {
  var wg sync.WaitGroup
  var counter int64 = 0

  for i := 0; i < 2; i++ {
    wg.Add(1)
    go doWork(&counter, &wg)
  }

  wg.Wait()
  log.Printf("counter = %v", counter)
}
$ go run ./sample.go
2022/02/07 15:02:18 counter = 158740
$ go run ./sample.go
2022/02/07 15:02:19 counter = 140789
$ go run ./sample.go
2022/02/07 15:02:19 counter = 200000
$ go run ./sample.go
2022/02/07 15:02:21 counter = 172553

结果每次不同,大多数情况下不是 20 万。数据竞争(data race)导致更新丢失。

go run -race 可以检测到:

$ go run -race ./sample.go
==================
WARNING: DATA RACE
Write at 0x... by goroutine ...:
  main.doWork(...)
==================

Rust:不上锁就无法访问数据

在 Rust 里,直接在线程间共享可变数据会被编译器拒绝。尝试用全局可变变量:

static mut COUNTER: u64 = 0;

fn do_work() {
    for _ in 0..100_000 {
        COUNTER += 1  // 编译错误
    }
}
error[E0133]: use of mutable static is unsafe and requires unsafe function or block
  |
5 |         COUNTER += 1
  |         ^^^^^^^^^^^^ use of mutable static
  |
  = note: mutable statics can be mutated by multiple threads: aliasing violations or data races will cause undefined behavior

尝试用线程共享局部变量:

fn do_work(counter: &mut u64) {
    for _ in 0..100_000 {
        *counter += 1
    }
}

fn main() {
    let mut counter: u64 = 0;
    let t1 = std::thread::spawn(|| do_work(&mut counter));
    let t2 = std::thread::spawn(|| do_work(&mut counter));
    // ...
}
error[E0373]: closure may outlive the current function, but it borrows `counter`

Rust 的借用检查器:线程可能比 main 存活更久,不能让线程持有对栈变量的可变引用。

每一条路都被堵死了,必须使用同步原语。


Go 的 Mutex 方案:数据和锁是两个独立变量

package main

import (
  "log"
  "sync"
)

func doWork(counter *int64, mutex *sync.Mutex) {
  for i := 0; i < 100000; i++ {
    mutex.Lock()
    *counter += 1
    mutex.Unlock()
  }
}

func main() {
  var wg sync.WaitGroup
  var counter int64 = 0
  var mutex sync.Mutex

  for i := 0; i < 2; i++ {
    wg.Add(1)
    go func() {
      defer wg.Done()
      doWork(&counter, &mutex)
    }()
  }

  wg.Wait()
  log.Printf("counter = %v", counter)
}
$ go run ./sample.go
2022/02/07 15:10:00 counter = 200000
$ go run ./sample.go
2022/02/07 15:10:01 counter = 200000

正确了。但问题是:Go 里的 Mutex 和 counter 是两个独立变量,没有任何机制阻止你直接访问 counter 而不经过锁。

问题一:不上锁就访问数据,编译器不报错:

func doWork(counter *int64, mutex *sync.Mutex) {
  for i := 0; i < 100000; i++ {
    // 直接修改,不上锁
    *counter += 1
  }
}

编译通过,但数据竞争又回来了。go buildgo vet 都不会发现这个问题。

可以用封装来缓解:

type ProtectedCounter struct {
  value int64
  mutex sync.Mutex
}

func (pc *ProtectedCounter) Increment() {
  pc.mutex.Lock()
  defer pc.mutex.Unlock()
  pc.value += 1
}

pc.value 还是可以直接访问。要真正防止直接访问,需要把 ProtectedCounter 移到另一个包,利用 Go 的包级别访问控制(小写字段 value 在包外不可见):

$ go build ./sample.go
# command-line-arguments
./sample.go:12:5: pc.value undefined (cannot refer to unexported field or method value)

这是 Go 目前能做到的最好的封装——把类型移到另一个包,靠包访问控制来限制。

问题二:忘记 Unlock,直接死锁:

func doWork(counter *int64, mutex *sync.Mutex) {
  for i := 0; i < 100000; i++ {
    mutex.Lock()
    *counter += 1
    // 忘记写 mutex.Unlock()
  }
}
$ go run ./sample.go
fatal error: all goroutines are asleep - deadlock!

goroutine 1 [semacquire]:
sync.runtime_Semacquire(0x0)
    /usr/local/go/src/runtime/sema.go:56
sync.(*WaitGroup).Wait(0xc000114000)
    /usr/local/go/src/sync/waitgroup.go:130 +0x71
main.main()
    /home/amos/bearcove/lox/sample.go:29 +0xfb

goroutine 18 [semacquire]:
sync.runtime_SemacquireMutex(0x0, 0x0, 0x0)
    /usr/local/go/src/runtime/sema.go:71 +0x25
sync.(*Mutex).lockSlow(0xc00013a018)
    /usr/local/go/src/sync/mutex.go:138 +0x165
sync.(*Mutex).Lock(...)
    /usr/local/go/src/sync/mutex.go:81
main.doWork(0xc00013a010, 0xc00013a018)
    /home/amos/bearcove/lox/sample.go:13

所有 goroutine 都在等待获取锁,而持有锁的那个永远不会释放。

defer mutex.Unlock() 是 Go 惯用的防忘写法,但有一个陷阱:

func doWork(counter *int64, mutex *sync.Mutex) {
  for i := 0; i < 100000; i++ {
    mutex.Lock()
    defer mutex.Unlock()  // 问题在这里
    *counter += 1
  }
}

defer 是在函数退出时执行,而不是在当前代码块结束时执行。这个循环里,mutex.Lock() 被调用了 10 万次,但 defer mutex.Unlock() 只会在函数返回时统一执行。结果:第一次迭代拿了锁,第二次迭代再试图拿锁,死锁。

正确写法是把循环体用匿名函数包裹,使 defer 在每次迭代结束时触发:

func doWork(counter *int64, mutex *sync.Mutex) {
  for i := 0; i < 100000; i++ {
    func() {
      mutex.Lock()
      defer mutex.Unlock()
      *counter += 1
    }()
  }
}

这是 Go 里一个不少见的踩坑点:defer 的作用域是函数,不是代码块。


如何调试 Go 的死锁:pprof

Go 有一个内置的性能分析工具 pprof,可以暴露当前所有 goroutine 的状态。死锁发生时可以查询:

import _ "net/http/pprof"
import "net/http"

// 注意:要用 go 关键字在独立 goroutine 里启动,否则会阻塞主线程
func main() {
  go func() {
    log.Println(http.ListenAndServe("localhost:6060", nil))
  }()
  // ...
}
$ curl 'http://localhost:6060/debug/pprof/goroutine?debug=1'
goroutine profile: total 7
2 @ ...
# sync.(*Mutex).Lock+0x57 /usr/local/go/src/sync/mutex.go:81
# main.doWork+0x6c /home/amos/bearcove/lox/sample.go:13

可以看到所有 goroutine 的调用栈,包括它们卡在哪里。

(作者顺带吐槽:写这个示例的时候自己也犯了"忘记用 goroutine 启动 server"的错误,以及"忘记用闭包包裹 log.Println(http.ListenAndServe(...))"的错误——go log.Println(...)go func() { log.Println(...) }() 行为不同,前者只把 log.Println 在新 goroutine 里运行,http.ListenAndServe 还是在当前线程阻塞了。)


Rust 的 Mutex:数据被包裹在锁里

use std::sync::{Arc, Mutex};

fn do_work(counter: &Mutex<u64>) {
    for _ in 0..100_000 {
        let mut counter = counter.lock().unwrap();
        *counter += 1
    }
}

fn main() {
    let counter: Arc<Mutex<u64>> = Default::default();
    let c1 = counter.clone();
    let c2 = counter.clone();

    let t1 = std::thread::spawn(move || do_work(&c1));
    let t2 = std::thread::spawn(move || do_work(&c2));

    t1.join().unwrap();
    t2.join().unwrap();

    println!("counter = {}", counter.lock().unwrap());
}
$ cargo run --quiet
counter = 200000
$ cargo run --quiet
counter = 200000
$ cargo run --quiet
counter = 200000

也可以用 crossbeam::scope 来避免 Arc,让 Rust 的生命周期检查器保证线程不会超出作用域:

use parking_lot::Mutex;

fn do_work(counter: &Mutex<u64>) {
    for _ in 0..100_000 {
        let mut counter = counter.lock();
        *counter += 1
    }
}

fn main() {
    let counter: Mutex<u64> = Default::default();
    crossbeam::scope(|s| {
        s.spawn(|_| do_work(&counter));
        s.spawn(|_| do_work(&counter));
    })
    .unwrap();
    println!("counter = {}", counter.lock())
}

这个版本用了 parking_lot::Mutex.lock() 不会返回 Result(不需要 .unwrap()),因为 parking_lot 不支持 mutex poisoning(见下)。

三个关键设计点:

1. 数据包裹在锁里,不上锁就无法访问。

Rust 里是 Mutex<u64>,不是 (Mutex, u64)。没有任何方式绕过 Mutex 直接访问 u64,类型系统从结构上保证了这一点。Go 里 Mutex 和数据是两个独立字段,任何拿到结构体引用的代码都可以直接操作数据字段。

2. MutexGuard 的 RAII:自动解锁,从结构上消除忘记 Unlock 的可能。

.lock() 返回的是 MutexGuard<u64>MutexGuard 实现了 Drop,当它离开作用域时(函数返回、块结束、显式 drop()),Mutex 自动解锁。开发者根本不需要手动写解锁,也不存在忘记写的可能。

Go 的 defer mutex.Unlock() 是一个习惯用法,依赖开发者的纪律;Rust 的 RAII 是语言机制强制执行的。

3. Mutex 中毒(Poisoning)。

std::sync::Mutex.lock() 返回 Result<MutexGuard<T>, PoisonError<MutexGuard<T>>>。如果一个线程在持有锁时 panic,Mutex 会被标记为"中毒"状态,之后其他线程 .lock() 会得到 Err

这是保守但合理的设计:线程 panic 可能发生在多步骤更新的中途,数据的不变量(invariant)可能已被破坏。中毒机制强迫调用方显式决定如何处理这种情况,而不是默默在一个不一致的状态上继续。

parking_lot::Mutex 不支持 poisoning,所以 .lock() 直接返回 MutexGuard,不需要 .unwrap()。两种选择各有取舍。


Rust 也无法在编译时捕获的:死锁

RAII 消除了"忘记解锁"导致的死锁,但没有消除所有死锁。

如果在同一个线程里对同一个 Mutex 上锁两次:

let lock1 = some_mutex.lock().unwrap();
// 做一些事...
let lock2 = some_mutex.lock().unwrap(); // 同一线程二次上锁 → 死锁

或者两个线程各自持有一把锁,同时等待对方释放(经典的 ABBA 死锁):

// 线程 1
let _a = mutex_a.lock();
let _b = mutex_b.lock();  // 等 mutex_b

// 线程 2
let _b = mutex_b.lock();
let _a = mutex_a.lock();  // 等 mutex_a,死锁

这些是运行时的逻辑问题,类型系统无法在编译期捕获。

Rust 生态里用于诊断这类问题的工具:

  • 同步代码:parking_lot crate 提供实验性的 deadlock detector 特性
  • 异步代码:tokio-console,可以实时查看所有任务的状态和等待关系

(作者注:parking_lot 的 deadlock detector 和 send_guard 特性不兼容,实际使用可能受依赖树的限制。)


整体对比

问题JavaScriptGoRust(安全代码)
不可达代码不管go vet 警告,不拦截编译编译器警告,可升级为错误
未定义符号运行时才报错编译错误编译错误
悬空函数指针可能通过 eval 等注入允许声明,运行时 panic结构上不可能
不上锁就访问数据无类型系统允许,靠包封装缓解不上锁就拿不到数据
忘记 Unlock无类型系统运行时死锁RAII 自动解锁,不可能忘记
Mutex 中毒保护std::sync::Mutex 内置
逻辑层面的死锁无法捕获无法捕获无法捕获,需运行时工具

小结

这篇文章用一系列具体的代码演进,展示了"更严格的语言"在实践中意味着什么。

Rust 的核心贡献不是新增了什么能力,而是把一整类错误从"运行时可能发生"变成了"编译期结构上不可能":悬空指针不存在,未初始化的变量不存在,绕过锁直接操作数据不存在,忘记解锁不存在。

但 Rust 也有它不能捕获的地方——死锁、竞态条件(业务逻辑层面的),以及所有类型系统天然无法感知的逻辑错误。这些需要运行时工具、测试、和良好的工程纪律来覆盖。

作者在开篇的那个观点,读到最后会越来越有感触:语言的价值,越来越多地体现在它拒绝让你做什么,而不是让你做什么。


参考链接