Rust in Everything
基于 dioxus 0.7.10

调度:scope、任务与 effect

主线位置:更新主线上「ReactiveContext::mark_dirty → SchedulerMsg → wait_for_work 返回 → render_immediate:按高度从根往下 pop_work → … → 最后跑 effect」这一段。signal 怎么找到要通知的 ReactiveContext 在 signal;本页从消息发出之后讲起,到 effect 跑完为止。

flowchart TB
  write1["signal 写入"] --> rc["ReactiveContext 标脏"]
  rc --> imm["Immediate scope"]
  waker["任务的 waker"] --> tn["TaskNotified task"]
  fin["finish_render"] --> eq["EffectQueued"]
  imm --> chan["无界通道"]
  tn --> chan
  eq --> chan
  chan --> qe["queue_events 收消息"]
  qe --> ds["dirty_scopes"]
  qe --> dt["dirty_tasks"]
  ds --> pw["pop_work 按高度取"]
  dt --> pw
  pw --> rerun["重跑 scope 或 poll 任务"]
  rerun --> idle["没有脏 scope 和任务"]
  idle --> eff["pop_effect 跑 effect"]

VirtualDom 本身的字段、四个入口各自做什么,在 VirtualDom 与 Runtime;本页讲这些入口里的排队和取活规则,代码主要在 scheduler.rs(289 行)、tasks.rs、effect.rs 和 virtual_dom.rs 的后半。

实验在原生 VirtualDom 上跑(依赖 dioxus =0.7.10、dioxus-core =0.7.10、dioxus-html =0.7.10,平台 M2 Max、macOS 27、rustc 1.99.0,debug 构建)。点击用 dom.runtime().handle_event("click", event, ElementId(n)) 发,构造方式见 事件;[trace] 开头的行是一个只打印 dioxus_core::virtual_dom 模块 tracing 事件的自定义 Layer 输出的,edit: 开头的行是一个逐条打印的 WriteMutations 实现。

三个目标,三类工作

scheduler.rs 开头的模块文档先列出要避免的三种情况,每条附了 issue:

目标文档里的例子相关
不在工作可能已经被丢掉之后还去执行它父组件判断 signal.read().is_some() 才渲染子组件,子组件里 signal.read().unwrap();signal 变成 None 后如果先跑子组件就 panic#1993
不在 DOM 更新之前跑 effecteffect 里用 document::eval 按 id 找元素,id 还没写进 DOM 就会找不到#2307
尽量不让人看到过时的状态用 use_memo 算出的两倍值,在组件中途改了 id 之后读,也应该等于新 id 的两倍#1935

接着把排队的工作分成三类,按优先级从高到低:

//! 1. Dirty Scopes:
//!    Description: When a scope is marked dirty, a rerun of the scope will be scheduled. This will cause the scope to rerun and update the DOM if any changes are detected during the diffing phase.
//!    Priority: These are the highest priority tasks. Dirty scopes will be rerun in order from the scope closest to the root to the scope furthest from the root. We follow this order to ensure that if a higher component reruns and drops a lower component, the lower component will not be run after it should be dropped.
//!
//! 2. Tasks:
//!    Description: Futures spawned in the dioxus runtime each have an unique task id. When the waker for that future is called, the task is rerun.
//!    Priority: These are the second highest priority tasks. They are run after all other dirty scopes have been resolved because those dirty scopes may cause children (and the tasks those children own) to drop which should cancel the futures.
//!
//! 3. Effects:
//!    Description: Effects should always run after all changes to the DOM have been applied.
//!    Priority: These are the lowest priority tasks in the scheduler. They are run after all other dirty scopes and futures have been resolved. Other tasks may cause components to rerun, which would update the DOM. These effects should only run after the DOM has been updated.

文档说的「先 scope 后任务」要按代码细读:在 render_immediate 里,scope 和任务是按所在 scope 的高度交错取的,并不是先清空所有脏 scope 再碰任务。下面逐层看。

一条通道,四种消息

VirtualDom 创建时开一条 futures_channel::mpsc::unbounded,发送端放进 Runtime(sender),接收端留在 VirtualDom(rx)。所有「有活要干」的通知都是这条通道上的 SchedulerMsg:

pub(crate) enum SchedulerMsg {
    /// All components have been marked as dirty, requiring a full render
    #[allow(unused)]
    AllDirty,

