Rust in Everything
基于 dioxus 0.7.10

异步: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 上的四个控制方法:

方法做什么
cancelremove_future(self):从任务表删掉,Rc<LocalTask> 的最后一个引用一放,future 就 drop 了;挂起计数和 dirty_tasks 里的排队一起清掉
pause / resumeset_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

三点:

  1. .await 之后读的 b 也订阅了,写 b 让资源重跑。
  2. a = 3 时 a = 2 那个 future 还在睡,它被直接 drop(future(a=2) dropped 之前没有 ends),没有任何取消信号,下一行就是新 future 开始。
  3. 重跑期间 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,也就是等下一次完成。

📝NOTE
没有蠢问题

问: 依赖变了以后 res() 还是 Some(旧值),为什么不先清成 None?

答: value 只在包装任务完成时 set 一次,cb 只动 state。保留旧值让界面在重新加载时不闪成空白;要区分「旧值」和「新值」,看 state() 是不是 Pending。真想清空,Resource::clear 是 self.value.write().take(),自己调。

状态:Pending、Ready、Paused、Stopped

UseResourceState 四个值,谁写它:

状态写入处
Pendingcb 开头(首次启动、依赖变化、restart);resume
Ready包装任务完成,先于 value.set
Pausedpause,同时 task.pause()
Stoppedcancel,同时 task.cancel()

restart 和监视器做的是同一件事(取消、调 cb),只是不等依赖变化。resume 在 finished()(Ready 或 Stopped)时什么也不做。suspend 在 Ready 时返回值的映射 signal,其他三种状态下只要任务没暂停,就返回 Err(RenderError::Suspended(..))。

📝NOTE
冷知识:重启时回到 Pending,是 0.7.0 才有的

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(),不读,不订阅。

❗IMPORTANT
动脑筋:读完再写同一个 signal,会怎样?

第三种情况,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,什么时候停

建几个任务依赖跟踪重跑停
spawn1无不会返回的 Task::cancel;所属 scope 删除
spawn_forever1,挂在根无不会cancel;VirtualDom drop
use_future1无(闭包同步部分订阅的是组件)restartcancel;组件没走到这行时暂停;scope 删除
use_coroutine1(就是 use_future 的)无restart同上
use_resource2:包装任务和监视器rc,每次 poll 都在里面依赖变化、restartcancel、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 按消息类型取句柄。

💬 关联讨论

发起讨论
加载中...