阶段三 · Tokio 核心

Tokio 入门与任务调度

一句话总结

Tokio 是 Rust 异步生态的事实标准运行时.#[tokio::main] 把同步 main 接入异步世界;tokio::spawn 把一个 Future 交给运行时作为独立任务并发执行,角色等同于 Go 的 go f().
但因为 Future 惰性,二者的"何时开始跑"略有不同.

引入 Tokio

Tokio 是第三方 crate,先加依赖.features = ["full"] 开启全部功能,入门期最省心:

# Cargo.toml
[dependencies]
tokio = { version = "1", features = ["full"] }

最小可运行程序:

#[tokio::main]
async fn main() {
println!("Hello from async Rust");
}

第 04 篇说过,#[tokio::main] 在背后创建运行时并 block_on async main.展开来看它等价于:

fn main() {
tokio::runtime::Runtime::new()
.unwrap()
.block_on(async {
println!("Hello from async Rust");
});
}

多数时候用宏即可;只有需要精细配置运行时(线程数,名字等)时,才手动构造 Runtime.

tokio::spawn:启动并发任务

只在 main 里顺序 .await 谈不上并发.要让多个任务同时推进,用 tokio::spawn 把 Future 提交给运行时,它会成为一个独立调度的任务(task):

#[tokio::main]
async fn main() {
let handle = tokio::spawn(async {
expensive_io().await;
42
});

do_other_work().await; // 与上面的任务并发推进

let result = handle.await.unwrap(); // 等任务结束,取返回值
println!("任务返回 {result}");
}

tokio::spawn(fut) ≈ Go 的 go f():都把一段工作丢到后台并发执行,立即返回一个句柄继续往下走.
Go 的句柄是隐式的(用 channel/WaitGroup 等结果),Tokio 给出一个 JoinHandle,.await 它就能拿到任务的返回值--更接近"带返回值的 goroutine".

JoinHandle:等待与取返回值

spawn 返回 JoinHandle<T>,它本身也是个 Future..await 它会等到任务完成,得到 Result<T, JoinError>:

let h1 = tokio::spawn(async { fetch("a").await });
let h2 = tokio::spawn(async { fetch("b").await });

// 两个任务已在并发跑;这里分别等它们结束
let a = h1.await.unwrap();
let b = h2.await.unwrap();

为什么 await 的结果是 Result

任务可能在执行中 panic,或被取消.JoinHandle 把这种"任务没能正常跑完"封装成 JoinError,因此 handle.await 返回 Result.

这与 Go 不同:Go 里一个 goroutine panic 默认会拖垮整个进程;Tokio 的 spawn 任务 panic 默认只影响该任务,由 JoinError 上报,运行时和其他任务继续存活.

spawn 与直接 await 的区别

初学者常困惑:fut.awaittokio::spawn(fut).await 看着都"等一个 Future",有何不同?区别在并发性:

写法 行为 并发?
a().await; b().await; 先把 a 跑完,再开始 b 否,顺序执行
spawn(a()); spawn(b()); a,b 作为独立任务同时推进 是,真正并发

一个反直觉点:直接 await 不产生并发

两个相邻的 .await顺序的--第一个没完成,第二个不会开始.这和 Go 不同:在 Go 里必须显式 go 才并发,直觉上不易错.

在 Rust 里"写了两个 await 以为它们并发"是常见误解.要并发,要么 spawn,要么用第 10 篇会讲的 join!.

两种调度器:多线程 vs 当前线程

呼应第 05 篇的"单线程 or 多线程",Tokio 提供两种运行时风格:

调度器 构造 特点 适用
多线程(默认) #[tokio::main] 线程池 + 工作窃取,任务可跨线程,利用多核 服务器,绝大多数场景
当前线程 #[tokio::main(flavor = "current_thread")] 单线程跑所有任务,无跨线程开销 测试,CLI,嵌入式,wasm

多线程调度器埋下的伏笔

默认的多线程调度器会把任务在不同线程间搬运(工作窃取).这就要求被 spawn 的 Future 必须能安全地跨线程移动--也就是满足 Send.

这正是下一篇要专门攻克的"跨 await 约束"的根源.先记住这个因果:多线程调度 → spawn 的任务需 Send.

异步的 sleep:别用错了

最后一个高频新手坑.异步任务里要"暂停一段时间",绝不能std::thread::sleep--它会阻塞整个线程(连带卡住该线程上的其他任务).要用 Tokio 的异步 sleep:

use std::time::Duration;

// 正确:异步 sleep,让出线程给其他任务
tokio::time::sleep(Duration::from_secs(1)).await;

// 错误:阻塞线程!会卡住同线程上所有任务
// std::thread::sleep(Duration::from_secs(1));

区别正是本系列的核心主题:tokio::time::sleep 返回一个 Future,.await 时让出执行权;thread::sleep 是同步阻塞调用,直接冻结线程.这个"阻塞 vs 让出"的对立会在第 14 篇系统展开.

快速回顾

  • 入口:#[tokio::main] = 创建运行时 + block_on(async main);需精细配置时手动构造 Runtime.
  • spawn:把 Future 提交为独立任务并发执行,类比 Go 的 go f(),返回 JoinHandle.
  • JoinHandle:.await 取任务返回值,结果是 Result(任务可能 panic / 被取消).
  • 并发来源:相邻 .await 是顺序的;真正并发需 spawnjoin!.
  • 两种调度器:多线程(默认,利用多核)vs 当前线程(单线程,无跨线程开销).
  • 异步 sleep:用 tokio::time::sleep().await,绝不用 thread::sleep 阻塞运行时.

动手练习

  1. 最小项目:建一个 Tokio 项目,用 #[tokio::main] 打印一行,再手动用 Runtime::block_on 改写一次.
  2. 并发 spawn:用 tokio::spawn 启动两个各 sleep 不同时长的任务,await 两个 JoinHandle 并打印返回值.
  3. 串行对比:把两个任务改成相邻 .await(不 spawn),用计时观察总耗时变化,验证"顺序 vs 并发".
  4. 观察 panic:在一个 spawn 的任务里故意 panic!,观察 handle.await 得到的 Err(JoinError),确认其他任务不受影响.
  5. sleep 对比:把某个任务里的 tokio::time::sleep 换成 thread::sleep,观察对并发的破坏(为第 14 篇预热).