Rust in Everything
基于 embassy-time 0.5.1

本站的定时循环搬到 embassy

主线位置:定时器主线的使用端。Ticker::next 把到期时刻交给 schedule_wake → std 驱动的闹钟线程到点唤醒任务 → __pender 叫醒执行器线程的条件变量 → 再次 poll,Ready。这一页不再走中间各步(见时间驱动和各平台的执行器),只比较两边的行为。

flowchart TB
  serve["dioxus serve"] --> rt["tokio 多线程运行时"]
  rt --> s1["搜索刷新 600 s"]
  rt --> s2["支付对账 300 s"]
  s1 --> w1["tantivy sea-orm"]
  s2 --> w2["sea-orm reqwest"]
  s1 -.->|骨架| e1["Ticker 静态任务"]
  s2 -.->|骨架| e2["Ticker 静态任务"]
  w1 --> stay["只能留在 tokio"]
  w2 --> stay

本站(yuxuetr/rustineverything)是 tokio 上的服务,没有用 embassy。这一页做一个对照实验:把它两个后台定时循环的结构原样搬到 embassy 的 std 执行器上,真正的工作换成可以控制耗时、可以阻塞线程的桩,两边并排跑,回答四个问题:一轮拖过了周期怎么补、工作阻塞了线程会怎样、任务的内存从哪来、任务数能不能动态。最后看哪些部分根本搬不过去。

本站的链接都指向提交 8729cfb,它的 Cargo.lock 锁的是 tokio 1.52.3;实验 crate 解析到 tokio 1.54.1(tokio 卷的版本),下面 tokio 的源码链接也指向 1.54.1。实验在 exp-embassy/site-std,四个 bin:missed、blocking、memory、noreactor。平台 M2 Max、macOS 27、rustc 1.99.0,release,每个跑三遍(负载 3.4 左右),下面贴第一遍;毫秒数每遍差一两毫秒,形状完全相同。为了让实验一秒跑完,周期缩成 100 ms 和 50 ms(本站是 600 s 和 300 s),比例不变。

本站的两个循环

全仓库非测试代码里只有两处 tokio::spawn,都在 dioxus::serve 的闭包里(crates/app/src/main.rs 第 186 行)。第 317 行,搜索索引每 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 {
          // ...
        }
      }
    });

第 346 行,支付对账,周期默认 300 s、最少 60 s:

      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;
        }
      });

git grep -n 'tokio::spawn' 8729cfb -- crates 还有第三处,crates/core/src/auth/mod.rs 第 427 行,但它在 #[cfg(all(test, feature = "server"))] 的测试模块里,是给单元测试起的一次性本地 HTTP 服务,不是后台循环。限流器(crates/app/src/server/rate_limit.rs)也没有后台清理任务,过期的桶在 try_acquire 里表满时顺手剪掉。所以要搬的就是上面这两个循环。tokio 一侧的细节(spawn 落在注入队列、第一次 tick 立即就绪、默认 Burst)tokio 卷的在本站中讲过,那一章用的是更早的提交 28171aa,行号是 300 和 329。

循环体做什么,决定了它能不能搬:

所以对账一轮最坏是 50 × 15 s = 750 s,是默认周期 300 s 的 2.5 倍。「一轮拖过周期」在本站不是假设。

搬过去:Ticker 加静态任务

exp-embassy/site-std/src/bin/blocking.rs 里的 embassy 版本,结构和上面一一对应,工作换成桩:

#[embassy_executor::task]
async fn embassy_search() {
  let mut ticker = Ticker::every(Duration::from_millis(SEARCH_MS));
  let mut round = 0;
  loop {
    SEARCH.lock().unwrap().push(now_ms());
    match search_work(round) {
      Some(ms) => std::thread::sleep(StdDuration::from_millis(ms)),
      None => Timer::after_millis(10).await,
    }
    round += 1;
    ticker.next().await;
  }
}

