设计取舍
主线位置:不在某一段上。这一章回头看两条主线上的八个关键选择,机制本身都在前面的章节里,这里只列取舍,并给出链接。
flowchart TB
subgraph task[任务主线]
t1[任务窃取] --> t2[LIFO 槽]
t2 --> t3[协作式预算]
t4[状态加引用计数一个原子]
t5["Box Core 交接"]
end
subgraph io[IO 主线]
i1[就绪模型] -.-> i2[io-uring 完成模型]
i3[两套定时器]
end
ll[侵入式链表] --- task
ll --- io
每一节回答同样四个问题:tokio 选了什么,买到什么,付出什么,另一条路是什么。
任务窃取,不是每核一个线程
选择。 多线程运行时的每个 worker 有一个容量 256 的本地队列,自己的活干完了,就从别的 worker 那里偷一半(Steal::steal_into)。
买到。 负载自动均衡。一个连接特别忙,它产生的任务会被空闲的线程分走,开发者不用考虑任务该放在哪个核上。
付出。
- 任务要能跨线程移动,
tokio::spawn要求F: Send + 'static(task::spawn)。 - 本地队列虽然只有一个生产者,也得用原子变量和 CAS,因为随时可能有人来偷(见本地队列)。
- 要协调「谁去偷、谁去睡」:同时找活的 worker 不超过一半,找到活的最后一个负责叫醒下一个,睡前还要用
SeqCst再查一遍所有队列(见任务窃取与空闲)。
另一条路。 glommio、monoio 每核一个线程,任务不迁移,不需要 Send,没有跨核同步;代价是负载不均时没有补救。tokio 里也能这么用:每个线程开一个 current_thread 运行时,配 LocalSet 或 LocalRuntime 跑 !Send 任务。对比见异步运行时由什么组成。
LIFO 槽:缓存局部性换公平性
选择。 在 worker 上 spawn 或唤醒的任务先进 LIFO 槽,当前任务 poll 完,紧接着跑槽里的任务,中间不经过 tick、不看注入队列(见 worker 主循环)。
买到。 消息传递的场景下,被唤醒的任务要读的数据刚被唤醒者写过,还在这个核的缓存里。
付出。
- 不公平:后 spawn 的任务先跑。那一页的实验里,依次 spawn A、B、C,运行顺序是 C、A、B。
- 两个任务互相唤醒可以一直占着 LIFO 槽,把本地队列饿死。所以每轮最多连跑
MAX_LIFO_POLLS_PER_TICK= 3 次,之后本轮关掉 LIFO 槽。源码注释承认 3 是经验值。 - 槽里的任务不能被偷。 当前任务如果一直不返回,槽里的任务只能等。
block_in_place交出Core前要先把它挪进本地队列,也是这个原因。
另一条路。 纯 FIFO。Builder::disable_lifo_slot(第 1429 行,需要 tokio_unstable)就是这个开关,它的文档说明这是 LIFO 槽不能被偷的权宜之计(issue #4941)。
协作式预算,不是抢占
选择。 每次 poll 任务前,线程本地的预算设为 128(Budget::initial)。tokio 自己的叶子 future 每次就绪前扣 1,扣不动就返回 Pending,任务等 worker 查过一次驱动后再回到队列(见协作式调度预算)。
买到。 一个一直就绪的 future(比如总有数据的 channel)不会把 worker 占死。不需要信号、不需要换栈,Future 的契约原样不变。
付出。
- 只管得到 await tokio 叶子 future 的代码。纯计算循环、阻塞的同步调用、不认识预算的第三方 future,它都管不到。
- 任务会在「明明就绪」的时候拿到
Pending。依赖「这一步一定立刻就绪」的代码会被打断。
另一条路。 Rust 的 future 不能被运行时在 poll 中途打断,可选的只有「谁来决定让出」:让用户自己在循环里 yield_now,或者像 tokio 这样在库里埋检查点。tokio 两样都给:consume_budget 让计算循环手动扣预算,unconstrained 关掉预算。
协作式预算来自 2020 年 3 月的 #2160(0.2.14)。PR 说明里的例子是一个对着 stream 循环 next().await、什么也不做的任务:如果 stream 一直就绪,这个任务就永远不会让出,会饿死同一个执行器上的其他任务和资源;而 Rust 没有运行时,很难强行抢占一个长时间运行的任务。
PR 加了 coop 模块:一个线程本地计数器,数任务这次被 poll 以来经过了多少个「协作点」,预算花完后,之后的协作点一律返回 Pending。关于 128,说明原文是这个数「或多或少是随意选的」,用来在「不必要的让出的代价」和「执行器被占住的时间」之间取个平衡。1.54.1 的 Budget::initial 还是 128,注释说生态里的协作点多起来以后,这个值大概也得调高。
状态和引用计数共用一个原子
选择。 任务头里一个 AtomicUsize:低 6 位是状态标志,其余是引用计数,REF_ONE 是 64(第 49 行)。所有状态转换都走 fetch_update_action 这一个 CAS 循环(见一个 usize 的状态机)。
买到。 「看状态、做决定、改状态、扣引用、判断要不要释放」是一次 CAS,要么整体成功,要么基于新值重来。唤醒和 poll 结束在两个线程上同时发生时,不会出现两边都不释放(泄漏)或者都去释放(double free)。
付出。
- 每个转换都要写成「快照 → 动作 + 新快照」的闭包,返回
#[must_use]的动作枚举,调用方按指示提交、释放或什么都不做。代码量和推理难度都在这里。 - 引用计数少了 6 位,溢出时直接
abort(和Arc一样)。
另一条路。 状态和引用计数分开放,比如一个 Arc 加一个状态原子或一把锁。那样「看到已有 Notified」和「把自己的引用减掉」之间就有缝,要么加锁,要么接受上面说的竞态。
侵入式链表:零分配,代价是 unsafe
选择。 链表节点(前后指针)放在被链起来的对象自己身上,而不是另外分配。通用实现在 util/linked_list.rs。
用在这些地方:
| 位置 | 节点在哪 | 讲解 |
|---|---|---|
| 注入队列 | 任务 Header 里的 queue_next(单向链表) | 全局注入队列 |
OwnedTasks | 任务 Trailer 里的 owned,按任务 ID 分片 | OwnedTasks 与关闭 |
| 时间轮 | Sleep 里的 TimerShared,每层 64 条(Level::slot) | 分层时间轮 |
ScheduledIo 等待者 | readable() 等 future 里的 Waiter(Waiters) | ScheduledIo 与就绪状态 |
| 信号量等待者 | Acquire future 里的 Waiter(Waitlist) | 批量信号量 |
Notify 等待者 | Notified future 里的 Waiter(waiters) | oneshot、watch、broadcast、Notify |
此外还有反应器的 RegistrationSet、JoinSet 用的 IdleNotifiedSet、broadcast 的等待者。
买到。
- 挂上、摘下都不分配内存。spawn 只有一次
Box分配;一个 future 等锁、等信号量、等 socket、等定时器,都不额外分配。 - 节点自己知道前后是谁,取消时 O(1) 摘下。
select!里输掉的分支、被 drop 的Sleep,都能立刻从链表里消失。
付出。
- 链表的大部分 API 是
unsafe,Linktrait 要求节点在链表里期间地址不变。这就是Sleep第一次 poll 时才登记的原因:那时它一定已经被 pin 住了(见 Sleep 的一生)。 - 节点所在的对象被 drop 时必须拿锁把自己摘掉。
Sleep的 drop 总要拿一次时间轮的锁,Readinessfuture 的 drop 要拿ScheduledIo的等待者锁。 - 链表 drop 时不会自动清空,调用方要保证 drop 前已经空了。
另一条路。 把 waker 放进 Vec 或 VecDeque。每次等待都要往集合里放一项,取消时要找到并删掉这一项。tokio 自己的 time_alt 定时器就走了另一条路:节点是 Arc<Entry>,每次登记分配一次,换来不用全局锁(见下文)。
Box<Core> 交接
选择。 worker 的全部私有状态(本地队列的生产端、LIFO 槽、tick、Parker)放在一个 Box<Core> 里。线程只是借用它,Core 在 Worker::core(一个 AtomicCell,存的是指针)和线程本地的 Context::core 之间交接(见 multi_thread 总览)。
买到。 block_in_place 可以把 worker 的身份交给一个新线程,自己留在原线程上执行阻塞代码,调度不中断。阻塞代码可以借用当前任务的局部变量,不需要 spawn_blocking 那样的 'static + Send。
付出。
- 调用
block_in_place的任务,后半段可能和前半段不在同一个线程上,执行它的线程也可能已经不是 worker。这段时间它唤醒的任务都进注入队列。 - 每次交接可能经
spawn_blocking起一个线程;阻塞池满了,Core要等。 - current_thread 只有一个线程,没人接手
Core,直接 panic:can call blocking only when running on the multi-threaded runtime。
另一条路。 spawn_blocking:闭包整个交给阻塞池,当前任务 await 它的 JoinHandle。worker 不动,代价是闭包要 'static + Send。两者都在阻塞线程池里讲。
两套定时器
选择。 稳定版只有一套:全局一个分层时间轮,Mutex 保护,节点 TimerShared 内嵌在 Sleep 里(见分层时间轮)。tokio_unstable 下多了一套 time_alt:每个 worker 一个时间轮,放在 Core 里(见 time_alt)。
| 买到 | 付出 | |
|---|---|---|
| 传统 | 插入不分配;current_thread 和 multi_thread 都能用;拿到驱动的任何线程都能触发到期定时器 | 所有 worker 登记、取消定时器都抢同一把锁 |
| time_alt | 去掉全局锁;worker 上的登记先进本地队列,park 前再插入 | 每次登记分配一个 Arc<Entry>;reset 往后推变成「新建加取消」;只支持 multi_thread;定时器由它所在的 worker 触发 |
最后一条的后果:那个 worker 被阻塞,它的定时器就跟着晚。time_alt 那一页的实验里,sleep(20ms) 在 worker 被卡 300 毫秒时实际用了 308 毫秒,传统实现是 23 毫秒。
两套并存,从源码能看到的原因是:time_alt 依赖 multi_thread 的 Core,又在 tokio_unstable 后面默认关闭,传统实现仍是唯一的稳定实现。
就绪模型,以及 io-uring 的完成模型
选择。 网络 IO 走就绪模型:mio 包装 epoll、kqueue、IOCP,内核报告「现在可读」,读还是任务自己调系统调用去读(见反应器、从 TcpStream::read 到挂起)。
买到。
- 缓冲区只在真正读的那一刻借用。取消一个读就是 drop 掉 future,内核手里没有任何在途操作。
- 同一套代码跑在三个平台上。
付出。
- 普通文件没有「就绪」可言,epoll 不接受它们,
tokio::fs每次操作都是一次spawn_blocking。 - 「就绪」和「真的去读」之间有时间差,要靠
ScheduledIo里的 tick 防止清掉新到的事件(见 ScheduledIo 与就绪状态)。
另一条路。 io_uring 的完成模型:提交「读这段到这块内存」,内核做完再通知。1.54.1 在 tokio_unstable 加 io-uring feature 下、只在 Linux 上、只给 tokio::fs 的几个操作用了它(见 io-uring)。它的代价正好是就绪模型的好处反过来:被取消的操作照常完成,缓冲区和 fd 要交给驱动保管到 CQE 回来;只有 Linux 有。
小结
| 取舍 | 买到 | 付出 | 另一条路 |
|---|---|---|---|
| 任务窃取 | 负载自动均衡 | Send + 'static;本地队列要原子操作;找活和睡眠要协调 | 每核一线程(glommio、monoio,或 current_thread + LocalSet) |
| LIFO 槽 | 唤醒者和被唤醒者共享热缓存 | 顺序不公平;槽里的任务偷不走;连跑上限 3 次 | disable_lifo_slot,纯 FIFO |
| 协作式预算 128 | 一直就绪的 future 不会占死 worker | 只管 tokio 叶子 future,管不到计算和阻塞 | 用户自己 yield_now |
| 状态和引用计数同一原子 | 每次转换一次 CAS,不泄漏、不 double free | 状态机代码复杂;计数少 6 位 | 分开存,加锁 |
| 侵入式链表 | 等待和登记不分配,取消 O(1) | unsafe;节点必须 pin 住;drop 要拿锁摘除 | Vec / VecDeque 存 waker;time_alt 的 Arc<Entry> |
Box<Core> 交接 | block_in_place 不中断调度 | 任务可能换线程;current_thread 上 panic | spawn_blocking |
| 两套定时器 | 传统:零分配、两种运行时都能用;alt:无全局锁 | 传统:全局锁;alt:每次分配、依赖本 worker 不被阻塞 | 二选一,目前稳定版只有传统实现 |
| 就绪模型 | 取消就是 drop;跨平台 | 文件 IO 只能进阻塞池;要防 ABA | io-uring 完成模型(unstable,仅 Linux,仅 fs) |
- 默认打开的是不需要使用者做选择的方案(任务窃取、LIFO 槽、预算、全局时间轮、就绪模型);另一条路要使用者自己选:
LocalSet和spawn_blocking是稳定 API,disable_lifo_slot、time_alt、io-uring 在 1.54.1 还需要tokio_unstable。 - 状态原子和侵入式链表都是为了在热路径上省分配、省锁:一次分配放下整个任务,状态和计数一次 CAS,等待节点放在 future 自己身上。代价集中在 unsafe 和 pin 的约束上。