Rust in Everything
基于 tokio 1.54.1

在本站中

主线位置:两条主线的使用端。前面各章讲的机制,在一个真实的 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 的一生。

📝NOTE
冷知识:1.8.0 之前没得选,只有 Burst

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();
📝NOTE
冷知识:1.30.0 之前,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),除了上面三处,非测试代码里还有一处值得提:

框架里没有用到 spawn_blocking、block_in_place、select! 和 JoinSet。

小结

  • 定时任务在 block_on 的闭包里 spawn,main 线程不是 worker,任务先进注入队列;之后每轮由时间轮唤醒,可能落在任何一个 worker 上。
  • interval 第一次 tick 立即就绪:搜索任务利用它做启动刷新,对账任务先吃掉它。默认 Burst 只在一轮耗时超过周期时才起作用。
  • 连接池用 tokio::sync::OnceCell:初始化是异步的,并发的初始化者在信号量上挂起等待;初始化失败或被取消,下一个等待者重试。
  • 搜索引擎的快路径用 std Mutex(锁内无 .await,每次请求都走),初始化用 tokio Mutex(锁要跨 .await,只走一次),再加一次双检。

💬 关联讨论

发起讨论
加载中...