Rust in Everything
基于 tokio 1.54.1

动手: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-tokiotokio
Arc<Task> 一次分配,future 再 Box 一次一次分配放下 Header、Core、Trailer(见任务的内存布局)
Wake trait 生成 vtable手写的 RawWakerVTable,传给 future 的 waker 不加引用计数(见 poll 与唤醒)
同一个任务被 wake 两次,就在队列里出现两次NOTIFIED 位保证同一时刻至多一个 Notified(见一个 usize 的状态机)
完成后 future 置 None,迟到的 wake 取出来发现是空的就跳过状态里的 COMPLETE 位,加引用计数决定何时释放
📝NOTE
没有蠢问题

问: 同一个任务在队列里出现两次,没完成的时候就会被连着 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 超时查一次驱动,正是为了防这个。

❗IMPORTANT
动脑筋:B 醒来时,A 让出了几次?

在 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 个任务查一次驱动)、任务状态机,以及让等待和取消都不分配的侵入式链表。

💬 关联讨论

发起讨论
加载中...