.
执行器与反应器:从零拼出异步运行时
. 执行器与反应器:从零拼出异步运行时
实现最小可运行的 block_on 与多任务队列执行器,剖析 epoll/kqueue/IOCP 反应器怎样通过 Waker 把操作系统事件接入事件循环.
至此,我们已经集齐了被动一侧的全部零件:
async
async
/
await
await
编译出枚举状态机,
Pin
Pin
把状态机安全地固定在内存里,
Future::poll
Future::poll
与
Waker
Waker
规定了推进与通知的契约. 但所有这些构造都是被动的——如果没有一段主动跑在线程上的循环代码去构造
Context
Context
并调用顶层任务的
poll
poll
,任何
Future
Future
都不会往前走哪怕一步.
在异步运行时(如
tokio
tokio
或
smol
smol
)中,主动驱动任务的部分分为两个协作角色: 执行器负责维护就绪任务队列并调用
Future::poll
Future::poll
; 反应器(在
tokio
tokio
中常称 I/O 驱动器或时间驱动器)负责向操作系统监听外部事件(如套接字可读、定时器到期),并在事件发生时调用对应任务的
Waker::wake()
Waker::wake()
.
1 整套运行时的闭环架构
先把执行器、顶层任务、叶子
Future
Future
与反应器摆在同一张架构图里,看一次异步 I/O 从发起、挂起到唤醒恢复的完整轨迹:
注意第 4 步与第 5 步之间的状态: 当任务在第 4 步向执行器返回
Poll::Pending
Poll::Pending
后,执行器不再持有该任务的就绪引用,也不会把它塞回就绪队列; 此时是谁还抓着这个任务,保证它没被丢掉? 是第 3 步注册进反应器的那个
Waker
Waker
(其内部
data
data
指针持有
Arc<Task>
Arc<Task>
的一份强引用,任务本体则安全地躺在堆上或执行器的任务槽中)! 一旦操作系统通知反应器“网卡数据到了”,反应器在表中查出对应的
Waker
Waker
并调用
wake()
wake()
,
wake()
wake()
的实现就是把
Arc<Task>
Arc<Task>
重新推回第 1 步的就绪队列.
2
第一步:三十行写出单任务
block_on
block_on
在写多任务队列之前,我们先写一个最简单的执行器
block_on<F: Future>(fut: F) -> F::Output
block_on<F: Future>(fut: F) -> F::Output
. 它只负责在当前线程上阻塞地驱动单个顶层
Future
Future
直到完成: 当
poll
poll
返回
Pending
Pending
时,通过标准库的线程驻留原语
std::thread::park()
std::thread::park()
让当前操作系统线程休眠; 当任何地方调用
Waker::wake()
Waker::wake()
时,通过
Thread::unpark()
Thread::unpark()
唤醒该线程继续下一轮
poll
poll
.
借助 Rust 标准库的
std::task::Wake
std::task::Wake
特型与
std::pin::pin!
std::pin::pin!
宏,这个完全符合标准协议的执行器不需要写一行
unsafe
unsafe
:
use std::future::Future;
use std::pin::pin;
use std::sync::Arc;
use std::task::{Context, Poll, Wake, Waker};
use std::thread::{self, Thread};
/// 唤醒器的数据载体:直接保存需要被唤醒的操作系统线程句柄.
struct ThreadNotify {
thread: Thread,
}
impl Wake for ThreadNotify {
fn wake(self: Arc<Self>) {
self.thread.unpark();
}
fn wake_by_ref(self: &Arc<Self>) {
self.thread.unpark();
}
}
pub fn block_on<F: Future>(fut: F) -> F::Output {
// 1. 将状态机安全地固定在当前函数栈帧上(零堆分配)
let mut fut = pin!(fut);
// 2. 为当前线程创建 Waker 与 Context
let notify = Arc::new(ThreadNotify {
thread: thread::current(),
});
let waker = Waker::from(notify);
let mut cx = Context::from_waker(&waker);
// 3. 循环轮询:未就绪时挂起线程,被 unpark 唤醒后再次轮询
loop {
match fut.as_mut().poll(&mut cx) {
Poll::Ready(output) => return output,
Poll::Pending => thread::park(),
}
}
}
use std::future::Future;
use std::pin::pin;
use std::sync::Arc;
use std::task::{Context, Poll, Wake, Waker};
use std::thread::{self, Thread};
/// 唤醒器的数据载体:直接保存需要被唤醒的操作系统线程句柄.
struct ThreadNotify {
thread: Thread,
}
impl Wake for ThreadNotify {
fn wake(self: Arc<Self>) {
self.thread.unpark();
}
fn wake_by_ref(self: &Arc<Self>) {
self.thread.unpark();
}
}
pub fn block_on<F: Future>(fut: F) -> F::Output {
// 1. 将状态机安全地固定在当前函数栈帧上(零堆分配)
let mut fut = pin!(fut);
// 2. 为当前线程创建 Waker 与 Context
let notify = Arc::new(ThreadNotify {
thread: thread::current(),
});
let waker = Waker::from(notify);
let mut cx = Context::from_waker(&waker);
// 3. 循环轮询:未就绪时挂起线程,被 unpark 唤醒后再次轮询
loop {
match fut.as_mut().poll(&mut cx) {
Poll::Ready(output) => return output,
Poll::Pending => thread::park(),
}
}
}
这里有一个体现 Rust 并发原语设计的细节: 如果外部线程在
fut.as_mut().poll(&mut cx)
fut.as_mut().poll(&mut cx)
刚刚决定返回
Pending
Pending
、但主线程还没来得及执行到
thread::park()
thread::park()
的那几纳秒间隙里,抢先一步调用了
wake()
wake()
(即
unpark()
unpark()
),主线程接下来执行
thread::park()
thread::park()
会不会永久睡死?
不会.因为
std::thread::park
std::thread::park
与
unpark
unpark
采用的是单令牌模型: 每个线程关联一个布尔令牌,提前到达的
unpark()
unpark()
会把令牌设为“可用”;紧接着调用的
park()
park()
看到令牌可用,会立即消耗该令牌并直接返回,根本不会进入休眠.
3
第二步:支持
spawn
spawn
的多任务队列执行器
block_on
block_on
只能在单条直线上等待一个顶层
Future
Future
. 如果想在同一个线程上并发跑多个独立的顶层任务,我们就需要引入任务控制块
Task
Task
与就绪队列:
use std::future::Future;
use std::pin::Pin;
use std::sync::{mpsc, Arc, Mutex};
use std::task::{Context, Poll, Wake, Waker};
/// 一个被擦除了具体类型的顶层异步任务.
struct Task {
/// 用 Mutex 包装以满足 Wake 要求的 Sync;
/// 单线程执行器中实际上不会发生锁竞争.
future: Mutex<Option<Pin<Box<dyn Future<Output = ()> + Send>>>>,
/// 指向执行器就绪队列的发送端,wake() 时将自身送回队列.
sender: mpsc::Sender<Arc<Task>>,
}
impl Wake for Task {
fn wake(self: Arc<Self>) {
let _ = self.sender.send(self.clone());
}
fn wake_by_ref(self: &Arc<Self>) {
let _ = self.sender.send(self.clone());
}
}
use std::future::Future;
use std::pin::Pin;
use std::sync::{mpsc, Arc, Mutex};
use std::task::{Context, Poll, Wake, Waker};
/// 一个被擦除了具体类型的顶层异步任务.
struct Task {
/// 用 Mutex 包装以满足 Wake 要求的 Sync;
/// 单线程执行器中实际上不会发生锁竞争.
future: Mutex<Option<Pin<Box<dyn Future<Output = ()> + Send>>>>,
/// 指向执行器就绪队列的发送端,wake() 时将自身送回队列.
sender: mpsc::Sender<Arc<Task>>,
}
impl Wake for Task {
fn wake(self: Arc<Self>) {
let _ = self.sender.send(self.clone());
}
fn wake_by_ref(self: &Arc<Self>) {
let _ = self.sender.send(self.clone());
}
}
看清
Task::wake
Task::wake
所做的唯一一件事:调用
self.sender.send(self.clone())
self.sender.send(self.clone())
,把
Arc<Task>
Arc<Task>
自己塞回执行器的就绪通道里! 接下来,执行器的主循环只需要不断地从通道接收端取出
Arc<Task>
Arc<Task>
,构造指向该
Task
Task
的
Waker
Waker
,再对其中的
Future
Future
调用一次
poll
poll
:
pub struct MiniExecutor {
receiver: mpsc::Receiver<Arc<Task>>,
sender: mpsc::Sender<Arc<Task>>,
}
impl MiniExecutor {
pub fn new() -> Self {
let (sender, receiver) = mpsc::channel();
Self { receiver, sender }
}
/// 向执行器提交一个新任务:装箱固定并立即送入就绪队列以触发首次 poll.
pub fn spawn(&self, fut: impl Future<Output = ()> + Send + 'static) {
let task = Arc::new(Task {
future: Mutex::new(Some(Box::pin(fut))),
sender: self.sender.clone(),
});
let _ = self.sender.send(task);
}
/// 运行就绪队列中的所有任务.
pub fn run(&self) {
while let Ok(task) = self.receiver.recv() {
let mut slot = task.future.lock().unwrap();
// 取出尚未完成的 Future;若已完成则忽略(防止重复唤醒再次 poll)
if let Some(mut future) = slot.take() {
let waker = Waker::from(task.clone());
let mut cx = Context::from_waker(&waker);
match future.as_mut().poll(&mut cx) {
Poll::Ready(()) => {
// 任务完成,不再放回 slot,future 在此处自然析构
}
Poll::Pending => {
// 尚未完成,把固定好的状态机放回槽位,等待未来 wake() 再次入队
*slot = Some(future);
}
}
}
}
}
}
pub struct MiniExecutor {
receiver: mpsc::Receiver<Arc<Task>>,
sender: mpsc::Sender<Arc<Task>>,
}
impl MiniExecutor {
pub fn new() -> Self {
let (sender, receiver) = mpsc::channel();
Self { receiver, sender }
}
/// 向执行器提交一个新任务:装箱固定并立即送入就绪队列以触发首次 poll.
pub fn spawn(&self, fut: impl Future<Output = ()> + Send + 'static) {
let task = Arc::new(Task {
future: Mutex::new(Some(Box::pin(fut))),
sender: self.sender.clone(),
});
let _ = self.sender.send(task);
}
/// 运行就绪队列中的所有任务.
pub fn run(&self) {
while let Ok(task) = self.receiver.recv() {
let mut slot = task.future.lock().unwrap();
// 取出尚未完成的 Future;若已完成则忽略(防止重复唤醒再次 poll)
if let Some(mut future) = slot.take() {
let waker = Waker::from(task.clone());
let mut cx = Context::from_waker(&waker);
match future.as_mut().poll(&mut cx) {
Poll::Ready(()) => {
// 任务完成,不再放回 slot,future 在此处自然析构
}
Poll::Pending => {
// 尚未完成,把固定好的状态机放回槽位,等待未来 wake() 再次入队
*slot = Some(future);
}
}
}
}
}
}
注意
slot.take()
slot.take()
的手法: 由于同一个任务注册出去的多个
Waker
Waker
可能在任务完成后仍被触发,我们把
future
future
放在
Option
Option
里. 当
poll
poll
返回
Poll::Ready(())
Poll::Ready(())
时,我们不再把
future
future
放回
slot
slot
(使其保持为
None
None
); 这样即便后续还有迟到的伪唤醒把同一个
Arc<Task>
Arc<Task>
再次送进队列,
if let Some(mut future) = slot.take()
if let Some(mut future) = slot.take()
也会直接跳过,严格兑现了“绝不再次
poll
poll
已完成的
Future
Future
”的契约.
4 反应器是如何工作的
有了执行器,最后一块拼图是:当叶子
Future
Future
发起网络读写或定时等待时,是谁在背后调用
waker.wake()
waker.wake()
?
以非阻塞 TCP 套接字读取为例:
- 套接字在创建时被设置为非阻塞模式(
O_NONBLOCKO_NONBLOCK),并注册到操作系统的多路复用实例中(Linux 上是epollepoll或异步完成接口io_uringio_uring,macOS/BSD 上是kqueuekqueue,Windows 上是IOCPIOCP),附带一个唯一标识号tokentoken. 当任务调用
stream.read(&mut buf).awaitstream.read(&mut buf).await时,叶子FutureFuture的pollpoll方法直接发起一次非阻塞系统调用readread.- 如果内核缓冲区里已经有数据,
readread当场把字节拷进bufbuf并返回字节数,pollpoll直接返回Poll::Ready(Ok(n))Poll::Ready(Ok(n)),根本不需要惊动反应器或挂起任务. - 如果内核缓冲区为空,系统调用立即返回
EAGAINEAGAIN/EWOULDBLOCKEWOULDBLOCK(在 Rust 中对应io::ErrorKind::WouldBlockio::ErrorKind::WouldBlock).
- 如果内核缓冲区里已经有数据,
- 收到
WouldBlockWouldBlock后,叶子FutureFuture将当前的cx.waker().clone()cx.waker().clone()登记到反应器的token -> Wakertoken -> Waker表中,然后返回Poll::PendingPoll::Pending. - 当执行器的就绪队列排空、无事可做时,线程进入反应器的
epoll_waitepoll_wait(或定时堆等待),让出 CPU 直到内核报告某个tokentoken对应的文件描述符就绪. - 反应器遍历内核返回的就绪事件列表,按
tokentoken查出先前登记的WakerWaker,逐一调用waker.wake()waker.wake()——这会将对应的Arc<Task>Arc<Task>重新推回执行器的就绪队列,随后下一轮执行循环就会把它们再次pollpoll起来.
5 协作式调度的约束:不要在异步任务中阻塞线程
看清了“单线程(或少量工作线程)循环
poll
poll
就绪任务 + 空闲时交由反应器
epoll_wait
epoll_wait
”的运行机制后,异步编程的核心工程约束也就清楚了:
异步任务采用的是协作式调度.执行器一旦调用了某个任务的
future.poll(&mut cx)
future.poll(&mut cx)
,除非这个
poll
poll
函数主动返回
Poll::Pending
Poll::Pending
或
Poll::Ready
Poll::Ready
,否则执行器无法中途抢占或打断它.
如果某个任务在
async fn
async fn
内部调用了阻塞式系统调用(如
std::thread::sleep
std::thread::sleep
、同步文件读写
std::fs::read
std::fs::read
、阻塞式数据库驱动),或者执行长达数百毫秒的密码学哈希、图像编解码等密集 CPU 计算而不经过任何
.await
.await
: 当前工作线程就会一直卡在这个任务的
poll
poll
调用栈里出不来,既没法去
poll
poll
队列里早已就绪的其他成百上千个任务,也没法去驱动反应器处理新的网络事件,导致整个线程上的所有并发任务集体出现尾延迟飙升甚至心跳超时断连.
遇到这类无法避免的同步阻塞或重度 CPU 计算时,应当:
- 对阻塞 I/O,交给运行时专门隔离的阻塞线程池(如
tokio::task::spawn_blockingtokio::task::spawn_blocking),并通过异步通道或一次性oneshotoneshotFutureFuture把结果接回异步世界; - 对重度 CPU 计算,交给并行计算线程池(如
rayonrayon),或者在长循环中定期穿插tokio::task::yield_now().awaittokio::task::yield_now().await(其内部实现正是在返回PendingPending前立刻调用cx.waker().wake_by_ref()cx.waker().wake_by_ref(),把排在后面的其他任务先让出来跑一轮).
除了不能阻塞工作线程,协作式挂起与“丢弃即取消”还引出了循环
select!
select!
中的取消安全性(cancellation safety)与异步清理(async clean-up)问题:
6 练习:执行器与调度行为
Exercises (2)
Exercise 5.1. 手写
yield_now()
yield_now()
异步原语
pub fn yield_now() -> YieldNow
pub fn yield_now() -> YieldNow
,使异步任务调用
yield_now().await
yield_now().await
时能够主动让出一次当前线程的执行权,让就绪队列中的其他任务先运行,随后自己再恢复执行.Hint
用一个布尔字段记录是否是第一次被poll
poll
;第一次返回
Pending
Pending
前必须自我唤醒,第二次直接返回
Ready(())
Ready(())
.Solution
use std::future::Future;
use std::pin::Pin;
use std::task::{Context, Poll};
pub struct YieldNow {
yielded: bool,
}
pub fn yield_now() -> YieldNow {
YieldNow { yielded: false }
}
impl Future for YieldNow {
type Output = ();
fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<()> {
if self.yielded {
Poll::Ready(())
} else {
self.yielded = true;
// 自我唤醒,使执行器将当前任务排到就绪队列队尾
cx.waker().wake_by_ref();
Poll::Pending
}
}
}
use std::future::Future;
use std::pin::Pin;
use std::task::{Context, Poll};
pub struct YieldNow {
yielded: bool,
}
pub fn yield_now() -> YieldNow {
YieldNow { yielded: false }
}
impl Future for YieldNow {
type Output = ();
fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<()> {
if self.yielded {
Poll::Ready(())
} else {
self.yielded = true;
// 自我唤醒,使执行器将当前任务排到就绪队列队尾
cx.waker().wake_by_ref();
Poll::Pending
}
}
}
在我们的
MiniExecutor
MiniExecutor
中,当前任务调用
yield_now().await
yield_now().await
时,
wake_by_ref()
wake_by_ref()
会把当前任务追加到 FIFO 通道的队尾,然后
poll
poll
返回
Pending
Pending
; 执行器接着就会先去执行排在通道前面的其他就绪任务,等轮到队尾时再恢复当前任务(第二次
poll
poll
进入
self.yielded == true
self.yielded == true
分支并返回
Ready(())
Ready(())
).
Exercise 5.2. 如果
MiniExecutor
MiniExecutor
忘记在
spawn
spawn
时入队会怎样
MiniExecutor::spawn
MiniExecutor::spawn
中,为什么要把新创建的
task
task
立即通过
self.sender.send(task)
self.sender.send(task)
发送到就绪队列里?如果不发送,反应器能唤醒它吗?Hint
回想 Rust 的Future
Future
是惰性的:谁负责触发第一次
poll
poll
以注册第一个
Waker
Waker
?Solution
如果spawn
spawn
只创建了
Arc<Task>
Arc<Task>
却没有将它送入
self.sender
self.sender
,那么这个任务连第一次
poll
poll
都不会被调用. 由于状态机处于初始的
Start
Start
状态,它还没有执行到任何内部叶子
Future
Future
,也没有向任何反应器注册过
Waker
Waker
; 既然没有注册过
Waker
Waker
,未来也就没有任何外部事件能调用它的
wake()
wake()
把它送进队列. 结果是该任务在创建后立刻因为没有任何剩余强引用(或即便保存在某处也无人驱动)而被直接丢弃或永久静默.