Rust async/await: Writing efficient non‑blocking I/O with Tokio
Async/await lets Rust code that talks to the network look like plain linear code. This article walks through the core concepts, shows a small HTTP example, and discusses the trade‑offs of using Tokio for high‑concurrency services.
03 Nov 2025, 18:35 UTC

Problem: Blocking I/O defeats concurrency
When a Rust program calls std::fs::read or a blocking TcpStream::read, the entire thread pauses until the operation finishes. In a server that handles many connections, each blocked thread means a limited number of requests can be processed concurrently, and the operating system must schedule threads, which costs CPU and memory.
Thesis: async/await + Tokio turns I/O into lightweight tasks
Rust’s async keyword turns a function into a state machine that implements the Future trait. The compiler rewrites the body into a poll loop that the runtime drives. The Tokio runtime provides a multithreaded, work‑stealing scheduler that polls many futures, allowing thousands of concurrent network connections with only a handful of OS threads.
Understanding Futures and the Poll Model
A Future is a value that may not be ready yet. It exposes a single method, poll, which the runtime calls repeatedly. When poll returns Poll::Pending, the runtime knows the future cannot finish now and will resume it later. When it returns Poll::Ready(value), the computation is complete.
Async/await is syntactic sugar: the compiler desugars
async fn foo() { /* body */ }
into a struct that implements Future and a poll method that tracks the function’s state. The await keyword simply waits for the awaited future to become ready.
Tokio Runtime: the engine that drives futures
Tokio’s #[tokio::main] macro creates a multi‑threaded runtime that runs the async main function. The runtime owns a thread pool; each thread runs a loop that polls tasks. When a task awaits a non‑blocking I/O operation, Tokio registers that I/O with the OS (via epoll/kqueue/IOCP) and immediately yields the thread to work on something else. When the OS signals that the I/O is ready, Tokio wakes the task and resumes it on the pool.
Because the runtime is work‑stealing, idle threads can steal tasks from busy threads, improving CPU utilization.
Concrete Example: an async HTTP GET with reqwest
Below is a minimal Cargo project that fetches https://example.com and prints the first 120 characters of the body. A second async task counts seconds while the request is in flight, demonstrating that the runtime is not blocked.
1. Cargo.toml
[package]
name = "async_http_example"
version = "0.1.0"
edition = "2021"
[dependencies]
tokio = { version = "1", features = ["full"] }
reqwest = { version = "0.11", features = ["json", "tokio-runtime"] }
2. src/main.rs
use std::time::Duration;
use tokio::time::sleep;
#[tokio::main]
async fn main() -> Result<(), Box> {
// Spawn a counter that runs concurrently with the HTTP request
tokio::spawn(async {
for i in 0..5 {
println!("counter {}", i);
sleep(Duration::from_secs(1)).await;
}
});
// Perform an HTTP GET
let resp = reqwest::get("https://example.com").await?;
let body = resp.text().await?;
// Print a snippet of the response body
let snippet = &body[..std::cmp::min(120, body.len())];
println!("\nResponse snippet: {}", snippet);
Ok(())
}
3. Run
$ cargo run
counter 0
counter 1
counter 2
counter 3
counter 4
Response snippet: <!DOCTYPE html>
Example Domain
The counter prints while the HTTP request is still in progress, proving that the runtime is non‑blocking.
Trade‑offs and Limitations
- Learning curve: Understanding the poll model and futures can be non‑trivial compared to synchronous code.
- Blocking code inside async: Calling
std::thread::sleeporstd::fs::readinside an async function blocks the entire thread pool, reducing concurrency. Usetokio::time::sleeportokio::fsinstead. - Runtime selection: The code depends on Tokio. If you later need a different runtime (e.g., async‑std) you must adjust macros and dependencies.
- Rapid ecosystem changes: Tokio’s API evolves; a program written for 1.0 might need minor edits for 1.1 or 1.2. Keep dependencies pinned or test on CI after upgrades.
When to Use Async/await with Tokio
If your application needs to handle many simultaneous network connections—web servers, proxies, or high‑throughput clients—async/await is the idiomatic Rust solution. For simple scripts or CPU‑bound tasks, synchronous code may be simpler and just as performant. Always profile and test concurrency to ensure the async model delivers the expected benefit for your workload.
Actionable Takeaway
- Create a new Cargo project and add
tokiowith thefullfeature andreqwestwithtokio-runtime. - Wrap your
mainin#[tokio::main]and write async functions that returnResult. - Replace any blocking calls with their Tokio equivalents (e.g.,
tokio::time::sleep,tokio::fs). - Run
cargo testorcargo runto confirm non‑blocking behavior by observing concurrent output. - Keep an eye on dependency versions—test after each minor upgrade of Tokio.
With these steps, you’ll be able to write clear, efficient, non‑blocking Rust code that scales with your application’s concurrency needs.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.