Rust in Everything
基于 tokio 1.54.1

异步运行时由什么组成

主线位置:这一章不在任何一条主线的某一段上,它给出两条主线共用的三个部件。
💡TIP
打个比方:一家没有前台的餐馆

厨师(worker 线程)照着单子(任务)做菜。有的单子做到一半要等:等电话里的消息(反应器,IO 事件),或者等墙上的计时器响(定时器)。厨师把这张单子夹到「等电话」或「等计时器」那一栏(存下 waker),先去做别的。电话一来、计时器一响,对应的单子就回到待做的那一摞(wake(),任务回到队列)。

这家店没有专职前台。哪个厨师手上没活了,就去前台坐着守电话,闹钟拨到最近一个计时器响的时刻(park 的超时)。前台只有一把椅子(TryLock),先到的坐下,后到的闲着的厨师去后屋打盹(条件变量),来了新单子再被叫起来。

比方到这里为止:真餐馆里守电话的人接到单子,会转给后屋的厨师。tokio 里守着驱动的 worker 把唤醒的任务先排进自己的本地队列(park 的文档注释这么写),满了才溢出到注入队列,别的 worker 要靠偷才能分走。

从 Future 的契约说起

Rust 标准库只定义了 Future trait 和 Waker,没有自带运行时。Future 的契约很简单:

  1. 有人调用 poll。能完成就返回 Ready(v)。
  2. 还不能完成,就把 cx.waker() 存到「将来会发生事件的地方」,然后返回 Pending。
  3. 事件发生时,那个地方调用 waker.wake()。之后应该有人再 poll 一次。

契约里有三个空缺需要运行时来填:

空缺谁来填tokio 里叫什么
「有人调用 poll」:谁、在哪个线程、按什么顺序执行器(executor)调度器 runtime/scheduler/ + 任务 runtime/task/
「socket 可读了」这种事件谁来发现反应器(reactor)IO 驱动 runtime/io/
「过了 100ms」这种事件谁来发现定时器(timer)时间驱动 runtime/time/

反应器和定时器是事件的来源,执行器消费事件:它们调用 wake(),执行器把对应的任务重新放进队列。

flowchart LR
  subgraph exec[执行器]
    Q[任务队列] --> W[worker 线程 poll 任务]
  end
  W -->|Pending,存下 waker| R[反应器:epoll / kqueue / IOCP]
  W -->|Pending,存下 waker| T[定时器:时间轮]
  R -->|wake| Q
  T -->|wake| Q

三件套在 tokio 里怎么装在一起

反应器和定时器都需要「等」:等 IO 事件,或者等到某个时刻。tokio 没有给它们单开线程,而是让空闲的 worker 线程亲自去等。worker 没有任务可跑时,会调用驱动的 park,最终阻塞在 epoll_wait(或 kqueue、IOCP)上,超时时间设为最近一个定时器的到期时间。

📝NOTE
没有蠢问题

问: 如果所有 worker 都很忙,没有一个空闲下来去 park,IO 事件和到期的定时器谁来处理?

答: 忙着的 worker 顺手处理。maintenance 每跑 event_interval 个任务调一次 park_yield,也就是超时为 0 的 park。event_interval 在多线程运行时默认是 61,可以用 Builder::event_interval 改。park_timeout 先试着锁驱动:锁到了,就不睡觉地查一遍 IO 和定时器,把就绪的任务唤醒;锁不到,说明别的线程正拿着驱动,直接回去干活。所以运行时再忙,每个 worker 每跑 61 个任务也会看一眼驱动,前提是任务本身不长时间占着线程。

所以驱动是一层套一层的。Driver::new:

pub(crate) fn new(cfg: Cfg) -> io::Result<(Self, Handle)> {
    let (io_stack, io_handle, signal_handle) =
        create_io_stack(cfg.enable_io, cfg.nevents, cfg.nevents_busy)?;

    let clock = create_clock(cfg.enable_pause_time, cfg.start_paused);

    let (time_driver, time_handle) =
        create_time_driver(cfg.enable_time, cfg.timer_flavor, io_stack, &clock);

    Ok((
        Self { inner: time_driver },
        // ...
    ))
}