    /// Immediate updates from Components that mark them as dirty
    Immediate(ScopeId),

    /// A task has woken and needs to be progressed
    TaskNotified(slotmap::DefaultKey),

    /// An effect has been queued to run after the next render
    EffectQueued,
}
消息谁发收到后
Immediate(scope)组件的 ReactiveContext:new_for_scope 给每个 scope 建的 context,update 闭包就是发这条消息;needs_update / schedule_update;suspense 边界增减挂起任务时mark_dirty(id):查出高度,插进 dirty_scopes
TaskNotified(key)任务的 waker(LocalTaskHandle 的 ArcWake::wake_by_ref);新任务插进任务表后立刻发一条,保证它被 poll 第一次;Task::wake;Task::resumemark_task_dirty:查出任务所属 scope 的高度,插进 dirty_tasks
EffectQueuedRuntime::finish_render:render_immediate 结束时还有待跑的 effect什么也不做,只为让 wait_for_work 醒来
AllDirtydebug 构建里 register_subsecond_handler 注册的热补丁回调mark_all_dirty:所有 scope 入队

接收在两个地方,处理逻辑完全一样:wait_for_event await 一条,queue_events 用 try_recv 把已经到的全部取完。发消息的一方不管对方在不在跑、也不碰 VirtualDom:signal 可能在事件回调里写,可能在任务里写,也可能在渲染过程中写,只要拿得到 Runtime 就能发。

TaskNotified 不直接 poll 任务,wait_for_event 里的注释写了原因:「The task may be marked dirty at the same time as the scope that owns the task is dropped.」先排队,等前面的 scope 跑完、确认任务还在再说。

ScopeOrder 和三个有序集合

排队的单位是 ScopeOrder:

pub struct ScopeOrder {
    pub(crate) height: u32,
    pub(crate) id: ScopeId,
}
impl Ord for ScopeOrder {
    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
        self.height.cmp(&other.height).then(self.id.cmp(&other.id))
    }
}

先比高度,再比 id;PartialEq 和 Hash 却只看 id。height 是 scope 在组件树里的深度,new_scope 里取父 scope 的高度加一。用户写的根组件是 ScopeId(3),前面是根包装、根 suspense 边界、根错误边界(见 Scope 与 hooks)。用 dom.runtime().height(id) 打印一个 app → Child 的应用:

ScopeId(0) height=0 parent=None
ScopeId(1) height=1 parent=Some(ScopeId(0))
ScopeId(2) height=2 parent=Some(ScopeId(1))
ScopeId(3) height=3 parent=Some(ScopeId(2))
ScopeId(4) height=4 parent=Some(ScopeId(3))

三类工作各有一个按 ScopeOrder 排序的 BTreeSet:

集合放在哪元素
dirty_scopes: BTreeSet<ScopeOrder>VirtualDom一个 scope 一项,重复插入就是一项
dirty_tasks: RefCell<BTreeSet<DirtyTasks>>Runtime一个 scope 一项,项里是一个 VecDeque<Task>;DirtyTasks::queue_task 先 contains 去重
pending_effects: RefCell<BTreeSet<Effect>>Runtime一个 scope 一项,项里是一个 VecDeque<Box<dyn FnOnce()>>

BTreeSet::first() 永远是离根最近的那一项,这就是「从根往下」的全部实现。

实验:一次事件里写三次 signal

fn app() -> Element {
  let mut a = use_signal(|| 0);
  let mut b = use_signal(|| 0);
  log(format!("App runs: a={a} b={b}"));
  rsx! {
    button {
      onclick: move |_| {
        a += 1;
        a += 1;
        b += 1;
        log("handler returns");
      },
      "a={a} b={b}"
    }
  }
}

rebuild_in_place 之后点一下,再依次调 wait_for_work、render_immediate:

App runs: a=0 b=0
-- handle_event(click)
handler returns
-- wait_for_work
  [trace] Marking scope ScopeId(3) as dirty
  [trace] Marking scope ScopeId(3) as dirty
  [trace] Marking scope ScopeId(3) as dirty
-- render_immediate
App runs: a=2 b=1
  edit: SetText "a=2 b=1" id=2
-- done

