Rust in Everything
基于 dioxus 0.7.10

渲染里阻塞,别的请求跟着慢

主线位置:首屏主线的「本地线程上建 VirtualDom」一步,以及调用主线的服务端执行一步。两者都经 FullstackState 里同一个 LocalPoolHandle::spawn_pinned 放到本地线程池上(池的大小、选线程的规则见一次请求的渲染)。每个线程只跑自己的任务,没有别的线程来分担,一个同步调用卡住的是整个线程。

flowchart TB
  page["页面请求"] --> pool["本地线程池 spawn_pinned"]
  sfn["server function 请求"] --> pool
  asset["静态文件"] --> mt["axum 多线程运行时"]
  pool --> t1["线程 A 正在 sleep"]
  t1 --> wait["排在 A 上的任务等它醒"]

实验应用 pit2-fs:dioxus = "=0.7.10",dx build --platform web --fullstack --release(dx 0.7.9),IP=127.0.0.1 PORT=18820 ./server。M2 Max(12 核)、macOS 27、rustc 1.99.0;锁文件里 tokio-util 是 0.7.20。

现象

三个页面,都等 500 毫秒:

#[component]
fn Block() -> Element {
  // synchronous sleep while rendering on the server
  let t = tid();
  #[cfg(feature = "server")]
  std::thread::sleep(std::time::Duration::from_millis(500));
  rsx! { p { "blocked on {t}" } }
}

#[component]
fn Wait() -> Element {
  let t = tid();
  let v = use_server_future(slow_sync)?;
  rsx! { p { "{v():?} rendered on {t}" } }
}

#[get("/api/slow-sync")]
pub async fn slow_sync() -> Result<String, ServerFnError> {
  // a synchronous call (std::fs, a blocking client, heavy CPU) inside an async fn
  #[cfg(feature = "server")]
  std::thread::sleep(std::time::Duration::from_millis(500));
  Ok(format!("sync on {}", tid()))
}

tid() 是 format!("{:?}", std::thread::current().id())。Offload 页面和 Wait 一样,只是 server function 换成下面「修法」里的 slow_offload。block.sh 对每个页面同时发 16 个 curl,排序后的 time_total,以及页面里出现最多的线程 id:

== /block x16
0.503489 0.504030 0.504831 0.505043 0.505515 0.505551 0.505585 0.505608 0.505745 0.506234 0.506720 0.506874 0.996012 0.996864 0.997204 0.999571 
   2 blocked on ThreadId(17)
   2 blocked on ThreadId(16)
   2 blocked on ThreadId(15)
== /wait x16
0.500836 0.501113 0.501763 0.502001 0.502291 0.502631 0.503457 0.503752 0.504946 0.505561 0.505562 0.505743 0.993974 1.000541 1.000930 1.002860 
   2 sync on ThreadId(17)
   2 sync on ThreadId(16)
   2 sync on ThreadId(15)

池里 12 个线程,前 12 个请求各占一个;第 13 到 16 个只能排到已经有任务的线程上,等前一个睡完,所以是 1 秒。同步调用写在组件里还是写在 use_server_future 调的 server function 里,结果一样:服务端渲染时 server function 的函数体就在页面的那个线程上直接 .await,里面的同步调用照样卡住线程。

卡住的不只是同一个页面。先发 12 个 /block 占满所有线程,100 毫秒后同时发首页、一个 server function、一个静态文件;再换成 12 个经 HTTP 调用的 /api/slow-sync 占满线程,100 毫秒后请求首页:

== 12 x /block, then after 100 ms:
static asset 0.001516
page /       0.386519
server fn    0.388617
ping body: "pong on ThreadId(15)"
== 12 x /api/slow-sync over HTTP, then after 100 ms:
page /       0.388975
== alone
page /       0.000991
server fn    0.000684
  • 首页平时 1 毫秒,这次等了 0.39 秒(500 毫秒减去先过去的 100 毫秒多一点)。
  • /api/ping 函数体只拼一个字符串,也等了 0.39 秒;它的返回值显示它跑在 ThreadId(15) 上,和 /block 是同一个池。
  • 静态文件 1.5 毫秒,不受影响。
  • 反过来也成立:HTTP 调用的 server function 里同步阻塞,同样把页面请求卡住。

