一个任务忙等,其他任务全停
主线位置:定时器主线的「执行器 poll 任务」那一步。执行器一次只 poll 一个任务,任务返回之前别的任务都排着(各平台的执行器);和 tokio 多线程运行时的对照见本站的定时循环搬到 embassy。
sequenceDiagram participant E as Executor participant S as sensor task participant H as heartbeat E->>S: poll S->>S: delay_ms 300 忙等 Note over H: 到期了 但没人 poll S-->>E: 300 ms 后才返回 E->>H: poll 晚了 300 ms
实验 crate exp-embassy/pita-std,bin blocking(platform-std,release,每种跑三次)和 exp-embassy/pita-trace,bin long_poll(release);M2 Max、macOS 27、rustc 1.99.0,本机负载约 4。
现象
一个心跳任务每 10 ms 醒一次,记下最晚晚了多少;另一个任务 100 ms 时读一次传感器。传感器驱动是生态里常见的写法,对 embedded_hal::delay::DelayNs(阻塞版)泛型,转换时间按数据手册等 300 ms;任务里交给它的是 embassy_time::Delay:
// A typical driver crate: generic over the blocking delay trait.
struct Sensor<D> {
delay: D,
}
impl<D: embedded_hal::delay::DelayNs> Sensor<D> {
fn measure(&mut self) -> u16 {
self.delay.delay_ms(300); // conversion time from the datasheet
42
}
}
#[embassy_executor::task]
async fn sensor_task(mode: &'static str) {
Timer::after_millis(100).await;
// mode "" (blocking); the "async" and "chunked" modes are shown below
let v = Sensor { delay: Delay }.measure();
println!("measured {v} at {} ms", Instant::now().as_millis());
}
mode: blocking
beat late 2 ms at 12 ms
measured 42 at 402 ms
beat late 302 ms at 402 ms
- 100 ms 到 400 ms 之间心跳一次也没跑,第一次醒来已经晚了 302 ms。三次运行分别是 302、302、301 ms。
- 编译器没有任何提示。
Delay是 embassy-time 提供的类型,名字里也没有「blocking」,看起来就是 embassy 的东西,应当是异步的。
出处
Delay 是一个零大小类型,同时实现了阻塞和异步两套 trait,阻塞版全部转给 block_for:
impl embedded_hal_1::delay::DelayNs for Delay {
// ...
fn delay_ms(&mut self, ms: u32) {
block_for(Duration::from_millis(ms as u64))
}
}
impl embedded_hal_async::delay::DelayNs for Delay {
// ...
fn delay_ms(&mut self, ms: u32) -> impl Future<Output = ()> {
Timer::after_millis(ms as _)
}
}
block_for 是一个 while Instant::now() < expires_at {} 循环。同一个值走哪一套,取决于接收它的泛型参数要的是哪个 trait;驱动库只写了阻塞版的约束,Delay 就以阻塞版身份进去了。embedded-hal 0.2 的 DelayMs / DelayUs 也一样是 block_for(三套 trait 的表见 Timer、Ticker 与超时)。
执行器没有抢占:任务的 poll 不返回,执行器就停在那次调用里。tokio 的多线程运行时上同样的错误只占掉一个 worker(在 async 里阻塞),这里占掉的是整个执行器。忙等、std::thread::sleep、一段长计算、一个同步的 flash 擦除,效果都一样。
修法
驱动库有异步版本,就用异步版本。 同一个驱动对 embedded_hal_async::delay::DelayNs 泛型、measure 改成 async fn,任务里 .await(blocking async):
impl<D: embedded_hal_async::delay::DelayNs> AsyncSensor<D> {
async fn measure(&mut self) -> u16 {
self.delay.delay_ms(300).await;
42
}
}
mode: async
beat late 2 ms at 12 ms
measured 42 at 401 ms
测量同样在 400 ms 左右完成,心跳全程最晚 2 ms(三次运行一样)。
只有阻塞版时,切碎加 yield_now 只能缓解。 把 300 ms 拆成 30 段 10 ms,每段之后 embassy_futures::yield_now().await(blocking chunked):
mode: chunked
beat late 2 ms at 12 ms
beat late 12 ms at 112 ms
beat late 22 ms at 132 ms
beat late 32 ms at 152 ms
...
beat late 142 ms at 372 ms
beat late 152 ms at 392 ms
measured 42 at 402 ms
心跳不再整段停住,但每 20 ms 才跑一次,按网格算越落越远,最后晚了 152 ms(三次运行 152、162、152 ms)。原因在两头:yield_now 第一次 poll 时叫醒自己再返回 Pending,传感器任务立刻回到运行队列;心跳每次 Timer::at(..).await 都要先返回一次 Pending,即使时刻已过(一次 Timer::after 里的 yielded_once)。两者交替,心跳每轮只前进一格,而每轮里传感器占 10 ms。切得越碎越好,但这只是把一次长停顿换成持续的拖慢。
真的要并行,就换一个执行上下文。 在芯片上把对延迟敏感的任务放进更高优先级的 InterruptExecutor,它能打断线程模式里的忙等(各平台的执行器量过:低优先级任务忙算时高优先级任务的响应在两百多个周期);std 上起第二个执行器线程(本站的定时循环搬到 embassy量过)。忙等本身仍然占着 CPU,执行器也因此不会 WFE 睡眠。
怎么确认没中招
用执行器的 trace 钩子量每次 poll 的时长(pita-trace 的 long_poll;钩子怎么写全见开了 trace:Undefined symbols _embassy_trace*):
#[unsafe(no_mangle)]
fn _embassy_trace_task_exec_begin(_executor_id: u32, _task_id: u32) {
*STARTED.lock().unwrap() = Some(StdInstant::now());
}
#[unsafe(no_mangle)]
fn _embassy_trace_task_exec_end(_executor_id: u32, task_id: u32) {
if let Some(t0) = STARTED.lock().unwrap().take() {
let us = t0.elapsed().as_micros();
if us > 5_000 {
println!("long poll: task {task_id:#x} ran {us} us without yielding");
}
}
}
一个任务 block_for(50 ms),另一个每 10 ms 一个 Timer:
task 0x4e68e38 spawned
task 0x4e68eb0 spawned
task 0x4e68f18 spawned
long poll: task 0x4e68eb0 ran 50003 us without yielding
第二个 spawn 的就是忙等的那个任务,只有它被报出来。芯片上可以把 StdInstant 换成 DWT 周期计数器、把锁换成原子量,这里没有在 QEMU 上实现。另外几条:
- 搜依赖里对阻塞 trait 的约束:
grep -rn 'delay::DelayNs\|DelayMs<' ~/.cargo/registry/src/*/<driver>-*/src,看驱动用的是embedded_hal::还是embedded_hal_async::。 - 任务里出现
block_for、std::thread::sleep、blocking_开头的 HAL 方法时,问一句它会占多久。
小结
- 一个执行器上没有抢占,一个任务的一次 poll 有多长,其他任务就等多久。
embassy_time::Delay交给阻塞版DelayNs时就是block_for忙等;驱动库优先选异步版本。- 切碎加
yield_now只能缓解(心跳仍晚 152 ms);需要实时性就换更高优先级的InterruptExecutor,并用trace钩子抓出长 poll。