三次写各发了一条 Immediate(ScopeId(3))。回调返回时消息还在通道里,wait_for_work 里的 queue_events 把它们一次取完,mark_dirty 被调三次,插的是同一个 ScopeOrder,集合里只有一项,组件只重跑一次,看到的是三次写之后的值。run_scope 结束时把这一项从 dirty_scopes 删掉。

pop_work:scope 和任务交错着取

render_immediate 的主体是一个 while let Some(work) = self.pop_work() 循环:Work::RerunScope 就 run_and_diff_scope,Work::PollTask 就 poll 任务并 queue_events。pop_work 先丢掉 dirty_tasks 开头已经空了的项,再比较两个集合的第一项:

        match (dirty_scope, dirty_task) {
            (Some(scope), Some(task)) => {
                let tasks_order = task.borrow();
                match scope.cmp(tasks_order) {
                    std::cmp::Ordering::Less => {
                        let scope = self.dirty_scopes.pop_first().unwrap();
                        Some(Work::RerunScope(scope))
                    }
                    std::cmp::Ordering::Equal | std::cmp::Ordering::Greater => {
                        Some(Work::PollTask(self.pop_task().unwrap()))
                    }
                }
            }
            (Some(_), None) => {
                let scope = self.dirty_scopes.pop_first().unwrap();
                Some(Work::RerunScope(scope))
            }
            (None, Some(_)) => Some(Work::PollTask(self.pop_task().unwrap())),
            (None, None) => None,
        }

只有脏 scope 严格更靠近根时才先重跑 scope;任务所属的 scope 更靠根,或者就是同一个 scope,先 poll 任务。目标一要求的「上层先跑、下层被删就不再跑」对任务同样成立:任务属于哪个 scope,就排在那个 scope 的位置上。

验证一下。app 和子组件 Child 各 spawn 一个永远 Pending、每次被 poll 就打一行的任务,句柄存进线程局部变量;点击回调按情况标脏一个 scope、唤醒(Task::wake)一个任务,然后直接 render_immediate:

fn app() -> Element {
  let mut p = use_signal(|| 0);
  let c = use_signal(|| 0);
  use_hook(|| PARENT_TASK.set(Some(spawn(forever("Parent task")))));
  log(format!("  Parent runs p={p}"));
  rsx! {
    button {
      onclick: move |_| {
        let mut c = c;
        match CASE.get() {
          1 => { c += 1; PARENT_TASK.get().unwrap().wake(); }
          2 => { p += 1; CHILD_TASK.get().unwrap().wake(); }
          _ => { c += 1; CHILD_TASK.get().unwrap().wake(); }
        }
      },
      "p={p}"
    }
    Child { c }
  }
}

#[component]
fn Child(c: Signal<i32>) -> Element {
  use_hook(|| CHILD_TASK.set(Some(spawn(forever("Child task")))));
  log(format!("  Child runs c={c}"));
  rsx! { "c={c}" }
}

前两种情况:

  Parent runs p=0
  Child runs c=0
-- first render_immediate (polls the two new tasks)
  Parent task polled
  Child task polled
-- case 1: Child dirty + Parent task woken
  Parent task polled
  Child runs c=1
  edit: SetText "c=1" id=3
-- case 2: Parent dirty + Child task woken
  Parent runs p=1
  edit: SetText "p=1" id=2
  Child task polled

情况 1:Child(高度 4)脏了,app(高度 3)的任务被唤醒,先 poll 任务。情况 2 反过来,先重跑 app。两个新任务第一次被 poll 的顺序也是按高度,和 spawn 的先后无关。

❗IMPORTANT
动脑筋:同一个 scope 既脏了、它的任务又被唤醒,谁先?

还是上面的程序,情况 3:回调里写 Child 读的 signal c,同时唤醒 Child 自己 spawn 的任务,然后 render_immediate。先打印的是 Child task polled 还是 Child runs c=2?

父子都脏:只从上往下跑一遍

两个场景:A 是子组件先被标脏、父组件后被标脏,而且父组件重跑会改子组件的 props;B 是 scheduler.rs 文档里那个「父组件把子组件删掉」的例子。

// Case A: child dirty first, parent dirty second; child props also change
fn app_a() -> Element {
  let mut p = use_signal(|| 0);
  let c = use_signal(|| 0);
  log(format!("Parent runs p={p}"));
  rsx! {
    button {
      onclick: move |_| {
        let mut c = c;
        c += 1; // child's own signal first
        p += 1; // then parent's
      },
      "p={p}"
    }
    Child { p: p(), c }
  }
}

