Rust Async Runtime: tokio and async-std
Rust made a deliberate choice: the language provides async/await syntax and the `Future` trait, but no built-in executor to actually run async code. This isn't an oversight—it's a design decision...
Key Insights
- Rust’s async/await syntax requires an external runtime executor—tokio dominates production workloads with its mature ecosystem, while async-std offers a gentler learning curve by mirroring standard library APIs
- Runtime choice locks in significant portions of your dependency tree; tokio-native crates like
hyper,tonic, andsqlxmake switching runtimes costly once you’ve committed - For most production services, tokio is the pragmatic choice due to ecosystem gravity, but async-std remains viable for simpler applications or when you prioritize API familiarity over library availability
Introduction to Async Runtimes in Rust
Rust made a deliberate choice: the language provides async/await syntax and the Future trait, but no built-in executor to actually run async code. This isn’t an oversight—it’s a design decision that lets you choose the runtime characteristics that match your workload.
When you write async fn in Rust, you’re creating a state machine that yields control at .await points. But someone needs to poll those futures to completion. That’s the runtime’s job: scheduling tasks, managing I/O, and driving futures forward.
Not every Rust program needs async. If you’re CPU-bound or handling a handful of concurrent connections, OS threads work fine. Async shines when you’re managing thousands of concurrent I/O operations—network servers, database connection pools, or orchestrating many external API calls. The overhead of one OS thread per connection becomes prohibitive at scale; async lets you multiplex many tasks onto fewer threads.
The two dominant runtimes are tokio and async-std. They solve the same fundamental problem but make different trade-offs in API design, performance characteristics, and ecosystem investment.
Tokio: The Industry Standard
Tokio has become the default choice for production Rust services. It powers Cloudflare’s edge infrastructure, Discord’s real-time messaging, and countless other high-scale systems. This adoption creates a network effect: library authors target tokio first, which makes it more attractive for new projects.
Tokio’s architecture centers on a work-stealing, multi-threaded scheduler. Worker threads pull tasks from a shared queue, and when one thread’s queue empties, it steals work from others. This balances load automatically without manual thread assignment.
The runtime provides more than just task execution. You get timers, I/O primitives, synchronization channels, and a growing ecosystem of utilities through tokio-util and the Tower middleware stack.
Here’s a TCP echo server that demonstrates tokio’s core patterns:
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let listener = TcpListener::bind("127.0.0.1:8080").await?;
println!("Listening on port 8080");
loop {
let (mut socket, addr) = listener.accept().await?;
tokio::spawn(async move {
let mut buf = [0u8; 1024];
loop {
let n = match socket.read(&mut buf).await {
Ok(0) => return, // Connection closed
Ok(n) => n,
Err(e) => {
eprintln!("Read error from {}: {}", addr, e);
return;
}
};
if let Err(e) = socket.write_all(&buf[..n]).await {
eprintln!("Write error to {}: {}", addr, e);
return;
}
}
});
}
}
The #[tokio::main] macro sets up the multi-threaded runtime. Each accepted connection spawns an independent task that the scheduler manages. This pattern handles thousands of concurrent connections efficiently.
async-std: The Standard Library Mirror
async-std takes a different approach: make async Rust feel like synchronous Rust. If you know std::fs::read_to_string, you already know async_std::fs::read_to_string. The API surface mirrors the standard library, reducing cognitive load for developers new to async.
The runtime defaults to a multi-threaded executor but with a simpler configuration model than tokio. You get fewer knobs to turn, which is either a limitation or a feature depending on your needs.
Here’s the same echo server in async-std:
use async_std::io::{ReadExt, WriteExt};
use async_std::net::TcpListener;
use async_std::prelude::*;
use async_std::task;
fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
task::block_on(async {
let listener = TcpListener::bind("127.0.0.1:8080").await?;
println!("Listening on port 8080");
let mut incoming = listener.incoming();
while let Some(stream) = incoming.next().await {
let mut stream = stream?;
let addr = stream.peer_addr()?;
task::spawn(async move {
let mut buf = [0u8; 1024];
loop {
let n = match stream.read(&mut buf).await {
Ok(0) => return,
Ok(n) => n,
Err(e) => {
eprintln!("Read error from {}: {}", addr, e);
return;
}
};
if let Err(e) = stream.write_all(&buf[..n]).await {
eprintln!("Write error to {}: {}", addr, e);
return;
}
}
});
}
Ok(())
})
}
The structure is nearly identical. The differences are subtle: task::block_on instead of a macro, incoming() returning a stream iterator, and traits imported through a prelude. If you squint, it looks like blocking code with .await sprinkled in.
Feature Comparison and Performance
Both runtimes provide the essential primitives: task spawning, timers, channels, and I/O. The implementations differ in ways that matter at scale.
Tokio’s select! macro offers more control over cancellation semantics. Its channels (mpsc, oneshot, broadcast, watch) cover more use cases than async-std’s offerings. Timer resolution and accuracy tend to favor tokio in high-precision scenarios.
Here’s concurrent task spawning with timeout handling in both runtimes:
// Tokio version
use tokio::time::{timeout, Duration};
async fn tokio_concurrent_tasks() {
let task1 = tokio::spawn(async {
tokio::time::sleep(Duration::from_millis(100)).await;
"task1 complete"
});
let task2 = tokio::spawn(async {
tokio::time::sleep(Duration::from_millis(200)).await;
"task2 complete"
});
tokio::select! {
result = task1 => println!("First: {:?}", result),
result = task2 => println!("First: {:?}", result),
_ = tokio::time::sleep(Duration::from_millis(150)) => {
println!("Timeout reached");
}
}
}
// async-std version
use async_std::future::timeout;
use std::time::Duration;
async fn async_std_concurrent_tasks() {
let task1 = async_std::task::spawn(async {
async_std::task::sleep(Duration::from_millis(100)).await;
"task1 complete"
});
let task2 = async_std::task::spawn(async {
async_std::task::sleep(Duration::from_millis(200)).await;
"task2 complete"
});
// async-std uses futures::select! or race patterns
let result = futures::future::select(task1, task2).await;
match result {
futures::future::Either::Left((val, _)) => println!("Task1 won: {}", val),
futures::future::Either::Right((val, _)) => println!("Task2 won: {}", val),
}
}
Performance benchmarks show tokio generally edges ahead in high-throughput scenarios, particularly with many concurrent connections and complex I/O patterns. The difference rarely matters below tens of thousands of concurrent tasks. Don’t choose based on microbenchmarks—choose based on ecosystem fit.
Ecosystem and Compatibility
This is where the decision gets practical. The Rust async ecosystem has largely consolidated around tokio. Critical libraries have made their choice:
- hyper/axum/actix-web: tokio
- tonic (gRPC): tokio
- sqlx: tokio (with optional async-std support)
- reqwest: tokio
If you’re building a web service, you’re likely pulling in tokio transitively whether you chose it or not.
For runtime-agnostic code, the async-compat crate provides bridging:
use async_compat::Compat;
// Run tokio-dependent code in an async-std context
async fn use_tokio_library_in_async_std() {
// Wrap tokio futures to run on async-std
let result = Compat::new(async {
// This uses tokio's runtime internally
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
42
}).await;
println!("Result: {}", result);
}
// Or write runtime-agnostic code using only futures traits
use futures::io::{AsyncRead, AsyncWrite};
async fn process_stream<S>(stream: S) -> std::io::Result<()>
where
S: AsyncRead + AsyncWrite + Unpin,
{
// This works with either runtime
use futures::io::{AsyncReadExt, AsyncWriteExt};
let mut stream = stream;
let mut buf = [0u8; 1024];
let n = stream.read(&mut buf).await?;
stream.write_all(&buf[..n]).await?;
Ok(())
}
Writing runtime-agnostic libraries is possible but adds complexity. Most library authors don’t bother—they pick tokio and move on.
Choosing the Right Runtime
Here’s a decision framework:
Choose tokio when:
- Building production web services
- Using hyper, axum, tonic, or other tokio-native libraries
- You need advanced features like
select!with cancellation semantics - Performance at extreme scale matters
- Your team already knows tokio
Choose async-std when:
- Building simpler applications or CLI tools
- Teaching async Rust (the std-like API reduces friction)
- Your dependencies don’t force a runtime choice
- You value API simplicity over ecosystem breadth
Consider alternatives:
- smol: Minimal runtime, good for understanding how executors work
- glommio: io_uring-based, for storage-heavy workloads on Linux
- Embassy: Embedded systems with no_std requirements
For most teams starting a new service today, tokio is the pragmatic default. The ecosystem gravity is too strong to ignore. You’ll spend less time fighting compatibility issues and more time shipping features.
Conclusion
Rust’s async runtime landscape has matured into a clear hierarchy: tokio dominates production workloads, async-std serves as a simpler alternative, and specialized runtimes address niche requirements.
The technical differences between tokio and async-std matter less than the ecosystem implications. Choosing async-std means accepting that some libraries will require workarounds or won’t be available at all. Choosing tokio means accepting a steeper learning curve and more configuration options than you might need.
For web services and APIs, use tokio. For CLI tools and simpler applications where you control the dependency tree, async-std remains a valid choice. For embedded or specialized I/O patterns, investigate the alternatives.
Don’t overthink it. Pick tokio unless you have a specific reason not to, and revisit the decision only if you hit concrete problems. The runtime is infrastructure—it should fade into the background while you focus on your application logic.