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

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


换一个角度看编程语言

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

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

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

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

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


一、不可达代码

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

JavaScript:

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();
ruby 复制代码
$ node sample.js
foo 0
foo 1
foo 2

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

Go 的 go build:

go 复制代码
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()
}
shell 复制代码
$ 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 会提示:

bash 复制代码
$ go vet ./sample.go
# command-line-arguments
./sample.go:18:2: unreachable code

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

Rust:

rust 复制代码
fn foo(i: usize) {
    println!("foo {}", i);
}

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

fn main() {
    for i in 0..=2 {
        foo(i)
    }
    return;
    bar()
}
typescript 复制代码
$ 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 顶部加上:

rust 复制代码
#![deny(unreachable_code)]

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


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

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

JavaScript:

javascript 复制代码
function foo(i) {
  console.log("foo", i);
}

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

main();
ruby 复制代码
$ node sample.js
foo 0
foo 1
foo 2

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

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

javascript 复制代码
function main() {
  for (i = 0; i < 3; i++) {
    foo(i);
  }
  // 去掉了 return
  bar();
}
css 复制代码
$ 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:

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

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

Rust:

rust 复制代码
fn main() {
    for i in 0..=2 {
        foo(i)
    }
    return;
    bar()
}
lua 复制代码
$ 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:

go 复制代码
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 复制代码
$ 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 赋一个有效实现就不会崩溃:

go 复制代码
func init() {
  bar = func() {
    log.Printf("bar!")
  }
}

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

rust 复制代码
static BAR: fn();  // 编译错误
sql 复制代码
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()>,而且必须赋初始值:

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

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

rust 复制代码
static BAR: Option<fn()> = None;

fn main() {
    for i in 0..=2 {
        foo(i)
    }
    BAR()  // 编译错误
}
vbnet 复制代码
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 强制你处理两种情况:

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)"),
    }
}
arduino 复制代码
$ cargo run
foo 0
foo 1
foo 2
(no bar implementation found)

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

rust 复制代码
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)"),
    }
}
arduino 复制代码
$ cargo run
foo 0
foo 1
foo 2
bar!

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

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();
ruby 复制代码
$ node sample.js
foo 0
foo 1
foo 2
bar!

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


安全 Rust 与 unsafe Rust

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

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

rust 复制代码
static BAR: fn() = unsafe { std::mem::transmute(&()) };
rust 复制代码
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

编译期就被抓住了。

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

rust 复制代码
const BAR: *const () = std::ptr::null();

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

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

三门语言立场对比:

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

四、类型系统与泛型

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

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

javascript 复制代码
function add(a, b) {
  return a + b;
}

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

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

go 复制代码
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 泛型: 第一次尝试:

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

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

go 复制代码
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 复制代码
$ go run ./main.go
2022/02/06 19:12:11 3
2022/02/06 19:12:11 foobar

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

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

rust 复制代码
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"));
}
rust 复制代码
$ 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" 确实在可执行文件里:

shell 复制代码
$ 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 有。

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

rust 复制代码
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"));
}
csharp 复制代码
$ cargo run
[src/main.rs:11] add(1, 2) = 3
[src/main.rs:12] add("foo".to_string(), "bar") = "foobar"

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

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

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

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

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

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

    // 不能编译!foo 在上面的 + 里被 move 了(所有权转移)
    let foobar = foo + &bar;
}
rust 复制代码
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:无同步时的数据竞争

最直接的写法:

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)
}
ini 复制代码
$ 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 可以检测到:

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

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

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

rust 复制代码
static mut COUNTER: u64 = 0;

fn do_work() {
    for _ in 0..100_000 {
        COUNTER += 1  // 编译错误
    }
}
arduino 复制代码
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

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

rust 复制代码
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));
    // ...
}
go 复制代码
error[E0373]: closure may outlive the current function, but it borrows `counter`

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

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


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

go 复制代码
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 复制代码
$ 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 而不经过锁。

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

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

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

可以用封装来缓解:

go 复制代码
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 在包外不可见):

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

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

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

go 复制代码
func doWork(counter *int64, mutex *sync.Mutex) {
  for i := 0; i < 100000; i++ {
    mutex.Lock()
    *counter += 1
    // 忘记写 mutex.Unlock()
  }
}
bash 复制代码
$ 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 惯用的防忘写法,但有一个陷阱:

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 在每次迭代结束时触发:

go 复制代码
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 的状态。死锁发生时可以查询:

go 复制代码
import _ "net/http/pprof"
import "net/http"

// 注意:要用 go 关键字在独立 goroutine 里启动,否则会阻塞主线程
func main() {
  go func() {
    log.Println(http.ListenAndServe("localhost:6060", nil))
  }()
  // ...
}
shell 复制代码
$ 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:数据被包裹在锁里

rust 复制代码
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());
}
ini 复制代码
$ cargo run --quiet
counter = 200000
$ cargo run --quiet
counter = 200000
$ cargo run --quiet
counter = 200000

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

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 上锁两次:

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

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

rust 复制代码
// 线程 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 特性不兼容,实际使用可能受依赖树的限制。)


整体对比

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

小结

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

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

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

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


参考链接

相关推荐
ZHOUPUYU1 小时前
PHP8高性能Web开发实战指南
后端·html·php
fliter1 小时前
一个 Emoji 是怎么让 rust-analyzer 崩溃的
后端
天涯明月19931 小时前
AEnvironment深度研究报告
人工智能·后端·云原生
springXu1 小时前
windows arm64上的VS CODE的GoLang环境的搭建
开发语言·后端·golang
怕浪猫2 小时前
听说后端又死了?AI 时代前端后端都怎么样了
后端·面试
IT_陈寒2 小时前
Redis突然吃掉所有内存,我的服务差点挂了
前端·人工智能·后端
鹏程十八少2 小时前
Android TransactionTooLargeException 的真相与修复:从 1.13MB Bundle 到 Binder 内核的完整剖析
前端·后端·面试
geovindu2 小时前
go: Monitor Pattern
开发语言·后端·设计模式·golang·监控模式
ZHOUPUYU3 小时前
PHP 开发实战:从零搭建一个高性能的 RESTful API 服务
运维·开发语言·后端·html·php