Rust in Everything
基于 embassy-time 0.5.1

通用定时器队列满了:不报错,忙转

主线位置:定时器主线第 2 步「进定时器队列」。集成队列和通用队列的区别、ConstGenericQueue 的结构见定时器队列。

flowchart TB
  t["任务 X schedule_wake"] --> f{"队列满了"}
  f -->|否| ok["插入,睡到期"]
  f -->|是| pop["pop 最后一项并 wake"]
  pop --> y["任务 Y 提前醒"]
  y --> chk["没到期"]
  chk --> again["Y 再 schedule_wake"]
  again --> f

实验 crate exp-embassy/pitb-gq,bin p19_sensors,三个可执行文件只差 feature:--features gq8、--features gq16、不开(集成队列)(release);M2 Max、macOS 27、rustc 1.99.0,负载 4.7。CPU 时间用 /usr/bin/time -p 量。

现象

项目里有一部分代码要在别的执行器上跑,于是给 embassy-time 开了 generic-queue-8。然后加了几个传感器任务,每个一个 Ticker:

#[embassy_executor::task(pool_size = 8)]
async fn sensor(id: u64) {
  let mut ticker = Ticker::every(Duration::from_millis(200 + 10 * id));
  loop {
    ticker.next().await;
    TICKS.fetch_add(1, Ordering::Relaxed);
  }
}

// main:spawn n 个 sensor,自己 Timer::after_millis(1000).await,打印 tick 总数

7 个传感器和 8 个传感器:

7 sensors + main: 28 ticks in 1005 ms user 0.00 sys 0.00
8 sensors + main: 31 ticks in 1000 ms user 0.64 sys 0.79
8 sensors + main: 31 ticks in 1000 ms user 0.69 sys 0.76
8 sensors + main: 31 ticks in 1000 ms user 0.71 sys 0.74

多一个任务,tick 数照样对,没有 panic、没有日志,但 1 秒墙钟里用掉了 1.4 秒 CPU(std 驱动有一个闹钟线程,所以能超过 1 秒)。在电池供电的板子上,这就是执行器一直不进 WFE。

出处

generic-queue-N 让 embassy-time-queue-utils 用 ConstGenericQueue,一个 heapless::Vec<Timer, N>,N 由 feature 选(第 103 行起)。schedule_wake 在找不到同一个 waker 的项时插入新项,插不进去就:

                loop {
                    match self.queue.push(timer) {
                        Ok(()) => break,
                        Err(e) => timer = e,
                    }

                    self.queue.pop().unwrap().waker.wake();
                }

把最后一项拿出来立刻唤醒。那个任务醒来一看没到期,再 schedule_wake,又挤掉一个。Driver::schedule_wake 的契约只有「到期前可以提前唤醒」这一条退路,没有错误返回,所以队列满了只能这样(时间驱动)。

main 也在等定时器,它也占一项:7 个传感器加 main 正好 8 项;8 个传感器是 9 项,多出的一项让两个任务互相挤。定时器队列用每个任务的 poll 次数量过同一件事:两个任务 500 ms 里各被 poll 近 200 万次。

main 分支上这段代码没有改过(git log embassy-time-queue-utils-v0.3.2..main -- embassy-time-queue-utils/src/queue_generic.rs 为空)。

修法

  1. 不需要别的执行器,就不开 generic-queue-*。默认的集成队列把节点放在每个任务头里,任务数多少都不会满:

    7 sensors + main: 26 ticks in 1005 ms user 0.00 sys 0.00
    8 sensors + main: 29 ticks in 1005 ms user 0.00 sys 0.00
    

    embassy-time 的 Cargo.toml 也写着:库不要开 generic-queue-*,留给最终程序选。依赖里有库开了它,feature 合并后整个程序都用通用队列。

  2. 确实要通用队列(RTIC、手写的 block_on、mock 驱动的测试),N 至少等于同时在等定时器的任务数(main 和每个 InterruptExecutor 上的任务都算),再留余量:

    embassy-time = { version = "=0.5.1", features = ["std", "generic-queue-16"] }
    
    8 sensors + main: 31 ticks in 1005 ms user 0.00 sys 0.00
    

    一个任务同时等几个定时器(select 两个 Timer)只占一项:同一个 waker 只留最早的时刻。

怎么确认没中招

  • cargo tree -e features -i embassy-time-queue-utils --offline | grep generic-queue:看是谁打开了 generic-queue-*。
  • 数一数会同时 .await 定时器的任务(包括 main),和 N 比。
  • 量 CPU:std 上用 /usr/bin/time 看 user + sys 是否接近墙钟时间;板子上在 __pender 或空闲循环里计数,看空闲时是否还在不停地 poll。

小结

  • 通用队列满了不报错,把最后一项提前唤醒腾位置;被挤掉的任务回来再挤,执行器忙转,结果仍正确。
  • 实测 generic-queue-8 下 8 个传感器加 main(9 项):1 秒用掉 1.4 秒 CPU;generic-queue-16 或集成队列下为 0。
  • 不需要就别开 generic-queue-*;要开就让 N 不小于同时等定时器的任务数。

💬 关联讨论

发起讨论
加载中...