Async
参考资料
- 《Rust中的异步编程》:介绍了基础概念与Rust异步生态
- 《Tokio源码分析》:写了多篇文章剖析了mio、Tokio源码
- 《Wiki-绿色线程》
概念地图(Map)
从三个维度记忆异步生态:
- 执行逻辑:顺序执行 ↔ 并发 ↔ 并行
- I/O 模型:同步 / 异步 + 阻塞 / 非阻塞 + IO 多路复用
- 执行载体:线程 / 事件循环 / 协程(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、macOSkqueue、WindowsIOCP)使用。
对比示例:同步睡眠 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 串行处理
read(FD1)→ 若无数据 → 阻塞- 数据来了 → 处理 → 再
read(FD2)→ 阻塞 - … 直到所有处理完。
- 缺点:每次只能等一个 FD,其它都闲着。
-
IO 多路复用(以 epoll 为例)
- 把所有 FD 注册到 1 个 epoll 实例:
epoll_ctl(epfd, ADD, fd, ...) - 线程调用
epoll_wait(epfd, ...)→ 若都未就绪,线程 sleep,释放 CPU; - 某个 socket 有数据到达:
- 网卡中断 → 内核把就绪 FD 挂到 epoll 的就绪队列;
- 唤醒阻塞在
epoll_wait的线程;
- 线程醒来,拿到“就绪 FD 列表”,对所有就绪 FD 批量处理;
- 再次
epoll_wait。
- 把所有 FD 注册到 1 个 epoll 实例:
内核中 epoll 的核心是:
- 红黑树:维护所有关注的 FD 集合;
- 链表:就绪队列,挂就绪事件。
好处:用一个线程 + 一个事件循环同时监控海量连接。
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 等。
- 内部持有一个“Reactor”(事件循环),底层就是
- Level 3:用户 async 代码
- 开发者只需要写
async fn/.await,Runtime 负责把它们映射到底层的事件循环 + 系统调用。
- 开发者只需要写
参考图(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等。
- 同一逻辑,不同 I/O 模式:
- 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 万)。
四、实验结论与适用范围
结论(在本实验条件下)
- I/O-bound + 高并发,业务逻辑极轻
- 单线程事件循环(
mio/tokio-ct)通常 明显快于:- 多线程 tokio runtime;
- 多线程阻塞 IO (
blocking-mt)。
- 原因:单线程 epoll + 非阻塞 IO 能高效利用一个 CPU 核心,而多线程 Runtime 在“超轻任务”场景下调度开销显得过大。
- 单线程事件循环(
- I/O + 明显 CPU 计算(CPU-bound 或混合)
- 多线程 tokio runtime 表现优于单线程事件循环;
- M:N 模型只有在“单个请求有足够 CPU 工作”时,才能真正发挥多核并行优势。
- 传统阻塞 IO 模型
- 单线程阻塞:性能最差;
- 多线程阻塞(一个连接若干线程复用):勉强可用,但线程/栈成本高,扩展性差。
- Tokio vs 手写 mio
- tokio 提供更好的编程模型(
async/await、Task、定时器),但在极端“超轻任务 + 玩命 I/O”的纯 Benchmark 下,手写 mio 更接近裸 epoll,性能略高; - 对真实业务,一般可以接受 tokio 的固定开销,换取更好的可维护性。
- tokio 提供更好的编程模型(
注
- 原生(native)线程 即 内核级线程
- 协程与绿色线程区分在:绿色线程由Runtime抢占式调度,协程是开发者通过
.await等让出控制权,做到协作式调度。