源码地图
仓库结构
tokio 仓库是一个 workspace,主线只看第一个。各 crate 之间、以及对外部 crate 的依赖关系见 crate 依赖图。
| crate | 内容 |
|---|---|
tokio | 运行时、IO、网络、定时器、同步原语,本卷的全部主线 |
tokio-macros | #[tokio::main]、#[tokio::test],以及 select! 用到的过程宏 |
tokio-util | codec、CancellationToken 等工具,用到时提一下 |
tokio-stream | Stream 适配,用到时提一下 |
tokio/src 各目录
下表的行数是 1.54.1 里该目录全部 .rs 文件的行数,包含注释和目录内的测试,只用来估计分量:
| 目录 | 约行数 | 管什么 | 对应部分 |
|---|---|---|---|
runtime/ | 31.4k | 调度器、任务、IO 驱动、时间轮、阻塞线程池、线程本地上下文 | 02–06 |
sync/ | 21.9k | Mutex、Semaphore、mpsc、oneshot、watch、broadcast、Notify | 07 |
net/ | 15.8k | TCP、UDP、Unix socket、Windows 命名管道 | 05 |
io/ | 13.9k | AsyncRead / AsyncWrite 及扩展方法,PollEvented | 05 |
fs/ | 5.3k | 文件操作,大多是丢进阻塞线程池执行 | 04 阻塞线程池 |
task/ | 4.4k | 用户层的 spawn、JoinSet、LocalSet、task_local! | 08 |
process/ | 3.5k | 子进程 | 不进主线 |
macros/ | 3.1k | select!、join!,以及下面要讲的 cfg 宏 | 08、本页 |
util/ | 2.9k | 侵入式链表、分片链表、TryLock、随机数等内部工具 | 用到时讲 |
time/ | 2.2k | 用户层的 sleep、timeout、interval | 06 |
signal/ | 2.0k | Unix 信号、Windows ctrl-c | 不进主线 |
loom/ | 1.1k | 同步原语的切换层,见本页最后一节 | 本页 |
有两组名字相同、位置不同的目录,第一次读很容易混:
task/是用户层 API,runtime/task/才是任务的内存布局和状态机。time/是用户层的Sleep,runtime/time/才是时间轮。
runtime/ 下面最值得先认识的几个:
| 路径 | 内容 |
|---|---|
runtime/builder.rs | Builder,所有配置项和构建流程 |
runtime/runtime.rs | Runtime,block_on 的入口 |
runtime/scheduler/current_thread/ | 单线程调度器 |
runtime/scheduler/multi_thread/ | 多线程调度器:worker.rs、queue.rs、idle.rs |
runtime/task/ | 任务:core.rs、state.rs、harness.rs、raw.rs |
runtime/io/ | IO 驱动和 ScheduledIo |
runtime/time/ | 时间轮 |
runtime/blocking/ | spawn_blocking 的线程池 |
runtime/context.rs | 线程本地的 CONTEXT |
feature flag:默认什么都不开
tokio/Cargo.toml 的 [features] 一开头就写着:
# Include nothing by default
default = []
只写 tokio = "1" 能用的只有很少一部分,tokio::spawn 都没有。常用的几个 feature:
| feature | 打开什么 |
|---|---|
rt | 任务和 current_thread 调度器 |
rt-multi-thread | 多线程调度器,依赖 rt |
macros | #[tokio::main]、select! 等 |
net | 网络类型,并带上 mio 的 os-poll |
time | 定时器 |
sync | 同步原语 |
io-util | AsyncReadExt 等扩展方法 |
full | 以上全部,再加 fs、io-std、process、signal、parking_lot |
2019 年 11 月的 #1811 之前,tokio/Cargo.toml 里写的是 default = ["full"]。这个 PR 把它改成 default = [],加上了上面那行注释,随 0.2.0 发布。
讨论在 issue #1791。当时 tokio 刚把各个组件合进一个 crate、按 feature 切分,全开时这个 crate「相当重」。issue 列出了默认全开的毛病:库会不小心依赖比自己需要更多的 feature,下游只能跟着打开;最终用户也可能用上了依赖替他打开的 feature,等依赖改了 feature,程序就坏了。默认全关的代价 issue 里也写了:入门多一道坎,要写 features = ["full"]。
feature 之间的组合会改变行为,不只是增减 API。最典型的是 #[tokio::main],lib.rs 第 704 行起:
cfg_macros! {
// ...
cfg_rt! {
#[cfg(feature = "rt-multi-thread")]
pub use tokio_macros::main;
// ...
cfg_not_rt_multi_thread! {
pub use tokio_macros::main_rt as main;
// ...
}
}
// Always fail if rt is not enabled.
cfg_not_rt! {
pub use tokio_macros::main_fail as main;
// ...
}
}
同一个 tokio::main,按 feature 不同指向三个不同的过程宏:开了 rt-multi-thread 是默认多线程的版本;只开 rt 是只能用 current_thread 的版本;连 rt 都没开,就是一个专门用来报编译错误的版本。
cfg 宏:一份源码,多种裁剪
tokio 里很少直接写 #[cfg(feature = "...")],而是用 macros/cfg.rs 里定义的 60 多个 cfg_*! 宏。以 cfg_rt! 为例:
macro_rules! cfg_rt {
($($item:item)*) => {
$(
#[cfg(feature = "rt")]
#[cfg_attr(docsrs, doc(cfg(feature = "rt")))]
$item
)*
}
}
它做了两件事:给包住的每一项加上 #[cfg(feature = "rt")];在 docs.rs 上构建文档时,再加一个「需要 rt feature」的标注。把条件集中写在一处,以后条件变了(比如某个功能改成还需要某个平台),只改宏定义,不用改上百个使用点。
读源码时遇到 cfg_xxx! { ... },把它当成「这段代码只在 xxx 打开时存在」就行。成对出现的 cfg_not_xxx! 是反过来的条件。比如 lib.rs 第 590 行:
cfg_rt! {
pub mod runtime;
}
cfg_not_rt! {
pub(crate) mod runtime;
}
没开 rt 时 runtime 模块依然会编译,只是不对外公开。
tokio_unstable:不走 feature 的开关
有一部分功能不承诺 semver 稳定,光开 feature 不够,还要给 rustc 传 --cfg tokio_unstable:
# .cargo/config.toml,不是 Cargo.toml
[build]
rustflags = ["--cfg", "tokio_unstable"]
为什么不直接用 feature?因为 feature 会传递:依赖树里任何一个库打开了它,整个程序就都打开了,最终用户可能并不知道自己用上了不稳定的 API。--cfg 只能由构建最终程序的人自己设置。
1.54.1 里需要 tokio_unstable 的功能:
| 功能 | 内容 |
|---|---|
io-uring feature | Linux 上用 io-uring 做文件 IO |
taskdump feature | 导出所有任务当前的调用栈,仅 Linux |
schedule-latency feature | 统计任务从被唤醒到被 poll 的延迟,仅 64 位平台 |
tracing | 给任务和资源打 tracing 事件,tokio-console 依赖它 |
runtime/time_alt/ | 按 worker 维护的另一套定时器实现 |
| 其他 unstable API | 如 Builder::unhandled_panic |
正文里遇到只在 tokio_unstable 下存在的代码,会单独标出来,不当作主线。
问: 我在 Cargo.toml 里打开了 tracing feature,忘了加 --cfg tokio_unstable,会报错吗?
答: 不会。编译照常通过,只是什么也没打开。这些代码包在同时要求两个条件的 cfg 里,比如 cfg_trace! 写的是 #[cfg(all(tokio_unstable, feature = "tracing"))];tracing 这个依赖本身也挂在 [target.'cfg(tokio_unstable)'.dependencies] 下面。在 macOS 上用 cargo tree -F tokio/tracing 看:不加 --cfg,依赖树里没有 tracing;加上 RUSTFLAGS="--cfg tokio_unstable",才出现 tracing 和 tracing-core。lib.rs 里和 unstable feature 有关的 compile_error! 只检查平台(比如 taskdump 只支持 Linux),而且前提都是已经打开了 tokio_unstable。
loom:把 std::sync 换掉做并发测试
tokio 内部几乎不直接写 std::sync::atomic,而是写 crate::loom::sync::atomic。整个切换层就是 loom/mod.rs:
#[cfg(not(all(test, loom)))]
mod std;
#[cfg(not(all(test, loom)))]
pub(crate) use self::std::*;
#[cfg(all(test, loom))]
mod mocked;
#[cfg(all(test, loom))]
pub(crate) use self::mocked::*;
正常编译时,crate::loom::* 就是 std 的东西。用 --cfg loom 跑测试时,换成 loom 提供的同名类型:loom 会穷举多个线程之间所有可能的交错顺序,以及内存序允许的各种可见性,找出普通测试几乎跑不出来的并发 bug。任务状态机、本地队列这些无锁结构,都有对应的 loom 测试。
这也解释了源码里一些看起来奇怪的地方,比如 Builder::new_current_thread 里:
#[cfg(loom)]
const EVENT_INTERVAL: u32 = 4;
#[cfg(not(loom))]
const EVENT_INTERVAL: u32 = 61;
loom 是穷举执行,每多一步状态空间就成倍增长,所以这类 #[cfg(loom)] 分支通常会把间隔、容量之类的常数调小,让测试用很少几步就能走到对应的代码。
小结
- 主线只读
tokio这一个 crate,核心在runtime/。 task/和runtime/task/、time/和runtime/time/是两层:前者是用户 API,后者是实现。- feature 默认全关;
cfg_xxx!宏就是「只在 xxx 打开时存在」。 tokio_unstable用--cfg而不是 feature,防止被依赖树悄悄打开。crate::loom::*平时就是std,测试时换成 loom 做并发穷举。