#[component]
fn Child(p: i32, c: Signal<i32>) -> Element {
  log(format!("Child runs p={p} c={c}"));
  rsx! { "child {p} {c}" }
}

// Case B: the scheduler.rs example — parent drops the child that would panic
fn app_b() -> Element {
  let mut s: Signal<Option<i32>> = use_signal(|| Some(1));
  log(format!("Parent runs s={:?}", s()));
  rsx! {
    button { onclick: move |_| s.set(None), "clear" }
    if s.read().is_some() {
      Child2 { s }
    }
  }
}

#[component]
fn Child2(s: Signal<Option<i32>>) -> Element {
  let v = s.read().unwrap();
  log(format!("Child2 runs v={v}"));
  rsx! { "{v}" }
}
== case A
Parent runs p=0
Child runs p=0 c=0
-- click
  [trace] Marking scope ScopeId(4) as dirty
  [trace] Marking scope ScopeId(3) as dirty
-- render_immediate
Parent runs p=1
  edit: SetText "p=1" id=2
Child runs p=1 c=1
  edit: SetText "child 1 1" id=3
== case B
Parent runs s=Some(1)
Child2 runs v=1
-- click
  [trace] Marking scope ScopeId(4) as dirty
  [trace] Marking scope ScopeId(3) as dirty
-- render_immediate
Parent runs s=None
  edit: CreatePlaceholder id=3
  edit: ReplaceWith id=2 m=1
  • A:标脏的顺序是先子后父,跑的顺序是先父后子,子组件只跑一次。父组件 diff 到 Child 时 props 变了,当场重跑它(diff 讲组件节点怎么比),run_scope 结束时把 ScopeId(4) 从 dirty_scopes 删掉,循环里就不会再取到它。
  • B:Child2 也订阅了 s,也被标脏了,但它一次都没再跑,unwrap 没有机会 panic。父组件重跑后 if 变成占位,Child2 被卸载,drop_scope 里有一句 self.dirty_scopes.remove(&ScopeOrder::new(height, id))。

wait_for_work 与 effect

render_immediate 只管脏 scope 和排在它们之前的任务。剩下的任务和全部 effect 在 wait_for_work 里跑:

    pub async fn wait_for_work(&mut self) {
        loop {
            // Process all events - Scopes are marked dirty, etc
            // Sometimes when wakers fire we get a slew of updates at once, so its important that we drain this completely
            self.process_events();

            // Now that we have collected all queued work, we should check if we have any dirty scopes. If there are not, then we can poll any queued futures
            if self.has_dirty_scopes() {
                return;
            }

            // Make sure we set the runtime since we're running user code
            let _runtime = RuntimeGuard::new(self.runtime.clone());

            // There isn't any more work we can do synchronously. Wait for any new work to be ready
            self.wait_for_event().await;
        }
    }

process_events 先 queue_events,有脏 scope 就返回;没有才进 poll_tasks:

        while !self.runtime.dirty_tasks.borrow().is_empty()
            || !self.runtime.pending_effects.borrow().is_empty()
        {
            // Next, run any queued tasks
            // We choose not to poll the deadline since we complete pretty quickly anyways
            while let Some(task) = self.pop_task() {
                let _ = self.runtime.handle_task_wakeup(task);

                // Running that task, may mark a scope higher up as dirty. If it does, return from the function early
                self.queue_events();
                if self.has_dirty_scopes() {
                    return;
                }
            }

            // At this point, we have finished running all tasks that are pending and we haven't found any scopes to rerun. This means it is safe to run our lowest priority work: effects
            while let Some(effect) = self.pop_effect() {
                effect.run();
                // Check if any new scopes are queued for rerun
                self.queue_events();
                if self.has_dirty_scopes() {
                    return;
                }
            }
        }

任务和 effect 跑完一个就看一眼通道,只要出现脏 scope 立刻返回,把控制权交还给渲染器去 render_immediate。所以 wait_for_work 返回就意味着「有 scope 要重跑」;任务、effect 都是在等待的过程中顺手跑掉的。web 的主循环就是这两步的循环:select! 等 wait_for_work(同时等水合数据和热重载消息),然后 render_immediate(&mut websys_dom),再 flush_edits() 把攒下的指令一次交给 JS,见 WebsysDom 与 interpreter。

