Rust Async/Await â A Practical Guide
Rust async/await provides zero-cost asynchronous programming where futures are polled lazily, enabling thousands of concurrent connections per thread with no runtime overhead for unused operations.
What You'll Learn
In this tutorial, you'll learn how Rust async/await works, how futures are polled, how the Tokio runtime schedules tasks, how to write async functions, and how to build high-performance async network services.
Why It Matters
Traditional threading hits limits at scale â each thread consumes megabytes of stack memory. Async Rust enables handling tens of thousands of concurrent connections on a single thread. This is essential for modern network services: web servers, proxies, databases, and real-time communication systems.
Real-World Use
Tokio powers Discord, AWS Lambda runtime, and Cloudflare's edge network. The Hyper HTTP library uses async for high-throughput request handling. SQLx uses async for database drivers. Doda Browser's networking layer uses async for parallel downloads without thread overhead.
flowchart LR
subgraph "Single OS Thread"
T1[Task 1: waiting for I/O] -->|yield| Runtime
T2[Task 2: ready to run] -->|poll| Runtime
T3[Task 3: waiting for timer] -->|yield| Runtime
Runtime -->|wake| T1
Runtime -->|wake| T3
end
note: "Many tasks, one thread -- cooperative multitasking"
Prerequisites: Rust Concurrency, Closures, and Smart Pointers.
Async Functions and Futures
An async function returns a Future. It does nothing until polled.
use std::time::Duration;
async fn do_work(id: u32) -> String {
// Simulates async I/O without blocking
tokio::time::sleep(Duration::from_millis(100)).await;
format!("Task {} completed", id)
}
#[tokio::main]
async fn main() {
let future = do_work(42); // creates Future, does NOT execute yet
println!("Future created, not yet executed");
let result = future.await; // executes the future
println!("{}", result);
}
Expected output:
Future created, not yet executed
Task 42 completed
Running Multiple Tasks Concurrently
Join multiple futures to run them concurrently.
use std::time::Duration;
async fn fetch_data(id: u32) -> String {
tokio::time::sleep(Duration::from_millis(100 * id as u64)).await;
format!("Data from source {}", id)
}
#[tokio::main]
async fn main() {
let start = std::time::Instant::now();
// Run three fetches concurrently
let results = tokio::join!(
fetch_data(1),
fetch_data(2),
fetch_data(3),
);
let elapsed = start.elapsed();
println!("Results: {:?}", results);
println!("Elapsed: {:?} (not sequential sum)", elapsed);
}
Expected output:
Results: ("Data from source 1", "Data from source 2", "Data from source 3")
Elapsed: 303ms (not sequential sum)
Spawning Tokio Tasks
tokio::spawn creates independent tasks that run concurrently on the Tokio runtime.
use std::time::Duration;
async fn scan_file(path: &str) -> String {
tokio::time::sleep(Duration::from_millis(100)).await;
format!("Scanned: {}", path)
}
#[tokio::main]
async fn main() {
let mut handles = vec![];
for i in 0..5 {
let handle = tokio::spawn(async move {
scan_file(&format!("file_{}.txt", i)).await
});
handles.push(handle);
}
for handle in handles {
let result = handle.await.unwrap();
println!("{}", result);
}
}
Expected output:
Scanned: file_0.txt
Scanned: file_1.txt
Scanned: file_2.txt
Scanned: file_3.txt
Scanned: file_4.txt
Async I/O
Tokio provides async versions of standard I/O operations.
use tokio::fs::File;
use tokio::io::AsyncReadExt;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let path = "Cargo.toml";
let mut file = File::open(path).await?;
let mut contents = String::new();
file.read_to_string(&mut contents).await?;
let lines: Vec<&str> = contents.lines().collect();
println!("File {} has {} lines", path, lines.len());
println!("First line: {}", lines.first().unwrap_or(&""));
// Async networking
let mut stream = tokio::net::TcpStream::connect("127.0.0.1:8080").await;
match stream {
Ok(_) => println!("Connected to server"),
Err(e) => println!("Connection failed (expected): {}", e),
}
Ok(())
}
Expected output:
File Cargo.toml has [number] lines
First line: [package]
Connection failed (expected): Connection refused
Building an Async Server
A simple async TCP echo server demonstrates the power of async.
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::TcpListener;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let listener = TcpListener::bind("127.0.0.1:0").await?;
let port = listener.local_addr()?.port();
println!("Server listening on port {}", port);
// Connect to self for demonstration
let client = tokio::spawn(async move {
let mut stream = tokio::net::TcpStream::connect(format!("127.0.0.1:{}", port)).await.unwrap();
stream.write_all(b"hello server\n").await.unwrap();
let mut buf = vec![0u8; 1024];
let n = stream.read(&mut buf).await.unwrap();
println!("Client received: {}", String::from_utf8_lossy(&buf[..n]));
});
let (mut stream, addr) = listener.accept().await?;
println!("Accepted connection from {}", addr);
let mut reader = BufReader::new(&mut stream);
let mut line = String::new();
reader.read_line(&mut line).await?;
stream.write_all(format!("Echo: {}", line).as_bytes()).await?;
client.await?;
Ok(())
}
Async and Security
Async Rust's cooperative multitasking model has security implications:
- No preemption: A misbehaving async task can block the entire thread if it never yields
- Cancellation safety: Futures can be dropped at await points â code must handle this safely
- Deadlock freedom: Holding a std::sync::Mutex across await points causes deadlocks; use tokio::sync::Mutex instead
- Resource limits: Spawning unlimited tasks can exhaust memory without backpressure
Durga Antivirus Pro's async scan pipeline uses bounded channels and cancellation tokens to ensure backpressure and graceful shutdown.
Common Mistakes
1. Blocking the Async Runtime
Calling std::thread::sleep or synchronous I/O inside async code blocks the entire runtime thread. Use tokio::time::sleep instead.
2. Holding Std Mutex Across .await
std::sync::MutexGuard is not Send across .await points. Use tokio::sync::Mutex for async code.
3. Not Spawning Tasks for Parallelism
Sequentially awaiting multiple async functions runs them one at a time. Use tokio::join! or tokio::spawn for concurrency.
4. Ignoring Cancellation Safety
Futures can be dropped at any .await. Code after .await may not run. Design state machines that handle partial execution.
5. Using Unbounded Channels Without Backpressure
tokio::sync::mpsc::unbounded_channel grows without limit. Use bounded channels and handle send errors for backpressure.
Practice Questions
1. What is a Future in Rust?
A Future is a value that may not be ready yet. It is polled by the async runtime. When it yields Poll::Pending, the runtime schedules other tasks. When ready, it returns Poll::Ready(value).
2. How does tokio::spawn differ from direct .await?
tokio::spawn creates a new independent task that runs concurrently. Direct .await blocks the current task until the future completes. Use spawn for parallelism, await for sequential composition.
3. What is the Tokio runtime? Tokio is an async runtime that provides I/O drivers, timers, and a task scheduler. It manages a thread pool and polls futures, waking them when I/O events occur.
4. Why should you not use std::sync::Mutex in async code?
std::sync::Mutex blocks the OS thread when locked. In async code, this blocks all tasks on that thread. Use tokio::sync::Mutex which yields the task instead of blocking.
5. Challenge: Build an async web crawler that fetches multiple URLs concurrently using reqwest, with a limit of 10 concurrent requests.
Mini Project: Async File Scanner
use tokio::fs;
use tokio::io::AsyncReadExt;
use std::time::Instant;
use tokio::sync::Semaphore;
async fn scan_file(path: &str, pattern: &str) -> Result<(String, usize), Box<dyn std::error::Error>> {
let mut file = fs::File::open(path).await?;
let mut contents = String::new();
file.read_to_string(&mut contents).await?;
let matches = contents.matches(pattern).count();
Ok((path.to_string(), matches))
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let start = Instant::now();
let files = vec!["Cargo.toml", "src/main.rs"];
let semaphore = Semaphore::new(2); // max 2 concurrent scans
let mut handles = vec![];
for file in &files {
let permit = semaphore.acquire().await.unwrap();
let f = file.to_string();
handles.push(tokio::spawn(async move {
let result = scan_file(&f, "rust").await;
drop(permit);
result
}));
}
for handle in handles {
match handle.await.unwrap() {
Ok((path, count)) => println!("{}: {} matches", path, count),
Err(e) => println!("Error: {}", e),
}
}
println!("Total time: {:?}", start.elapsed());
Ok(())
}
FAQ
Related Concepts
What's Next
Explore Unsafe Rust for low-level control, and Performance Optimization for profiling and tuning async code.
Built by the developers of Doda Browser, DodaZIP, and Durga Antivirus Pro.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro