Rust in Everything
基于 embassy-executor 0.10.0

状态与运行队列

主线位置:定时器主线的「唤醒任务(进运行队列 + SEV)→ 再次 poll」,中断主线的「处理函数唤醒 → 任务被 poll」也走同一段代码。闹钟中断里调用 waker.wake(),到执行器在主循环里 poll 这个任务,中间只经过本页的三样东西:任务头里的状态位、运行队列、__pender。

stateDiagram-v2
  [*] --> Idle
  Idle --> Queued: claim 置两位
  Queued --> Running: dequeue 清 RUN_QUEUED
  Running --> Queued: wake
  Running --> Waiting: 返回 Pending
  Waiting --> Queued: wake
  Running --> Exited: 返回 Ready
  Exited --> Idle: despawn
  Exited --> Stale: 旧 waker 唤醒
  Stale --> Exited: poll_exited

图里的状态是按「两个位 + 是否正在 poll」画的:Queued 是 SPAWNED|RUN_QUEUED,Waiting 是只有 SPAWNED,Stale 是任务已经结束、却被一个旧 waker 塞进了队列。raw/mod.rs 的 TaskHeader 注释里有一张同样意思的字符图,标了 6 种转换。

代码在 raw/state_atomics.rs、state_atomics_arm.rs、state_critical_section.rs(各 70 到 83 行)、raw/run_queue.rs(213 行)和 raw/waker.rs(49 行)。实验在 exp-embassy/exec-std(std,release,M2 Max、macOS 27、rustc 1.99.0)。

两个状态位,三种实现

状态只有两位:STATE_SPAWNED(格子里有一个活的 future)和 STATE_RUN_QUEUED(任务在某个运行队列里)。用哪种实现由 raw/mod.rs 开头的 cfg_attr 决定:

#[cfg_attr(all(cortex_m, target_has_atomic = "32"), path = "state_atomics_arm.rs")]
#[cfg_attr(
    all(not(cortex_m), any(target_has_atomic = "8", target_has_atomic = "32")),
    path = "state_atomics.rs"
)]
#[cfg_attr(
    not(any(target_has_atomic = "8", target_has_atomic = "32")),
    path = "state_critical_section.rs"
)]
mod state;

cortex_m 是 build.rs 按目标名设置的 cfg。target_has_atomic 只在目标支持完整的原子读改写(CAS)时才成立,rustc --print cfg --target <t> 的结果:

目标target_has_atomic选中的实现运行队列
thumbv7em-none-eabihf(Cortex-M4)8、16、32、ptrstate_atomics_arm.rsTransferStack(原子)
thumbv6m-none-eabi(Cortex-M0)无state_critical_section.rsMutexTransferStack(临界区)
riscv32imac-unknown-none-elf8、16、32、ptrstate_atomics.rsTransferStack
riscv32imc-unknown-none-elf无state_critical_section.rsMutexTransferStack
aarch64 / x86_64(std)都有state_atomics.rsTransferStack

通用的 state_atomics.rs 把两位放在一个原子整数里(32 位目标上是 AtomicU32,其他是 AtomicU8),三个操作各是一条原子指令:

    pub fn spawn(&self) -> bool {
        self.state
            .compare_exchange(0, STATE_SPAWNED | STATE_RUN_QUEUED, Ordering::AcqRel, Ordering::Acquire)
            .is_ok()
    }
    // ...
    pub fn run_enqueue(&self, f: impl FnOnce(Token)) {
        let prev = self.state.fetch_or(STATE_RUN_QUEUED, Ordering::AcqRel);
        if prev & STATE_RUN_QUEUED == 0 {
            locked(f);
        }
    }

run_enqueue 的闭包 f 是「真正入队」:只有把 RUN_QUEUED 从 0 变成 1 的那一次唤醒才会入队,后面的唤醒看到位已经是 1,什么也不做。这就是「唤醒多次、只 poll 一次」的全部机制。

