上周和一个朋友吃饭,他跟我吐槽说花了三天时间调试一个Rust服务。服务跑着,Tokio也在转,CPU占用率低得可怜,内存稳如老狗。但就是延迟飙升,请求莫名其妙地卡住。监控面板上一切正常,日志里连个warning都没有。

我问他:“你是不是在async函数里用了std::fs::read?”

他愣了一下,然后骂了一句脏话。

没错,他踩到了Rust异步编程里那个经典的坑——在async函数里搞阻塞操作。这个async阻塞的坑有意思的地方在于,它能编译,能运行,测试也能过,甚至压测都没问题。但一到生产环境高并发的时候,整个服务就像被人掐住了脖子。

餐厅服务员的故事

要理解这个问题,我们先来聊聊Tokio的工作原理。你可以把Tokio的worker线程想象成餐厅里的服务员。

一个服务员同时负责好几桌客人。他的工作方式是这样的:走到A桌问一下"菜好了吗",没好就去B桌看看,B桌也没好就去C桌。一圈转下来,可能A桌的菜就好了,他就端过去。这种工作方式效率很高,一个服务员能同时照顾十几桌客人。

┌───────────────────────────┐
│   Worker Thread (服务员)   │
├───────────────────────────┤
│ poll(A桌) → 还没好,下一个  │
│ poll(B桌) → 还没好,下一个  │
│ poll(C桌) → 好了!端菜      │
│ poll(A桌) → 好了!端菜      │
└───────────────────────────┘

这就是异步编程的精髓——不等待,轮询。每次poll都应该很快返回,要么"我好了",要么"我还没好你先忙别的"。

现在问题来了。如果服务员走到某一桌,客人拉着他聊起了人生,聊了整整一分钟,会发生什么?其他所有桌的客人都在干等着,菜凉了也没人端,新来的客人也没人招呼。整个餐厅的服务质量瞬间崩塌。

这就是在async代码里搞阻塞操作的后果。

那段看起来人畜无害的代码

来看这段代码:

async fn handle_request() {
    let config = std::fs::read("config.json").unwrap();
    process(config).await;
}

看着挺正常的对吧?读个配置文件而已。但std::fs::read是个阻塞调用,它会让当前线程傻等着,直到文件读完。在普通的同步代码里这没什么问题,但在async函数里,这一行代码会把整个worker线程卡住。

不是卡住一个任务,是卡住整个线程上的所有任务

更要命的是,Rust编译器对此毫无察觉。它不会给你任何警告,因为从语法上来说这完全合法。编译器不知道std::fs::read会阻塞,它只知道这是个普通的函数调用。

这就是Rust的设计哲学——它选择信任程序员,而不是在背后搞什么运行时魔法。Go语言会在你阻塞的时候偷偷帮你切换goroutine,JavaScript压根就没有阻塞I/O这回事。但Rust说:“你是成年人了,你应该知道自己在干什么。”

三种常见的踩坑姿势

除了文件读写,还有两种常见的踩坑方式。

第一种是用标准库的Mutex:

async fn handle(state: Arc<Mutex<Data>>) {
    let guard = state.lock().unwrap();
    guard.update();
    do_something_async().await;  // 灾难发生在这里
}

这段代码的问题在于,你拿着锁去await了。想象一下:服务员A拿着厨房的唯一一把菜刀,然后跑去和客人聊天了。服务员B想切菜,发现菜刀被A拿走了,只能等着。服务员C也想切菜,也只能等着。整个厨房停摆了。

第二种是CPU密集型计算:

async fn handle() {
    let result = calculate_prime_numbers(1000000);  // 算了200毫秒
    send_response(result).await;
}

很多人以为只有I/O操作才算阻塞,其实CPU密集型计算也是阻塞。你的服务员在那儿心算200毫秒,其他桌的客人一样在干等。异步不等于并行,这是两码事。

正确的打开方式

知道了问题在哪,解决方案就很直接了。

对于文件I/O,用Tokio提供的异步版本:

// 错误
let data = std::fs::read("file.txt").unwrap();

// 正确
let data = tokio::fs::read("file.txt").await.unwrap();

对于必须阻塞的操作,用spawn_blocking把它扔到专门的线程池:

let data = tokio::task::spawn_blocking(|| {
    std::fs::read("config.json")
}).await.unwrap();

这就像餐厅里遇到需要长时间准备的菜,服务员不会自己去做,而是交给后厨,自己继续服务其他桌。后厨做好了会通知服务员,服务员再端过去。

对于锁,要么用Tokio的异步Mutex,要么确保在释放锁之后再await:

// 方案一:用异步Mutex
let guard = state.lock().await;
guard.update();
do_something_async().await;

// 方案二:先释放锁再await
{
    let guard = state.lock().unwrap();
    guard.update();
}  // 锁在这里释放了
do_something_async().await;

那个9倍性能提升的故事

回到开头我朋友的故事。他的服务在启动时会读一个配置文件,用的就是std::fs::read。平时没什么问题,因为配置文件很小,读取很快。但在高并发场景下,这个"很快"的操作被放大了——每个请求都要读一次,几百个请求同时来,worker线程就被堵得死死的。

他把那一行改成tokio::fs::read之后,吞吐量直接涨了9倍。没有任何其他改动,就改了一行代码。这种Rust性能优化往往就是这么简单粗暴——找到那个阻塞点,干掉它。

这就是Rust异步编程的魅力和陷阱所在。它给你极致的性能和控制力,但前提是你得知道规则。编译器不会拦着你犯错,它只会在你犯错之后,用生产环境的火焰来教育你。

每个Rust异步工程师都会踩这个坑,区别只在于踩几次。聪明的工程师踩一次就记住了,然后把这个教训分享给其他人,让大家少走弯路。


觉得有用的话,转发给你身边写Rust的朋友吧,说不定能帮他们省下三天的调试时间。关注我,下次继续聊Rust里那些让人又爱又恨的设计。