阶段三 · 多线程并发

线程基础与 move 闭包

一句话总结

thread::spawn 启动一个操作系统线程, 接收一个闭包作为线程体.
因为线程可能比创建它的函数活得久, 闭包必须用 move 夺取所捕获变量的所有权--这是借用规则在并发场景的自然延伸.
JoinHandle 用于等待线程结束, 取回返回值.

起一个线程

Rust 的线程是真正的操作系统线程(1:1 模型), 用 std::thread::spawn 启动, 传入一个闭包作为线程要执行的代码:

use std::thread;

fn main() {
let handle = thread::spawn(|| {
for i in 1..5 {
println!("子线程: {i}");
}
});

for i in 1..3 {
println!("主线程: {i}");
}

handle.join().unwrap(); // 等待子线程结束
}

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()阻塞当前线程, 直到对应子线程结束; 闭包的返回值也通过它取回:

use std::thread;

let handle = thread::spawn(|| {
// 线程体可以有返回值
42
});

let result = handle.join().unwrap(); // 等它结束, 拿到 42
println!("子线程返回 {result}");

不 join 会怎样

如果不调用 join, 主线程结束时, 所有未完成的子线程会被直接终止(不等它们跑完). 这和 Go 类似(main 返回则所有 goroutine 一起结束).

要确保子线程工作完成, 必须 join. join 返回 Result 是因为线程体可能 panic--子线程 panic 不会拖垮主线程, 而是被捕获进这个 Result.

move: 线程为什么几乎总要它

现在到了关键处. 如果线程闭包要用到外部数据, 直接借用会被编译器拒绝:

use std::thread;

let data = vec![1, 2, 3];
let handle = thread::spawn(|| {
println!("{:?}", data); // 错误! 闭包借用了 data
});
// 编译错误: closure may outlive the current function,
// but it borrows `data`, which is owned by the current function

报错信息一针见血: 闭包可能比当前函数活得久, 但它借用了当前函数拥有的 data. 设想--如果允许借用, 主函数返回后 data 被释放, 而子线程还在跑, 还在用那个已失效的引用, 就是悬垂引用. 编译器把这个并发场景下的内存风险, 在编译期就拦下了.

解法是 move: 让闭包夺取 data 的所有权, 带着它一起进入线程. 这样 data 的存活就由线程负责, 与主函数解耦:

let data = vec![1, 2, 3];
let handle = thread::spawn(move || { // move: data 的所有权移入闭包
println!("{:?}", data);
});
// 这之后主线程不能再用 data -- 它已经"搬家"到子线程了
handle.join().unwrap();

把数据交给新线程, 像让同事独立出差办一件事: 不能只给一张"数据放在我办公室"的便签(借用)--万一下班锁门(函数返回, 数据释放), 同事就够不着了.
正确做法是把那份材料的原件直接交给对方带走(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, 夺取数据所有权, 避免"闭包比被借数据活得久"的悬垂.
  • 思想: 并发安全是借用规则的自然延伸, 无需新规则--"无畏并发"的根基.

动手练习

  1. 下面代码为什么编译不过? 报错的核心信息是什么? 如何修复?

    use std::thread;
    let s = String::from("hi");
    thread::spawn(|| println!("{s}"));
参考答案

报错核心: 闭包借用了 s, 但闭包(在子线程里)可能比当前函数活得久, 而 s 归当前函数所有--若函数先返回, s 被释放, 子线程就持有悬垂引用.

修复: 用 moves 的所有权移入闭包.

thread::spawn(move || println!("{s}"));
  1. 这段程序的输出确定吗? 加上 join 后有什么变化?

    use std::thread;
    fn main() {
    thread::spawn(|| println!("子线程"));
    println!("主线程");
    }
参考答案

输出不确定, 甚至"子线程"可能根本不打印--因为没有 join, 主线程打印完"主线程"就可能结束, 进而终止尚未运行的子线程.

要保证子线程执行完, 需保留 handle 并 join:

let h = thread::spawn(|| println!("子线程"));
println!("主线程");
h.join().unwrap(); // 确保子线程跑完
  1. 用线程并行计算: 开 4 个线程, 每个返回它编号的平方, 主线程 join 后收集所有结果.
参考答案
use std::thread;

fn main() {
let handles: Vec<_> = (0..4)
.map(|i| thread::spawn(move || i * i)) // move 捕获 i
.collect();

let results: Vec<i32> = handles
.into_iter()
.map(|h| h.join().unwrap())
.collect();

println!("{:?}", results); // [0, 1, 4, 9]
}

注意闭包用 move 捕获循环变量 i(i32 是 Copy, move 后原值仍可用, 但闭包需要拥有自己的副本). 每个 join 取回对应线程的返回值.

  1. 为什么说"线程的 move 要求不是新规则, 而是借用规则的延伸"?
参考答案

单线程里的核心约束是"引用不能比它指向的数据活得久"(否则悬垂). 线程场景只是这条规则的具体化: 子线程闭包的存活时间可能超过创建它的函数, 如果它借用函数里的数据, 就会出现"引用比数据活得久".

编译器用同一套生命周期/借用逻辑拦下它, 要求 move 把所有权一并转移. 没有引入任何并发专属的新规则--这正是 Rust 并发安全"免费"的来源.