Cortex-M 的 state_atomics_arm.rs 换了一种布局:

// Must be kept in sync with the layout of `State`!
pub(crate) const STATE_SPAWNED: u32 = 1 << 0;
pub(crate) const STATE_RUN_QUEUED: u32 = 1 << 8;

#[repr(C, align(4))]
pub(crate) struct State {
    /// Task is spawned (has a future)
    spawned: AtomicBool,
    /// Task is in the executor run queue
    run_queued: AtomicBool,
    pad: AtomicBool,
    pad2: AtomicBool,
}

两个标志各占一个字节,spawn 时把四个字节当成一个 AtomicU32 做 CAS(所以常量是 1 << 0 和 1 << 8,依赖小端),而 despawn 和 run_dequeue 只需要对单个字节做一次普通的 store,不用 LDREX/STREX 循环;run_enqueue 是对 run_queued 那个字节的 swap。这个实现由 PR #2189 加入(提交说明「executor: add faster ARM-specific impl. Does a wake+poll in 79 cycles in nrf52840.」,首次进入 0.4.0,仓库里没有 0.4.0 的 tag,按 embassy-executor 0.4 那次版本提交 c5e8a2e83 判断)。

没有 CAS 的目标用 state_critical_section.rs:一个 critical_section::Mutex<Cell<usize>>,每次读改写都关一次中断。这时 locked 就是 critical_section::with,入队用的 Token 就是临界区令牌,运行队列也换成临界区保护的版本(下文)。

三份实现的注释都写着 run_enqueue「Mark the task as run-queued if it's spawned and isn't already run-queued」,但代码只看 RUN_QUEUED,不看 SPAWNED。不看是有意的,见下面的冷知识。

📝NOTE
冷知识:RUN_QUEUED 从 0.7.0 起无条件置位

0.7.0 之前,state_atomics.rs 的 run_enqueue 是一个 fetch_update 循环:「If already scheduled, or if not started」就放弃,否则置位;ARM 版是手写的 ldrex/strex 内联汇编,同样先检查 STATE_SPAWNED。PR #3573(「Unconditionally set RUN_QUEUED」,首次进入 embassy-executor 0.7.0)去掉了对 SPAWNED 的检查,把通用版改成一条 fetch_or,ARM 版改成对一个字节的 swap(提交「Simplify ARM run_enqueue」)。代价是一个已经结束的任务也可能被旧 waker 塞进队列;同一天合并的 PR #3622(「Allow polling exited tasks」,同样首次进入 0.7.0)让任务结束时把 poll_fn 换成空函数 poll_exited:执行器照常出队、调用 poll_fn,什么也不发生,RUN_QUEUED 在出队时被清掉。TaskHeader 注释里的转换 5、6 说的就是这条路。

运行队列:cordyceps 的 TransferStack

运行队列不是执行器自己写的数据结构。run_queue.rs 用的是 cordyceps(0.10.0 要求 0.3.4,新项目解析到 0.3.5)的侵入式链表:

#[cfg(target_has_atomic = "ptr")]
type TransferStack<T> = cordyceps::TransferStack<T>;

#[cfg(not(target_has_atomic = "ptr"))]
type TransferStack<T> = MutexTransferStack<T>;
// ...
/// Use `cordyceps::sorted_list::Links` as the singly linked list
/// for RunQueueItems.
pub(crate) type RunQueueItem = Links<TaskHeader>;

「侵入式」是说链表节点就是任务头里的 run_queue_item 字段,入队不分配任何东西。TaskHeader 实现 cordyceps 的 Linked trait,告诉它节点句柄是 TaskRef、从任务头怎么找到 run_queue_item。因为一个任务同时最多在一个队列里(RUN_QUEUED 保证),一个节点就够了。

TransferStack 只有两个操作,cordyceps 0.3.5 的实现:

  • push_was_empty:把节点的 next 设成当前栈顶,再用 compare_exchange_weak 把栈顶换成自己,失败就重来;返回旧栈顶是不是空。
  • take_all:head.swap(null),一次把整条链拿走,变成一个普通的单线程 Stack。