effect 怎么进 pending_effects:queue_effect 先看 scope 的 ScopeStatus。组件第一次运行时 scope 还是 Unmounted,effect 先存在 scope 自己的 effects_queued 里;create_scope 真正把节点写出去之后调 Scope::mount,才把它们转进 pending_effects。已挂载的 scope 直接进 pending_effects。use_effect 用的就是这个 queue_effect:第一次运行排一次,之后它读过的 signal 变了,内部任务被唤醒,再排一次(细节在 memo 与 effect)。

📝NOTE
冷知识:effect 到 0.5.5 才成为调度器里单独的一类工作

在 issue #2307(「UseEffect only runs on render」)里,用户描述当时的实现:「Currently use_effect uses wait_for_next_render().await; which means that it only runs on renders.」一个 signal 在任务里被更新、却没有触发重渲染时,effect 不会跑。PR #2370(「Fix effects triggered from async tasks; improve work scheduling docs」)的说明是「This PR introduces effects as a new task time in the virtual dom.」它把 dirty_scope.rs 改名成 scheduler.rs、写下本页开头那段三个目标和三类工作的模块文档,新增 effect.rs 和 queue_effect,并让 effect「after all tasks are polled if there are no components rerendered」时运行(提交说明里的一条)。仓库里第一个包含它的 tag 是 v0.5.5。之后 PR #3201(「Don't run effects from dead recycled scopes」,首次出现在 v0.6.0-alpha.5,正式版 0.6.0)补上了下一节 remove_scope 里删掉该 scope 排队 effect 的那几行:「removing effects queued for the scope as the scope itself is removed」。

实验:effect 在指令之后

fn app() -> Element {
  let mut n = use_signal(|| 0);
  log(format!("App runs n={n}"));
  use_effect(move || log(format!("effect runs, sees n={n}")));
  rsx! {
    button { onclick: move |_| n += 1, "n={n}" }
  }
}

wait_for_work 在没有脏 scope 时不会返回,实验里每次给它 50 毫秒(tokio::time::timeout),超时就说明「没有活了」:

-- rebuild
App runs n=0
  edit: LoadTemplate index=0 id=1
  edit: NewEventListener click id=1
  edit: CreateTextNode "n=0" id=2
  edit: ReplacePlaceholder [0] m=1
  edit: AppendChildren id=0 m=1
effect runs, sees n=0
-- wait_for_work: timed out (nothing dirty)
-- click
-- wait_for_work: returned (dirty scope)
-- render_immediate
App runs n=1
  edit: SetText "n=1" id=2
-- render_immediate returned
effect runs, sees n=1
-- wait_for_work: timed out (nothing dirty)

两次 effect 都在产生指令的那次调用(rebuild、render_immediate)返回之后,在下一次 wait_for_work 里才跑。渲染器正好在这两步之间应用指令。

📝NOTE
没有蠢问题

问: 既然 effect 要等 DOM 改完,render_immediate 为什么不在循环结束时顺手把 effect 跑了?

答: render_immediate 返回的时候 DOM 还没改。它只是把指令写进渲染器传进来的 to,web 渲染器在它返回之后才 flush_edits(),把整批指令交给 JS 执行。core 不知道渲染器什么时候应用完,只能等渲染器下一次调用它:finish_render 发一条 EffectQueued,什么也不携带,作用只是让下一次 wait_for_work 的 rx.next() 立刻就绪,进到 poll_tasks 里把 effect 跑掉。

任务属于哪个 scope

全局的 spawn 是 Runtime::with_current_scope(|cx| cx.spawn(fut)):取 scope 栈的栈顶。Scope::spawn 调 Runtime::spawn(self.id, fut),再把返回的 Task 记进自己的 spawned_tasks。任务本体是 LocalTask:

pub(crate) struct LocalTask {
    scope: ScopeId,
    parent: Option<Task>,
    task: RefCell<Pin<Box<dyn Future<Output = ()> + 'static>>>,
    waker: Waker,
    ty: RefCell<TaskType>,
    active: Cell<bool>,
}

