一句话总结
thread::spawn 启动一个操作系统线程, 接收一个闭包作为线程体.
因为线程可能比创建它的函数活得久, 闭包必须用 move 夺取所捕获变量的所有权--这是借用规则在并发场景的自然延伸.
JoinHandle 用于等待线程结束, 取回返回值.
起一个线程
Rust 的线程是真正的操作系统线程(1:1 模型), 用 std::thread::spawn 启动, 传入一个闭包作为线程要执行的代码:
|
thread::spawn(闭包) 对应 Go 的 go func(){...}(): 都是"把一段代码丢到另一个执行流去跑".
但有个根本差异--Go 的 goroutine 是运行时调度的轻量协程(几 KB 栈, 可同时几十万个), Rust 的 thread 是操作系统线程(MB 级栈, 数量受限). 这个差异决定了高并发场景 Rust 要走异步(后续分类), 而非堆线程.
1:1 线程模型, 与 Go 的 M:N 不同
Go runtime 把大量 goroutine 多路复用到少量 OS 线程上(M:N 调度). Rust 标准库的 thread 是 1:1--一个 thread::spawn 就是一个 OS 线程, 由内核调度.
Rust 不内建协程调度器(这与不内建异步运行时是同一哲学), 把"要不要 M:N"的选择交给异步生态(如 Tokio). 本系列讲的是 OS 线程, 异步留到专门分类.
JoinHandle: 等待与取返回值
spawn 返回一个 JoinHandle. 调用它的 .join() 会阻塞当前线程, 直到对应子线程结束; 闭包的返回值也通过它取回:
|
不 join 会怎样
如果不调用 join, 主线程结束时, 所有未完成的子线程会被直接终止(不等它们跑完). 这和 Go 类似(main 返回则所有 goroutine 一起结束).
要确保子线程工作完成, 必须 join. join 返回 Result 是因为线程体可能 panic--子线程 panic 不会拖垮主线程, 而是被捕获进这个 Result.
move: 线程为什么几乎总要它
现在到了关键处. 如果线程闭包要用到外部数据, 直接借用会被编译器拒绝:
|
报错信息一针见血: 闭包可能比当前函数活得久, 但它借用了当前函数拥有的 data. 设想--如果允许借用, 主函数返回后 data 被释放, 而子线程还在跑, 还在用那个已失效的引用, 就是悬垂引用. 编译器把这个并发场景下的内存风险, 在编译期就拦下了.
解法是 move: 让闭包夺取 data 的所有权, 带着它一起进入线程. 这样 data 的存活就由线程负责, 与主函数解耦:
|
把数据交给新线程, 像让同事独立出差办一件事: 不能只给一张"数据放在我办公室"的便签(借用)--万一下班锁门(函数返回, 数据释放), 同事就够不着了.
正确做法是把那份材料的原件直接交给对方带走(move 所有权), 从此这份材料的保管责任就在对方身上, 与原办公室无关.
思想: 借用规则无缝延伸到并发
注意这里没有任何新规则. "引用不能比数据活得久"在单线程是悬垂引用检查, 在多线程就变成了"闭包不能借用活得比它短的数据".
move 也不是并发专属--它就是基础闭包里学过的那个 move. Rust 的并发安全, 很大程度上是所有权与借用规则"免费"延伸过来的结果--这正是它"无畏并发(fearless concurrency)"口号的底气.
遗留的问题: 多个线程要共享数据怎么办
move 解决了"把数据交给一个线程". 但如果多个线程都要访问同一份数据呢? move 只能把所有权交给一个闭包, 没法同时给多个. 而且--编译器凭什么相信某个类型可以安全地跨线程传递或共享?
这两个问题, 正是接下来两篇的主题: 第 09 篇讲 Send/Sync(编译器判断"能否跨线程"的依据), 第 10 篇讲 Arc/Mutex(多线程安全共享数据的工具).
快速回顾
- thread::spawn: 启动 OS 线程(1:1 模型), 传入闭包作为线程体; 对应 Go 的
go, 但是重量级 OS 线程而非协程. - JoinHandle:
.join()阻塞等待线程结束并取回返回值; 不 join, 主线程结束会终止子线程. - join 返回 Result: 子线程 panic 不拖垮主线程, 被捕获进 Result.
- move: 线程闭包几乎总需 move, 夺取数据所有权, 避免"闭包比被借数据活得久"的悬垂.
- 思想: 并发安全是借用规则的自然延伸, 无需新规则--"无畏并发"的根基.
动手练习
-
下面代码为什么编译不过? 报错的核心信息是什么? 如何修复?
use std::thread;
let s = String::from("hi");
thread::spawn(|| println!("{s}"));
参考答案
报错核心: 闭包借用了 s, 但闭包(在子线程里)可能比当前函数活得久, 而 s 归当前函数所有--若函数先返回, s 被释放, 子线程就持有悬垂引用.
修复: 用 move 把 s 的所有权移入闭包.
|
-
这段程序的输出确定吗? 加上
join后有什么变化?use std::thread;
fn main() {
thread::spawn(|| println!("子线程"));
println!("主线程");
}
参考答案
输出不确定, 甚至"子线程"可能根本不打印--因为没有 join, 主线程打印完"主线程"就可能结束, 进而终止尚未运行的子线程.
要保证子线程执行完, 需保留 handle 并 join:
|
- 用线程并行计算: 开 4 个线程, 每个返回它编号的平方, 主线程 join 后收集所有结果.
参考答案
|
注意闭包用 move 捕获循环变量 i(i32 是 Copy, move 后原值仍可用, 但闭包需要拥有自己的副本). 每个 join 取回对应线程的返回值.
- 为什么说"线程的 move 要求不是新规则, 而是借用规则的延伸"?
参考答案
单线程里的核心约束是"引用不能比它指向的数据活得久"(否则悬垂). 线程场景只是这条规则的具体化: 子线程闭包的存活时间可能超过创建它的函数, 如果它借用函数里的数据, 就会出现"引用比数据活得久".
编译器用同一套生命周期/借用逻辑拦下它, 要求 move 把所有权一并转移. 没有引入任何并发专属的新规则--这正是 Rust 并发安全"免费"的来源.