多个中断可以同时 push,只有执行器自己 take_all,这正是「很多生产者、一个消费者」的形状。没有指针宽度原子操作的目标上,MutexTransferStack 用 critical_section::Mutex 包一个普通 Stack,push 用调用方已经拿着的临界区令牌(就是 run_enqueue 传下来的 Token),take_all 自己开一个临界区。

入队与 __pender

SyncExecutor::enqueue 只在队列从空变非空时通知平台:

    unsafe fn enqueue(&self, task: TaskRef, l: state::Token) {
        #[cfg(feature = "_any_trace")]
        trace::task_ready_begin(self, &task);

        if self.run_queue.enqueue(task, l) {
            self.pender.pend();
        }
    }

队列非空说明执行器已经被叫过、还没来得及处理,不必再叫一次。Pender::pend 调用一个链接期符号 __pender(context),由平台实现:Cortex-M 线程模式是 SEV,InterruptExecutor 是挂起一个中断,std 是条件变量,见各平台的执行器。

出队:整批取走,批内后进先出

不开调度 feature 时,dequeue_all 只有几行:

    pub(crate) fn dequeue_all(&self, on_task: impl Fn(TaskRef)) {
        let taken = self.stack.take_all();
        for taskref in taken {
            run_dequeue(&taskref);
            on_task(taskref);
        }
    }

先清 RUN_QUEUED 再 poll:poll 期间任务若被唤醒(包括唤醒自己),run_enqueue 能重新置位、把它推进新的栈,留给下一次 dequeue_all。RunQueue 的注释承认批内顺序和入队顺序相反,并解释为什么这不算问题:「the next batch won't run until the current batch is completely processed, so even if a task enqueues itself instantly (for example by waking its own waker) can't prevent other tasks from running.」README 说的「Fair」就是这一条。

SyncExecutor::poll 把 on_task 设成「调用任务头里的 poll_fn」,平台的主循环每醒一次调一次 poll。一次 poll 只处理调用那一刻栈里的任务,处理期间新入队的任务要等主循环下一圈。

❗IMPORTANT
动脑筋:四个任务的出队顺序

exec-std/src/bin/order.rs:main 依次 spawn A、B、C、D 四个任务,每个任务循环 3 次,每次先往日志里记下「名字 + 轮次」,再 embassy_futures::yield_now().await(唤醒自己、返回一次 Pending)。main 随后 Timer::after_millis(50).await,最后打印日志。默认配置(不开 scheduler-*)下,12 条日志是什么顺序?

waker:一个指针加一张静态 vtable

waker.rs 的全部内容:

static VTABLE: RawWakerVTable = RawWakerVTable::new(clone, wake, wake, drop);

unsafe fn clone(p: *const ()) -> RawWaker {
    RawWaker::new(p, &VTABLE)
}

unsafe fn wake(p: *const ()) {
    wake_task(TaskRef::from_ptr(p as *const TaskHeader))
}

unsafe fn drop(_: *const ()) {
    // nop
}

pub(crate) unsafe fn from_task(p: TaskRef) -> Waker {
    Waker::from_raw(RawWaker::new(p.as_ptr() as _, &VTABLE))
}

waker 的数据指针就是任务头地址。任务存储是 'static 的、永不释放,所以 clone 只是复制指针,drop 什么也不做,wake 和 wake_by_ref 是同一个函数。tokio 的 waker 要维护引用计数,任务结束后内存才能回收(见 poll 与唤醒);embassy 没有这个问题,代价是任务内存永远占着。TaskStorage::poll 每次 poll 都现做一个 waker,用完 mem::forget,注释说「the compiler is emitting a virtual call for waker drop, but we know it's a noop for our waker」。

wake_task 把前面几节串起来:

