Rust异步编程之Runtime

0 阅读6分钟

Rust 异步编程的核心是 Runtime(运行时);但语言本身(标准库 std)只定义了核心接口(Future traitWakerContext),而将具体的 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(时间驱动):处理 sleepinterval 和超时。
  • 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 工具、或者作为库嵌入到其他系统中。

  1. multi_thread (多线程模式 - 默认)
  • 宏:#[tokio::main] (默认就是 multi_thread,但显式写是 flavor = "multi_thread")

  • 行为:启动一个线程池。

  • 特点:

    • 任务可以在线程间移动(Work Stealing)。
    • Future 必须实现 Send trait。
    • 利用多核优势。

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,重新排队。这模拟了操作系统层面的“抢占”,防止饿死其他任务。