#[embassy_executor::task]
async fn embassy_pay() {
  // Ticker 的第一个 tick 本来就在一个周期之后,不用像 tokio 那样先吃掉一个。
  let mut ticker = Ticker::every(Duration::from_millis(PAY_MS));
  loop {
    ticker.next().await;
    PAY.lock().unwrap().push(now_ms());
    Timer::after_millis(5).await;
  }
}

搬的时候要改的地方:

  • 第一个 tick 的时刻相反。 interval 的第一个 tick 立即就绪,Ticker::every 的第一个 tick 在一个周期之后(Instant::now() + duration)。搜索任务要「启动先跑一轮」,所以工作挪到 next() 前面;对账任务原来要专门吃掉第一个 tick,搬过来这一行直接删掉。照抄 tokio 的写法,两个任务都会差一个周期。
  • 任务是静态的。 #[embassy_executor::task] 把函数变成一个 static 任务池(默认 pool_size = 1),调用它得到 Result<SpawnToken, SpawnError>,交给 Spawner::spawn,没有 JoinHandle,见任务存储和 Spawner。
  • 执行器只有一个线程。 platform-std 的 Executor::run 要 &'static mut self、永不返回,实验里用 StaticCell 放执行器;所有任务都在调用 run 的那个线程上 poll。
  • tick() 的返回值没了。 tokio 返回这一 tick 计划的 Instant,Ticker::next() 返回 ()。

一轮拖过周期:Burst、Delay、Skip 与 Ticker

missed.rs 在同一个进程里并排跑搜索循环的六个版本:主线程上一个 tokio 多线程运行时跑 interval 的三种 MissedTickBehavior,另一个线程上一个 embassy 执行器跑三个 Ticker 版本。周期 100 ms,第 1 轮耗时 350 ms,其余 10 ms,全是 .await(tokio::time::sleep / Timer::after_millis),不阻塞线程。打印的是每一轮开始的时刻(从同一个 std::time::Instant 起算的毫秒数):

round              r0    r1    r2    r3    r4    r5    r6    r7
tokio Burst         1   102   453   465   477   502   602   702
embassy Ticker      0   105   457   470   482   501   605   702
tokio Delay         1   102   453   555   655   755   855   955
embassy +Delay      0   105   457   561   661   762   867   972
tokio Skip          1   102   453   502   602   702   802   902
embassy +Skip       0   105   457   501   605   702   802   901
  • 默认行为一样。 r1 在 100 开始、450 结束,这时 200、300、400 三个 tick 都过了。tokio 的默认 Burst(impl Default)和 Ticker 都按原网格一个个补:r2、r3、r4 背靠背开始(每轮 10 ms 的工作把它们隔开),r5 回到 500。Ticker 只有这一种行为,next() 里是 expires_at += dur,见 Timer、Ticker 与超时。
  • Delay 和 Skip 要自己写。 embassy 没有 MissedTickBehavior,实验里在 next() 返回后判断这一 tick 晚了没有,晚了就改 Ticker:
      if now > due + tolerance {
        match missed {
          Missed::Burst => {}
          Missed::Delay => {
            ticker.reset(); // expires_at = now + period
            next_due = now + period;
          }
          Missed::Skip => {
            let p = period.as_ticks();
            let grid = now + Duration::from_ticks(p - (now - due).as_ticks() % p);
            ticker.reset_at(grid - period); // expires_at = grid
            next_due = grid;
          }
        }
      }

公式照抄 tokio 的 next_timeout,5 ms 的容差也照抄 poll_tick。Ticker 不暴露 expires_at,所以 next_due 要在任务里另记一份,和它同步。

  • 手写的 Delay 会漂。 tokio Delay 从 555 起每 100 ms 一轮;embassy 版 561、661、762、867、972,越走越晚。原因是 std 驱动每次唤醒晚 2 到 5 ms(闹钟线程加条件变量,见时间驱动),常常跨过 5 ms 的容差,于是几乎每轮都 reset() 一次,晚的那几毫秒全累积下来;tokio 每次只晚 1 ms 左右(毫秒取整),不会超出容差。Skip 不受影响,因为它对齐的是原网格。

