200字
Rust 异步
2025-11-30
2025-11-30

Async

参考资料

  1. Rust中的异步编程》:介绍了基础概念与Rust异步生态
  2. Tokio源码分析》:写了多篇文章剖析了mio、Tokio源码
  3. Wiki-绿色线程

概念地图(Map)

从三个维度记忆异步生态:

  1. 执行逻辑:顺序执行 ↔ 并发 ↔ 并行
  2. I/O 模型:同步 / 异步 + 阻塞 / 非阻塞 + IO 多路复用
  3. 执行载体:线程 / 事件循环 / 协程(M:N Runtime)

一、异步生态通用基础概念

1. 执行逻辑:顺序执行 vs 并发 vs 并行

  • 顺序执行:单核 CPU 按程序计数器(PC)递增顺序执行指令,符合冯·诺依曼架构的“单条控制流”。
  • 并发(Concurrency):单核通过任务切换 + 状态保存(中断、系统调用、进程/线程切换、时间片轮转)实现“在时间上交错运行多个任务”。
  • 并行(Parallelism): 多核 CPU 上同时执行多条指令流,物理上真正“同时进行”。

完整理论背景可以回看 CSAPP 第 8 & 12 章(异常控制流 / 并发编程)。

2. I/O 模型:同步 vs 异步(重点针对 I/O)

讨论的是用户态调用系统调用做 I/O 时的行为

  • 同步 I/O(Synchronous)
    • 线程发起系统调用 → 陷入内核 → 若数据未就绪,则被内核阻塞挂起
    • 直到 I/O 完成被唤醒,系统调用返回。
    • 特点:代码简单,但一个阻塞 I/O 会占用一个线程
  • 异步 I/O(Asynchronous)
    • 发起 I/O 后不阻塞等待结果
    • 结果通过 轮询 / 回调 / 事件通知 / Future 完成
    • 通常配合 IO 多路复用(Linux epoll、macOS kqueue、Windows IOCP)使用。

对比示例:同步睡眠 vs 异步并发睡眠

use std::time::Duration; use tokio::time::sleep;
​
fn sync_task() { std::thread::sleep(Duration::from_secs(2));    }
​
async fn async_task() { sleep(Duration::from_secs(2)).await;    }
#[tokio::main]
async fn main() {
    // 同步:串行,两次共约 4 秒
    let start = std::time::Instant::now();
    sync_task();
    sync_task();
    println!("sync:  {:?}", start.elapsed());
​
    // 异步:并发,两次共约 2 秒(有调度开销)
    let start = std::time::Instant::now();
    let t1 = tokio::spawn(async_task());
    let t2 = tokio::spawn(async_task());
    let _ = tokio::try_join!(t1, t2);
    println!("async: {:?}", start.elapsed());
}

3. 执行载体:线程 vs 事件循环 / 协程 / "用户态线程"

谁在执行代码、谁在调度?

核心维度 原生线程 (OS Thread) 绿色线程 (Green Thread) 无栈协程 (Stackless Coroutine)
典型代表 C++std::thread Go (Goroutine) Rust Future
管理者 操作系统内核 (Kernel) 语言 Runtime 编译器 (状态机) + Runtime
调度机制 抢占式 (OS决定) 抢占式 (Runtime决定) 协作式(必须 await)
能否被打断 能 (时间片用完强切) 能 (Runtime 监控强切) 不能(必须主动让出)
内存模型 有栈 (固定栈, MB级) 有栈 (动态伸缩, KB级) 无栈(堆上结构体字段)
切换成本 内核态切换 用户态, 需切栈 仅函数指针跳转
Rust 对应 std::thread (Rust 1.0 已移除) async/tokio::spawn

工业级 Runtime(如 Tokio)通常采用 M:N 混合模型

  • M:用户态的协程/任务(几万级)
  • N:OS 线程(通常≈CPU 核数)
  • Runtime 维护N个线程,每个线程有自己的任务队列,当其任务队列为空,则"窃取"其他线程的绿色线程来执行

二、IO 多路复用与 Rust 异步栈

1. 什么是 IO 多路复用?

场景:有 N=100 个连接/FD,需要等待“哪一个先有数据”。

  • 阻塞 I/O 串行处理

    1. read(FD1) → 若无数据 → 阻塞
    2. 数据来了 → 处理 → 再 read(FD2) → 阻塞
    3. … 直到所有处理完。
    • 缺点:每次只能等一个 FD,其它都闲着
  • IO 多路复用(以 epoll 为例)

    1. 把所有 FD 注册到 1 个 epoll 实例:epoll_ctl(epfd, ADD, fd, ...)
    2. 线程调用 epoll_wait(epfd, ...) → 若都未就绪,线程 sleep,释放 CPU
    3. 某个 socket 有数据到达:
      • 网卡中断 → 内核把就绪 FD 挂到 epoll 的就绪队列;
      • 唤醒阻塞在 epoll_wait 的线程;
    4. 线程醒来,拿到“就绪 FD 列表”,对所有就绪 FD 批量处理
    5. 再次 epoll_wait

内核中 epoll 的核心是:

  1. 红黑树:维护所有关注的 FD 集合;
  2. 链表:就绪队列,挂就绪事件。

好处:用一个线程 + 一个事件循环同时监控海量连接。

2. Rust 异步生态分层

和 Rust 的关系(Tokio/mio 等):

OS 内核 → mio → Tokio Runtime → 用户 async 代码

  • Level 0:OS Kernel
    • 提供 epoll(Linux)、kqueue(macOS)、IOCP(Windows)。
  • Level 1:mio
    • Rust 的“金属级 I/O 层”,直接封装 OS 的 IO 多路复用。
    • mio::Poll 在 Linux 下对应 epoll,在 macOS 对应 kqueue 等。
  • Level 2:tokio / async-std 等 Runtime
    • 内部持有一个“Reactor”(事件循环),底层就是 mio::Poll
    • 上层还实现 task 调度、定时器、spawn 等。
  • Level 3:用户 async 代码
    • 开发者只需要写 async fn / .await,Runtime 负责把它们映射到底层的事件循环 + 系统调用。

参考图(Tokio/mio/OS 分层):

tokio-mio-os

三、Benchmark:blocking-mt / mio / tokio 对比

目的:比较多线程阻塞 IO、单线程 mio 事件循环、Tokio 多线程 / 单线程 runtime 在不同场景下的表现,理解各自适用场景。

1. 实验设计

  • Server (server.rs)
    • 基于 Tokio 的异步 TCP server;
    • 参数:delay_ms(响应前 sleep)、payload_bytes(写回数据大小);
    • 每个连接:sleep(delay_ms) → 写 payload_bytes → 关闭。
  • Client (client.rs)
    • 同一逻辑,不同 I/O 模式:
      • blocking-mt:多线程阻塞 IO;
      • mio:单线程 epoll 事件循环;
      • tokio:Tokio 多线程 runtime;
      • tokio-ct:Tokio current-thread(单线程 runtime)。
    • 统一参数:conns 并发连接数、payload_bytes 等。
  • Benchmark 脚本 (bench_mio_tokio.sh)
    • 启动 server(指定 delay / payload);
    • 在不同 conns 下分别跑上述 4 种 client;
    • 收集总耗时 & req/s,生成对比表。

2. 核心数据

场景 条件(delay / payload / conns) blocking-mt mio tokio tokio-ct fastest
小包高延迟 20ms / 1KB / 800 0.877 / 911.8 0.072 / 11116.8 0.157 / 5099.0 0.038 / 20821.6 tokio-ct
小包高延迟 20ms / 1KB / 1500 1.644 / 912.2 0.071 / 21038.2 0.172 / 8739.3 0.045 / 33485.9 tokio-ct
大包高吞吐 1ms / 256KB / 400 0.174 / 2296.5 0.299 / 1335.7 0.449 / 890.3 0.144 / 2780.8 tokio-ct
扇出微包 10ms / 256B / 800 0.477 / 1675.7 0.078 / 10268.3 0.158 / 5048.5 0.028 / 28876.3 tokio-ct
扇出微包 10ms / 256B / 1500 0.893 / 1679.6 0.060 / 24848.6 0.186 / 8073.0 0.038 / 39295.0 tokio-ct
零等待中等负载 0ms / 8KB / 800 0.090 / 8865.0 0.016 / 49422.2 0.169 / 4720.5 0.029 / 27546.8 mio
零等待中等负载 0ms / 8KB / 1500 0.159 / 9453.4 0.004 / 387646.4 0.197 / 7616.0 0.094 / 16000.3 mio
I/O 后 CPU 计算 5ms / 1KB / 800 0.289 / 2771.8 0.153 / 5220.8 0.141 / 5666.9 0.171 / 4681.7 tokio
I/O 后 CPU 计算 5ms / 1KB / 1500 0.532 / 2821.6 0.289 / 5189.6 0.188 / 7961.3 0.312 / 4801.6 tokio

测试规模:本机 loopback、并发 50–1500 左右(< 1 万)。

四、实验结论与适用范围

结论(在本实验条件下)

  1. I/O-bound + 高并发,业务逻辑极轻
    • 单线程事件循环(mio / tokio-ct)通常 明显快于
      • 多线程 tokio runtime;
      • 多线程阻塞 IO (blocking-mt)。
    • 原因:单线程 epoll + 非阻塞 IO 能高效利用一个 CPU 核心,而多线程 Runtime 在“超轻任务”场景下调度开销显得过大。
  2. I/O + 明显 CPU 计算(CPU-bound 或混合)
    • 多线程 tokio runtime 表现优于单线程事件循环;
    • M:N 模型只有在“单个请求有足够 CPU 工作”时,才能真正发挥多核并行优势。
  3. 传统阻塞 IO 模型
    • 单线程阻塞:性能最差;
    • 多线程阻塞(一个连接若干线程复用):勉强可用,但线程/栈成本高,扩展性差。
  4. Tokio vs 手写 mio
    • tokio 提供更好的编程模型(async/await、Task、定时器),但在极端“超轻任务 + 玩命 I/O”的纯 Benchmark 下,手写 mio 更接近裸 epoll,性能略高
    • 对真实业务,一般可以接受 tokio 的固定开销,换取更好的可维护性。

  • 原生(native)线程 即 内核级线程
  • 协程与绿色线程区分在:绿色线程由Runtime抢占式调度协程是开发者通过 .await等让出控制权,做到协作式调度

评论