Rust 异步编程的核心是 Runtime(运行时);但语言本身(标准库 std)只定义了核心接口(Future trait、Waker、Context),而将具体的 Runtime(运行时)留给了社区实现。
Rust 的异步 Runtime 本质上是一个 “用户态的操作系统内核”。它在用户空间实现了线程调度(Task 调度)、中断处理(Reactor 事件)和进程间通信(Channel)。
核心组件
异步 Runtime 通常都由以下组件“组装”而成:
-
Executor (执行器):任务调度的核心,负责调度和轮询(polling)顶层的
Future(通常被包装成一个Task结构体);其每个worker线程基本都是一个死循环:- 从队列获取 Task。
- 调用
Task::poll()。 - 如果返回
Ready,任务完成。 - 如果返回
Pending,任务挂起,去处理下一个任务。
-
Reactor (反应器) :负责监听系统 I/O 事件(如 socket 可读/可写、定时器到期),并唤醒等待这些事件的 Task;Reactor 使用
epoll(Linux),kqueue(macOS),IOCP(Windows) 监听系统事件。 -
Waker (唤醒器):连接 Executor 和 Reactor 的桥梁。当 Reactor 发现资源就绪时,通过 Waker 通知 Executor 再次轮询该 Task。
-
Timer (定时器):管理时间相关的事件(如
sleep,timeout)。通常它是通过最小堆(Min-Heap)或时间轮(Hashed Timing Wheel)算法实现的。
Executor 的本质是一个任务调度器。大多数通用 Runtime(如 Tokio)采用 M:N 线程模型。即 M 个轻量级异步任务映射到 N 个系统线程上。
- 为了最大化多核 CPU 利用率,主流 Executor(如 Tokio 的多线程调度器)使用 Work-Stealing(工作窃取) 算法。
- 每个 Worker 线程都有自己的本地任务队列(Local Queue)。
- 当一个线程处理完自己的任务后,它会尝试从其他线程的队列(Global Queue 或其他 Local Queue)中“窃取”任务来执行。
Tokio的Runtime
Tokio 是目前 Rust 生态中地位最核心、使用最广泛的异步运行时。主要由以下几个核心驱动器(Drivers)和组件构成:
- Scheduler(调度器):负责管理和运行 Task(异步任务)。
- I/O Driver(I/O 驱动):基于
mio,负责封装操作系统的epoll/kqueue/IOCP。 - Time Driver(时间驱动):处理
sleep、interval和超时。 - Blocking Pool(阻塞线程池):专门用于运行无法异步化的阻塞代码(如文件 I/O 或 CPU 密集型计算)。
手动构建 Runtime
use tokio::runtime::Builder;
fn main() {
let runtime = Builder::new_multi_thread()
.worker_threads(4) // 指定核心 Worker 线程数
.max_blocking_threads(512) // 指定阻塞池最大线程数
.enable_all() // 启用 I/O 和 Time 驱动
.thread_name("my-custom-worker")
.thread_stack_size(3 * 1024 * 1024) // 设置栈大小
.build()
.unwrap();
runtime.block_on(async {
println!("Hello from the custom runtime!");
});
}
调度器
Tokio 默认使用的是多线程工作窃取调度器 (Multi-Threaded Work-Stealing Scheduler)。
- 默认情况下,Runtime 会启动与 CPU 物理核心数相等的 Worker 线程。
- 每个 Worker 线程都被绑定到特定的 CPU 核心上(通过 CPU 亲和性优化缓存命中率)。
队列结构:
-
本地队列 (Local Queue):每个 Worker 线程都有一个属于自己的任务队列。这是一个
固定大小的环形缓冲区(Ring Buffer)。- 优势:单生产者单消费者(SPMC),Worker 存取自己的队列几乎不需要锁(只有很少的原子操作开销),极快。
-
全局队列 (Global Queue/Injection Queue):所有 Worker 共享的队列。
- 作用:当本地队列满时,或者从 runtime 外部(非 async 线程)spawn 任务时,任务会被放入这里。它受 Mutex 保护,速度较慢。
工作窃取算法:Worker 线程运行在一个循环中:
- LIFO Slot 优化:Tokio 有一个特殊的优化,刚被唤醒的任务通常会被放在一个“LIFO Slot”中直接执行,而不是进队列。这优化了消息传递模式的延迟(Task A 唤醒 Task B,B 立即运行)。
- 处理本地队列:从自己的 Local Queue 拿任务执行。
- 全局获取:如果本地空了,尝试从 Global Queue 拿任务(偶尔也会去 Global 拿,防止全局饿死)。
- 窃取 (Steal):如果全局也空了,它会随机选择另一个 Worker 线程,试图从其 Local Queue 中“偷”走一半的任务。
- 休眠 (Park):如果实在没活干,线程进入休眠状态,等待 I/O 事件或新任务唤醒。
运行时模式 (Runtime Flavors)
在构建 Runtime 时(或使用宏时)可以选择模式:
current_thread (单线程模式)
-
宏:
#[tokio::main(flavor = "current_thread")] -
行为:所有的组件(Executor, Driver, Timer)都运行在 当前线程 上。
-
特点:
- 没有线程切换开销。
- 任务不会被并行执行,完全并发。
!Send的 Future 可以在这里运行(因为任务永远不会跨线程移动)。
-
场景:嵌入式设备、CLI 工具、或者作为库嵌入到其他系统中。
multi_thread(多线程模式 - 默认)
-
宏:
#[tokio::main](默认就是 multi_thread,但显式写是flavor = "multi_thread") -
行为:启动一个线程池。
-
特点:
- 任务可以在线程间移动(Work Stealing)。
- Future 必须实现
Sendtrait。 - 利用多核优势。
Blocking Pool
-
原则:绝对不要在 async fn 中执行阻塞操作(如
std::thread::sleep, 大量同步文件读写, 复杂的加密计算)。 -
后果:因为 Tokio 是协作式调度,如果一个 Worker 线程被阻塞,它就无法切换去处理其他成百上千个任务,也无法去窃取别人的工作,更无法响应 I/O 事件(心跳包可能会超时)。
-
Tokio 的解决方案:
Tokio 维护了一个 完全独立 的线程池,称为Blocking Pool。- 当你调用
tokio::task::spawn_blocking时,闭包内的代码会被发送到这个特殊的池子中运行。 - 这个池子的大小是动态的(默认上限很大,约 512 线程),专门用来“硬扛”阻塞操作。
- 这保证了核心的 Async Worker 线程永远保持敏捷和非阻塞。
- 当你调用
协作式抢占 (Cooperative Preemption)
虽然 Tokio 是协作式调度的(Cooperative),但为了防止某个写得烂的 CPU 密集型 Future 霸占线程太久,Tokio 引入了 Budget(预算)机制。
- 机制:每个 Task 被分配一定的“预算”(通常以 Poll 次数计算)。
- 自动让出:Tokio 的基础 I/O 组件(如
TcpStream)和同步原语(如Mutex)都埋了点。每当任务执行这些操作,预算就会减少。 - 强制 Yield:如果预算耗尽但任务还没完成,Tokio 内部会强制该任务返回
Pending并让出 CPU,重新排队。这模拟了操作系统层面的“抢占”,防止饿死其他任务。