放回本站:对账一轮最坏 750 s,默认 Burst 下下一轮会在这一轮结束后立刻开始,第三轮紧跟着,再回到网格,等于在网关大面积超时的时候连着再打两轮。在 tokio 上改成 Delay 或 Skip 是一行 set_missed_tick_behavior(第 629 行);在 embassy 上就是上面这十几行,外加自己记 next_due。

📝NOTE
冷知识:reset_at 的参数是「这一刻算作刚 tick 过」,合并前一刻才改成这样

Ticker 2020 年就有了,最早没有任何重置方法,想丢掉错过的 tick 只能新建一个 Ticker。#1839(「embassy-time: Introduce reset function for Ticker.」,首个包含它的 tag 是 embassy-time-v0.1.4)加了 reset:expires_at = Instant::now() + self.duration。#2295(「Introduce reset_{at|after} functions for ticker.」)又加了两个。PR 的第一个提交 663fa2add 里,reset_at(deadline) 是 self.expires_at = deadline,下一个 tick 就在 deadline;reset_after(after) 是 Instant::now() + after。同一天的 8707462ec(「Adjusted documentation and reset_after behaviour.」)把 reset_after 改成再加一个周期,三个月后合并前的 2ea1040e0(「Adjusted behavior.」)把 reset_at 也改成 deadline + self.duration。所以现在三个方法是一个意思:把参数给出的时刻当作「刚 tick 过」,下一个 tick 在它之后一个周期。上面的 Skip 要让下一个 tick 落在 grid,所以传的是 grid - period。embassy-time 0.3.x 没有打 tag,带 tag 的版本里第一个包含 #2295 的是 0.4.0;embassy-time 的 CHANGELOG 里这三个方法一个都没提。

工作阻塞了线程:多线程运行时与一个执行器

本站的 reindex_incremental 后半段是同步的 tantivy 写入,执行时占着线程。blocking.rs 把两个循环和一个代表请求处理的心跳任务放在一起:搜索刷新周期 100 ms,第 1 轮用 std::thread::sleep(350ms) 阻塞线程;对账周期 50 ms;心跳每 10 ms 一次 sleep_until / Timer::at,记下最晚晚了多少。四种跑法:

[tokio-mt]
search rounds at [1, 102, 456, 467, 478, 502, 602, 702, 802, 901, 1003]
pay    rounds at [52, 102, 153, 203, 252, 302, 352, 402, 451, 502, 552, 602, 652, 702, 752, 802, 851, 901, 952, 1003]
beat   max late 2 ms (at 12 ms), 0 of 100 beats late > 5 ms
[tokio-ct]
search rounds at [1, 102, 457, 468, 480, 502, 602, 702, 802, 902]
pay    rounds at [52, 457, 463, 470, 476, 482, 489, 496, 502, 508, 552, 602, 652, 702, 752, 802, 852, 902, 952]
beat   max late 357 ms (at 457 ms), 36 of 99 beats late > 5 ms
[embassy]
search rounds at [0, 102, 456, 467, 478, 501, 602, 702, 802, 901, 1000]
pay    rounds at [52, 456, 461, 467, 473, 478, 484, 489, 495, 501, 552, 602, 652, 702, 752, 802, 852, 901, 952, 1000]
beat   max late 356 ms (at 456 ms), 36 of 99 beats late > 5 ms
[embassy-2exec]
search rounds at [0, 101, 452, 462, 472, 501, 602, 702, 802, 902, 1002]
pay    rounds at [52, 101, 152, 202, 252, 302, 352, 401, 452, 501, 552, 602, 650, 702, 751, 802, 852, 902, 952, 1002]
beat   max late 2 ms (at 12 ms), 0 of 99 beats late > 5 ms
  • tokio 多线程(tokio-mt,本站的配置):dioxus-server 0.7.9 的 block_on 用 new_multi_thread() 建运行时,本机 12 个 worker。搜索任务占住一个 worker 350 ms,对账和心跳在别的 worker 上照常跑:对账每 50 ms 一轮没断,心跳最晚 2 ms。三遍都是这样。
  • tokio 单线程(tokio-ct)和 embassy:结果几乎一模一样。阻塞的 350 ms 里线程上什么都不跑:心跳最晚 356 ms,约 100 次里 36 次晚于 5 ms;对账错过 100 到 450 的 8 个 tick,阻塞一结束就按 Burst / Ticker 的规则在 456 到 495 之间连跑 8 轮(每轮 5 ms 的工作把它们隔开),501 回到网格。embassy 的 std 执行器和 tokio 的 current_thread 调度器一样,一个线程、协作式,任务不返回 Pending,别人就没机会(见各平台的执行器)。
  • embassy 两个执行器(embassy-2exec):搜索任务放到第二个线程上的第二个 Executor(std 的 Executor::new 每次 Box::leak 一个自己的 Signaler,所以可以有多个),心跳和对账留在第一个,就回到了 tokio-mt 的数字。这相当于手工做了 tokio 的 spawn_blocking:阻塞的东西换一个线程。在芯片上没有第二个线程,对应的办法是更高优先级的 InterruptExecutor,但那只解决「谁先跑」,阻塞的代码照样占着 CPU。

