Rust in Everything
基于 embassy-executor 0.10.0

调度策略

主线位置:定时器主线的最后一步「再次 poll」。默认配置下,闹钟中断唤醒的任务和同一批里的其他任务按后进先出的顺序被 poll(见状态与运行队列);本页讲 0.10.0 新加的两个 feature 怎么改变这个顺序,以及怎么用 trace 钩子看到每一次 poll。

flowchart TB
  start["dequeue_all"] --> take["take_all 取走新入队的"]
  take --> ins["逐个插入 SortedList"]
  ins --> pop{"pop_front"}
  pop -->|空| done["返回"]
  pop -->|有| clr["清 RUN_QUEUED"]
  clr --> pollt["poll 这一个任务"]
  pollt --> take

代码在 raw/run_queue.rs(排序版的 dequeue_all)、raw/deadline.rs(44 行)、metadata.rs、raw/trace.rs(380 行)。std 实验在 exp-embassy/exec-std(release,M2 Max、macOS 27、rustc 1.99.0),交叉编译实验在 exp-embassy/exec-qemu(thumbv7em-none-eabihf,release,opt-level = "s")。

两个 feature,一个排序队列

Cargo.toml 里的说明只有一句:scheduler-deadline 是「"Earliest Deadline First" Scheduler, using soft-realtime "deadlines" to prioritize tasks based on the remaining time before their deadline. Adds some overhead.」,scheduler-priority 是「"Highest Priority First" Scheduler. Adds some overhead.」。两个可以同时开。

开了任意一个,运行队列的入队不变(仍然是 TransferStack),出队换成 dequeue_all 的另一个版本:

    pub(crate) fn dequeue_all(&self, on_task: impl Fn(TaskRef)) {
        let mut sorted = SortedList::<TaskHeader>::new_with_cmp(|lhs, rhs| {
            // compare by priority first
            #[cfg(feature = "scheduler-priority")]
            {
                let lp = lhs.metadata.priority();
                let rp = rhs.metadata.priority();
                if lp != rp {
                    return lp.cmp(&rp).reverse();
                }
            }
            // compare deadlines in case of tie.
            #[cfg(feature = "scheduler-deadline")]
            {
                let ld = lhs.metadata.deadline();
                let rd = rhs.metadata.deadline();
                if ld != rd {
                    return ld.cmp(&rd);
                }
            }
            core::cmp::Ordering::Equal
        });

        loop {
            // For each loop, grab any newly pended items
            let taken = self.stack.take_all();

            // Sort these into the list - this is potentially expensive! We do an
            // insertion sort of new items, which iterates the linked list.
            //
            // Something on the order of `O(n * m)`, where `n` is the number
            // of new tasks, and `m` is the number of already pending tasks.
            sorted.extend(taken);

            // Pop the task with the SOONEST deadline. If there are no tasks
            // pending, then we are done.
            let Some(taskref) = sorted.pop_front() else {
                return;
            };

            // We got one task, mark it as dequeued, and process the task.
            run_dequeue(&taskref);
            on_task(taskref);
        }
    }

和默认版本比,有三处不同:

  1. 每 poll 一个任务就重新取一次新入队的任务。 默认版本一次 take_all 处理一整批;这里每轮循环都 take_all,新唤醒的任务立刻参与比较。
  2. 比较规则。 先比优先级(u8,大的先),相等再比截止时间(u64 的 tick,小的先)。SortedList 是 cordyceps 的有序单链表,链表节点仍然是任务头里那个 run_queue_item(它本来就是 sorted_list::Links),所以不用额外内存。
  3. 一直循环到两边都空才返回。 所以 poll 一次调用可能处理任意多轮。

SortedList 是局部变量,dequeue_all 返回时一定是空的;任务在队列之间流动的仍然只是 TransferStack。插入是从头往后找第一个比自己「大」的节点,相等就继续往后走(cordyceps 0.3.5 sorted_list.rs 的 insert:「If cursor node is LESS or EQUAL: keep moving」),所以同优先级、同截止时间的任务按插入顺序排,插入顺序又来自 take_all 的后进先出。

