Asynchronous programming in Rust allows you to write concurrent code
that's both efficient and safe. In this guide, we'll explore how to use
async/await
and the Tokio runtime to build responsive, high-performance applications.
What is Async Programming?
Async programming allows you to handle multiple tasks concurrently without using threads. This is particularly useful for I/O-bound operations like:
- Web servers handling many concurrent requests
- Database queries that wait for responses
- File I/O operations
- Network calls to external services
Key insight
Unlike threads, async tasks are extremely lightweight. A single thread can drive thousands of async tasks because they only consume resources when actively doing work, not while waiting for I/O.
Setting Up Tokio
First, add Tokio to your
Cargo.toml:
[dependencies] tokio = { version = "1.35", features = ["full"] }
Your First Async Function
Here's a simple async function:
async fn fetch_data() -> String { "Hello from async!".to_string() }
To call an async function, you need to
.await
it:
#[tokio::main] async fn main() { let data = fetch_data().await; println!("{}", data); }
The
#[tokio::main]
macro sets up the async runtime for you. It transforms your
async fn main()
into a synchronous
fn main()
that starts the Tokio runtime and blocks on your async code.
Concurrent Operations
One of the biggest advantages of async is running multiple operations concurrently:
use tokio::time::{sleep, Duration}; async fn task1() { sleep(Duration::from_secs(1)).await; println!("Task 1 complete"); } async fn task2() { sleep(Duration::from_secs(1)).await; println!("Task 2 complete"); } #[tokio::main] async fn main() { let handle1 = tokio::spawn(task1()); let handle2 = tokio::spawn(task2()); // Wait for both to complete let _ = tokio::join!(handle1, handle2); }
Both tasks run concurrently, so the total time is ~1 second, not 2!
The
tokio::spawn
function creates a new async task, and
tokio::join!
waits for all of them to finish.
Key insight
tokio::spawn
creates a new task that runs independently on the runtime's thread pool.
tokio::join!
runs futures concurrently on the
same task. Use
spawn
when you need true parallelism, and
join!
when you just need concurrency within a single task.
Error Handling in Async
Async functions can return
Result
types just like synchronous functions:
use std::io; async fn read_file() -> io::Result<String> { tokio::fs::read_to_string("data.txt").await } #[tokio::main] async fn main() { match read_file().await { Ok(contents) => println!("File contents: {}", contents), Err(e) => eprintln!("Error reading file: {}", e), } }
The
?
operator works seamlessly inside async functions too, so you can
propagate errors just as you would in synchronous code.
Selecting Between Futures
Sometimes you want to wait for the first of multiple operations to
complete. The
tokio::select!
macro handles this:
use tokio::time::{sleep, Duration}; async fn operation1() -> &'static str { sleep(Duration::from_secs(1)).await; "operation 1" } async fn operation2() -> &'static str { sleep(Duration::from_millis(500)).await; "operation 2" } #[tokio::main] async fn main() { tokio::select! { result = operation1() => println!("First: {}", result), result = operation2() => println!("First: {}", result), } }
In this example,
operation2
completes first (500ms vs 1s), so the
select!
macro runs its branch and cancels the other future. This is useful for
implementing timeouts, cancellation, and racing between alternatives.
Async Streams
Tokio also supports async streams for processing sequences of values
over time. You'll need the
tokio-stream
crate for this:
use tokio_stream::{self as stream, StreamExt}; #[tokio::main] async fn main() { let mut stream = stream::iter(vec![1, 2, 3, 4, 5]); while let Some(value) = stream.next().await { println!("Got: {}", value); } }
Async streams are the asynchronous equivalent of iterators. They are especially useful for processing data from network connections, message queues, or any source that produces values over time.
Best Practices
Follow these guidelines to write effective async Rust code:
- 1 Don't block the runtime: Avoid long-running CPU-intensive operations in async code
-
2
Use
spawn_blockingfor CPU work: If you must do CPU-intensive work, usetokio::task::spawn_blocking -
3
Handle errors properly: Always propagate errors using
?or handle them explicitly -
4
Be careful with locks: Use async-aware locks like
tokio::sync::Mutexinstead ofstd::sync::Mutex
Key insight
The async runtime uses a thread pool to execute tasks cooperatively.
When you block a thread with synchronous code (like
std::thread::sleep
or heavy computation), you prevent that thread from running other async
tasks. This can starve the entire runtime and cause deadlocks or
severe performance degradation.
Common Pitfalls
Even experienced Rust developers run into these issues when working with async code. Here are the most common mistakes and how to avoid them.
Blocking in Async Context
⚠ Pitfall
Using
std::thread::sleep
or other blocking operations inside an async function will block the
entire runtime thread. This prevents other tasks from making progress
and can cause your application to appear frozen or unresponsive.
// DON'T DO THIS async fn bad_example() { std::thread::sleep(Duration::from_secs(1)); // Blocks the runtime! } // DO THIS INSTEAD async fn good_example() { tokio::time::sleep(Duration::from_secs(1)).await; // Async sleep }
Forgetting to Await
⚠ Pitfall
Calling an async function without
.await
returns a
Future
that is never executed. The Rust compiler will warn you about this, but
it's a common source of confusing bugs, especially for developers coming
from languages like JavaScript where promises start executing immediately.
// This doesn't do what you think! async fn wrong() { fetch_data(); // Returns a Future, but doesn't execute! } // Correct: async fn right() { fetch_data().await; // Actually executes the future }
Conclusion
Async Rust with Tokio provides powerful tools for writing concurrent, efficient code. While the learning curve can be steep, the benefits in terms of performance and safety are substantial.
In the next post, we'll explore more advanced async patterns and building a real-world web server with Tokio and Axum.