在 tokio 上,阻塞 worker 的代价是少一个 worker(何时该挪进 spawn_blocking 见在 async 里阻塞);在一个 embassy 执行器上,代价是整个执行器停下。搬过去的话,搜索刷新那段 tantivy 写入会让同一个执行器上的所有任务一起等。

任务的内存从哪来

memory.rs 装了一个数分配次数和字节数的全局分配器,在 spawn 前后各读一次:

size_of_val(tokio_search()) = 152
size_of_val(tokio_job(0))   = 128
tokio: spawn(search)                         allocs     1  bytes     256
tokio: 500 ms of the running loop            allocs     0  bytes       0
tokio: spawn 1 jobs (incl. Vec of handles)   allocs     3  bytes     346
tokio: spawn 1000 jobs (incl. Vec of handles) allocs  1002  bytes  264082
embassy: Executor::new()                     allocs     1  bytes      32
embassy: first Instant::now() (alarm thread) allocs     4  bytes     176
size_of_val(job_body(0))    = 32
embassy: spawn(search)                       allocs     0  bytes       0
embassy: 500 ms of the running loop          allocs     0  bytes       0
embassy: job(0..6), pool_size = 4            allocs     0  bytes       0
  job(0) Ok id 0x4d0d638
  job(1) Ok id 0x4d0d6b0
  job(2) Ok id 0x4d0d728
  job(3) Ok id 0x4d0d7a0
  job(4) Err(Some(Busy - Too many instances of this task are already running. Check the `pool_size` attribute of the task.))
  job(5) Err(Some(Busy - Too many instances of this task are already running. Check the `pool_size` attribute of the task.))
  • tokio:每次 spawn 一次堆分配。 搜索循环的 future 152 字节,spawn 时分配 256 字节,这是任务头、future 和尾部放进一个按 128 字节对齐的 Cell(见任务的内存布局)。1000 个 job 是 1002 次分配、264 082 字节:1000 × 256 加上装 1000 个 JoinHandle 的 Vec(8000 字节),剩下一次 82 字节的分配来源没有查。
  • embassy:spawn 不分配。 两个循环和 4 个 job 的 spawn 都是 0 次分配,格子在编译期就放进了 static。4 个 job 的 id 相邻两个差 0x78 = 120 字节,这就是 64 位 std 上一格的大小(job_body 的 future 32 字节,加上任务包装和任务头)。
  • embassy 的 std 平台不是一点堆都不用。 Executor::new 用 Box::leak 放 Signaler(1 次、32 字节);第一次读时间时 std 驱动起闹钟线程(4 次、176 字节,thread::spawn 本身的分配)。都只在启动时发生一次。
  • 稳态两边都不分配。 循环跑起来以后,500 ms 里两边都是 0 次。所以「heap vs 静态」的差别只在 spawn 那一刻:tokio 付一次分配,换来任务数没有上限;embassy 不付,换来任务数在编译期定死。

任务数:动态还是编译期定死

