Skip to content
Home
Rust Async Programming: Tokio and Async/Await

Rust Async Programming: Tokio and Async/Await

Rust Rust 7 min read 1481 words Beginner ExcellentWiki Editorial Team

Rust’s async model provides zero-cost asynchronous I/O — no garbage collector, no implicit allocation, no runtime overhead beyond what you use. The async/await syntax compiles down to state machines that implement the Future trait.

Futures

A Future represents a value that may not be available yet. It’s a poll-based abstraction — the runtime calls poll() to advance the future, and the future returns Poll::Pending (not ready) or Poll::Ready(value) (done).

use std::future::Future;
use std::pin::Pin;
use std::task::{Context, Poll};

struct MyFuture {
    ready: bool,
    value: u32,
---

impl Future for MyFuture {
    type Output = u32;

    fn poll(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<Self::Output> {
        if self.ready {
            Poll::Ready(self.value)
        } else {
            Poll::Pending
        }
    }
---

You rarely implement Future manually — the async keyword generates the state machine for you.

Async/Await

async fn fetch_user(id: u32) -> Result<User, Error> {
    let url = format!("https://api.example.com/users/{}", id);
    let response = reqwest::get(&url).await?;
    let user = response.json::<User>().await?;
    Ok(user)
---

The await keyword yields control back to the runtime. The runtime can run other tasks while waiting for I/O. When the future is ready, the runtime resumes execution from the await point.

Async Blocks

let future = async {
    let data = fetch_data().await;
    process(data).await
---;

The Tokio Runtime

Tokio is the most popular async runtime for Rust — providing an event-driven, non-blocking I/O platform with a work-stealing scheduler.

Setup

[dependencies]
tokio = { version = "1", features = ["full"] }

The Runtime

#[tokio::main]
async fn main() {
    println!("Hello from Tokio");
---

The #[tokio::main] macro expands to:

fn main() {
    let rt = tokio::runtime::Runtime::new().unwrap();
    rt.block_on(async {
        println!("Hello from Tokio");
    });
---

Spawning Tasks

#[tokio::main]
async fn main() {
    let handle = tokio::spawn(async {
        // This runs concurrently on the Tokio runtime
        do_work().await
    });

    // Do other work while the spawned task runs
    other_work().await;

    // Wait for the spawned task to complete
    let result = handle.await.unwrap();
---

Tasks are the unit of concurrency in Tokio. Each task runs independently and is scheduled across available threads.

Key Tokio Primitives

TCP Listener

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?;

    loop {
        let (socket, addr) = listener.accept().await?;
        tokio::spawn(async move {
            handle_connection(socket).await;
        });
    }
---

async fn handle_connection(mut socket: tokio::net::TcpStream) {
    // ... handle connection
---

Async Read/Write

use tokio::io::{AsyncReadExt, AsyncWriteExt};

async fn echo(mut socket: TcpStream) {
    let mut buf = [0; 1024];
    loop {
        let n = socket.read(&mut buf).await.unwrap();
        if n == 0 { return; }  // EOF
        socket.write_all(&buf[..n]).await.unwrap();
    }
---

Channels

use tokio::sync::mpsc;

#[tokio::main]
async fn main() {
    let (tx, mut rx) = mpsc::channel::<String>(32);

    // Producer
    tokio::spawn(async move {
        for i in 0..10 {
            tx.send(format!("message {}", i)).await.unwrap();
        }
    });

    // Consumer
    while let Some(msg) = rx.recv().await {
        println!("Received: {}", msg);
    }
---

Shared State with Mutex

use tokio::sync::Mutex;
use std::sync::Arc;

async fn increment(counter: Arc<Mutex<u32>>) {
    let mut count = counter.lock().await;
    *count += 1;
---

#[tokio::main]
async fn main() {
    let counter = Arc::new(Mutex::new(0u32));
    let mut handles = vec![];

    for _ in 0..10 {
        let counter = counter.clone();
        handles.push(tokio::spawn(increment(counter)));
    }

    for handle in handles {
        handle.await.unwrap();
    }

    println!("Final count: {}", *counter.lock().await);
---

Building an Async HTTP Client

use reqwest;
use serde::Deserialize;

#[derive(Deserialize, Debug)]
struct Post {
    userId: u32,
    id: u32,
    title: String,
    body: String,
---

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let client = reqwest::Client::new();

    let posts: Vec<Post> = client
        .get("https://jsonplaceholder.typicode.com/posts")
        .send()
        .await?
        .json()
        .await?;

    for post in &posts[..5] {
        println!("Post {}: {}", post.id, post.title);
    }

    Ok(())
---

Error Handling in Async Code

use std::error::Error;

async fn fallible_operation() -> Result<String, Box<dyn Error>> {
    let response = reqwest::get("https://example.com").await?;
    let body = response.text().await?;
    Ok(body)
---

async fn with_timeout() -> Result<String, Box<dyn Error>> {
    let result = tokio::time::timeout(
        std::time::Duration::from_secs(5),
        fallible_operation(),
    ).await;

    match result {
        Ok(Ok(body)) => Ok(body),
        Ok(Err(e)) => Err(e),
        Err(_) => Err("Operation timed out".into()),
    }
---

Async Testing

#[tokio::test]
async fn test_fetch_user() {
    let user = fetch_user(1).await.unwrap();
    assert_eq!(user.id, 1);
    assert_eq!(user.name, "Alice");
---

Performance Considerations

  • Async is best for I/O-bound work. CPU-bound tasks should use tokio::task::spawn_blocking
  • Task spawning is cheap but not free — don’t spawn per-byte
  • Bounded channels prevent unbounded memory growth
  • Use tokio::sync::Semaphore to limit concurrent operations
use tokio::sync::Semaphore;

#[tokio::main]
async fn main() {
    let semaphore = Arc::new(Semaphore::new(10));  // max 10 concurrent
    let mut handles = vec![];

    for url in urls {
        let permit = semaphore.clone().acquire_owned().await.unwrap();
        handles.push(tokio::spawn(async move {
            let _permit = permit;
            fetch_url(url).await
        }));
    }
---

Async Runtimes: Tokio vs async-std

Tokio is the dominant async runtime in the Rust ecosystem, powering most production async applications. It provides an I/O-driven, work-stealing scheduler optimized for network services. async-std offers a more stdlib-like API but has a smaller ecosystem. For most projects, Tokio is the recommended choice due to its maturity, performance, and ecosystem support. The #[tokio::main] attribute macro transforms an async main function into a properly configured runtime.

Pinning and the Async Lifecycle

Rust’s async system relies on Pin to ensure that futures remain at the same memory address after they are polled. This is necessary because futures may contain self-referential structures — a pattern that emerges naturally when borrowing across .await points. The pin_mut! macro and Box::pin are the two main ways to pin futures. Understanding pinning is essential for writing manual futures or working with certain async abstractions.

Select Macros and Graceful Shutdown

Tokio’s tokio::select! macro enables waiting on multiple concurrent branches, completing when the first branch finishes. This is essential for implementing graceful shutdown — listen for a shutdown signal while also handling incoming connections. The CancellationToken primitive from tokio_util provides a structured approach to propagating cancellation across task trees without manual flag checking.

Summary

Rust’s async model provides efficient, zero-cost concurrency. Tokio is the production runtime with TCP, timers, channels, and synchronization primitives. Async/await keeps code readable while the runtime handles scheduling. Use async for I/O-bound work, spawn for concurrency, and channels for communication between tasks.

FAQ

What is the difference between async and threads? Async is best for I/O-bound tasks that spend most of their time waiting (network requests, file reads). Threads are better for CPU-bound computation. Async tasks are multiplexed onto a small number of threads, while each thread requires its own OS stack.

How do I choose between Tokio and async-std? Tokio has the largest ecosystem, best performance, and most production deployments. async-std has a simpler API that mirrors the standard library. For new projects, Tokio is recommended.

Can I mix async and sync code? Yes, use tokio::task::spawn_blocking for CPU-bound synchronous work, and tokio::runtime::Handle::block_on to call async from sync contexts.

What causes deadlocks in async code? Common causes include holding a Mutex guard across an .await point, circular dependencies between tasks, and filling bounded channels without consuming. Use tokio::sync::Mutex (not std::sync::Mutex) inside async contexts.

Async Traits and Dynamically Dispatch

Rust’s async/await model is zero-cost by default, but combining async with traits requires care. Before Rust 1.75, async functions in traits required boxing or manual polling. The #[async_trait] crate from the async ecosystem provided a workaround by boxing the returned future:

#[async_trait]
pub trait Fetch {
    async fn get_data(&self) -> Result<String, Error>;
---

The async_trait macro transforms the method to return Pin<Box<dyn Future<Output = ...> + Send>>, which introduces heap allocation and dynamic dispatch overhead. For most applications this overhead is negligible, but hot-path code may benefit from manual implementations using Future directly.

Structured Concurrency

Structured concurrency ensures that spawned tasks complete before their parent scope exits. Without it, leaked tasks can exhaust resources or continue running after they are no longer needed. The tokio::task::JoinSet provides structured concurrency out of the box:

use tokio::task::JoinSet;

let mut set = JoinSet::new();
for url in urls {
    set.spawn(fetch_url(url));
---
while let Some(result) = set.join_next().await {
    process(result?);
---
// All tasks are guaranteed to complete when JoinSet drops

Cancellation Safety

Async Rust has a subtlety around cancellation: if you select! between two futures and one branch completes, the other future is dropped (cancelled). Code that acquires a resource (like a mutex lock) in one .await and releases it in a later .await is not cancellation-safe:

// NOT cancellation-safe
let guard = mutex.lock().await;
do_something().await;  // If this future is cancelled, guard never drops!
drop(guard);

Always acquire resources and use them within the same async block, or use futures::FutureExt::boxed with proper cleanup.

Async Testing Patterns

Testing async code requires special handling. Use tokio::test to run async test functions:

#[tokio::test]
async fn test_fetch_data() {
    let result = fetch_data("https://example.com").await;
    assert!(result.is_ok());
---

For mocking in async tests, the mockall crate supports async methods. Use tokio::time::pause() to test timeout scenarios deterministically without waiting for real time. The #[tokio::test(start_paused = true)] option automatically freezes time at test start, making timeout-based tests fast and reliable.

Resource Management with Async Drop

Rust does not have async drop — the Drop trait is synchronous. For async cleanup (closing connections, flushing buffers), use patterns like:

  • Explicit close() or shutdown() methods that return futures
  • The defer crate or Scope::spawn for structured concurrency with cleanup
  • Tracking active resources with Arc<AtomicUsize> and awaiting their completion before shutdown

Related: Rust Concurrency Guide | Rust Web Development

Section: Rust 1481 words 7 min read Beginner 756 articles in section Report inaccuracy Back to top