本内容是对 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
int 和 int64 不是同一个类型,需要参考官方类型参数提案,把所有支持 + 的类型全部列出来:
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 build 和 go 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_lotcrate 提供实验性的 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 也有它不能捕获的地方------死锁、竞态条件(业务逻辑层面的),以及所有类型系统天然无法感知的逻辑错误。这些需要运行时工具、测试、和良好的工程纪律来覆盖。
作者在开篇的那个观点,读到最后会越来越有感触:语言的价值,越来越多地体现在它拒绝让你做什么,而不是让你做什么。
参考链接
- 原文:fasterthanli.me/articles/so...
- Rust
std::sync::Mutex文档:doc.rust-lang.org/std/sync/st... - Rust
std::ops::Add文档:doc.rust-lang.org/std/ops/tra... parking_lotcrate:docs.rs/parking_lotcrossbeamcrate:docs.rs/crossbeamtokio-console(异步运行时调试工具):github.com/tokio-rs/co...- Go pprof 文档:pkg.go.dev/net/http/pp...