调度策略
主线位置:定时器主线的最后一步「再次 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);
}
}
和默认版本比,有三处不同:
- 每 poll 一个任务就重新取一次新入队的任务。 默认版本一次
take_all处理一整批;这里每轮循环都take_all,新唤醒的任务立刻参与比较。 - 比较规则。 先比优先级(
u8,大的先),相等再比截止时间(u64的 tick,小的先)。SortedList是 cordyceps 的有序单链表,链表节点仍然是任务头里那个run_queue_item(它本来就是sorted_list::Links),所以不用额外内存。 - 一直循环到两边都空才返回。 所以
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-priority | AtomicU8,Relaxed |
set_deadline(ticks) / deadline() | scheduler-deadline | 绝对时刻,和 embassy-time 同一个 tick |
set_deadline_after(ticks) | scheduler-deadline + embassy-time-driver | now() + 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)和原因见状态与运行队列的动脑筋。
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() 里的值从来不会被读到。这是一个上游问题候选。
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 后成环。