API:元数据里的两个字段

优先级和截止时间存在任务头的 Metadata 里,spawn 前用 SpawnToken::metadata(),运行中用 Metadata::for_current_task().await 拿到:

方法feature说明
set_priority(u8) / priority()scheduler-priorityAtomicU8,Relaxed
set_deadline(ticks) / deadline()scheduler-deadline绝对时刻,和 embassy-time 同一个 tick
set_deadline_after(ticks)scheduler-deadline + embassy-time-drivernow() + ticks,饱和加法
increment_deadline(ticks)scheduler-deadline在上一个截止时间上加,对应 Ticker 的节奏
unset_deadline()scheduler-deadline恢复成 u64::MAX

每次 spawn 时 Metadata::reset 把优先级清成 0、截止时间设成「未设置」(Deadline::UNSET_TICKS = u64::MAX),所以没设截止时间的任务排在所有设了的任务后面。set_deadline_after 要读当前时间,所以多依赖一个 embassy-time-driver,执行器的 embassy-time-driver feature 就是为它开的;执行器本身仍然不依赖 embassy-time。

Deadline 把 u64 拆成两个 AtomicU32 分别存取,32 位的 Cortex-M 上没有 64 位原子操作。读写都是两次 Relaxed,可能读到半新半旧的值,注释承认「Interacting with the deadline should be done locally in a task. In theory you could try to set or read the deadline from another task, but that will result in weird (though not unsound) behavior.」increment_deadline 的注释说「Returns the deadline that was set」,签名其实返回 ()。

这两个 feature 只改变同一个执行器内的出队顺序,不抢占:一个正在被 poll 的任务不 .await,优先级再高的任务也得等它返回。要抢占,得用 InterruptExecutor 把任务放到更高的中断优先级上,见各平台的执行器。

实测:出队顺序

exec-std/src/bin/order.rs:main 依次 spawn A、B、C、D,spawn 前用令牌的 metadata() 分别设优先级 1、3、2、0 和截止时间 300、400、100、200;每个任务循环 3 次,记录「名字 + 轮次」后 yield_now().await。默认配置的结果(D0 C0 B0 A0 A1 B1 C1 D1 D2 C2 B2 A2)和原因见状态与运行队列的动脑筋。

❗IMPORTANT
动脑筋:开 scheduler-priority 后,12 条日志是什么顺序?

同一个程序,--features prio(只开 scheduler-priority)。B 的优先级最高(3),它每轮 yield_now 都唤醒自己。B 的三轮之间,会不会穿插别的任务?

另外两种配置的输出:

features: prio=false edf=true
C0 C1 C2 D0 D1 D2 A0 A1 A2 B0 B1 B2
features: prio=true edf=true
B0 B1 B2 C0 C1 C2 A0 A1 A2 D0 D1 D2

只开 scheduler-deadline 时按截止时间 C(100)、D(200)、A(300)、B(400)的顺序,每个任务连跑三轮。两个都开时优先级说了算,截止时间只在优先级相等时才比较,这里四个优先级各不相同,结果和只开优先级一样。

四个任务优先级都设成 2(EQUAL=1,--features prio):

features: prio=true edf=false
D0 C0 B0 A0 D1 C1 B1 A1 D2 C2 B2 A2

同优先级时变成了轮转:D 先跑(第一批 take_all 后进先出),yield 后被插到同优先级的 C、B、A 之后;而默认配置下批与批之间的顺序每次翻转。

代价:不再「批次公平」

README 写着「Fair: a task can't monopolize CPU time even if it's constantly being woken. All other tasks get a chance to run before a given task gets polled for the second time.」这是默认 dequeue_all 整批处理的性质。开了调度 feature,一个不断唤醒自己的高优先级任务(或一直在最早截止时间上的任务)会一直被选中,其他任务要等它真正挂起(等一个还没就绪的东西)才有机会。这正是「最高优先级优先」和 EDF 想要的语义,但 README 的那条保证在这两个 feature 下不成立,文档里没有说明。

