异步:resource、future 与 spawn
主线位置:更新主线的一条支路。主线上用户闭包同步写 signal;这里写 signal 的是一个被调度器 poll 的任务,写完照常走
ReactiveContext::mark_dirty→SchedulerMsg→render_immediate。首屏主线上use_server_future也是建在use_resource之上的,服务端等的就是本页这种任务。
flowchart TB render1["组件渲染"] --> cb["cb 调用户闭包"] cb --> rc["在 rc 里建 future"] rc --> spawnres["spawn 包装任务"] spawnres --> poll1["每次 poll 都 rc.run_in"] poll1 --> sub["读 signal 订阅 rc"] poll1 --> done1["完成 写 value 和 state"] sub --> wr["signal 被写"] wr --> mark["rc.mark_dirty"] mark --> chan["changed 通道"] chan --> watch["监视任务醒来"] watch --> cancel1["cancel 旧任务"] cancel1 --> cb
这一页讲 dioxus-hooks 里的三个异步 hook 和 dioxus-core 的 spawn 一族:它们各自建了几个任务、订阅了什么、什么时候重跑、什么时候被丢掉。任务进了调度器以后怎么排队、和脏 scope 谁先,调度已经讲过;挂起和 suspend() 怎么让 SuspenseBoundary 显示 fallback,在 Suspense 与错误边界。代码在 use_resource.rs(559 行,大半是文档注释)、use_future.rs、use_coroutine.rs 和 core/src/tasks.rs。
实验在原生 VirtualDom 上跑:依赖 dioxus =0.7.10、dioxus-core =0.7.10、tokio,平台 M2 Max、macOS 27、rustc 1.99.0,debug 构建。从外面写 signal 用 dom.in_runtime(|| sig.set(..))(signal 句柄先存进线程局部变量);settle 是一个辅助函数,循环调用 wait_for_work 和 render_immediate,直到 30 或 50 毫秒内没有新工作(tokio::time::timeout),渲染器指令以 edit: 打印。
任务:scope 名下的一个 future
所有异步 hook 最后都落到 spawn。它把 future 装箱,插进 Runtime.tasks 这张 slotmap,返回一个 Task,也就是这张表里的键。任务属于 spawn 时 scope 栈顶的那个组件;waker 被调用时只往调度通道发一条 SchedulerMsg::TaskNotified(id);poll 时 handle_task_wakeup 把任务所属的 scope 压上栈;scope 删除时它的任务被直接 drop。这些在调度里有逐行的说明和实验,这里只补 Task 上的四个控制方法:
| 方法 | 做什么 |
|---|---|
cancel | remove_future(self):从任务表删掉,Rc<LocalTask> 的最后一个引用一放,future 就 drop 了;挂起计数和 dirty_tasks 里的排队一起清掉 |
pause / resume | set_active 改 active 标志;从暂停变回活动时补发一条 TaskNotified |
wake | 发一条 TaskNotified |
poll_now | 当场 handle_task_wakeup,不排队 |
暂停只是一个标志。handle_task_wakeup 发现 !task.active.get() 就直接返回 Pending,这次唤醒被吞掉;所以 resume 必须自己补发一次唤醒,否则任务永远不会再被 poll。
spawn 的两个兄弟:spawn_forever 把任务挂到 ScopeId::ROOT,活到 VirtualDom drop;spawn_isomorphic 的任务类型是 Isomorphic,服务端等 suspense 时也会 poll 它。事件回调返回一个 async 块时,SpawnIfAsync 先 now_or_never() 同步 poll 一次,没完成才 spawn,所以 .await 之前的代码和回调本身在同一个调用栈里(事件讲了这对 prevent_default 意味着什么)。
task 里读 signal 订阅谁
Signal::try_read_unchecked 只看 ReactiveContext::current(),也就是线程局部栈 CURRENT 的栈顶(Signal 讲了这个栈)。handle_task_wakeup 只压 scope 栈,不压 ReactiveContext。调度器 poll 任务的时候,render_immediate 和 poll_tasks 都不在任何组件的 run_scope 里,栈是空的。所以普通任务里的 read() 等于 peek(),谁也不订阅。
use_resource 是例外:它自己在每次 poll 外面包一层 rc.run_in。三种写法放在一起比:
fn app() -> Element {
let x = use_signal(|| 0);
let y = use_signal(|| 0);
let z = use_signal(|| 0);
// ...
log("App runs");
use_future(move || {
let x_now = x(); // sync part of the closure
log(format!(" use_future closure called, x={x_now}"));
async move {
let y_now = y(); // inside the async block, before any .await
log(format!(" use_future body polled, y={y_now}"));
tokio::time::sleep(Duration::from_millis(5)).await;
log(format!(" use_future after .await, z={}", z()));
}
});
use_hook(|| {
spawn(async move {
log(format!(" spawn body polled, y={}", y()));
tokio::time::sleep(Duration::from_millis(5)).await;
log(format!(" spawn after .await, z={}", z()));
})
});
rsx! { "hi" }
}
依次写 x、y、z:
App runs
use_future closure called, x=0
use_future body polled, y=0
spawn body polled, y=0
use_future after .await, z=0
spawn after .await, z=0
-- x = 1
App runs
-- y = 1
-- z = 1
-- end
use_future闭包的同步部分是在组件渲染时调用的,那时栈顶是组件自己的ReactiveContext,所以写x让组件重跑了一次;use_future本身没有重启(没有第二行closure called),因为use_hook只在第一次渲染时调它。- async 块里的
y、z不管在.await前后,都没有订阅任何东西。
换成 use_resource:
fn app() -> Element {
let a = use_signal(|| 1);
let b = use_signal(|| 10);
// ...
let res = use_resource(move || {
let a_now = a(); // read before the async block
async move {
let _g = Guard(a_now); // logs "future(a=..) dropped" in Drop
log(format!(" future(a={a_now}) starts"));
tokio::time::sleep(Duration::from_millis(20)).await;
let b_now = b(); // read after an .await
log(format!(" future(a={a_now}) ends, b={b_now}"));
a_now + b_now
}
});
log(format!("App runs: state={:?} value={:?}", res.state().cloned(), res.read().clone()));
rsx! { "{res.read().clone():?}" }
}
先等它跑完,再写只在 .await 之后读的 b,最后连写两次 a,中间隔 5 毫秒:
App runs: state=Pending value=None
-- settle
future(a=1) starts
future(a=1) ends, b=10
future(a=1) dropped
App runs: state=Ready value=Some(11)
edit: SetText { value: "Some(11)", id: ElementId(1) }
-- b = 20 (read only after .await)
future(a=1) starts
App runs: state=Pending value=Some(11)
future(a=1) ends, b=20
future(a=1) dropped
App runs: state=Ready value=Some(21)
edit: SetText { value: "Some(21)", id: ElementId(1) }
-- a = 2, 5ms later a = 3
future(a=2) starts
App runs: state=Pending value=Some(21)
future(a=2) dropped
future(a=3) starts
App runs: state=Pending value=Some(21)
future(a=3) ends, b=20
future(a=3) dropped
App runs: state=Ready value=Some(23)
edit: SetText { value: "Some(23)", id: ElementId(1) }
-- end
三点:
.await之后读的b也订阅了,写b让资源重跑。a = 3时a = 2那个 future 还在睡,它被直接 drop(future(a=2) dropped之前没有ends),没有任何取消信号,下一行就是新 future 开始。- 重跑期间
value还是上一次的Some(21),只有state变回Pending。组件可以继续显示旧值。
use_resource:两个任务、一个 ReactiveContext
use_resource 的全部状态是四个 hook:
let mut value = use_signal(|| None);
let mut state = use_signal(|| UseResourceState::Pending);
let (rc, changed) = use_hook(|| {
let (rc, changed) = ReactiveContext::new_with_origin(location);
(rc, Rc::new(Cell::new(Some(changed))))
});
let mut waker = use_waker::<()>();
rc 是资源自己的 ReactiveContext::new_with_origin:它的 update 闭包不发调度消息,而是往一个无界通道 changed 里塞一个 ();通道里已经有一个没取走的就不再塞(if !tx.is_empty() { return; }),所以一次写多个依赖只触发一次重启。
启动一次资源的代码是一个 use_callback:
let cb = use_callback(move |_| {
// Set the state to Pending when the task is restarted
state.set(UseResourceState::Pending);
// Create the user's task
let fut = rc.reset_and_run_in(&mut future);
// Spawn a wrapper task that polls the inner future and watches its dependencies
spawn(async move {
// Move the future here and pin it so we can poll it
let fut = fut;
pin_mut!(fut);
// Run each poll in the context of the reactive scope
// This ensures the scope is properly subscribed to the future's dependencies
let res = future::poll_fn(|cx| {
rc.run_in(|| {
tracing::trace_span!("polling resource", location = %location)
.in_scope(|| fut.poll_unpin(cx))
})
})
.await;
// Set the value and state
state.set(UseResourceState::Ready);
value.set(Some(res));
// Notify that the value has changed
waker.wake(());
})
});
rc.reset_and_run_in(&mut future):先清掉rc上一轮的全部订阅,再在rc里调用户闭包。闭包的同步部分(上例的a())在这里订阅。- 包装任务里每次 poll 都
rc.run_in:async 块的每一段,无论在第几个.await之后,都在rc里执行。上一节b能订阅就是因为这一行。订阅只在下次reset_and_run_in时清,所以一个 future 跑到一半新读到的 signal 会一直留到它重启。 use_callback每次渲染都替换里面的闭包(use_callback的inner.replace),重启时用的是最近一次渲染传进来的future闭包。普通 props 变了不会触发重启,但下次因别的原因重启时会用到新值;要让 props 也成为依赖,用use_reactive!(见 memo 与 effect)。
第二个任务是监视器,只在第一次渲染时 spawn:
let mut task = use_hook(|| Signal::new(cb(())));
use_hook(|| {
let mut changed = changed.take().unwrap();
spawn(async move {
loop {
// Wait for the dependencies to change
let _ = changed.next().await;
// Stop the old task
task.write().cancel();
// Start a new task
task.set(cb(()));
}
})
});
依赖变了 → rc.mark_dirty → changed 里有一个 () → 监视器被唤醒 → cancel 旧任务(drop future)→ cb 建新任务。两个任务都属于调用 use_resource 的组件,组件卸载时一起被 drop,不需要专门的清理代码。
返回的 Resource 是五个 Copy 句柄:value: Signal<Option<T>>、state: Signal<UseResourceState>、task: Signal<Task>、waker 和 callback。Readable 转发给 value,所以 res()、res.read() 读的是值并订阅 value;state() 是另一个 signal。Resource 还实现了 Future,await 它就是等 waker 下一次 wake,也就是等下一次完成。
问: 依赖变了以后 res() 还是 Some(旧值),为什么不先清成 None?
答: value 只在包装任务完成时 set 一次,cb 只动 state。保留旧值让界面在重新加载时不闪成空白;要区分「旧值」和「新值」,看 state() 是不是 Pending。真想清空,Resource::clear 是 self.value.write().take(),自己调。
状态:Pending、Ready、Paused、Stopped
UseResourceState 四个值,谁写它:
| 状态 | 写入处 |
|---|---|
Pending | cb 开头(首次启动、依赖变化、restart);resume |
Ready | 包装任务完成,先于 value.set |
Paused | pause,同时 task.pause() |
Stopped | cancel,同时 task.cancel() |
restart 和监视器做的是同一件事(取消、调 cb),只是不等依赖变化。resume 在 finished()(Ready 或 Stopped)时什么也不做。suspend 在 Ready 时返回值的映射 signal,其他三种状态下只要任务没暂停,就返回 Err(RenderError::Suspended(..))。
issue #2812 的描述是:「When using use_resource with reactive, dependencies, the state go from Pending to Ready, but when the future is restarted (when a dependency changes), the state remains Ready.」提的人也预见了副作用:「Note that the behaviour of SuspenseBoundary would change. It would display the fallback each time the future is restarted.」PR #3617(「fixed the bug when future restarts the state goes to ready instead of pending」)在 cb 开头加了上面那两行 // Set the state to Pending when the task is restarted / state.set(UseResourceState::Pending);。它首次出现在 v0.7.0-alpha.0,正式版 0.7.0。所以 0.6 里依赖变化后 suspend()? 仍然直接返回旧值;0.7 起它返回挂起,边界会重新显示 fallback。
逐个调用这些方法,每次之后组件打印 state、value 和 suspend() 的结果(组件只看 suspend() 的返回值,不 ?;输出里的 edit: 行已删去):
fn app() -> Element {
let res = use_resource(|| async {
RUNS.set(RUNS.get() + 1);
let run = RUNS.get();
log(format!(" future #{run} starts"));
tokio::time::sleep(Duration::from_millis(10)).await;
run
});
RES.set(Some(res));
let s = match res.suspend() {
Ok(v) => format!("Ok({})", v()),
Err(e) => format!("Err({e:?})"),
};
log(format!("App runs: state={:?} value={:?} suspend()={s}", res.state().cloned(), res.peek().clone()));
rsx! { "x" }
}
App runs: state=Pending value=None suspend()=Err(Suspended(SuspendedFuture { origin: ScopeId(3, "root"), task: DefaultKey(1v1) }))
future #1 starts
App runs: state=Ready value=Some(1) suspend()=Ok(1)
-- restart()
future #2 starts
App runs: state=Pending value=Some(1) suspend()=Err(Suspended(SuspendedFuture { origin: ScopeId(3, "root"), task: DefaultKey(1v3) }))
App runs: state=Ready value=Some(2) suspend()=Ok(2)
-- restart(); pause()
App runs: state=Paused value=Some(2) suspend()=Ok(2)
-- resume()
future #3 starts
App runs: state=Pending value=Some(2) suspend()=Err(Suspended(SuspendedFuture { origin: ScopeId(3, "root"), task: DefaultKey(1v5) }))
App runs: state=Ready value=Some(3) suspend()=Ok(3)
-- restart(); cancel()
App runs: state=Stopped value=Some(3) suspend()=Err(Suspended(SuspendedFuture { origin: ScopeId(3, "root"), task: DefaultKey(1v7) }))
-- end
restart(); pause():新任务在第一次 poll 之前就被暂停,future #3直到resume才开始;暂停期间suspend()返回旧值。- 最后一行:
Stopped时suspend()返回的SuspendedFuture指向1v7,而这个任务已经被cancel从任务表里删掉了。
cancel 之后 suspend()? 会 panic
把上面的组件改成真正 ? 挂起,等资源 Ready 后只调一次 cancel()(edit: 行已删去):
fn app() -> Element {
let res = use_resource(|| async {
tokio::time::sleep(Duration::from_millis(10)).await;
7
});
RES.set(Some(res));
let v = res.suspend()?;
log(format!("App runs: v={}", v()));
rsx! { "v={v}" }
}
App runs: v=7
html: v=7
-- cancel() after Ready
thread 'main' (1785960) panicked at ~/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/dioxus-core-0.7.10/src/scope_arena.rs:123:22:
Suspended on a task that no longer exists
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
cancel 把 state 设成 Stopped,组件因此重跑,suspend() 返回一个指向已删除任务的挂起错误,handle_element_return 去任务表里找它:.expect("Suspended on a task that no longer exists")。这个 panic 不在组件函数里,不会被 VProps::render 的 catch_unwind 接住,整个 VirtualDom 跟着崩;release 构建一样。restart(); cancel() 也是同样的结果。用了 suspend()? 的资源,要停就用 pause,或者干脆让组件卸载。
在资源里写 signal
包装任务完成时写 state 和 value,这是资源自己的写。用户的 future 也可以写别的 signal,结果取决于这个 signal 有没有被 rc 订阅:
fn app() -> Element {
let mut other = use_signal(|| 0);
let mut n = use_signal(|| 0);
let res = use_resource(move || async move {
POLLS.set(POLLS.get() + 1);
let p = POLLS.get();
if p <= 3 || p % 1000 == 0 { log(format!(" resource future starts (#{p})")); }
match CASE.get() {
0 => other += 1, // write a signal the resource never reads
1 => n += 1, // AddAssign on the resource's own signal, no read
_ => {
let v = n(); // read ...
n.set(v + 1); // ... then write the same signal
}
}
tokio::time::sleep(Duration::from_millis(1)).await;
});
let _ = res.state().cloned();
log(format!("App runs other={} n={}", other.peek(), n.peek()));
rsx! { "x" }
}
main 用命令行参数选情况,rebuild_in_place 后给 settle 100 毫秒,最后打印 future 一共开始了几次。前两种:
== case 0
App runs other=0 n=0
resource future starts (#1)
App runs other=1 n=0
resource future started 1 times
== case 1
App runs other=0 n=0
resource future starts (#1)
App runs other=0 n=1
resource future started 1 times
写一个没读过的 signal,资源不重启。n += 1 也一样:Signal 讲过,+= 走的是 write(),不读,不订阅。
第三种情况,future 里先 n() 再 n.set(v + 1)。外面有 100 毫秒的 tokio::time::timeout,程序用 timeout 5 跑。5 秒内 App runs 会打印几次?程序能自己结束吗?
use_future:只跑一次,不看依赖
use_future 比 use_resource 少了 ReactiveContext、监视任务和 value:
let mut state = use_signal(|| UseFutureState::Pending);
let callback = use_callback(move |_| {
let fut = future();
dioxus_core::spawn(async move {
state.set(UseFutureState::Pending);
fut.await;
state.set(UseFutureState::Ready);
})
});
// Create the task inside a CopyValue so we can reset it in-place later
let task = use_hook(|| CopyValue::new(callback(())));
future() 在 use_hook 的初始化里调,也就是组件第一次渲染时,此时栈顶是组件的 ReactiveContext。这就是前面实验里写 x 会让组件重跑的原因:订阅的是组件,不是 future。之后再也没有人调 callback,除非用户自己 restart()。
它多做了一件 use_resource 不做的事:
use_hook_did_run(move |did_run| match did_run {
true => task.peek().resume(),
false => task.peek().pause(),
});
use_hook_did_run 在渲染前把标志清成 false,执行到这个 hook 时设成 true,渲染后检查。组件提前 return(比如上面某个 ? 挂起了)、没走到 use_future 这一行时,任务被暂停,下次走到再恢复。源码注释说得直白:「We don't want futures to be running if the component early returns.」
use_coroutine:通道加 use_future 加 context
use_coroutine 一共 18 行:
let mut tx_copy_value = use_hook(|| CopyValue::new(None));
let future = use_future(move || {
let (tx, rx) = futures_channel::mpsc::unbounded();
tx_copy_value.set(Some(tx));
init(rx)
});
use_context_provider(|| Coroutine {
tx: tx_copy_value,
future,
})
每次(重新)启动都新开一条无界通道,发送端放进一个 CopyValue,Coroutine 句柄只拿着这个 CopyValue,send 时现取。它还把句柄 provide_context 到当前 scope,子孙组件用 use_coroutine_handle 按消息类型取出来(context 怎么查找见下一页)。
fn app() -> Element {
let mut total = use_signal(|| 0usize);
let co = use_coroutine(move |mut rx: UnboundedReceiver<String>| async move {
log(" coroutine starts");
while let Some(msg) = rx.next().await {
log(format!(" coroutine got {msg:?}"));
total += msg.len();
}
});
log(format!("App runs: total={total} task={:?}", co.task()));
rsx! { Child {} }
}
#[component]
fn Child() -> Element {
let co = use_coroutine_handle::<String>();
CHILD_TX.set(Some(co));
log("Child runs");
rsx! { "child" }
}
在任务第一次被 poll 之前从 Child 拿到的句柄发两条消息,再 restart(),再发一条:
App runs: total=0 task=Task { id: DefaultKey(1v1), unsend: PhantomData<*const ()> }
Child runs
-- send two messages before the first poll
coroutine starts
coroutine got "hello"
coroutine got "dioxus"
App runs: total=11 task=Task { id: DefaultKey(1v1), unsend: PhantomData<*const ()> }
-- restart()
coroutine starts
-- send after restart
coroutine got "again"
App runs: total=16 task=Task { id: DefaultKey(1v3), unsend: PhantomData<*const ()> }
通道在第一次渲染时就建好了,所以第一次 poll 之前发的消息不会丢。restart 之后 Child 手里还是同一个句柄,send 读到的是新通道的发送端;旧通道里没处理完的消息随旧 future 一起被 drop。
谁在 poll,什么时候停
| 建几个任务 | 依赖跟踪 | 重跑 | 停 | |
|---|---|---|---|---|
spawn | 1 | 无 | 不会 | 返回的 Task::cancel;所属 scope 删除 |
spawn_forever | 1,挂在根 | 无 | 不会 | cancel;VirtualDom drop |
use_future | 1 | 无(闭包同步部分订阅的是组件) | restart | cancel;组件没走到这行时暂停;scope 删除 |
use_coroutine | 1(就是 use_future 的) | 无 | restart | 同上 |
use_resource | 2:包装任务和监视器 | rc,每次 poll 都在里面 | 依赖变化、restart | cancel、pause;scope 删除 |
poll 它们的都是调度器:浏览器里是 dioxus-web 的主循环 wait_for_work + render_immediate(一次点击),服务端是 wait_for_suspense,后者只 poll 挂起类型和 Isomorphic 类型的任务(Suspense 与错误边界)。没有线程池、没有 Send 要求:Task 带 PhantomData<*const ()>,future 只要 'static。
动脑筋答案
== case 2
App runs other=0 n=0
resource future starts (#1)
resource future starts (#2)
resource future starts (#3)
...
resource future starts (#305000)
resource future starts (#306000)
App runs 只有 rebuild 那一次;程序没有自己结束,是 timeout 5 在 5 秒后杀掉的(退出码 124),这 5 秒里 future 开始了 30 万次以上。100 毫秒的 tokio::time::timeout 也没起作用,因为控制权一直没有回到 tokio。
过程是:future 读 n 订阅了 rc,写 n 让 rc.mark_dirty 往 changed 塞一个 (),监视任务被唤醒,取消当前任务、调 cb 建新任务;新任务的第一次 poll 又读、又写。cb 里的 state.set(Pending) 确实标脏了组件,但组件、包装任务、监视任务都在同一个 scope,pop_work 遇到相同的 ScopeOrder 时任务优先(调度的动脑筋),于是 render_immediate 里永远有一个被唤醒的任务排在组件前面,循环不返回,组件一次也轮不到。浏览器里这就是页面卡死。要在资源里改它依赖的 signal,用 peek() 读,或者把写挪到资源外面。
小结
- 所有异步 hook 都是
spawn出来的Task,属于调用它的组件,scope 删除时 future 被 drop;取消就是 drop,没有取消信号。pause只是一个标志,被吞掉的唤醒由resume补发。 - 调度器 poll 任务时不压
ReactiveContext,普通任务和use_future的 async 块里read()不订阅任何东西;use_future闭包的同步部分订阅的是组件。 use_resource有自己的ReactiveContext,建 future 时reset_and_run_in、每次 poll 都run_in,所以.await之后读的 signal 也是依赖;依赖一变,监视任务 drop 旧 future、建新的,state回到Pending(#3617,0.7.0 起),value保留旧值。- 资源
cancel()后组件suspend()?会 panic「Suspended on a task that no longer exists」;资源里读了又写同一个 signal,会在render_immediate里无限重启。 use_coroutine= 一条每次重启都新建的通道 +use_future+provide_context;子孙用use_coroutine_handle按消息类型取句柄。