static 里的状态在请求之间串了
主线位置:首屏主线的「每个请求新建一个
VirtualDom」一步。GlobalSignal的 static 里只有构造函数,值懒建在当前VirtualDom的根上(context 与全局 signal),所以跟着请求走;普通的static不认识VirtualDom,进程里只有一份。
flowchart TB r1["请求 1 的 VirtualDom"] --> g1["GlobalSignal 值 1"] r2["请求 2 的 VirtualDom"] --> g2["GlobalSignal 值 2"] r1 --> st["static Mutex 进程一份"] r2 --> st
实验应用 pit2-fs(dioxus = "=0.7.10",dx 0.7.9,release 构建,PORT=18820),M2 Max、macOS 27、rustc 1.99.0。机制已经在 context 与全局 signal 的「SSR:两个并发请求」一节用同样的实验讲过,本页重跑一次,重点放在修法。
现象
pub static VISITS: GlobalSignal<u32> = Signal::global(|| 0);
pub static SHARED: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
#[post("/api/slow")]
pub async fn slow(ms: u64) -> Result<u64, ServerFnError> {
#[cfg(feature = "server")]
tokio::time::sleep(std::time::Duration::from_millis(ms)).await;
Ok(ms)
}
#[get("/api/peek")]
pub async fn peek_global() -> Result<u32, ServerFnError> {
Ok(*VISITS.peek())
}
#[component]
fn Global() -> Element {
// once per VirtualDom (once per request on the server)
let (global_at_mount, shared_at_mount) = use_hook(|| {
*VISITS.write() += 1;
(*VISITS.peek(), SHARED.fetch_add(1, Ordering::SeqCst) + 1)
});
let waited = use_server_future(|| async { slow(300).await })?;
rsx! {
div { id: "out",
"global_at_mount={global_at_mount} shared_at_mount={shared_at_mount} "
"after_wait: global={VISITS} shared={SHARED.load(Ordering::SeqCst)} waited={waited():?}"
}
}
}
刚启动的服务端,同时发两个 GET /global,再顺序发一个,最后直接调 /api/peek(global.sh,只截 #out):
<div id="out" data-node-hydration="0"><!--node-id1-->global_at_mount=1 shared_at_mount=2 <!--#--><!--node-id2-->after_wait: global=1 shared=2 waited=Some(Ok(300))<!--#--></div><script>window.initial_dioxus_hydration_data="hIEY9ogYZxgvGGcYbBhvGGIYYRhs9ocYoRhiGE8YaxgZARgs";</script></div>
t=0.302617
<div id="out" data-node-hydration="0"><!--node-id1-->global_at_mount=1 shared_at_mount=1 <!--#--><!--node-id2-->after_wait: global=1 shared=2 waited=Some(Ok(300))<!--#--></div><script>window.initial_dioxus_hydration_data="hIEY9ogYZxgvGGcYbBhvGGIYYRhs9ocYoRhiGE8YaxgZARgs";</script></div>
t=0.302855
-- third, sequential
<div id="out" data-node-hydration="0"><!--node-id1-->global_at_mount=1 shared_at_mount=3 <!--#--><!--node-id2-->after_wait: global=1 shared=3 waited=Some(Ok(300))<!--#--></div><script>window.initial_dioxus_hydration_data="hIEY9ogYZxgvGGcYbBhvGGIYYRhs9ocYoRhiGE8YaxgZARgs";</script></div>
t=0.302862
-- GET /api/peek
HTTP/1.1 500 Internal Server Error
content-length: 21
Internal Server Error
global每个请求都是 1:两个并发请求各有一份VISITS。shared串了:一个请求挂载时是 1,另一个是 2,等待之后两个都读到 2,也就是对方的写入;第三个请求从 3 开始。把计数换成「当前用户的购物车」「这次请求的语言」,就是把一个人的数据渲染进另一个人的页面。/api/peek经 HTTP 调用,读GlobalSignal时不在任何VirtualDom里,panic,回 500。服务端 stderr 是panicked at .../dioxus-core-0.7.10/src/runtime.rs:100:17:加一行Must be called from inside a Dioxus runtime.。release 只有Internal Server Error;debug 构建会把整段 panic 文字发给客户端(context 与全局 signal 里有原文)。同一个函数在 SSR 时被组件直接调用却能读到值,两条路径行为不同。
出处
GlobalSignal的 static 只存构造函数和调用位置;第一次读时resolve在当前Runtime根 scope 的GlobalLazyContext里建值(context 与全局 signal 的「GlobalSignal:static 里没有值」)。服务端每个请求由render_handler里的工厂闭包新建一个VirtualDom,所以每个请求一份。static+Mutex/ 原子量是普通的 Rust 全局变量,服务端进程里所有请求、所有用户共用一份;浏览器里是 wasm 实例自己的另一份,两边毫无关系,改了也不触发重渲染。- server function 经 HTTP 调用时由
make_handler在本地线程池上执行,外面只有FullstackContext,没有 dioxusRuntime,GlobalSignal找不到根 scope 就 panic。
修法
按状态属于谁来放:
| 状态属于 | 放在哪 |
|---|---|
| 这一次渲染 / 这一个浏览器标签页 | use_signal、context、GlobalSignal(服务端每个请求一份,浏览器每个标签页一份) |
| 某个用户,要跨请求保留 | 服务端存储(数据库、或进程内按用户分键的表),经 server function 读写,函数里从 cookie / 会话判断是谁 |
| 和用户、请求都无关(配置、字典、只读缓存) | static + OnceLock / LazyLock,可以 |
第二行的最小写法,按 cookie 里的用户分键:
// per-user state kept on the server, keyed by who is asking
#[cfg(feature = "server")]
static VISITS_BY_USER: std::sync::LazyLock<std::sync::Mutex<std::collections::HashMap<String, u32>>> =
std::sync::LazyLock::new(Default::default);
#[get("/api/visit", headers: HeaderMap)]
pub async fn count_visit() -> Result<String, ServerFnError> {
let user = headers
.get("cookie")
.and_then(|v| v.to_str().ok())
.and_then(|c| c.split(';').find_map(|kv| kv.trim().strip_prefix("user=")))
.unwrap_or("anonymous")
.to_string();
let mut map = VISITS_BY_USER
.lock()
.map_err(|_| ServerFnError::new("lock poisoned"))?;
let n = map.entry(user.clone()).or_insert(0);
*n += 1;
Ok(format!("{user} visit #{n}"))
}
#[component]
fn Visits() -> Element {
let v = use_server_future(count_visit)?;
rsx! { p { id: "visits", "{v():?}" } }
}
刚启动的服务端,依次以 alice、bob、alice 请求 /visits:
GET /visits (cookie: user=alice)
<p id="visits" data-node-hydration="0"><!--node-id1-->Some(Ok("alice visit #1"))<!--#--></p>
GET /visits (cookie: user=bob)
<p id="visits" data-node-hydration="0"><!--node-id1-->Some(Ok("bob visit #1"))<!--#--></p>
GET /visits (cookie: user=alice)
<p id="visits" data-node-hydration="0"><!--node-id1-->Some(Ok("alice visit #2"))<!--#--></p>
static 本身没变,变的是键:值属于谁,键里就有谁。static VISITS_BY_USER 放在 #[cfg(feature = "server")] 下面,wasm 里没有它。真实应用里这张表是数据库,进程内的 HashMap 重启就没了,多个实例之间也不共享。
server function 里需要的信息(当前用户、语言)不要从 GlobalSignal 读,从请求里取(server 参数 headers: HeaderMap,或 FullstackContext::extract),或者作为参数传进来。
怎么确认没中招
- 搜
static里的Mutex、RwLock、Atomic*、RefCell(thread_local!),逐个问:它的值会因用户或请求不同而不同吗?会的话就是串请求。 - 搜 server function 函数体里对
GlobalSignal/GlobalMemo的读写:经 HTTP 调用时 panic。 - 用两个不同的 cookie 并发请求同一个页面,比较两份 HTML 里用户相关的部分。
小结
- 服务端每个请求一个
VirtualDom:GlobalSignal每个请求一份,并发请求互不可见。 static+Mutex/ 原子量是进程级的,所有请求、所有用户共享,并发时互相看到写入;浏览器里另有一份,互不相通。- 经 HTTP 调用的 server function 里没有 dioxus 运行时,读
GlobalSignalpanic 成 500。 - 用户的状态存在服务端、按用户分键,经 server function 读写;
static只放和请求无关的东西。
💬 关联讨论
发起讨论加载中...