异步运行时由什么组成
主线位置:这一章不在任何一条主线的某一段上,它给出两条主线共用的三个部件。
厨师(worker 线程)照着单子(任务)做菜。有的单子做到一半要等:等电话里的消息(反应器,IO 事件),或者等墙上的计时器响(定时器)。厨师把这张单子夹到「等电话」或「等计时器」那一栏(存下 waker),先去做别的。电话一来、计时器一响,对应的单子就回到待做的那一摞(wake(),任务回到队列)。
这家店没有专职前台。哪个厨师手上没活了,就去前台坐着守电话,闹钟拨到最近一个计时器响的时刻(park 的超时)。前台只有一把椅子(TryLock),先到的坐下,后到的闲着的厨师去后屋打盹(条件变量),来了新单子再被叫起来。
比方到这里为止:真餐馆里守电话的人接到单子,会转给后屋的厨师。tokio 里守着驱动的 worker 把唤醒的任务先排进自己的本地队列(park 的文档注释这么写),满了才溢出到注入队列,别的 worker 要靠偷才能分走。
从 Future 的契约说起
Rust 标准库只定义了 Future trait 和 Waker,没有自带运行时。Future 的契约很简单:
- 有人调用
poll。能完成就返回Ready(v)。 - 还不能完成,就把
cx.waker()存到「将来会发生事件的地方」,然后返回Pending。 - 事件发生时,那个地方调用
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)上,超时时间设为最近一个定时器的到期时间。
问: 如果所有 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 在各自的条件变量上睡觉,等有新任务时再被叫醒。
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 |
| smol | async-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 走每核一线程,换来无锁和缓存友好。