从 #[tokio::main] 开始
主线位置:任务主线的全程,
#[tokio::main]→block_on→spawn→ 队列 → poll → 唤醒 → 完成。每一步在后面的章节里展开。
宏展开成什么
最常见的入口:
#[tokio::main]
async fn main() {
println!("hello");
}
main 不能是 async 的,所以这个属性宏把它改写成一个普通的 fn main。展开逻辑在 tokio-macros/src/entry.rs 的 parse_knobs,生成的代码整理后大致是:
fn main() {
let body = async {
println!("hello");
};
// 编译期检查 future 的输出类型和函数返回类型一致,运行时不会执行
let body = {
if false {
let _: &dyn ::core::future::Future<Output = ()> = &body;
}
body
};
{
use tokio::runtime::Builder;
return Builder::new_multi_thread()
.enable_all()
.build()
.expect("Failed building the Runtime")
.block_on(body);
}
}
函数体原封不动地变成一个 async 块,然后交给新建运行时的 block_on。宏的参数只是往 builder 链上加调用:
| 写法 | 生成的调用 |
|---|---|
flavor = "current_thread" | Builder::new_current_thread() |
flavor = "local" | new_current_thread() 加 build_local(...),得到 LocalRuntime |
worker_threads = 4 | .worker_threads(4) |
start_paused = true | .start_paused(true),测试里冻结时间 |
所以 #[tokio::main] 没有任何魔法,完全可以手写。需要在进入 async 之前做点同步的初始化(比如读配置决定线程数),就手写这几行。
build() 创建了哪些东西
Builder::build 按 kind 分成两支,多线程走 build_threaded_runtime:
fn build_threaded_runtime(&mut self) -> io::Result<Runtime> {
// ...
let worker_threads = self.worker_threads.unwrap_or_else(num_cpus);
let (driver, driver_handle) = driver::Driver::new(self.get_cfg())?;
// Create the blocking pool
let blocking_pool =
blocking::create_blocking_pool(self, self.max_blocking_threads + worker_threads, worker_threads);
let blocking_spawner = blocking_pool.spawner().clone();
// ...
let (scheduler, handle, launch) = MultiThread::new(
worker_threads,
driver,
driver_handle,
blocking_spawner,
// ...
);
let handle = Handle { inner: scheduler::Handle::MultiThread(handle) };
// Spawn the thread pool workers
let _enter = handle.enter();
launch.launch();
Ok(Runtime::from_parts(Scheduler::MultiThread(scheduler), handle, blocking_pool))
}
按顺序是四样东西:
- 驱动:
Driver::new建好时间驱动和 IO 驱动(enable_all就是把enable_io和enable_time都打开)。 - 阻塞线程池:上限是
max_blocking_threads + worker_threads。max_blocking_threads默认 512,多出来的worker_threads个名额是留给 worker 自己的。 - 调度器:
MultiThread::new创建每个 worker 的本地队列、共享的注入队列,并把驱动交给它们共用。 - 启动 worker:
Launch::launch。
第 2 点的那个加法很有意思,看 launch 就明白了:
impl Launch {
pub(crate) fn launch(mut self) {
for worker in self.0.drain(..) {
runtime::spawn_blocking(move || run(worker));
}
}
}
worker 线程本身就是阻塞线程池里的线程。 每个 worker 是一个「永远不返回」的阻塞任务,所以线程池上限要先给它们留出位置。这个设计还有一个用处:block_in_place 需要把当前 worker 的工作交给一个新线程,新线程同样从这个池子里来。
block_on 的 future 跑在哪个线程
Runtime::block_on 先进入运行时上下文,再按调度器类型分派。多线程版本在 multi_thread/mod.rs 第 85 行:
pub(crate) fn block_on<F>(&self, handle: &scheduler::Handle, future: F) -> F::Output
where
F: Future,
{
crate::runtime::context::enter_runtime(handle, true, |blocking| {
blocking.block_on(future).expect("failed to park thread")
})
}
blocking.block_on 最终是 CachedParkThread::block_on:
loop {
if let Ready(v) = crate::task::coop::budget(|| f.as_mut().poll(&mut cx)) {
return Ok(v);
}
self.park();
}
这是一个最朴素的执行器:poll 一次,没好就让当前线程睡下,等 waker 把它叫醒再 poll。这里的 waker 就是「唤醒这个线程」。
结论:在多线程运行时里,block_on 传进去的 future(也就是 main 的函数体)跑在调用 block_on 的线程上,也就是主线程,而不是 worker。 只有 tokio::spawn 出去的任务才跑在 worker 上。由此可以推出两个常见现象:
- 在
main里写一个重计算循环,不会卡住 worker,因为它根本不在 worker 上;但同一个循环放进tokio::spawn,就会占住一个 worker。 - current_thread 运行时不一样:它没有别的线程,
block_on所在的线程同时也在跑所有 spawn 的任务(见 current_thread)。
#[tokio::main]
async fn main() {
println!("A: {:?}", std::thread::current().name());
tokio::spawn(async {
println!("B: {:?}", std::thread::current().name());
})
.await
.unwrap();
}
第二个把属性换成 #[tokio::main(flavor = "current_thread")],两行的标签换成 C 和 D。
提示:Builder 会给 worker 线程起名字。答案在本页末尾。
enter_runtime 还负责检查嵌套。如果当前线程已经在运行时里,它会直接 panic(context/runtime.rs 第 68 行):
Cannot start a runtime from within a runtime. This happens because a function (like `block_on`) attempted to block the current thread while the thread is being used to drive asynchronous tasks. If you are in an async function, use `.await` on the future instead of blocking on it.
这是 tokio 最常见的报错之一,「10 常见坑」里有单独一页:运行时里再起运行时。
tokio::spawn 之后
tokio::spawn 的调用链:
| 步骤 | 函数 | 做什么 |
|---|---|---|
| 1 | spawn_inner | 分配任务 ID;从线程本地上下文取当前运行时的 Handle,取不到就 panic |
| 2 | scheduler::Handle::spawn | 按 current_thread / multi_thread 分派 |
| 3 | bind_new_task | OwnedTasks::bind:一次堆分配建好任务,挂进存活任务链表,拿到 JoinHandle 和一个 Notified |
| 4 | schedule_task | 决定任务放进哪个队列 |
第 1 步里「取不到就 panic」,panic 的内容就是另一句常见报错:
there is no reactor running, must be called from the context of a Tokio 1.x runtime
在没有进入运行时的线程上(比如自己 std::thread::spawn 出来的线程)调用 tokio::spawn,就会看到它。
第 4 步是调度器的入口,值得完整看一眼:
pub(super) fn schedule_task(&self, task: Notified, is_yield: bool) {
// ...
with_current(|maybe_cx| {
if let Some(cx) = maybe_cx {
// Make sure the task is part of the **current** scheduler.
if self.ptr_eq(&cx.worker.handle) {
// And the current thread still holds a core
if let Some(core) = cx.core.borrow_mut().as_mut() {
self.schedule_local(core, task, is_yield);
return;
}
}
}
// Otherwise, use the inject queue.
self.push_remote_task(task);
self.notify_parked_remote();
});
}
- 在 worker 线程上 spawn:走
schedule_local,默认放进当前 worker 的 LIFO 槽,槽里原来的任务挪到本地队列尾部。新任务会是这个 worker 接下来最先跑的。 - 在 其他线程上 spawn(包括
main函数体所在的主线程):放进共享的注入队列,再叫醒一个睡着的 worker 来取。
被唤醒的任务也走同一个函数:任务的 Waker 最终调用 Schedule::schedule,也就是 schedule_task(task, false)。所以「新 spawn 的任务」和「被唤醒的任务」对调度器来说是一回事,都是一个 Notified。
worker 的循环
每个 worker 线程执行 run,进入运行时上下文后调 Context::run,主体是一个循环:
while !core.is_shutdown {
// ...
// Increment the tick
core.tick();
// Run maintenance, if needed
core = self.maintenance(core);
// First, check work available to the current worker.
if let Some(task) = core.next_task(&self.worker) {
core = match self.run_task(task, core) { /* ... */ };
continue;
}
// There is no more **local** work to process, try to steal work
// from other workers.
if let Some(task) = core.steal_work(&self.worker) {
core = match self.run_task(task, core) { /* ... */ };
} else {
// Wait for work
core = /* ... */ self.park(core);
}
}
一句话概括:先跑自己的,没有就去偷,偷不到就睡。 next_task 决定先看 LIFO 槽、本地队列还是注入队列;park 时如果抢到了驱动,就去等 IO 和定时器。这三个函数是「04 调度器」的主体。
run_task 把任务交给 Harness::poll,后者根据任务状态决定:真的 poll future、future 完成后写回输出,还是发现任务已被取消直接收尾。这是「03 任务」的主体。
整条任务主线
sequenceDiagram participant M as 主线程(block_on) participant I as 注入队列 participant W as worker participant T as 任务(Harness) participant D as 驱动(IO / 定时器) M->>M: Builder::build 建驱动、线程池,启动 worker M->>I: tokio::spawn → bind_new_task → push_remote_task M->>W: notify_parked_remote 叫醒一个 worker W->>I: next_task 取出任务 W->>T: run_task → Harness::poll T-->>D: future 返回 Pending,waker 存进驱动 W->>D: 没有任务了,park 在驱动上 D-->>W: 事件就绪,wake → schedule_task W->>T: 再次 poll,返回 Ready T-->>M: 输出写进任务,唤醒等待的 JoinHandle M->>M: block_on 的 future 完成,main 返回
动脑筋答案
M2 Max、rustc 1.99.0、--release,两个程序各跑 3 次,每次输出都一样。第一个:
A: Some("main")
B: Some("tokio-rt-worker")
第二个:
C: Some("main")
D: Some("main")
- A:
main的函数体是block_on的 future,跑在调用block_on的主线程上。 - B:spawn 出去的任务进注入队列,被某个 worker 取走。worker 线程的默认名字在
Builder::new里设成"tokio-rt-worker"。1.50.0 之前它叫tokio-runtime-worker,#7880 把它改短,理由是 Linux 的线程名超过 15 个字符会被截断,原来的名字显示出来只剩tokio-runtime-w。 - C:和 A 一样,函数体在主线程上。
- D:current_thread 没有 worker 线程,spawn 的任务也由
block_on所在的主线程来跑。
小结
#[tokio::main]就是Builder::new_multi_thread().enable_all().build().expect(...).block_on(async { 原函数体 })。build()依次创建驱动、阻塞线程池、调度器,然后把 worker 作为阻塞任务启动起来。block_on的 future 跑在调用线程上,spawn的任务才跑在 worker 上。- 在 worker 上 spawn 进 LIFO 槽,在外部 spawn 进注入队列;被唤醒的任务也走同一个
schedule_task。 - worker 循环:先跑自己的,没有就偷,偷不到就 park。