Must be called from inside a Dioxus runtime.
主线位置:三条主线的公共前提。hook、
spawn、GlobalSignal都通过Runtime::current()找到自己属于哪个VirtualDom,「当前 runtime」是一个线程局部的栈,只有VirtualDom的入口、事件派发和回调调用期间才有东西。栈和RuntimeGuard见 VirtualDom 与 Runtime 的「当前 runtime:线程局部的栈」。
flowchart TB
code["use_* / spawn / GlobalSignal"] --> cur["Runtime::current"]
cur --> stack{"线程局部栈有 runtime?"}
stack -->|有| ok["正常"]
stack -->|空| pn["panic:Must be called ..."]
pn --> where{"在哪个线程 / 任务里"}
where -->|"tokio 任务"| swallow["被 JoinHandle 吞掉"]
where -->|"普通线程"| thr["那个线程退出"]
where -->|"server function"| s500["HTTP 500"]
本页实验在 M2 Max、macOS 27、rustc 1.99.0 上运行,依赖 dioxus = "=0.7.10",原生 VirtualDom,点击用 Runtime::handle_event 派发。
现象
fn main() { use_signal(|| 0); } 这种直接在组件外调 hook 的写法,VirtualDom 与 Runtime 已经跑过。实际代码里更常见的是:调用发生在组件里写的闭包中,但闭包运行时已经不在 dioxus 的调用栈上了。pit1-exp/src/bin/outside_runtime.rs:
static COUNT: GlobalSignal<i32> = Signal::global(|| 0);
fn app() -> Element {
let mut synced = use_signal_sync(|| 0);
rsx! {
// 1: a tokio task writes a global signal
button { onclick: move |_| { tokio::spawn(async { *COUNT.write() += 1; }); }, "tokio" }
// 2: a plain thread calls dioxus' spawn
button { onclick: move |_| { std::thread::spawn(|| { spawn(async { *COUNT.write() += 1; }); }); }, "thread" }
// 3: dioxus' spawn
button { onclick: move |_| { spawn(async { *COUNT.write() += 1; }); }, "dioxus spawn" }
// 4: a plain thread writes a SyncSignal
button { onclick: move |_| { std::thread::spawn(move || synced.set(42)).join().unwrap(); }, "sync" }
"COUNT={COUNT} synced={synced}"
}
}
依次点四个按钮,每次之后等 50 毫秒、把 VirtualDom 跑到空闲,打印最后那段文字。debug 第一次 panic(帮助文字末尾是一段 rust 示例代码,这里用 ... 省略):
--- click tokio
thread 'main' (2165611) panicked at ~/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/dioxus-core-0.7.10/src/runtime.rs:100:17:
Must be called from inside a Dioxus runtime.
Help: Some APIs in dioxus require a global runtime to be present.
If you are calling one of these APIs from outside of a dioxus runtime
(typically in a web-sys closure or dynamic library), you will need to
grab the runtime from a scope that has it and then move it into your
new scope with a runtime guard.
For example, if you are trying to use dioxus apis from a web-sys
closure, you can grab the runtime from the scope it is created in:
...
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
COUNT=0 synced=0
整个过程按 panicked|COUNT|click|Must 过滤后(帮助文字里恰好有两行含 COUNT):
--- click tokio
thread 'main' (2238998) panicked at ~/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/dioxus-core-0.7.10/src/runtime.rs:100:17:
Must be called from inside a Dioxus runtime.
static COUNT: GlobalSignal<i32> = Signal::global(|| 0);
tracing::info!("The count is: {COUNT}");
COUNT=0 synced=0
--- click thread
thread '<unnamed>' (2238999) panicked at ~/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/dioxus-core-0.7.10/src/runtime.rs:100:17:
Must be called from inside a Dioxus runtime.
static COUNT: GlobalSignal<i32> = Signal::global(|| 0);
tracing::info!("The count is: {COUNT}");
COUNT=0 synced=0
--- click dioxus spawn
COUNT=1 synced=0
--- click sync
COUNT=1 synced=42
release 的 panic 文字和位置相同。
- tokio 任务:
tokio::spawn的任务和点击发生在同一个线程(current_thread运行时,panic 报的线程就是main),但任务是在点击回调返回之后才被 tokio 调度的,那时 dioxus 早已把 runtime 弹出栈。panic 被 tokio 接住、存进没人await的JoinHandle,进程照常运行,只在 stderr 留一段文字,界面上COUNT一直是 0。服务端渲染、桌面端里tokio::spawn都编译得过:GlobalSignal是static,满足Send。 - 普通线程:新线程的线程局部栈本来就是空的。
spawn是 dioxus 的spawn,panic 掉的是那个线程。 - dioxus 的
spawn:任务登记在当前 scope 名下,由VirtualDom自己 poll,每次 poll 都在 runtime 里,正常。 SyncSignal跨线程写:成功,界面更新了。写通知订阅者时只是往调度通道里发一条消息,不需要 runtime。
普通 Signal(UnsyncStorage)不能被移进线程或 tokio::spawn:把上面第 2 个按钮里的全局 signal 换成组件里的 use_signal,rustc 拒绝编译,`RefCell<StorageEntry<RefCellStorageEntryData>>` cannot be shared between threads safely。所以会碰到这个 panic 的,是全局 signal、spawn、use_*、consume_context 这些不需要句柄、靠「当前 runtime」找状态的 API。
server function 是同一个问题的另一种形式:它在 axum 的处理函数里运行,外面没有 VirtualDom,在里面读 GlobalSignal 得到 HTTP 500,debug 构建还把整段 panic 文字发给客户端(实验见 context 与全局 signal 的「SSR:两个并发请求」)。
出处
panic 在 Runtime::current,栈顶为空时 panic! 这段文字。各个入口怎么走到它:
spawn:spawn是Runtime::with_current_scope(|cx| cx.spawn(fut))。GlobalSignal:每次读写先get_global_context,第一行就是Runtime::current(),因为值存在当前VirtualDom根 scope 的 context 里(每个VirtualDom一份)。- 所有 hook:
use_hook是Runtime::with_current_scope(..)。
SyncSignal 跨线程能写,是因为通知的终点是组件 ReactiveContext 里的那个闭包,new_for_scope 建的:sender.unbounded_send(SchedulerMsg::Immediate(id)),只捕获了一个通道发送端。
帮助文字里省略掉的示例代码不能照抄。它以 use dioxus::prelude::*; 开头,然后写 let runtime = Runtime::current().expect("Components run in the Dioxus runtime");、let _guard = RuntimeGuard::new(runtime);:但 Runtime 和 RuntimeGuard 不在 dioxus::prelude 里(要 use dioxus::core::{Runtime, RuntimeGuard},下面的修法就是这样写的),而且 0.7 的 Runtime::current() 直接返回 Rc<Runtime>,后面没有 .expect(..) 可调。
修法
按场景选:
- 组件里要起异步任务:用 dioxus 的
spawn/use_future/use_resource,不用tokio::spawn。任务归属当前 scope,组件卸载时一起取消;要活过组件,用spawn_forever。服务端、桌面端真需要在 tokio 的线程池里做阻塞或 CPU 密集的事,在 dioxus 任务里tokio::task::spawn_blocking(..).await取回结果,再在 dioxus 任务里写 signal。 - 别的线程要通知界面:用
use_signal_sync,或者让一个 dioxus 任务await一个异步通道的接收端、收到后写 signal,别的线程只往通道里发。 - JS 回调、事件监听、定时器这类之后才被调用的非
Send闭包:在组件里取Runtime::current(),闭包里先建RuntimeGuard。src/bin/outside_runtime_guard.rs把两个闭包存起来,等rebuild返回之后在main里调用,模拟浏览器稍后回调:
use dioxus::core::{Runtime, RuntimeGuard};
use dioxus::prelude::*;
static COUNT: GlobalSignal<i32> = Signal::global(|| 0);
thread_local! {
// stands in for a JS callback (web_sys Closure) that the browser calls later
static CALLBACKS: RefCell<Vec<(&'static str, Box<dyn Fn()>)>> = RefCell::new(Vec::new());
}
fn app() -> Element {
use_hook(|| {
let bare = Box::new(|| *COUNT.write() += 1) as Box<dyn Fn()>;
let runtime = Runtime::current();
let guarded = Box::new(move || {
let _guard = RuntimeGuard::new(runtime.clone());
*COUNT.write() += 10;
}) as Box<dyn Fn()>;
CALLBACKS.with(|c| c.borrow_mut().extend([("bare", bare), ("guarded", guarded)]));
});
rsx! { "COUNT={COUNT}" }
}
fn main() {
let mut dom = VirtualDom::new(app);
dom.rebuild_in_place();
let callbacks = CALLBACKS.with(|c| c.take());
for (name, cb) in &callbacks {
let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(cb));
dom.render_immediate_to_vec();
println!("{name:<8} panicked={:<5} html={}", r.is_err(), dioxus_ssr::render(&dom));
}
}
thread 'main' (2183253) panicked at ~/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/dioxus-core-0.7.10/src/runtime.rs:100:17:
Must be called from inside a Dioxus runtime.
bare panicked=true html=COUNT=0
guarded panicked=false html=COUNT=10
(帮助文字已删去。)guarded 把 runtime 压回栈上,COUNT 找到了这个 VirtualDom 的那一份,组件也被标脏重跑了。Rc<Runtime> 不是 Send,这一招只能用在同一个线程上,正好对应 wasm 里的 JS 回调。原生代码里手上有 VirtualDom 时,用 VirtualDom::in_runtime 也是同样的效果。
- server function 里要共享状态:不要用
GlobalSignal。按请求的状态从请求里拿(FullstackContext、axum extractor),进程级状态放在static+ 锁或者 axum 的 state 里,见 context 与全局 signal。
怎么确认没中招
- 搜组件代码里的
tokio::spawn、std::thread::spawn、wasm_bindgen_futures::spawn_local,看闭包里有没有GlobalSignal、spawn(、use_、consume_context。有就换成 dioxus 的spawn或加RuntimeGuard。 - 搜 server function(
#[server]/#[get]/#[post])的函数体里有没有GlobalSignal/GlobalMemo的名字。在服务端渲染时调它能拿到值,从浏览器发请求时才 500,测试只测首屏会漏掉。 - 服务端、桌面端的 stderr 里出现
Must be called from inside a Dioxus runtime.而界面没崩,多半是tokio::spawn里的 panic 被吞了:看 panic 报在哪个线程,再看那个时刻起了哪些任务。
小结
- 「当前 runtime」是线程局部的栈,只在
VirtualDom入口、事件派发和回调期间有值;tokio::spawn的任务、新线程、稍后被调用的 JS 闭包、server function 里都是空的。 GlobalSignal、spawn、所有 hook 都要它;SyncSignal写入只发通道消息,跨线程可用。tokio::spawn里的这个 panic 被吞掉,表现是界面不更新;server function 里是 500。- 修法:组件里用 dioxus 的
spawn;跨线程用SyncSignal或通道;同线程稍后回调用Runtime::current()加RuntimeGuard;server function 不碰全局 signal。