Rust in Everything
基于 dioxus 0.7.10

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,没有 dioxus Runtime,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(&#34;alice visit #1&#34;))<!--#--></p>
GET /visits (cookie: user=bob)
<p id="visits" data-node-hydration="0"><!--node-id1-->Some(Ok(&#34;bob visit #1&#34;))<!--#--></p>
GET /visits (cookie: user=alice)
<p id="visits" data-node-hydration="0"><!--node-id1-->Some(Ok(&#34;alice visit #2&#34;))<!--#--></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 运行时,读 GlobalSignal panic 成 500。
  • 用户的状态存在服务端、按用户分键,经 server function 读写;static 只放和请求无关的东西。

💬 关联讨论

发起讨论
加载中...