Storforge
编码预计 55 分钟

tokio:异步运行时是怎么回事

一个线程怎么同时伺候一万个连接?把 async/await、Future、运行时调度讲透,再把 forge-store 的接口改造成异步版本。

学完这节你能做到

  • 解释 async fn 编译成状态机的过程
  • 说清 tokio 的多线程调度与 work stealing
  • 知道什么时候该用 spawn_blocking:文件 IO 的尴尬地位

从 epoll 说起:你在监控里见过的那些 fd

做运维时你八成排查过这样的问题:一台 nginx 顶着几万并发连接,ss -s 显示几万条 established, 但 top 里它只有几个 worker 进程,CPU 还很闲。几万个连接,几个进程,怎么伺候得过来?

答案你其实知道,叫 epoll:进程把几万个 fd 注册给内核,然后睡觉; 哪个 fd 上来了数据,内核把它叫醒,它只处理「有事的那几个」。 这就是「一个线程伺候一万个连接」的全部秘密 —— 不是更快地轮询,而是不轮询,等通知。

问题在于,直接用 epoll 写程序是灾难:每个连接的处理逻辑被切成碎片, 状态要自己存(读到一半的 buffer、处理到哪一步),代码变成一张回调织成的网。 async/await 就是为了解决这个:让你用顺序代码的写法,得到 epoll 的并发能力。 编译器负责把顺序代码切碎,运行时负责在事件到来时接着跑。

Future 与状态机:await 点就是让出点

一个 async fn 编译后不是普通函数,而是一个状态机(实现了 Future trait 的匿名类型):

async fn handle_get(conn: &mut Conn, key: &[u8]) -> Result<(), Error> {
    let req = conn.read_request().await?;   // 状态 1:等请求可读
    let value = store_lookup(&req)?;        // 同步计算,不让出
    conn.write_response(&value).await?;     // 状态 2:等写缓冲可写
    Ok(())
}

编译器把它变成大致这样的东西:一个 enum,每个 .await 是一个状态; 每次被 poll,就从上次停下的状态继续跑,跑到下一个 .await 如果没就绪, 保存现场、返回 Pending、把线程让出来。

三个要点,记住它们后面全用得上:

  1. .await 是唯一的让出点。两个 await 之间的同步代码会一口气跑完, 谁也打断不了 —— 这既是性能(没有抢占开销),也是坑(下面讲)。
  2. Future 是惰性的。不 poll 就什么都不发生,这和你熟悉的「起个线程就开跑」完全不同。
  3. 状态机就是那点被切碎的栈。一万个任务就是一万个小 struct,而不是一万个 8MB 的线程栈 —— 这就是 async 能扛超高并发的内存账。

tokio 运行时:worker 线程与任务队列

状态机自己不会跑,需要有人反复 poll 它 —— 这就是运行时(runtime)的活。 tokio 的多线程运行时结构,用运维的眼光看非常眼熟:

  • worker 线程:默认每个 CPU 核一个,类比 nginx 的 worker_processes auto
  • 每个 worker 一个本地任务队列 + 一个全局注入队列
  • work stealing:自己队列空了,去偷别的 worker 的任务 —— 负载自动均衡, 不会出现「一个核 100% 其他核围观」
  • 一个 IO driver:封装 epoll,fd 就绪时把对应任务标记为可运行
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let listener = tokio::net::TcpListener::bind("0.0.0.0:9090").await?;
    loop {
        let (socket, _addr) = listener.accept().await?;
        // spawn:把任务丢进队列,不等它完成 —— 一万个连接就是一万个轻量任务
        tokio::spawn(async move {
            if let Err(e) = serve_conn(socket).await {
                eprintln!("conn error: {e}");
            }
        });
    }
}
×不许在 async 里做长同步操作

worker 线程是全体任务共享的。你在一个任务里跑了 100ms 的同步计算或同步 IO, 这个 worker 上排队的所有任务都陪着卡 100ms —— 表现为 P99 毛刺,和你运维时 见过的「某核被单进程打满、软中断堆积」是同一类病。规矩:两个 await 之间 不许有慢操作;慢的丢给 spawn_blocking。

文件 IO 的真相:tokio::fs 底下是线程池

现在说一个对存储工程师至关重要的事实:epoll 对普通文件基本无效。 epoll 的模型是「fd 没就绪时等通知」,但磁盘文件永远「就绪」—— read 会直接发起, 然后阻塞到数据从盘上回来。内核没有给 buffered 文件 IO 提供像 socket 那样的就绪通知。

所以 tokio::fs::File 的实现是个「善意的谎言」:表面是 async, 底下把每个操作丢进一个阻塞线程池(spawn_blocking,默认上限 512 个线程)执行。 你得到了不卡 worker 的效果,但代价是:每次 IO 一次线程切换 + 任务在池里排队。

用你熟的量级感受一下:一块 NVMe 标称 100 万 4k 随机读 IOPS, 每个 IO 走一趟线程池的调度开销在几微秒量级,叠加线程数上限, 线程池方案单机通常在十几万到几十万 IOPS 就见顶 —— 离硬件差着倍数。 这就是下一课 io_uring 出场的理由。

Checkpoint单选

为什么 tokio 处理 TCP 用 epoll 就够了,处理文件却要走线程池?

改造 forge-store:同步核心 + 异步外壳

L1 写完的 forge-store 是纯同步的,这是对的,不要重写。我们采用一个工程上非常主流的结构: 同步核心 + 异步外壳 —— 引擎内部保持同步(落盘逻辑简单可审), 对外提供 async 接口,内部用 spawn_blocking 桥接:

