渲染里阻塞,别的请求跟着慢
主线位置:首屏主线的「本地线程上建
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的rtfeature,只在服务端用,放在#[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把同步调用挪到阻塞线程池。