pub fn wake_task(task: TaskRef) {
    let header = task.header();
    header.state.run_enqueue(|l| {
        // We have just marked the task as scheduled, so enqueue it.
        unsafe {
            let executor = header.executor.load(Ordering::Relaxed).as_ref().unwrap_unchecked();
            executor.enqueue(task, l);
        }
    });
}

置位成功才读 executor、入队、可能 pend。它是 pub 的:embassy-sync 这类等待队列可以只存一个 TaskRef(一个字)而不是整个 Waker(两个字),醒的时候直接调 wake_task。反方向的 task_from_waker 比较 vtable 的地址判断 waker 是不是本执行器造的,不是就 panic,信息里专门提了一句「Unless the generic timer queue is enabled, embassy_time::Timer only works with the Embassy executor.」:集成定时器队列靠它从 waker 找回任务头。另有一个不 pend 的 wake_task_no_pend,给那些自己会随后调 poll 的调用方用。

turbowakers feature 换成 waker_turbo.rs,用的是 Waker::from_turbo_ptr,这个 API 只存在于打过补丁的 core 里,Cargo.toml 注释说它「requires patching core」,文档默认不展示。

实测:唤醒三次,poll 几次

exec-std/src/bin/trace.rs 开了 trace feature(钩子的写法见调度策略),按任务 id 统计 _embassy_trace_task_ready_begin(真正入队一次记一次)和 _embassy_trace_task_exec_begin(被 poll 一次记一次)。waiter 第一次 poll 时把 waker 存进一个 static 后返回 Pending;waker_task 5 ms 后连续调用 wake_by_ref()、wake_by_ref()、wake() 三次。ticker 是对照组,Timer::after_millis(10) 三次(std,release):

waiter     Counts { new: 1, ready: 2, exec: 2, end: 1 }
waker_task Counts { new: 1, ready: 2, exec: 2, end: 1 }
ticker     Counts { new: 1, ready: 4, exec: 4, end: 1 }
executor   poll_start=8 executor_idle=7

waiter 一共被 poll 两次:spawn 时的第一次,和三次唤醒合并成的一次。三次唤醒里只有第一次把 RUN_QUEUED 从 0 变成 1,ready 只记了 1 次(另一次是 spawn 时的入队)。ticker 是 1 次 spawn 加 3 次定时器唤醒,正好 4 次。poll_start 比 executor_idle 多 1,是因为打印时 main 所在的那一轮 poll 还没结束。

动脑筋答案

features: prio=false edf=false
D0 C0 B0 A0 A1 B1 C1 D1 D2 C2 B2 A2
  • 第一批只有 main。main 按 A、B、C、D 的顺序 spawn,四个任务依次压栈,栈顶是 D,所以第二批是 D、C、B、A。
  • 第二批里每个任务 yield 时唤醒自己,按 D、C、B、A 的顺序压进新栈,栈顶变成 A,第三批是 A、B、C、D。
  • 第四批又反过来。每一批都完整跑完才开始下一批,没有一个任务能连跑两轮,但批内的顺序每批翻一次。依赖「先 spawn 的先跑」的代码,在 embassy 上会得到相反的结果。

小结

  • 任务状态只有 SPAWNED、RUN_QUEUED 两位;Cortex-M 上两位各占一个字节,没有 CAS 的目标用临界区。
  • 只有把 RUN_QUEUED 从 0 置成 1 的那次唤醒才入队,所以唤醒多少次都只 poll 一次;置位不看 SPAWNED,结束的任务靠 poll_exited 吸收迟到的唤醒。
  • 运行队列是 cordyceps 的 TransferStack:节点在任务头里,入队一次 CAS,出队一次 swap 取走整批;队列由空变非空时才调 __pender。
  • 批内后进先出,批与批之间公平:一个任务在同一批里最多被 poll 一次。
  • waker 是任务头指针加静态 vtable,clone/drop 都不做事;task_from_waker 比较 vtable 地址反查任务。

💬 关联讨论

发起讨论
加载中...