Tonic服务处理首次批量阻塞RPC请求时并行度异常降低原因排查
Tonic服务处理首次批量阻塞RPC请求时并行度异常降低原因排查
问题场景重现
你基于Tonic的gRPC HelloWorld教程做了两处针对性修改:
- 在
say_hello方法返回响应前,加入std::thread::sleep(std::time::Duration::from_millis(10000)),故意用标准库的阻塞sleep而非Tokio的异步sleep,以此模拟真实场景中的CPU/IO阻塞型工作负载 - 将Tokio运行时配置为多线程模式,先后指定过10、100个worker线程:
#[tokio::main(flavor = "multi_thread", worker_threads = 100)]
测试时用批量启动客户端请求的方式验证并行能力:
for i in $(seq 1 20); do cargo run --quiet --bin helloworld-client& done
预期vs实际行为
- 预期:按照配置的worker线程数,前10秒应该同时处理10个请求,接下来10秒处理剩余10个,总耗时约20秒
- 实际:首次批量请求时,仅有1-3个请求被同时处理,后续批次才能达到满并行度,总耗时约30秒;更奇怪的是,即使服务器已经运行过,只要闲置一小段时间后再发批量请求,这个低并行度的问题依然会重复出现
核心原因分析
这个问题的根源在于Tokio多线程调度器的线程唤醒策略,再加上你使用阻塞型代码干扰了调度信号:
- Tokio worker线程的休眠(parking)机制:当worker线程没有任务可执行时,会进入parked(休眠)状态,等待新任务到来时被唤醒。但Tokio不会一次性唤醒所有闲置的worker线程,而是采用逐步唤醒的策略——一开始只唤醒少量线程处理请求,只有当检测到当前活跃线程都处于繁忙状态时,才会继续唤醒更多闲置线程。
- 阻塞代码干扰调度感知:你用的
std::thread::sleep会直接阻塞整个worker线程的执行,而Tokio的调度器无法感知到这个线程内部的阻塞(因为这不是Tokio自身的异步休眠逻辑)。当第一批请求进来时,Tokio先唤醒少量worker线程处理请求,这些线程被阻塞后,调度器需要一段时间才能检测到它们真的“忙”,才会继续唤醒更多闲置线程——而这段检测窗口正好错过了第一批请求的并行处理时机,导致只有少数请求同时执行。
你的观察验证
你通过top -H -p <pid>确认worker线程确实都已创建,只是处于parked状态;用gdb调试看到闲置worker线程的调用栈停在tokio::runtime::scheduler::multi_thread::park::Inner::park相关逻辑,这正好验证了线程被休眠等待唤醒的状态,和我们的原因分析完全吻合。
解决方案建议
针对这种阻塞型工作负载,Tokio官方推荐的正确做法是:
使用tokio::task::spawn_blocking将阻塞代码放到专门的阻塞线程池中执行,而不是直接在worker线程里运行阻塞逻辑。这样worker线程可以快速释放,回到调度池处理其他请求,而阻塞任务由专门的线程处理,不会干扰Tokio的调度逻辑。
修改你的say_hello方法示例:
async fn say_hello( &self, request: Request<HelloRequest>, ) -> Result<Response<HelloReply>, Status> { println!("Got a request: {:?}", request); let name = request.into_inner().name; // 将阻塞逻辑放到spawn_blocking中,交给专门的阻塞线程池处理 let reply = tokio::task::spawn_blocking(move || { std::thread::sleep(std::time::Duration::from_millis(10000)); HelloReply { message: format!("Hello {}!", name), } }).await.unwrap(); Ok(Response::new(reply)) }
这样修改后,worker线程不会被阻塞,Tokio调度器可以正常唤醒足够多的线程处理批量请求,并行度就能达到你配置的worker线程数。
内容来源于stack exchange
相关产品推荐
相关产品推荐