use std::sync::Arc;
use forge_store::{BlobStore, StoreError};

/// 异步外壳:把同步引擎包进 Arc,IO 操作转投阻塞线程池。
pub struct AsyncStore<S: BlobStore + Send + Sync + 'static> {
    inner: Arc<S>,
}

impl<S: BlobStore + Send + Sync + 'static> AsyncStore<S> {
    pub async fn get(&self, key: Vec<u8>) -> Result<Option<Vec<u8>>, StoreError> {
        let store = Arc::clone(&self.inner);
        // 同步 get 在阻塞池里跑,worker 线程不被占用
        tokio::task::spawn_blocking(move || store.get(&key))
            .await
            .map_err(|e| StoreError::Internal(format!("blocking task failed: {e}")))?
    }
}

注意两个细节 —— 它们不是语法细节,而是行为差异,让 AI 讲到你能复述:

  • spawn_blocking 的闭包要 move 进所有权(所以参数改成 Vec<u8> 而非借用)—— 借用检查器会拦住你把引用送进另一个线程,这正是 L0 讲的「拦一下 = 别处一次事故」。
  • .await 后面那个 ? 处理的是任务本身失败(panic 或取消), 和 get 的业务错误是两层,不能混在一起吞掉。

这层外壳性能不惊艳,但它让 L3 的 gRPC 服务端(天然 async)能直接调用引擎。 性能问题我们用接下来两课解决,而且解决之后只换外壳,不动核心 —— 这就是接口边界的红利。

⚡AI 结对:给 forge-store 加异步外壳pair with ai

三个重点,都不需要你读代码。一,让 AI 指出「后台任务本身失败」这条路径最后落在哪里 —— 它很爱在这儿图省事,结果一个任务出事、整个进程陪葬,上面第四条验收动作就是专门抓它的。 二,让它解释为什么引擎实例要被多个任务共享着用、不这么写会撞上什么问题,讲到你能复述。 三,最后那个反问它必须答到两层:工作线程被落盘操作卡住,同一个工作线程上排队的任务 全部陪跪,表现就是 P99 毛刺。答不到就别听它讲道理,让它把上面那个对照实验跑出来,用数字说话。

目标:给 L1 写完的同步 forge-store 套一层异步外壳,让 L3 的网络服务(天生是 async 的)能直接调用它,同时引擎内部保持同步、一行不改 —— 落盘逻辑要一直是简单可审的。

行为规格:
1. 异步能力做成可选开关:不开启时 forge-store 保持纯同步、不引入运行时依赖;只有开启时才把 tokio 拉进来;
2. 外壳对外提供 put/get/delete 的异步版本,内部把同步引擎的调用放到 tokio 的阻塞线程池上执行。绝不允许在事件循环的工作线程上直接跑落盘操作 —— 一个慢操作会把同一个工作线程上排队的所有任务一起拖住;
3. 同一个引擎实例要能被大量并发任务共享,不能每个请求各开一个引擎实例;
4. 这里有两层完全不同的失败,必须分开:一层是引擎自己的业务错误(键不存在、校验失败),另一层是这个后台任务压根没跑成(它崩了,或者被取消)。第二层要有自己独立的错误表示,不能混进第一层被当成业务错误吞掉,更不能让整个进程跟着一起死;
5. 自动化测试至少覆盖两条:同时发起 64 个并发写入不同的键,全部完成后逐个读回来校验内容一致;读一个不存在的键要正常返回「没有这个键」这个结果,而不是报错。

交付:实现 + 上述测试 + 静态检查干净的输出 + 回答我一个问题:如果把落盘操作直接放在事件循环的工作线程上跑,并发压测时会发生什么?
你的验收动作自己跑、自己看,不用读代码
  • 把并发测试的任务数从 64 提到 512 再跑一次,应该照样全过,而且总耗时不该翻好几倍 —— 翻了说明阻塞池被打满、请求在排长队
  • 起一个持续调用的小压测,同时用 top -H 看线程:阻塞池的线程数应该随并发上涨,而事件循环那几个工作线程不该被长时间占死
  • 对照实验:让 AI 加一个临时开关,把落盘改成直接在事件循环的工作线程上跑,同样并发再压一次,对比两边的 P99。亲眼看见「一个慢操作拖垮一整批任务」是这节课的重点
  • 破坏性验证:让 AI 给你造一个能触发引擎内部异常的输入,发过去之后确认进程还活着、这一次请求收到明确的失败,而不是整个服务陪葬
必须问到听懂的问题答不上来就别合并
  • 一个请求从进来到落盘,中间换了几次线程?每次换线程大概花掉多少时间?
  • 同时来一万个请求,它们排在哪里?排队的地方满了之后,新请求是等着还是被拒绝?
  • 如果某次落盘卡住十秒不返回,其他请求受不受影响?为什么?

小结

  • epoll 的本质是「等通知而不是轮询」,async/await 让你用顺序代码写出这种并发
  • async fn 编译成状态机,await 点就是让出点;两个 await 之间的慢操作会卡住整个 worker
  • tokio 多线程运行时 = 每核一个 worker + work stealing,负载自动均衡
  • 普通文件 IO 没有就绪通知,tokio::fs 底下是阻塞线程池 —— 有并发,但离硬件极限远
  • forge-store 采用同步核心 + 异步外壳:接口先 async 化,性能下一课用 io_uring 解决