排序本身也有代价:插入是 O(n) 的链表遍历,每 poll 一个任务都要 take_all 一次。注释里自己写了「this is potentially expensive」。

代价:任务池从 .bss 挪进 .data

任务存储讲过,任务池能放进 .bss 是因为 TaskStorage::new() 的每个字段都是零。开了 scheduler-deadline,Metadata::new 里有这样一段:

            // NOTE: The deadline is set to zero to allow the initializer to reside in `.bss`. This
            // will be lazily initalized in `initialize_impl`
            #[cfg(feature = "scheduler-deadline")]
            deadline: raw::Deadline::new_unset(),

注释说设成零,代码却是 new_unset(),也就是 u64::MAX:每个任务头里有 8 个 0xFF 字节,整个任务池的初值不再全零。exec-qemu/src/bin/size.rs(六个任务池,见任务存储页)分别不开和开 --features dl(scheduler-deadline):

   text	   data	    bss	    dec	    hex	filename
   8668	      0	   2188	  10856	   2a68	.../release/size
   8924	   2296	     28	  11248	   2bf0	.../release/size
200008c0 00000038 d size::empty::POOL
20000000 00000050 d size::__embassy_main::POOL
20000050 00000090 d size::p1::POOL
200000e0 00000120 d size::p2::POOL
20000200 00000240 d size::p4::POOL
20000440 00000480 d size::p8::POOL

每个池的符号类型从 b(.bss)变成 d(.data)。RAM 占用只多了截止时间本身(每格 8 字节:p1 从 0x88 到 0x90),但 .data 的初值要存在 flash 里、启动时拷进 RAM:flash 从 8668 字节涨到 11220 字节,多出的 2552 字节里 2296 字节就是这些任务池的初值镜像,.text 也多了 256 字节。只开 scheduler-priority 时(--features prio)池子仍在 .bss,每格同样多 8 字节(1 字节的 AtomicU8 加对齐)。初始化成零本来就够用:initialize_impl 每次 spawn 都会调 reset 把截止时间设成 UNSET_TICKS,new() 里的值从来不会被读到。这是一个上游问题候选。

📝NOTE
冷知识:这条注释原本是对的

EDF 调度器最早叫「DRS scheduler」,在 PR #4608(「Upstream "Earliest Deadline First" Scheduler (version 2)」,2025-09-11 合并,首次进入 embassy-executor 0.10.0)的分支上逐步成形。分支早期的提交 ed2e51bfa 写下这条注释时,代码是 deadline: SyncUnsafeCell::new(0u64),和注释一致,spawn 时再设成「未设置」。同一分支后来的提交 3f606b28f(「Change deadline to use internal atomics」)把字段换成两个 AtomicU32 组成的 Deadline,初值顺手写成了 Deadline::new_unset(),注释没动;再后来 52d178560 把它从 TaskHeader 挪进 Metadata,注释也跟着搬了过去。三个提交都在这一个 PR 里,所以从第一个发布版起,开了 scheduler-deadline 的任务池就在 .data 里。

trace:七个由用户定义的钩子

开了 trace feature,执行器在七个地方调用 extern "Rust" 函数(trace.rs),模块文档说「All callbacks must be implemented」:

钩子调用处
_embassy_trace_poll_start(executor_id)SyncExecutor::poll 开头
_embassy_trace_executor_idle(executor_id)SyncExecutor::poll 结尾
_embassy_trace_task_new(executor_id, task_id)SyncExecutor::spawn
_embassy_trace_task_ready_begin(executor_id, task_id)SyncExecutor::enqueue,真正入队时;可能在中断里
_embassy_trace_task_exec_begin(executor_id, task_id)poll 一个任务之前
_embassy_trace_task_exec_end(executor_id, task_id)poll 一个任务之后
_embassy_trace_task_end(executor_id, task_id)任务的 future 返回 Ready 之后

两个 id 都是地址截成的 u32。ready_begin 只在 run_enqueue 成功时调用,所以它数的是「入队次数」而不是「wake 次数」。钩子的定义方式是导出同名的无修饰符号(exec-std/src/bin/trace.rs 摘录):

