Rust in Everything
基于 tokio 1.54.1

源码地图

仓库结构

tokio 仓库是一个 workspace,主线只看第一个。各 crate 之间、以及对外部 crate 的依赖关系见 crate 依赖图。

crate内容
tokio运行时、IO、网络、定时器、同步原语,本卷的全部主线
tokio-macros#[tokio::main]、#[tokio::test],以及 select! 用到的过程宏
tokio-utilcodec、CancellationToken 等工具,用到时提一下
tokio-streamStream 适配,用到时提一下

tokio/src 各目录

下表的行数是 1.54.1 里该目录全部 .rs 文件的行数,包含注释和目录内的测试,只用来估计分量:

目录约行数管什么对应部分
runtime/31.4k调度器、任务、IO 驱动、时间轮、阻塞线程池、线程本地上下文02–06
sync/21.9kMutex、Semaphore、mpsc、oneshot、watch、broadcast、Notify07
net/15.8kTCP、UDP、Unix socket、Windows 命名管道05
io/13.9kAsyncRead / AsyncWrite 及扩展方法,PollEvented05
fs/5.3k文件操作,大多是丢进阻塞线程池执行04 阻塞线程池
task/4.4k用户层的 spawn、JoinSet、LocalSet、task_local!08
process/3.5k子进程不进主线
macros/3.1kselect!、join!,以及下面要讲的 cfg 宏08、本页
util/2.9k侵入式链表、分片链表、TryLock、随机数等内部工具用到时讲
time/2.2k用户层的 sleep、timeout、interval06
signal/2.0kUnix 信号、Windows ctrl-c不进主线
loom/1.1k同步原语的切换层,见本页最后一节本页

有两组名字相同、位置不同的目录,第一次读很容易混:

  • task/ 是用户层 API,runtime/task/ 才是任务的内存布局和状态机。
  • time/ 是用户层的 Sleep,runtime/time/ 才是时间轮。

runtime/ 下面最值得先认识的几个:

路径内容
runtime/builder.rsBuilder,所有配置项和构建流程
runtime/runtime.rsRuntime,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-utilAsyncReadExt 等扩展方法
full以上全部,再加 fs、io-std、process、signal、parking_lot
📝NOTE
冷知识:0.2.0 发布前,默认是全开的

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 featureLinux 上用 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 下存在的代码,会单独标出来,不当作主线。

📝NOTE
没有蠢问题

问: 我在 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 做并发穷举。

💬 关联讨论

发起讨论
加载中...