线程管理:std::thread详解、线程池原理、作用域线程、线程局部存储
聊到 Rust 的并发,线程管理是绕不开的基础。很多人一上来就冲 tokio、async/await,结果连 std::thread 的坑都没踩明白。我个人习惯是,先把底层线程模型摸透,再去碰那些花哨的运行时。今天我们就来掰扯掰扯线程管理的四个核心话题。
一、std::thread:最朴素的并发工具
Rust 标准库提供的 std::thread,说白了就是直接操作 OS 线程。你创建一个线程,系统就真的给你开一个原生线程。这在 Linux 上就是 pthread,在 Windows 上就是 CreateThread。
use std::thread;
use std::time::Duration;
fn main() {
let handle = 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));
}
handle.join().unwrap();
}
这段代码很简单,但有个细节我提醒你注意:join() 必须调用。不调用的话,主线程跑完直接退出,子线程可能还没执行完就被强制终止了。我在项目中遇到过这种问题——一个后台日志线程没 join,结果丢了一堆关键日志,排查了半天才发现是线程被主进程带走了。
spawn 闭包必须满足 Send + 'static。如果你试图把局部引用传进去,编译器会直接拒绝。这不是 Rust 在为难你,而是为了防止悬垂指针。
二、线程池原理:别让线程成为你的性能瓶颈
你想想看,如果每个任务都开一个新线程,那开销有多大?创建线程需要分配栈空间(默认 2MB),还要做上下文切换。高并发场景下,线程数一多,系统光忙着切换线程了,正事反而没干。
线程池的核心思想很简单:提前创建一批线程,任务来了就丢给空闲线程去处理。任务处理完了,线程不销毁,而是继续等待下一个任务。说白了就是「复用」两个字。
- 线程池大小通常设为 CPU 核心数的 2-4 倍(I/O 密集型可以更大)
- 任务队列用无锁队列或 Mutex + VecDeque 实现
- 空闲线程要能自动休眠,避免空转浪费 CPU
我自己写过一个简单的线程池,核心结构大概长这样:
use std::sync::mpsc;
use std::sync::Arc;
use std::sync::Mutex;
use std::thread;
struct ThreadPool {
workers: Vec<Worker>,
sender: mpsc::Sender<Job>,
}
type Job = Box<dyn FnOnce() + Send + 'static>;
struct Worker {
id: usize,
thread: Option<thread::JoinHandle<()>>,
}
impl Worker {
fn new(id: usize, receiver: Arc<Mutex<mpsc::Receiver<Job>>>) -> Worker {
let thread = thread::spawn(move || loop {
let job = receiver.lock().unwrap().recv().unwrap();
println!("Worker {} 开始执行任务", id);
job();
});
Worker { id, thread: Some(thread) }
}
}
嗯,这里要注意:recv() 是阻塞的。当没有任务时,线程会挂起等待,不会消耗 CPU。这就是线程池优雅的地方——忙时全力干活,闲时安静等待。
三、作用域线程:解决引用的生命周期难题
标准线程有个烦人的限制:闭包必须是 'static 的。这意味着你不能轻易把栈上的引用传进去。比如你想让子线程访问一个局部变量,编译器会直接报错。
作用域线程(scoped threads)就是来解决这个问题的。它由 crossbeam 库提供,后来 Rust 标准库也引入了 thread::scope。它的原理是:保证所有子线程在作用域结束前全部退出,这样父线程的引用就一定是有效的。
use std::thread;
fn main() {
let numbers = vec![1, 2, 3, 4, 5];
thread::scope(|s| {
s.spawn(|| {
println!("子线程访问: {:?}", numbers);
});
s.spawn(|| {
println!("另一个子线程也访问: {:?}", numbers);
});
}); // 这里 scope 结束,所有子线程保证已退出
println!("主线程继续: {:?}", numbers);
}
你看,这里 numbers 是栈上的变量,没有 'static 约束,但编译器允许了。为什么?因为 scope 会阻塞当前线程,直到所有子线程执行完毕。这就保证了引用不会悬空。
四、线程局部存储:每个线程的私有空间
有时候你希望每个线程都有自己的独立数据,互不干扰。比如每个线程的日志上下文、随机数种子、或者连接池。这时候就需要线程局部存储(Thread Local Storage, TLS)。
Rust 提供了 thread_local! 宏来声明 TLS 变量:
use std::cell::RefCell;
thread_local! {
static COUNTER: RefCell<u32> = RefCell::new(0);
}
fn main() {
let handles: Vec<_> = (0..5).map(|i| {
thread::spawn(move || {
COUNTER.with(|c| {
let mut val = c.borrow_mut();
*val += i;
println!("线程 {} 的 COUNTER = {}", i, *val);
});
})
}).collect();
for h in handles {
h.join().unwrap();
}
}
每个线程的 COUNTER 都是独立的副本。你在一个线程里修改它,不会影响其他线程。这在底层实现上,通常是通过线程控制块(TCB)中的一个指针来定位 TLS 数据块的。
知识体系总览
下面这张图概括了本章的核心脉络,我把它画成了 SVG,方便你对照理解:
这四个知识点其实有一条暗线:如何安全高效地管理线程的生命周期和数据。std::thread 给了你基础能力,线程池解决了创建销毁的开销,作用域线程解决了引用生命周期问题,TLS 解决了数据隔离问题。把它们组合起来,你就能写出既安全又高效的并发代码。
公众号:蓝海资料掘金营,微信deep3321