#[unsafe(no_mangle)]
fn _embassy_trace_task_exec_begin(_executor_id: u32, task_id: u32) {
  with_task(task_id, |c| c.exec += 1);
}

用这七个钩子按任务统计入队和 poll 次数的结果,见状态与运行队列的「唤醒三次,poll 几次」。少定义钩子是链接错误,而不是编译错误(exec-std/src/bin/trace_nohooks.rs,开 trace、一个钩子都不定义,macOS):

  = note: Undefined symbols for architecture arm64:
            "__embassy_trace_executor_idle", referenced from:
...
            "__embassy_trace_poll_start", referenced from:
...
            "__embassy_trace_task_exec_begin", referenced from:
...
            "__embassy_trace_task_exec_end", referenced from:
...
            "__embassy_trace_task_new", referenced from:
...
            "__embassy_trace_task_ready_begin", referenced from:

符号名前多出的一个下划线是 macOS(Mach-O)给 C 符号加的前缀,源码里只有一个。列表里只有六个:_embassy_trace_task_end 在泛型的 TaskStorage::<F>::poll 里调用,这个程序的 main 永不结束,编译器没有生成引用它的代码。

rtos-trace feature 是另一套:它把这些事件转发给 rtos-trace crate(给 SystemView 一类的工具用),并用一个侵入式链表 TaskTracker 记下所有任务,任务头因此多一个 all_tasks_next 指针。这个链表有一个问题:TaskTracker::add 在每次 task_new 时把任务压到表头,却从不移除,同一格任务结束后再被 spawn,就被压进链表第二次,all_tasks_next 被改成指向当时的表头,而它自己还留在链表原来的位置上(或者它本来就是表头,于是指向自己),链表成环。exec-std/src/bin/rtos.rs(开 rtos-trace,自己实现 RtosTrace,在 task_send_info 里计数)把一个立即结束的任务 spawn N 次,再调用 _rtos_trace_task_list(rtos-trace 用来列出所有任务的回调):

task_new calls: 2, send_info so far: 2
task_list sent 2 entries
task_new calls: 3, send_info so far: 3
task_send_info called more than 1000 times: the task list has a cycle
task_new calls: 4, send_info so far: 4
task_send_info called more than 1000 times: the task list has a cycle

N = 1 时列出 2 个任务(main 和它),N = 2 起遍历不再结束(程序在第 1001 次回调时主动退出)。这是一个上游问题候选;main 分支在 2026-09-01 的提交 bc866462b(「executor: remove rtos-trace」)里整个删掉了 rtos-trace 支持,但 0.10.0 和之前开了 rtos-trace 的程序都有这个问题。

动脑筋答案

features: prio=true edf=false
B0 B1 B2 C0 C1 C2 A0 A1 A2 D0 D1 D2

B 的三轮之间没有任何别的任务。B 第一次被 poll 后 yield,唤醒自己,入队;下一轮循环 take_all 拿到 B,插进 SortedList,B 的优先级 3 仍然最高,pop_front 又是它。直到 B 返回 Ready,C(2)才轮到,C 同样连跑三轮,然后是 A(1)、D(0)。main 的优先级是默认的 0,它这时在等 50 ms 的定时器,不在队列里。

小结

  • scheduler-priority / scheduler-deadline 只换出队:每 poll 一个任务就 take_all 一次、插进有序链表、取最优的一个,直到两边都空。
  • 先比优先级(大的先),再比截止时间(小的先),都相等时按插入顺序,于是同优先级任务变成轮转。
  • 实测高优先级任务连续 yield 会连跑到底;README 的「Fair」保证在这两个 feature 下不成立。这两个 feature 都不抢占,抢占要靠 InterruptExecutor。
  • 开 scheduler-deadline 后截止时间初值是 u64::MAX,任务池全部进 .data,实测 flash 多 2552 字节;注释写的是「set to zero」。
  • trace 的七个钩子由用户以无修饰符号定义,少一个就是链接错误;rtos-trace 的任务链表在同一任务第二次 spawn 后成环。

💬 关联讨论

发起讨论
加载中...