线程基础
本教程共 78 篇 · 第 65 篇 · 更新于 2026-08-08 · 约 8 分钟阅读
本节目标:学会用
std::thread::spawn创建操作系统线程,理解 main 线程结束会带走所有子线程、用join等待子线程、用move把数据交给新线程,并建立多线程的性能直觉。
并发可以让程序同时做多件事。在 Rust 里,最基础、也是和操作系统关系最紧密的并发方式,就是直接使用线程(thread)。这一章我们就从零开始,把线程这件事讲透。
1-1 什么是线程
你写的程序跑起来之后,操作系统会给它一个主执行流,这就是主线程(main 线程)。默认情况下,你写的所有代码都在这个线程里按顺序执行:上一行没跑完,下一行就不会开始。
线程是操作系统调度的最小执行单位。一个进程里可以有多个线程,它们共享同一块内存地址空间,但各自有独立的调用栈。你可以把线程想象成同一条生产线上的多个工人:大家身处同一个车间(同一块内存),可以看见同样的原材料,但每个人手里正在做的事是各自独立的。
Rust 的线程模型是 1:1 模型:语言层面的一个线程,就对应操作系统内核的一个线程。这样做的好处是运行时(runtime)非常小,没有额外的调度层;代价是线程的创建和切换都要听操作系统的,开销相对偏大。这一点和后面要讲的 async(M:N 模型)正好形成对比。
1-2 用 thread::spawn 创建线程
Rust 标准库提供了 std::thread,最常用的就是 thread::spawn 函数。它接收一个闭包,闭包里的代码就是新线程要执行的任务。
use std::thread;
use std::time::Duration;
fn main() {
thread::spawn(|| {
for i in 1..10 {
println!("来自子线程: {}", i);
thread::sleep(Duration::from_millis(1));
}
});
for i in 1..5 {
println!("来自主线程: {}", i);
thread::sleep(Duration::from_millis(1));
}
}
这段代码里,主线程和子线程各自用一个循环打印数字。thread::sleep 会让当前线程休眠一小会儿,给另一个线程运行的机会。哪怕你的电脑只有一个 CPU 核心,操作系统也会在两个线程之间来回切换,让你看到”并发”的效果。
Note
thread::spawn返回一个JoinHandle,它本身实现了JoinHandle类型。如果不管它,子线程就”放飞自我”了,主线程也不会等它。
初学者常在这里踩一个坑:多运行几次,你会发现打印顺序每次都不太一样。这完全正常。线程之间谁先跑、谁后跑、各跑多少,由操作系统的调度器决定,Rust 不保证任何执行顺序。所以写多线程代码时,千万不要依赖”我以为它会先打印这个”这种假设。
1-3 main 线程结束,子线程就完了
上面那段代码还有个更隐蔽的问题:主线程的循环很快跑完,main 函数一返回,整个程序就结束,子线程可能还没来得及打印完就被强行终止了。有时候你甚至只看到主线程的输出。
原因很简单:main 线程是程序的入口,它一结束,操作系统就会把整个进程收掉,所有子线程自然也没了。这就像工厂下班断电,不管工人手里的活干没干完,灯一关全得停。
所以一个铁律要记牢:main 线程活着,子线程才有机会活着;main 线程结束,所有子线程被强制终止。
1-4 用 join 等待子线程
解决办法是抓住 thread::spawn 返回的句柄,调用它的 join 方法。join 会阻塞当前线程,直到它等那个子线程彻底跑完。
use std::thread;
use std::time::Duration;
fn main() {
let handle = thread::spawn(|| {
for i in 1..5 {
println!("来自子线程: {}", i);
thread::sleep(Duration::from_millis(1));
}
});
// 主线程在这里卡住,等子线程结束
handle.join().unwrap();
for i in 1..5 {
println!("来自主线程: {}", i);
thread::sleep(Duration::from_millis(1));
}
}
因为 join 把主线程堵住了,所以子线程会先完整地打印 1 到 4,主线程才开始打印。
join 放在哪里,决定了代码的并发形态。如果把它放在主线程的循环前面,那就是”先等子线程全跑完,再跑主线程”;如果放在循环后面,那就是”主线程先跑一部分,再等子线程”。把 join 的位置当作一个开关,你就能控制谁等谁。
Tip
join返回的是Result。子线程如果panic了,join会得到Err。用unwrap可以简单处理,真实项目里你可能需要更细致地应对子线程的失败。
1-5 用 move 把数据交给新线程
线程闭包里常常要用到主线程的变量。比如你想把一个 Vec 传到子线程里去打印:
use std::thread;
fn main() {
let v = vec![1, 2, 3];
let handle = thread::spawn(|| {
println!("拿到了向量: {:?}", v);
});
handle.join().unwrap();
}
这段代码编译不过。报错信息大意是:闭包可能比当前函数活得更久,但它借用了 v,而 v 属于当前函数。
问题出在哪?新线程什么时候创建、什么时候结束,都是不确定的。万一主线程先跑完,v 被释放了,而子线程还没创建成功或者还没跑完,那它手里的 v 引用就指向了一块已经不存在的内存——这是典型的安全隐患。Rust 的编译器一眼看穿了这个风险,直接拦下。
解法是用 move 关键字,把 v 的所有权整个转移到子线程:
use std::thread;
fn main() {
let v = vec![1, 2, 3];
let handle = thread::spawn(move || {
println!("拿到了向量: {:?}", v);
});
handle.join().unwrap();
// 下面这行如果取消注释会报错:borrow of moved value
// println!("{:?}", v);
}
move 让闭包拿走环境中变量(这里是 v)的所有权。所有权转移后,主线程再也不能用 v,编译器保证了数据在子线程使用期间一定合法有效。这就是 Rust”用所有权换安全”的经典体现。
Warning
move是单向转移。一旦把数据move进线程闭包,原线程就再也用不了它。如果需要多个线程共享同一份数据,后面章节讲的Arc才是正解。
1-6 线程会怎么结束
父线程结束后,子线程会怎样?在 Rust 里,粗暴地”杀掉”一个线程的接口并不存在——因为强行终止可能导致锁没释放、内存没清理,留下一堆烂摊子。所以 Rust 的线程结束方式很朴素:线程里的代码跑完了,线程就自动结束。
有一种特殊情况要注意:如果子线程里是一个不带任何阻塞的死循环(既没有 IO 等待,也没有 sleep),它会一直霸占一个 CPU 核心,停不下来,直到 main 线程结束被强行终止。反之,如果循环里大部分时间在等待 IO,那 CPU 占用其实很低,这是网络服务最常见的模型。
1-7 多线程的性能直觉
讲线程不谈性能,等于没讲。给你几个实用结论:
创建线程是有成本的,粗略估算每次大约要零点几毫秒,线程越多成本越高。所以别为了一个微不足道的任务去开线程,那反而拖慢程序。
线程数该怎么定?关键看任务类型。如果是 CPU 密集型任务(比如大量计算),线程数超过 CPU 核心数并不会更快,因为每个核心本来就跑满了。此时让线程数等于 CPU 核心数最合适。如果是 IO 密集型任务(比如等待网络响应),大部分时间线程都在”睡觉”等数据,那多开一些线程完全没问题,一个核心就能撑起成百上千个等待中的连接。
但线程也不是越多越好。线程太多会带来上下文切换的开销、缓存命中率下降,甚至内存带宽成为瓶颈。所以现代高并发场景(海量网络连接)往往不再用纯多线程,而是改用 async/await 那一套 M:N 模型,原因就在这里。这一章先把线程吃透,异步是后面的事。
1-8 小结
这一章我们认识了 Rust 最朴素的并发工具——操作系统线程。要点就几句:thread::spawn 开线程,闭包里写任务;main 线程一结束,所有子线程跟着没;想等子线程就用 join;想在线程里用外面的数据,就用 move 把所有权交过去。线程简单可靠,适合少量任务并发和 CPU 并行计算。下一章我们看线程之间怎么”说话”——用消息传递。