出处

FullstackState::new 建一个 LocalPoolHandle,线程数是 available_parallelism。页面渲染在 SsrRendererPool::render_to 里 rt.spawn_pinned;server function 在 make_handler 里用的是同一个 state.rt:

                // Allow !Send futures by running in the render handlers pinned local pool
                let result = state.rt.spawn_pinned(move || async move {

serve_dioxus_application 把 server function 路由和渲染 fallback 挂在同一个 FullstackState 上(serve_dioxus_application),所以是一个池。静态文件由 ServeDir 在 axum 的多线程运行时上处理,不进这个池。

为什么要本地池:VirtualDom 和 server function 的 future 都可以不是 Send。代价是 tokio 的多线程调度器那种「别的 worker 来偷任务」在这里没有:任务一旦分到某个线程,就只在那个线程上 poll。tokio 多线程运行时上的同一类问题见 tokio 卷在 async 里阻塞。

修法

把同步调用挪出本地线程:

#[get("/api/slow-offload")]
pub async fn slow_offload() -> Result<String, ServerFnError> {
  #[cfg(feature = "server")]
  tokio::task::spawn_blocking(|| std::thread::sleep(std::time::Duration::from_millis(500)))
    .await
    .map_err(|e| ServerFnError::new(e.to_string()))?;
  Ok(format!("offloaded on {}", tid()))
}

同样 16 个并发请求:

== /offload x16
0.501564 0.501767 0.501979 0.502141 0.502540 0.503413 0.503814 0.503870 0.504272 0.504448 0.504452 0.504631 0.504792 0.505551 0.505718 0.506284 

页面里同样有线程接了两个请求(ThreadId(15) 到 ThreadId(17) 各出现两次),但时间全是 0.5 秒:线程在 .await 处让出,同一线程上的两个渲染交替进行,睡觉发生在 tokio 的阻塞线程池里。

  • 同步 IO(std::fs、阻塞的 HTTP 客户端、同步数据库驱动):换成异步版本(tokio::fs、reqwest、sqlx / sea-orm),或者包进 spawn_blocking。
  • 算很久的 CPU 活(图片处理、大文档渲染):spawn_blocking,或者交给专门的线程池再 .await 结果。
  • 组件函数本身只做渲染。需要服务端数据就放进 use_server_future 调的 server function 里,那里是 async 的,可以 .await。
  • spawn_blocking 需要 tokio 的 rt feature,只在服务端用,放在 #[cfg(feature = "server")] 下面。

怎么确认没中招

  • 搜 std::thread::sleep、std::fs::、reqwest::blocking、block_on 出现在组件或 server function 里的地方。
  • 压测时同时打一个慢页面和一个很快的页面(或者一个只返回常量的 server function),看快的那个的 p99 有没有跟着涨到慢页面的耗时。只顺序请求是测不出来的:上一个请求结束之后线程已经空了。
  • 并发数超过 CPU 核数时,耗时按整倍数分层(上面的 0.5 秒和 1.0 秒)是同步阻塞的特征;异步等待的耗时不随并发数这样分层。

小结

  • 页面渲染和 server function(不管经 HTTP 还是被组件直接调用)都在同一个 LocalPoolHandle 的线程上跑,线程数等于 CPU 核数,每个线程是单线程调度器。
  • 同步阻塞卡住整个线程,之后分到这个线程的页面和 server function 都要等;静态文件走 axum 的多线程运行时,不受影响。
  • 修法:异步 IO,或 tokio::task::spawn_blocking 把同步调用挪到阻塞线程池。

💬 关联讨论

发起讨论
加载中...