最外层是时间驱动,它包着 IO 栈(IO 驱动外面还套着信号和子进程的驱动)。park 时,时间驱动先算出最近一个定时器还有多久到期,然后用这个时长去 park 里层的 IO 驱动;醒来后,再处理已经到期的定时器。如果构建时没开 IO(没调 enable_io),IO 栈就退化成 ParkThread,用条件变量等待。这一套留到驱动栈与 park细讲。

多线程运行时有好几个 worker,但驱动只有一份。它放在一个 TryLock 里(multi_thread/park.rs 第 53 行):哪个空闲 worker 先抢到锁,就由它去 epoll_wait;其他空闲 worker 在各自的条件变量上睡觉,等有新任务时再被叫醒。

📝NOTE
冷知识:0.2 之前,每个 worker 有自己的驱动

2019 年 11 月的 #1807(0.2.0)之前,多线程调度器给每个线程各建一个资源驱动(mio::Poll 加定时器)。提交说明写道:这样「大体上没问题」,只是要在多个驱动之间做均衡,而从线程池外面使用驱动时,均衡很难做;当初每线程一个驱动,是为了不要专门的驱动线程。

这个 PR 改成只建一个驱动:每个调度线程 park 之前先试着「锁」住它,锁不到就在条件变量上 park。说明里的判断是:负载高时调度器很少去碰驱动,争用应该很低。TryLock(util/try_lock.rs)就是这个 PR 加进来的。

执行器:多线程加任务窃取

多线程调度器启动 N 个 worker 线程,N 默认等于 CPU 核数,可以用 Builder::worker_threads 或环境变量 TOKIO_WORKER_THREADS 改。每个 worker 有:

  • 一个容量 256 的本地队列,只有自己能往里放,别的 worker 可以从里面偷。
  • 一个 LIFO 槽,放最近一个被唤醒的任务,下一个优先跑它。

所有 worker 还共享一个带锁的注入队列,从非 worker 线程 spawn 的任务放在这里。

worker 自己的活干完了,就去别的 worker 的本地队列偷一半过来。这就是任务窃取(work stealing)。它的好处是负载自动均衡:某个连接特别忙,它产生的任务会被闲着的线程分走。代价是任务必须能在线程间移动,所以 tokio::spawn 要求 F: Send + 'static。

另一条路:每核一个线程

同样是 Rust 的异步运行时,架构上分成两派:

运行时执行器IO任务能否跨线程
tokio多线程任务窃取(也有 current_thread)epoll / kqueue / IOCP,经 mio能,要求 Send
smolasync-executor:全局队列加各线程本地队列,也会窃取async-io,和执行器是两个独立的 crate能,要求 Send
glommio每核一个线程,任务不迁移io_uring,只支持 Linux不能
monoio每核一个线程,任务不迁移io_uring,可退回 epoll / kqueue不能

每核一个线程(thread-per-core)的思路是:每个线程绑一个核,各自有独立的反应器和任务队列,任务在哪个线程创建就一直在哪个线程跑。

  • 好处:任务不跨线程,所以不需要 Send,可以随便用 Rc、RefCell;没有跨核的同步开销,缓存也一直是热的。配合 io_uring,适合存储、代理这类每个连接负载比较均匀的场景。
  • 代价:负载不均时没有补救。一个线程上的连接特别忙,别的核再闲也帮不上忙。

tokio 选择任务窃取,是因为它的目标是「通用」:大多数网络服务的连接负载不均匀,开发者也不想为每个任务考虑它该放在哪个核上。tokio 也给出了每核一线程的写法:current_thread 运行时加 LocalSet(或 LocalRuntime)跑 !Send 的任务,每个线程开一个这样的运行时。这些取舍在「09 设计取舍」里汇总。

小结

  • 运行时 = 执行器(谁来 poll)+ 反应器(IO 事件)+ 定时器(时间事件)。反应器和定时器负责调用 wake(),执行器负责重新 poll。
  • tokio 不给驱动单开线程:空闲的 worker 去 park 在驱动上,驱动是「时间驱动包着 IO 驱动」的一层层结构。
  • 多线程运行时只有一份驱动,用 TryLock 保护,谁抢到谁去等 IO。
  • tokio 用多线程任务窃取换来负载均衡,代价是任务要 Send;glommio、monoio 走每核一线程,换来无锁和缓存友好。

💬 关联讨论

发起讨论
加载中...