上面 job(4)、job(5) 拿到的是 SpawnError::Busy,0.10.0 起在调用任务函数时就返回(见任务存储)。本站的两个后台循环各只有一个实例,数量固定,用默认 pool_size = 1 正合适,这一点搬起来毫无问题。

数量不固定的是请求处理。dioxus-server 0.7.9 在 release 下走 axum::serve,axum 0.8.9 每接受一个连接就在 handle_connection 里 tokio::spawn 一个任务(第 389 行)。连接数由访客决定。搬到 embassy 只有两种写法:给连接任务一个 pool_size = N,第 N+1 个连接拿到 Busy,只能拒绝;或者在一个任务里自己管一组 future(select_slice、join_array,见 embassy-futures),数组长度同样是编译期常量。两种都是「最多 N 个」,N 要在链接前定下来。

搬不过去的部分

骨架能搬,循环体不能。noreactor.rs 在 embassy 任务里直接用 tokio 的类型:

[sleep]

thread 'main' (4877283) panicked at src/bin/noreactor.rs:13:3:
there is no reactor running, must be called from the context of a Tokio 1.x runtime
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
[bind]

thread 'main' (4877287) panicked at ~/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/tokio-1.54.1/src/net/tcp/listener.rs:304:22:
there is no reactor running, must be called from the context of a Tokio 1.x runtime
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

tokio::time::sleep 在创建时、TcpListener::bind 在把套接字登记进反应器(PollEvented::new)时,都要找当前线程上的 tokio 运行时句柄,embassy 执行器线程上没有,直接 panic。先在 main 里建一个多线程运行时、rt.enter() 进入它的上下文、但不 block_on,再启动 embassy 执行器(enter-mt):

[enter-mt]
  sleep(10ms) done after 12.02925ms
  echo "ping" after 12.423417ms, polled on thread Some("main")

能跑了:回环上的一次 TCP echo,监听、连接、收发都是 tokio 的类型,poll 它们的是 main 线程上的 embassy 执行器。但这只说明 embassy 执行器能当 tokio future 的又一个 poll 者,叫醒它们的仍是 tokio:定时器在 tokio 的时间轮里,套接字的就绪事件来自 tokio worker 线程 park 时的 kqueue(见驱动栈与 park)。tokio 运行时一样不少,embassy 只是多出来的一层。

❗IMPORTANT
动脑筋:把 enter-mt 里的多线程运行时换成 new_current_thread(),同样只 enter() 不 block_on,任务里那句 sleep(10ms) 多久完成?

任务外面包了一层 embassy_time::with_timeout(Duration::from_secs(1), ...)。答案在本页末尾。

本站循环体里的每一样 I/O 都落在 tokio 上:

部分本站用的为什么离不开 tokio
HTTP 服务dioxus::serve → axum 0.8.9serve_router 用 tokio 的 TcpListener::bind(第 140 行),每个连接一个 tokio::spawn
数据库sea-orm 1.1.20,runtime-tokio-rustls(Cargo.toml 第 49 行)sqlx-core 0.8.6 的运行时只有 tokio 和 async-std 两种,connect_tcp 用 tokio::net::TcpStream;两者都不可用时 missing_rt panic「this functionality requires a Tokio context」
网关查单reqwest 0.12.28内部是 hyper-util 的 TokioExecutor 和 TokioTimer
搜索写入tantivy 0.26.1本来就是同步代码,放在哪个执行器上都会阻塞它
读内容目录std::fs同步,同上

能原样搬的只有循环的骨架:interval 换 Ticker、tokio::spawn 换静态任务,十来行。

📝NOTE
没有蠢问题

问: embassy 有自己的网络栈 embassy-net,HTTP 和数据库连接不能换成它吗?

答: embassy-net 是 smoltcp 上的一整套 TCP/IP 协议栈,它要的是一个收发以太网帧或 IP 包的驱动(见驱动接口),自己做 ARP、TCP 重传、窗口。它是给没有操作系统的芯片准备的。服务器上内核已经有协议栈,进程拿到的是套接字;要让 embassy-net 上线,只能开一个 TAP 设备、在用户态再跑一个协议栈(tuntap 驱动只支持 Linux)。即便这样,sqlx、reqwest、axum 要的仍是 tokio 的 TcpStream,不是 embassy-net 的 TcpSocket,上面的整列都得重写。

