Rust 异步编程踩坑:从 tokio 到 async-std 的迁移实录

Rust Async Programming Pitfalls: Migration from Tokio to Async-std

| Alex Chen | 2026-07-25T11:44:01

最近把项目的异步运行时从 tokio 换成了 async-std,过程中踩了不少坑,这篇文章把迁移中遇到的问题和解决方案完整记录下来。

A complete record of pitfalls and solutions encountered during the migration of a project's async runtime from tokio to async-std.

## 背景 上个月老板说要把我们的网关服务从 tokio 迁移到 async-std,理由是 async-std 在某些场景下的性能测试更好。我当时心想:不就是换个运行时嘛,能有多难? 结果差点把我人干没了。 ## 第一个坑:Runtime 不兼容 tokio 的 `spawn` 和 async-std 的 `task::spawn` 看起来用法差不多,但底层调度器完全不同。最坑的是,tokio 的 `TcpStream` 和 async-std 的 `TcpStream` 不是一个东西,不能混用。 ```rust // tokio 的写法 use tokio::net::TcpStream; let stream = TcpStream::connect("127.0.0.1:8080").await?; // async-std 的写法 —— 看起来一样但类型完全不同 use async_std::net::TcpStream; let stream = TcpStream::connect("127.0.0.1:8080").await?; ``` 这意味着所有依赖 tokio runtime 的第三方库都得找替代品,或者换成同时支持两者的库。 ## 第二个坑:Timer 行为差异 tokio 的 `sleep` 精度比 async-std 高不少。我们有个心跳检测逻辑,用了 100ms 的定时器,迁移后偶发超时。排查了半天才发现 async-std 的 timer 最小粒度大概在 1ms 左右,但在高负载下抖动比 tokio 大。 ```rust // 改成了更宽松的超时阈值 let timeout = Duration::from_millis(500); // 之前是 200ms ``` ## 第三个坑:Channel 语义 tokio 的 `mpsc` channel 在 sender 端 drop 后,receiver 会收到 `None`。async-std 用的 `async-channel` crate 行为类似,但 bounded channel 的背压策略不太一样,导致我们的消息队列在压力测试时出现了死锁。 最后的解决方案是统一用 `flume` 这个 crate,它同时兼容 tokio 和 async-std: ```rust // flume 是运行时无关的 let (tx, rx) = flume::bounded(1024); // 在 async 上下文中 tx.send_async(msg).await?; let msg = rx.recv_async().await?; ``` ## 第四个坑:生态库兼容 这是最痛苦的部分。我们用了 `reqwest`(依赖 tokio)、`tonic`(gRPC,强绑 tokio)、`sqlx`(支持两者但需要 feature flag 切换)。 最终不得不: - `reqwest` → `surf`(async-std 生态的 HTTP 客户端) - `tonic` → 暂时保留,用 `async-compat` 做桥接 - `sqlx` → 改 feature flag:`sqlx = { features = ["runtime-async-std-native-tls"] }` ## 总结 迁移花了将近两周,比预期多了一倍。如果你也在考虑换运行时,我的建议是: 1. **先盘点第三方依赖**,看哪些强绑了特定 runtime 2. **用 `async-compat` 做过渡**,不要一刀切 3. **压测一定要跑全**,timer 和 channel 的行为差异在低负载下根本看不出来 4. **考虑用运行时无关的库**,比如 `flume` 替代 `mpsc` 踩完这些坑之后,我现在对 Rust 异步生态的理解深了不少。虽然过程痛苦,但确实学到了很多。 希望这篇踩坑记能帮到正在做类似迁移的兄弟们。


## Background Last month we decided to migrate our gateway service's async runtime from tokio to async-std. What seemed like a simple runtime swap turned into a two-week adventure full of unexpected pitfalls. ## Pitfall 1: Runtime Incompatibility Tokio's `TcpStream` and async-std's `TcpStream` are completely different types. All third-party libraries depending on tokio's runtime needed alternatives or compatibility layers. ## Pitfall 2: Timer Behavior Differences Async-std's timer has higher jitter under load compared to tokio. Our heartbeat detection with 100ms timers started experiencing intermittent timeouts, requiring us to increase thresholds. ## Pitfall 3: Channel Semantics The backpressure strategy for bounded channels differs between the two runtimes, causing deadlocks under stress testing. We solved this by switching to `flume`, a runtime-agnostic channel library. ## Pitfall 4: Ecosystem Library Compatibility The most painful part was replacing runtime-bound libraries: `reqwest` to `surf`, keeping `tonic` with `async-compat` bridge, and switching `sqlx` feature flags. ## Conclusion The migration took nearly two weeks. Key advice: audit third-party dependencies first, use `async-compat` for gradual transition, run comprehensive stress tests, and prefer runtime-agnostic libraries.

← Back to News