动手:mini-tokio
主线位置:把两条主线各自最简的形态写出来。任务主线:spawn → 进队列 → poll → Pending → wake → 再进队列;IO 主线:注册 waker → 睡在
poll(2)上 → 事件到 → wake。
flowchart TB
spawn["spawn:Arc Task 进队列"] --> runq{队列里有任务?}
runq -->|有| poll[取出一个,poll]
poll -->|Ready| done[槽置空,存活数减 1]
poll -->|Pending| leaf[叶子 future 已登记 waker]
done --> runq
leaf --> runq
runq -->|没有| park["park:poll(2),超时取最近定时器"]
park --> fire[收集就绪 fd 和到期定时器的 waker]
fire --> wake[放开借用后 wake]
wake -->|任务回到队列| runq
整个运行时 187 行,另外 50 行是演示,只依赖标准库和 libc(为了调 poll(2))。本页只讲思路和关键代码。
任务:一个 Arc,加一个会把自己放回队列的 waker
type BoxFuture = Pin<Box<dyn Future<Output = ()> + Send>>;
type RunQueue = Arc<Mutex<VecDeque<Arc<Task>>>>;
struct Task {
// 完成后置 None;Mutex 只是为了让 Task: Sync,单线程下从不争用
future: Mutex<Option<BoxFuture>>,
queue: RunQueue,
}
impl Wake for Task {
// 唤醒 = 把自己放回运行队列尾部
fn wake(self: Arc<Self>) {
let queue = self.queue.clone();
queue.lock().unwrap().push_back(self);
}
}
Waker 要求 Send + Sync,所以任务用 Arc,队列用 Arc<Mutex<..>>。标准库的 Wake trait 让 Waker::from(Arc<Task>) 直接可用,不用手写 vtable。
对照 03 任务:
| mini-tokio | tokio |
|---|---|
Arc<Task> 一次分配,future 再 Box 一次 | 一次分配放下 Header、Core、Trailer(见任务的内存布局) |
Wake trait 生成 vtable | 手写的 RawWakerVTable,传给 future 的 waker 不加引用计数(见 poll 与唤醒) |
| 同一个任务被 wake 两次,就在队列里出现两次 | NOTIFIED 位保证同一时刻至多一个 Notified(见一个 usize 的状态机) |
完成后 future 置 None,迟到的 wake 取出来发现是空的就跳过 | 状态里的 COMPLETE 位,加引用计数决定何时释放 |
问: 同一个任务在队列里出现两次,没完成的时候就会被连着 poll 两次。第二次 poll 会出错吗?
答: 不会出错,只是白跑一趟。标准库 Future::poll 的文档要求 future 能承受多次 poll,只要保证最近一次传进来的 waker 会被唤醒。mini-tokio 的叶子 future 被多 poll 一次,只是把条件再查一遍:poll_io 再调一次系统调用,拿到 WouldBlock 就用新 waker 覆盖表里的旧 waker;sleep 再看一眼时间,没到就再往 timers[deadline] 里塞一个 waker。后者留下一个小尾巴:到期时同一个任务又被唤醒两次,又多 poll 一次。tokio 用 NOTIFIED 位把重复的唤醒挡在入队之前,这一趟也省了。
执行器:先跑完队列,再睡
fn run(main: impl Future<Output = ()> + Send + 'static) {
spawn(main);
let queue = QUEUE.with(|q| q.clone());
loop {
// 1. 把队列里的任务都跑一遍
loop {
let task = queue.lock().unwrap().pop_front();
let Some(task) = task else { break };
let mut slot = task.future.lock().unwrap();
// 已完成的任务又被唤醒过一次:丢掉
let Some(fut) = slot.as_mut() else { continue };
let waker = Waker::from(task.clone());
if fut.as_mut().poll(&mut Context::from_waker(&waker)).is_ready() {
*slot = None;
LIVE.with(|n| n.set(n.get() - 1));
}
}
if LIVE.with(|n| n.get()) == 0 {
return;
}
// 2. 没有可跑的任务:睡在 poll(2) 上
park();
}
}
spawn 做三件事:把 future 装进 Arc<Task>,存活数 LIVE 加 1,推进队列。存活数归零,run 返回。
这就是 current_thread 的骨架。差别在第 1 步:mini-tokio 一直跑到队列空才去 park。一个不停唤醒自己的任务(比如循环里 yield)会让 park 永远轮不到,IO 和定时器都没人管。tokio 的 current_thread 每跑 61 个任务就用 0 超时查一次驱动,正是为了防这个。
在 mini-tokio 上跑两个任务。yield_now 是手写的:唤醒自己一次,返回 Pending 一次。
static YIELDS: AtomicU64 = AtomicU64::new(0);
async fn yield_now() {
let mut yielded = false;
poll_fn(|cx| {
if yielded {
return Poll::Ready(());
}
yielded = true;
cx.waker().wake_by_ref();
Poll::Pending
})
.await
}
// 在 run(async { ... }) 里:
spawn(async {
for _ in 0..100_000 {
yield_now().await;
YIELDS.fetch_add(1, Relaxed);
}
log("A: done");
});
spawn(async {
sleep(Duration::from_millis(1)).await;
log(format!("B: woke up, A has yielded {} times", YIELDS.load(Relaxed)));
});
在 M2 Max 上,A 跑完 10 万次让出大约要 3 毫秒。B 打印的数字是多少?换成 tokio 的 current_thread 运行时(同一个 yield_now,sleep 换成 tokio::time::sleep),大概又是多少?答案在本页末尾。
反应器和定时器:一张表,一棵树,一次 poll(2)
#[derive(Default)]
struct Reactor {
// 每个 (fd, 方向) 只存一个 waker,后来的覆盖先前的
io: HashMap<(RawFd, Interest), Waker>,
timers: BTreeMap<Instant, Vec<Waker>>,
}
park 是整个运行时唯一会阻塞的地方:
fn park() {
let wakers = REACTOR.with(|r| {
let mut r = r.borrow_mut();
// 超时 = 最近一个定时器还有多久,向上取整到毫秒;没有定时器就无限等
let timeout = match r.timers.keys().next() {
Some(&when) => {
let ns = when.saturating_duration_since(Instant::now()).as_nanos();
ns.div_ceil(1_000_000).min(i32::MAX as u128) as i32
}
None => -1,
};
let keys: Vec<(RawFd, Interest)> = r.io.keys().copied().collect();
let mut fds: Vec<libc::pollfd> = keys
.iter()
.map(|&(fd, interest)| libc::pollfd {
fd,
events: if interest == Interest::Read { libc::POLLIN } else { libc::POLLOUT },
revents: 0,
})
.collect();
let n = unsafe { libc::poll(fds.as_mut_ptr(), fds.len() as libc::nfds_t, timeout) };
// ...
let mut wakers = Vec::new();
for (pfd, key) in fds.iter().zip(&keys) {
if pfd.revents != 0 {
wakers.extend(r.io.remove(key));
}
}
// 到期的定时器:split_off 留下 > now 的,前面那一半全部到期
let later = r.timers.split_off(&(Instant::now() + Duration::from_nanos(1)));
let expired = std::mem::replace(&mut r.timers, later);
// ...
wakers.extend(expired.into_values().flatten());
// ...
wakers
});
// 放开 RefCell 之后再 wake(tokio 也是放锁之后才叫醒)
for w in wakers {
w.wake();
}
}
和 tokio 对应的几处:
- 定时器决定超时。 最近一个定时器还有多久,就让
poll(2)等多久。tokio 的驱动栈也是这样一层套一层:时间驱动算出超时,再去 park 里层的 IO 驱动(见驱动栈与 park)。向上取整到毫秒,保证不会早醒;tokio 的deadline_to_tick也是向上取整(见 Sleep 的一生)。 - 事件只调 waker,不碰任务。 和 tokio 的
turn()一样,反应器不知道任务和队列的存在,只知道 waker。 - 先收集,放开借用再 wake。
wake会执行 waker 背后的任意代码。这里只是推进队列,但如果某个 waker 回头登记定时器,在借用期间调用就会让RefCell重复借用而 panic。tokio 的ScheduledIo和时间轮都是在锁里收集 waker,放锁后再调用(见 ScheduledIo 与就绪状态)。
叶子 future:先做,做不了再登记
所有 IO 都走同一个函数:
// 先直接做系统调用;WouldBlock 才登记 waker、返回 Pending
fn poll_io<T>(fd: RawFd, interest: Interest, cx: &Context<'_>, r: io::Result<T>) -> Poll<io::Result<T>> {
match r {
Err(e) if e.kind() == io::ErrorKind::WouldBlock => {
register(fd, interest, cx);
Poll::Pending
}
r => Poll::Ready(r),
}
}
async fn read(s: &mut TcpStream, buf: &mut [u8]) -> io::Result<usize> {
let fd = s.as_raw_fd();
poll_fn(|cx| poll_io(fd, Interest::Read, cx, s.read(buf))).await
}
socket 都设成非阻塞。read 先直接调系统调用,拿到 WouldBlock 才把 waker 登记到 (fd, Read),返回 Pending。accept、write 一模一样。sleep 更简单:没到 deadline 就把 waker 塞进 timers[deadline]。
这就是 从 TcpStream::read 到挂起 那条链去掉所有细节后的样子。tokio 多出来的几层,正好对应 mini-tokio 偷的懒:
- mini-tokio 用水平触发的
poll(2),每次WouldBlock都重新登记,触发一次就从表里删掉。数据还在缓冲区里,下一次poll(2)一定会再报。所以它不需要缓存就绪状态,也就没有「清位时吞掉新事件」的问题。 - tokio 用 mio,在 epoll 和 kqueue 上是边沿触发,fd 只注册一次,就绪状态缓存在
ScheduledIo里,省掉每次WouldBlock后的重新注册。代价是要用 tick 防 ABA(见 ScheduledIo 与就绪状态)。 - mini-tokio 每个
(fd, 方向)只有一个 waker 槽,相当于 tokio 给poll_*风格 API 的reader、writer两个槽;没有给readable()这类 API 用的等待者链表。
跑一下:TCP 回显、一个 ticker
演示开三个任务:服务端 accept 后把收到的回显回去;客户端先 sleep(50ms),再连上去发两条消息;ticker 每 30 毫秒打一次点,共三次。park 每次返回时打印一行,看反应器做了什么。
spawn(async move {
log("server: waiting for a connection");
let mut s = accept(&listener).await.unwrap();
log("server: accepted");
let mut buf = [0u8; 64];
loop {
let n = read(&mut s, &mut buf).await.unwrap();
if n == 0 {
log("server: client closed");
break;
}
log(format!("server: echo {:?}", std::str::from_utf8(&buf[..n]).unwrap()));
write(&mut s, &buf[..n]).await.unwrap();
}
});
spawn(async move {
sleep(Duration::from_millis(50)).await;
// 连本机监听端口,内核 backlog 直接完成握手,这里用阻塞 connect 图省事
let mut s = TcpStream::connect(addr).unwrap();
s.set_nonblocking(true).unwrap();
log("client: connected");
let mut buf = [0u8; 64];
for msg in ["hello", "mini-tokio"] {
write(&mut s, msg.as_bytes()).await.unwrap();
let n = read(&mut s, &mut buf).await.unwrap();
log(format!("client: got {:?}", std::str::from_utf8(&buf[..n]).unwrap()));
}
});
spawn(async {
for i in 1..=3 {
sleep(Duration::from_millis(30)).await;
log(format!("ticker: tick {i}"));
}
});
macOS 上 cargo run --release 的输出:
[ 0ms] main: spawned 3 tasks
[ 0ms] server: waiting for a connection
[ 31ms] park: poll(1 fds, timeout 30ms) -> 0 io, 1 timer
[ 31ms] ticker: tick 1
[ 52ms] park: poll(1 fds, timeout 19ms) -> 0 io, 1 timer
[ 52ms] client: connected
[ 52ms] park: poll(2 fds, timeout 9ms) -> 1 io, 0 timer
[ 52ms] server: accepted
[ 52ms] server: echo "hello"
[ 52ms] park: poll(2 fds, timeout 9ms) -> 1 io, 0 timer
[ 52ms] client: got "hello"
[ 53ms] park: poll(2 fds, timeout 9ms) -> 1 io, 0 timer
[ 53ms] server: echo "mini-tokio"
[ 53ms] park: poll(2 fds, timeout 9ms) -> 1 io, 0 timer
[ 53ms] client: got "mini-tokio"
[ 53ms] park: poll(1 fds, timeout 9ms) -> 1 io, 0 timer
[ 53ms] server: client closed
[ 62ms] park: poll(0 fds, timeout 9ms) -> 0 io, 1 timer
[ 62ms] ticker: tick 2
[ 93ms] park: poll(0 fds, timeout 30ms) -> 0 io, 1 timer
[ 93ms] ticker: tick 3
[ 93ms] run: all tasks done
逐段看:
- 0 ms。 主任务 spawn 三个任务后结束。服务端
accept得到WouldBlock,登记监听 fd;客户端和 ticker 各登记一个定时器(50 和 30 毫秒)。队列空了,park:1 个 fd,超时取最近的 30 毫秒。 - 31 ms。 ticker 的定时器到期。超时 30 毫秒却在 31 毫秒醒来,是向上取整加上系统调度的延迟。ticker 登记下一个 30 毫秒(到期在 61 毫秒)。
- 52 ms。 客户端的定时器到期,连接、写入
hello、读到WouldBlock。此时登记了两个 fd:监听 fd 和客户端 socket。 - 52~53 ms。 每次
park只有一个 fd 就绪,回显来回两轮。服务端accepted之后紧接着就是echo "hello":客户端在 accept 之前已经把数据写进去了,第一次read直接成功,没有挂起。 - 53 ms。 客户端任务结束,socket 被 drop;服务端读到 0,结束。注意这里打印的是
poll(1 fds, ...):客户端的 fd 关了,但它登记的 waker 早在上一轮被取走,表里只剩服务端 socket。 - 62 ms、93 ms。 只剩 ticker,
poll(0 fds, ...)退化成单纯的睡眠,tick 2 和 tick 3 正常到达。存活数归零,run返回。
mini-tokio 省掉了什么
| 省掉的 | tokio 里在哪 |
|---|---|
| 多线程、任务窃取、本地队列、注入队列 | 本地队列、任务窃取与空闲、全局注入队列 |
跨线程唤醒时叫醒睡着的线程:mini-tokio 的 waker 能在别的线程上调用,但只是推进队列,睡在 poll(2) 里的线程不会醒,要等超时 | 反应器保留 Token(0) 专门用来叫醒 epoll_wait(见反应器) |
| LIFO 槽 | worker 主循环 |
| 协作式预算;每 61 个任务查一次驱动 | 协作式调度预算、current_thread |
任务状态机:重复唤醒去重、JoinHandle 取输出、abort、panic 隔离 | 一个 usize 的状态机、JoinHandle、取消与 JoinError |
| 就绪状态缓存和防 ABA 的 tick;一个 socket 上任意多个等待者 | ScheduledIo 与就绪状态 |
定时器的取消:mini-tokio 的 sleep 被 drop 后,它的 waker 还留在 BTreeMap 里直到到期 | 分层时间轮:节点是侵入式链表,插入、删除 O(1),drop 时立即摘除 |
持久的注册:mini-tokio 每次 park 都从表里重建 pollfd 数组,fd 多了就是 O(n) | fd 只注册一次,事件的 token 就是 ScheduledIo 的地址(见反应器) |
阻塞线程池、block_in_place | 阻塞线程池 |
| 关闭:drop 运行时时取消所有存活任务 | OwnedTasks 与关闭 |
异步 connect:演示里用的是阻塞 connect,只因为连的是本机已在监听的端口 | TcpStream::connect 发起非阻塞连接后等待可写(connect_mio) |
完整的多线程版本(带任务窃取)计划放进付费课程「从零实现异步运行时」。
动脑筋答案
mini-tokio 版本就是上面的代码,park 里打印日志的那一行去掉了。tokio 版本:
#[tokio::main(flavor = "current_thread")]
async fn main() {
let start = Instant::now();
let a = tokio::spawn(async move {
for _ in 0..100_000 {
yield_now().await; // 和 mini-tokio 用的是同一个手写 yield_now
YIELDS.fetch_add(1, Relaxed);
}
println!("[{:>3}ms] A: done", start.elapsed().as_millis());
});
let b = tokio::spawn(async move {
tokio::time::sleep(Duration::from_millis(1)).await;
println!("[{:>3}ms] B: woke up, A has yielded {} times", start.elapsed().as_millis(), YIELDS.load(Relaxed));
});
a.await.unwrap();
b.await.unwrap();
}
M2 Max、rustc 1.99.0、--release,两个版本各跑 5 次。mini-tokio 5 次的输出完全相同:
[ 3ms] A: done
[ 3ms] B: woke up, A has yielded 100000 times
tokio 第一次的输出:
[ 1ms] B: woke up, A has yielded 7075 times
[ 24ms] A: done
另外 4 次,B 那一行的次数分别是 7807、8295、7990、7685,时间都是 1ms;A 都在 23ms 结束。
- mini-tokio:100000。 A 每次让出都把自己推回队列尾部,队列永远不空,
run的第 1 步就停不下来,park一次也轮不到。B 的定时器 1 毫秒时就到期了,可只有park会去看定时器,所以 B 要等 A 全部跑完、队列第一次变空才被唤醒。这个数字和机器快慢无关,换多少次循环都等于循环次数。 - tokio:七八千次,每次不同,但 B 总在 1 毫秒醒来。 current_thread 的
block_on循环每跑event_interval(61)个任务就调一次park_yield,时间驱动在这时发现 B 到期。具体是几千次,取决于 1 毫秒里 A 跑了多少轮,所以每次不同。 - 代价也看得到:同样 10 万次让出,tokio 用了 23 毫秒,mini-tokio 3 毫秒。差距里有每 61 个任务进一次驱动的开销,也有任务状态机等 mini-tokio 没有的开销,这个实验分不开两者。
小结
- 一个能跑的异步运行时只需要四样东西:会把自己放回队列的任务、一个执行循环、一个把 fd 映射到 waker 的反应器、一个按时间排序的定时器表。
- 定时器的最早到期时间就是
poll(2)的超时,这一点 tokio 的驱动栈也一样。 - 反应器只调 waker,不知道任务;先收集 waker,放开借用后再 wake。
- mini-tokio 用水平触发、每次重新登记,所以不需要就绪缓存和 tick;tokio 用边沿触发、注册一次,为此付出了
ScheduledIo的复杂度。 - 和 tokio 相比,省掉的主要是多线程调度、公平性(预算、每 61 个任务查一次驱动)、任务状态机,以及让等待和取消都不分配的侵入式链表。