在本站中
主线位置:两条主线的使用端。前面各章讲的机制,在一个真实的 Web 服务里落在哪几行代码上。
本站的框架开源:yuxuetr/rustineverything。本章的链接都指向提交 28171aa,行号以这个提交为准。
那个提交的 Cargo.lock 锁的是 tokio 1.52.3,不是本卷的 1.54.1。本章用到的三个文件,sync/once_cell.rs 和 sync/mutex.rs 在两个版本之间没有改动;time/interval.rs 的改动是重构,第一次 tick 立即就绪、默认 Burst 这两点没变。下面的 tokio 源码链接仍指向 1.54.1。
flowchart TB main["main 线程:block_on"] --> cb[serve 闭包] cb --> pool["init_pool:OnceCell"] cb --> sp1["spawn:搜索索引刷新"] cb --> sp2["spawn:支付对账"] sp1 --> inj[注入队列] sp2 --> inj inj --> w[某个 worker 取走] w --> tick["interval.tick 挂起"] tick --> wheel[时间轮] wheel -->|到期 wake| w req[请求处理任务] --> getp["get_or_init_pool"] req --> eng["search get_or_build"] eng --> m1["std Mutex:读引擎槽"] eng --> m2["tokio Mutex:串行初始化"]
两个定时任务:spawn 加 interval
crates/app/src/main.rs 第 300 行,搜索索引每 10 分钟增量刷新一次:
tokio::spawn(async {
let mut ticker = tokio::time::interval(std::time::Duration::from_secs(600));
loop {
ticker.tick().await;
match module_search::engine::reindex_incremental().await {
Ok(r) => tracing::info!(
upserts = r.upserts,
deletes = r.deletes,
elapsed_ms = r.elapsed_ms,
"search: index refreshed"
),
Err(e) => tracing::warn!(error = %e, "search: index refresh failed"),
}
}
});
第 329 行,支付对账,周期默认 300 秒、最少 60 秒:
tokio::spawn(async move {
let mut ticker = tokio::time::interval(std::time::Duration::from_secs(interval_secs));
// interval 的首个 tick 立即就绪:先吃掉,避免启动即打网关。
ticker.tick().await;
loop {
ticker.tick().await;
// 回填/关单明细已在 target=pay_audit 日志留痕。
let _ = module_course::server::reconcile_pending_orders_once().await;
}
});
第一次 tick 立即就绪
interval 的第一个 deadline 是创建时的 Instant::now(),所以第一次 tick().await 马上返回(实际会晚 1 毫秒左右,因为 deadline 向上取整到毫秒后要进时间轮,见 Sleep 的一生里 interval 那一节的实验)。
两个任务对这一点的处理正好相反,都是对的:
- 搜索任务要的就是「启动时先刷一次」(第 298 行的注释),所以直接进循环,第一轮立即执行。
- 对账任务不想启动就打网关,第 332 行 在进循环前先把第一次 tick 吃掉,第一轮对账在
interval_secs之后。
MissedTickBehavior:默认 Burst,这里几乎碰不到
两个任务都没调 set_missed_tick_behavior,用的是默认的 Burst(impl Default)。poll_tick 只在晚了超过 5 毫秒时才看这个设置;晚了,Burst 按原来的网格一次次补,补的那几次立即连续返回。
循环体在两次 tick() 之间执行。所以只有一轮刷新或对账的耗时超过一个周期(600 秒,或者至少 60 秒),下一次 tick 才会过期,Burst 才会让下一轮紧接着开始。增量刷新的耗时记在 elapsed_ms 日志里,正常远小于 600 秒,这里实际碰不到。对账如果遇上网关大面积超时,一轮可能拖过周期,Burst 会让后几轮背靠背执行;要避免,可以设成 MissedTickBehavior::Delay,从当前时刻重新计周期。三种模式的实测对比在 Sleep 的一生。
MissedTickBehavior 是 2021 年 6 月的 #3721 加的(1.8.0)。PR 说明:Interval 有时会被耽误,通常是因为 tick 调得不够勤,比如任务正在 .await 别的东西;默认它照常排 tick,结果是一阵连发,直到追上应有的时刻,而这并不总是想要的。需求来自 issue #3574,里面画了三种行为(追赶、延后、跳过)的时间线,也就是后来的 Burst、Delay、Skip。
默认值仍是 Burst。impl Default 的文档注释给了两个理由:多数场景要的就是它;为了向后兼容,也必须是它。
用 interval 而不是 loop { sleep(period).await; ... } 还有一个好处:周期按网格算,一轮的耗时不会累加到下一轮的间隔上。
spawn 落在哪个队列
main 里的这段代码在 dioxus::serve 的闭包里(第 169 行)。dioxus-server 0.7.9 的 serve(src/launch.rs)发现当前没有运行时,就用 Builder::new_multi_thread().enable_all() 建一个,再 block_on 这个闭包。所以闭包跑在 main 线程上:有运行时句柄,但不是 worker,没有调度器上下文。
spawn 全路径的最后一跳 schedule_task 在这种情况下走 push_remote_task:加锁挂进注入队列,再叫醒一个睡着的 worker。两个定时任务各经过一次注入队列,之后就和别的任务一样:
- 第一次 tick 之后任务挂在时间轮上;
- 到期时,拿着驱动的那个 worker 在 park 里触发定时器,任务进那个 worker 的 LIFO 槽或本地队列(见 worker 主循环);
- 所以每一轮可能在不同的 worker 上跑,任务不绑定线程。
reindex_incremental 的后半段(engine.rs 第 513 行 起的 upsert_documents、delete_documents、save)是同步调用,执行期间占着这个 worker。多线程运行时里别的 worker 照常处理请求;这类代码什么时候该挪到 spawn_blocking,见在 async 里阻塞。
开发时还有一个细节:dioxus-server 0.7.9 在 debug 构建下收到热补丁(hot-patch)时会在同一个进程里再调用一次这个闭包,重建 router。每调用一次,上面两个 tokio::spawn 也会多起一份,旧的那份不会停。release 构建只调用一次,不受影响。
连接池:tokio::sync::OnceCell
crates/core/src/db/pool.rs 第 17 行:
use tokio::sync::OnceCell;
static POOL: OnceCell<DatabaseConnection> = OnceCell::const_new();
const_new 要开 parking_lot 才有POOL 能写成模块顶层的 static,靠的是 OnceCell::const_new 是 const fn。tokio 1.29.0 里这个函数带着 #[cfg(all(feature = "parking_lot", not(all(loom, test))))],不开 parking_lot feature 就没有它。2023 年 7 月的 #5885(1.30.0)去掉了这个条件,PR 的说明是:MSRV 升到 1.63 以后,Mutex::new 可以在 const 上下文里用了,所以这些 const_new 应该总是可用。
启动时 init_pool(第 67 行)初始化一次,请求里 get_or_init_pool(第 77 行)兜底:
pub async fn init_pool(url: &str) -> Result<(), DbErr> {
POOL.get_or_try_init(|| async { Database::connect(build_connect_options(url)).await }).await?;
Ok(())
}
为什么不用 std 的 OnceLock
初始化本身是异步的。 Database::connect 要 .await。std::sync::OnceLock::get_or_init 只接受同步闭包,要在里面拿到连接,只能在闭包里阻塞地等这个 future:在 worker 上再 block_on 会 panic(见运行时里套运行时),用别的办法阻塞则占住 worker 线程,建连超时是 5 秒。
并发的初始化者是等,不是抢,也不阻塞线程。 tokio 的 OnceCell 是一个 AtomicBool、一个存值的 UnsafeCell、一个只有 1 个许可的信号量。get_or_try_init:
pub async fn get_or_try_init<E, F, Fut>(&self, f: F) -> Result<&T, E>
where
F: FnOnce() -> Fut,
Fut: Future<Output = Result<T, E>>,
{
crate::trace::async_trace_leaf().await;
if self.initialized() {
// SAFETY: The OnceCell has been fully initialized.
unsafe { Ok(self.get_unchecked()) }
} else {
// Here we try to acquire the semaphore permit. Holding the permit
// will allow us to set the value of the OnceCell, and prevents
// other tasks from initializing the OnceCell while we are holding
// it.
match self.semaphore.acquire().await {
Ok(permit) => {
debug_assert!(!self.initialized());
// If `f()` panics or `select!` is called, this
// `get_or_try_init` call is aborted and the semaphore
// permit is dropped.
let value = f().await;
match value {
Ok(value) => Ok(self.set_value(value, permit)),
Err(e) => Err(e),
}
}
Err(_) => {
debug_assert!(self.initialized());
// SAFETY: The semaphore has been closed. This only happens
// when the OnceCell is fully initialized.
unsafe { Ok(self.get_unchecked()) }
}
}
}
}
- 已初始化:读一个原子,直接返回。这是绝大多数请求走的路。
- 未初始化:抢信号量的唯一许可。抢到的执行
f().await;没抢到的在信号量的等待队列里挂起,是任务在等,worker 去跑别的任务(信号量见批量信号量)。 - 成功:
set_value写值、Release置位,然后关闭信号量。所有等待者的acquire返回Err,走下面的分支直接读值。 - 失败、被取消或 panic:许可随之 drop,下一个等待者拿到许可,再试一次。
最后一点和 main.rs 第 230 行的日志对得上:启动时 init_pool 失败只记一条 startup: DB pool init failed (will retry on demand),之后第一个请求调 get_or_init_pool 时会重新建连。
实验:5 个任务同时初始化,第一次尝试失败:
static POOL: OnceCell<String> = OnceCell::const_new();
static ATTEMPTS: AtomicUsize = AtomicUsize::new(0);
async fn get_pool(caller: usize, start: Instant) -> Result<&'static String, String> {
POOL
.get_or_try_init(|| async move {
let n = ATTEMPTS.fetch_add(1, Ordering::SeqCst) + 1;
println!("[{:>3}ms] caller {caller}: init attempt {n} starts", start.elapsed().as_millis());
tokio::time::sleep(Duration::from_millis(50)).await;
if n == 1 {
println!("[{:>3}ms] caller {caller}: attempt {n} fails", start.elapsed().as_millis());
Err(format!("connect refused (attempt {n})"))
} else {
Ok(format!("pool#{n}"))
}
})
.await
}
#[tokio::main(flavor = "multi_thread", worker_threads = 4)]
async fn main() {
let start = Instant::now();
let handles: Vec<_> = (1..=5)
.map(|caller| {
tokio::spawn(async move {
let r = get_pool(caller, start).await;
println!("[{:>3}ms] caller {caller}: {r:?}", start.elapsed().as_millis());
})
})
.collect();
for h in handles {
h.await.unwrap();
}
println!("init attempts in total: {}", ATTEMPTS.load(Ordering::SeqCst));
}
[ 0ms] caller 1: init attempt 1 starts
[ 53ms] caller 1: attempt 1 fails
[ 53ms] caller 1: Err("connect refused (attempt 1)")
[ 53ms] caller 3: init attempt 2 starts
[106ms] caller 3: Ok("pool#2")
[106ms] caller 2: Ok("pool#2")
[106ms] caller 4: Ok("pool#2")
[106ms] caller 5: Ok("pool#2")
init attempts in total: 2
5 个调用者,初始化闭包只跑了 2 次。第一次失败的错误只返回给 caller 1;等待中的 caller 3 接手重试,成功后其余三个直接拿到同一个值。接手的是谁取决于谁先排进信号量的队列,多线程下每次运行可能不同。
同一个 main.rs 的第 345 行用的是 std::sync::OnceLock:初始化只是算一个路径字符串,同步、瞬间完成,不需要 tokio 的版本。
搜索引擎:一个 std Mutex,一个 tokio Mutex
crates/modules/search/src/engine.rs 第 396 行 和第 408 行:
static ENGINE: OnceLock<Mutex<Option<Arc<SearchEngine>>>> = OnceLock::new();
static INIT_LOCK: OnceLock<tokio::sync::Mutex<()>> = OnceLock::new();
ENGINE 里的 Mutex 是 std::sync::Mutex(文件开头 use std::sync::{Arc, Mutex, OnceLock};)。两把锁在 get_or_build 里配合:
pub async fn get_or_build() -> Result<Arc<SearchEngine>, String> {
// 快路径:引擎已就绪,无需加初始化锁。
{
let guard = engine_slot().lock().map_err(|e| format!("search lock poisoned: {}", e))?;
if let Some(e) = guard.as_ref() {
return Ok(e.clone());
}
}
// 慢路径:串行化初始化。
let _init = init_lock().lock().await;
// 双检:等待锁期间可能已被其他调用者完成初始化。
{
let guard = engine_slot().lock().map_err(|e| format!("search lock poisoned: {}", e))?;
if let Some(e) = guard.as_ref() {
return Ok(e.clone());
}
}
init_or_load(&resolve_index_dir()).await
}
ENGINE 用 std 的 Mutex,因为锁从不跨 .await。 每次加锁都包在一对花括号里,锁内只做一次 Option 判断和一次 Arc::clone,出了花括号 guard 就 drop 了,后面才有 .await。init_or_load 末尾写槽的那一次(第 464 行)之后也没有 .await。这正是 tokio 文档推荐用 std 锁的情形(sync/mutex.rs 第 24 行):「锁后面只是数据时,通常用阻塞的锁就合适」,async 锁更贵。每一次搜索请求都走这条快路径,用便宜的锁是对的。花括号也有编译期的作用:std 的 MutexGuard 不是 Send,如果它活过一个 .await,get_or_build 返回的 future 就不是 Send,不能被 tokio::spawn,也不能当多线程运行时上的请求处理器。
INIT_LOCK 用 tokio 的 Mutex,因为锁要跨 .await。 _init 一直活到函数结束,期间要 init_or_load(...).await,首次建索引会全量扫描磁盘并查数据库。这把锁防的是第 404 行注释里的问题:两个 typeahead 查询同时进来,各自打开一个 IndexWriter,第二个失败 LockBusy。等锁的那个请求要等完整个初始化:
- 用 tokio 的锁,等待者是挂起的任务,worker 去处理别的请求;guard 是
Send,future 仍然可以在线程间移动。 - 用 std 的锁,guard 跨
.await让 future 变成!Send,tokio::spawn直接编译不过。即使在不要求Send的地方绕过去,等待者也会把 worker 线程阻塞在锁上;持锁的任务挂起后如果被排到这个 worker 上(比如进了它的 LIFO 槽,别人偷不走),就再没有线程 poll 它,形成死锁。tokio 文档对这种写法的评价是「很容易导致死锁」。
两把锁分开,是把冷路径和热路径分开:热路径每次请求都走,只碰 std 锁;冷路径只在引擎还没就绪时走一次,才付 tokio 锁的代价。拿到 INIT_LOCK 之后再查一次 ENGINE(双检),等锁期间别人已经建好的话直接复用。
一个写法细节:变量名是 _init 而不是 _。let _ = lock().await 会让 guard 立即 drop,锁等于没加;_init 是一个普通绑定,guard 活到作用域结束。
两种锁怎么选,tokio 的 Mutex 还是 std 的有完整的对照。
其他用法
在 28171aa 上搜 tokio::(git grep -n 'tokio::' 28171aa -- crates),除了上面三处,非测试代码里还有一处值得提:
crates/modules/admin/src/server.rs第 183 行:后台概览页用tokio::try_join!同时发起 7 个COUNT查询。它们在同一个任务里并发,不是 7 个线程;任何一个出错就提前返回(见 select! 与 join!)。
框架里没有用到 spawn_blocking、block_in_place、select! 和 JoinSet。
小结
- 定时任务在
block_on的闭包里 spawn,main 线程不是 worker,任务先进注入队列;之后每轮由时间轮唤醒,可能落在任何一个 worker 上。 interval第一次 tick 立即就绪:搜索任务利用它做启动刷新,对账任务先吃掉它。默认Burst只在一轮耗时超过周期时才起作用。- 连接池用
tokio::sync::OnceCell:初始化是异步的,并发的初始化者在信号量上挂起等待;初始化失败或被取消,下一个等待者重试。 - 搜索引擎的快路径用 std
Mutex(锁内无.await,每次请求都走),初始化用 tokioMutex(锁要跨.await,只走一次),再加一次双检。