首页 / Rust 入门教程 / 线程基础

Rust 入门教程

线程基础

本教程共 78 篇 · 第 65 篇 · 更新于 2026-08-08 · 约 8 分钟阅读

RustRust 入门教程线程thread::spawn并发操作系统线程join

本节目标:学会用 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 并行计算。下一章我们看线程之间怎么”说话”——用消息传递。