结论:本站不该用 embassy

embassy 给的东西,服务器都不缺,或者不需要:

  • 没有堆:服务器有堆,一次 spawn 256 字节换来任务数没有上限,正是请求处理要的。
  • 静态任务:本站真正多的是连接任务,数量由访客决定,编译期定不下来。
  • 中断唤醒、WFE 睡眠:服务器上的事件来自 kqueue / epoll,tokio 的驱动已经在等它们;embassy 在 std 上用的是一个闹钟线程加条件变量,精度还不如 tokio(上面 std 驱动每次晚 2 到 5 ms)。
  • 一个执行器一个线程:本站的搜索刷新里有同步的 tantivy 写入,多线程运行时只损失一个 worker,一个 embassy 执行器会整个停下。

而本站真正做的事(HTTP、数据库、网关查单)全建在 tokio 的 I/O 驱动上,搬不过去。所以结论很直接:本站继续用 tokio,不引入 embassy。

embassy 的边界在「有没有操作系统」:

  • 该用:没有操作系统的芯片,事件来源是外设中断;任务数在写代码时就知道(几个周期任务、每个外设一个驱动任务);内存要在链接时就确定,放不下由链接器报错,而不是运行中分配失败。
  • std 上的 embassy:platform-std 执行器和 std 时间驱动是为了在电脑上跑、测 embassy 代码(本卷的 std 实验都是这么做的),不是为了写服务。
  • 会改变结论的情况:本站的某一部分要跑在没有操作系统的设备上,比如一块向站点上报数据的板子。那块板子的固件是 embassy 的地盘,站点这一侧仍然是 tokio。

动脑筋答案

noreactor enter-ct(同一台机器,release,三遍都一样):

[enter-ct]
  TimeoutError after 1.005040542s

永远不会完成,1 s 后被外面的 with_timeout 截住。和 bind 那次不同,这次没有 panic:enter() 让 tokio::time::sleep 找到了运行时句柄,定时器也登记进了时间轮。但 current_thread 运行时没有 worker 线程,它的时间轮和 I/O 驱动只在某个线程 block_on 它时才被推进;这里没人 block_on,到期了也没人去触发,任务永远等不到唤醒。enter-mt 能跑,是因为多线程运行时的 worker 线程自己在 park 里推进驱动。tokio 的 future 离不开 tokio 的驱动,而驱动得有 tokio 的线程去转。

小结

  • 本站非测试代码里只有两个后台循环(main.rs 第 317、346 行),auth/mod.rs:427 是测试里的一次性 HTTP 服务;两个循环的骨架十来行就能搬到 Ticker 加静态任务上,但第一个 tick 的时刻相反:interval 立即就绪,Ticker 在一个周期之后。
  • 一轮拖过周期时,Ticker 和 tokio 默认的 Burst 行为相同;Delay、Skip 要用 reset / reset_at 自己写,手写的 Delay 在 std 驱动上因为唤醒晚几毫秒而越走越晚。本站对账一轮最坏 750 s,是周期的 2.5 倍。
  • 工作阻塞线程时,tokio 多线程运行时只少一个 worker(心跳最晚 2 ms);一个 embassy 执行器和 tokio current_thread 一样整个停下(心跳最晚 356 ms,错过的 tick 事后连补 8 轮);把阻塞的任务放到第二个执行器线程上才恢复。
  • tokio 每次 spawn 一次堆分配(这里 256 字节);embassy spawn 不分配,一格 120 字节在 static 里,pool_size 满了返回 SpawnError::Busy,连接数这种动态的数量放不进去。
  • HTTP、数据库、网关查单都建在 tokio 的 I/O 驱动上;embassy 任务里直接用 tokio 类型会 panic,进入运行时上下文后能用,但干活的仍是 tokio。本站不该用 embassy,它的边界是没有操作系统的设备。

💬 关联讨论

发起讨论
加载中...