Task 只是 slotmap 里的一个键(另带一个 PhantomData<*const ()> 让它 !Send),任务表 tasks 在 Runtime 上。waker 是 futures_util::task::waker(Arc<LocalTaskHandle>),被唤醒时只做一件事:往通道发 TaskNotified(id)。poll 的时候 handle_task_wakeup 把 task.scope 压上 scope 栈,所以任务里再 spawn、读 context、throw_error,都按它所属的 scope 算。ty 是 ClientOnly(spawn)、Isomorphic(spawn_isomorphic)或 Suspended,决定挂起期间跑不跑,见 Suspense 与错误边界。

「当前 scope」由谁决定,有几种情况:

  • 组件函数里:正在运行的组件。
  • 事件回调里:ListenerCallback::call 用 with_scope_on_stack(self.origin, ..) 调闭包,origin 是创建这个闭包时的 scope。回调里 spawn 出来的任务属于写这个 onclick 的组件,不属于被点的元素。
  • 任务里:任务所属的 scope。
  • spawn_forever:固定挂到 ScopeId::ROOT,只有整个 VirtualDom drop 时才会被丢掉。

scope 被删时,Runtime::remove_scope 在这个 scope 的上下文里依次:remove_task 掉 spawned_tasks 里的每个任务(从任务表移除、drop future,挂起计数和 dirty_tasks 里的排队一并清掉),删掉 pending_effects 里这个 scope 的项,倒序 drop hooks,最后丢掉 context。这里没有「取消信号」,取消就是 drop 掉 future。

实验:子组件卸载,任务跟着被 drop

struct Guard(&'static str);
impl Drop for Guard {
  fn drop(&mut self) {
    log(format!("drop future: {}", self.0));
  }
}

#[component]
fn Child() -> Element {
  use_hook(|| {
    let g = Guard("spawn in Child");
    spawn(async move {
      let _g = g;
      log("task spawned in Child polled");
      std::future::pending::<()>().await;
    });
    let g = Guard("spawn_forever in Child");
    dioxus_core::spawn_forever(async move {
      let _g = g;
      log("task spawn_forever polled");
      std::future::pending::<()>().await;
    });
  });
  rsx! { "child" }
}

app 里 if show() { Child {} },点按钮把 show 设成 false:

task spawn_forever polled
task spawned in Child polled
-- click hide
-- render_immediate
  edit: CreatePlaceholder id=3
  edit: ReplaceWith id=2 m=1
drop future: spawn in Child
-- drop VirtualDom
drop future: spawn_forever in Child
-- end

两个任务都是在 Child 里创建的,spawn_forever 那个却先被 poll:它挂在高度 0 的根 scope 上,在 dirty_tasks 里排第一。Child 卸载时只有它自己的任务被 drop;spawn_forever 的任务活到 VirtualDom 被 drop。

动脑筋答案

-- case 3: Child dirty + Child task woken
  Child task polled
  Child runs c=2
  edit: SetText "c=2" id=3

先 poll 任务。两项的 ScopeOrder 都是 (4, ScopeId(4)),scope.cmp(tasks_order) 得到 Equal,落进 Equal | Greater 分支。这样排的效果是:任务在组件重跑之前推进一步,如果任务里又写了 signal,组件重跑时一并看到;反过来,如果任务读到的状态要等组件重跑才更新,它看到的就是旧的。

小结

  • 所有通知都是 SchedulerMsg,走 Runtime.sender → VirtualDom.rx 一条无界通道:Immediate 标脏 scope,TaskNotified 排任务,EffectQueued 只为唤醒,AllDirty 给热补丁用。
  • 脏 scope、被唤醒的任务、待跑的 effect 各在一个按 ScopeOrder(先高度后 id)排序的 BTreeSet 里,按 scope 合并去重:一次事件写三次 signal,组件只跑一次。
  • render_immediate 用 pop_work 交错取 scope 和任务:严格更靠根的 scope 先跑,同一 scope 任务先 poll。父组件先跑,被它删掉的子组件从队列里移除、不会再跑。
  • 剩下的任务和 effect 在 wait_for_work 里、没有脏 scope 时才跑,一出现脏 scope 就返回;effect 因此总在渲染器应用完指令之后。effect 成为单独一类工作始于 #2370(v0.5.5)。
  • 任务属于 spawn 时 scope 栈顶的 scope(事件回调里是定义回调的组件),scope 删除时 future 被直接 drop;spawn_forever 挂在根上。

💬 关联讨论

发起讨论
加载中...