Skip to content
Codeloom
Rust

Rust Async Programming with Tokio: Getting Started

Learn async Rust with Tokio from scratch. Covers async/await syntax, spawning tasks, channels, TCP servers, and common pitfalls with working examples.

·8 min read · By Codeloom
Intermediate 14 min read

What you'll learn

  • Set up a Tokio project and understand the runtime
  • Write async functions with async/await
  • Spawn concurrent tasks and communicate between them
  • Build a simple async TCP echo server

Prerequisites

  • Comfortable with Rust basics — see /blog/rust-variables-and-types
  • Understanding of ownership — see /blog/rust-ownership-basics
  • Familiarity with Result and error handling

Async programming lets your program do useful work while waiting for I/O operations like network requests, file reads, or database queries. Instead of blocking a thread, an async function yields control so other tasks can run on the same thread. Rust’s async model is zero-cost — you pay no runtime overhead beyond what you use.

Tokio is the most widely used async runtime for Rust. It provides the executor that drives your futures to completion, along with async versions of standard I/O primitives, timers, channels, and more.

Setting Up a Tokio Project

Create a new project and add Tokio:

// In your terminal:
// cargo new async-demo
// cd async-demo
// cargo add tokio --features full

The full feature flag enables all Tokio components. For production, you would pick only the features you need (like rt-multi-thread, macros, net, io-util).

Your Cargo.toml dependencies should look like:

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

Your First Async Program

use tokio::time::{sleep, Duration};

#[tokio::main]
async fn main() {
    println!("Starting...");
    sleep(Duration::from_secs(1)).await;
    println!("One second later!");

    let result = fetch_data().await;
    println!("Got: {}", result);
}

async fn fetch_data() -> String {
    // Simulate a network request
    sleep(Duration::from_millis(500)).await;
    String::from("some data from the server")
}

The #[tokio::main] macro transforms your async fn main() into a regular fn main() that sets up the Tokio runtime and blocks on your future. Under the hood, it expands to:

fn main() {
    tokio::runtime::Runtime::new()
        .unwrap()
        .block_on(async {
            // your async main body here
        });
}

Understanding Futures and .await

An async fn does not run immediately when called. It returns a Future — a value representing work that has not started yet. Nothing happens until you .await it.

async fn compute() -> u64 {
    println!("Computing...");
    42
}

#[tokio::main]
async fn main() {
    let future = compute(); // Nothing printed yet
    println!("Future created");
    let value = future.await; // Now "Computing..." prints
    println!("Value: {}", value);
}

This is different from Go’s goroutines or JavaScript’s promises, which start executing immediately. In Rust, futures are lazy by design.

Running Tasks Concurrently

Calling .await on futures one after another runs them sequentially. To run them concurrently, use tokio::join! or tokio::spawn.

Using join! for Structured Concurrency

use tokio::time::{sleep, Duration};

async fn download_file(name: &str) -> String {
    println!("Starting download: {}", name);
    sleep(Duration::from_secs(2)).await;
    println!("Finished download: {}", name);
    format!("{} contents", name)
}

#[tokio::main]
async fn main() {
    // Sequential — takes 6 seconds
    // let a = download_file("a.txt").await;
    // let b = download_file("b.txt").await;
    // let c = download_file("c.txt").await;

    // Concurrent — takes 2 seconds
    let (a, b, c) = tokio::join!(
        download_file("a.txt"),
        download_file("b.txt"),
        download_file("c.txt"),
    );

    println!("Got: {}, {}, {}", a, b, c);
}

join! runs all three futures on the current task, interleaving their execution. All three downloads happen concurrently, so the total time is the duration of the longest one, not the sum.

Using spawn for Background Tasks

tokio::spawn creates a new task that runs independently on the runtime’s thread pool:

use tokio::time::{sleep, Duration};

#[tokio::main]
async fn main() {
    let handle = tokio::spawn(async {
        sleep(Duration::from_secs(1)).await;
        println!("Task completed");
        42
    });

    println!("Task is running in the background");
    // Do other work here...

    let result = handle.await.unwrap();
    println!("Task returned: {}", result);
}

Spawned tasks are true concurrent units. They can run on different threads in a multi-threaded runtime. The JoinHandle lets you await the result or cancel the task by dropping it.

Important: spawned tasks must be 'static — they cannot borrow from the calling scope. If you need to share data, use Arc or clone the data into the task.

use std::sync::Arc;

#[tokio::main]
async fn main() {
    let shared_data = Arc::new(vec![1, 2, 3, 4, 5]);

    let data = Arc::clone(&shared_data);
    let handle = tokio::spawn(async move {
        let sum: i32 = data.iter().sum();
        sum
    });

    println!("Sum: {}", handle.await.unwrap());
}

Async Channels

Tokio provides async channels for communication between tasks. The most common is mpsc (multi-producer, single-consumer):

use tokio::sync::mpsc;

#[derive(Debug)]
struct Job {
    id: u32,
    payload: String,
}

#[tokio::main]
async fn main() {
    let (tx, mut rx) = mpsc::channel::<Job>(32); // buffer size of 32

    // Spawn producers
    for i in 0..5 {
        let tx = tx.clone();
        tokio::spawn(async move {
            let job = Job {
                id: i,
                payload: format!("data-{}", i),
            };
            tx.send(job).await.unwrap();
        });
    }

    // Drop the original sender so the receiver knows when all senders are done
    drop(tx);

    // Consume jobs
    while let Some(job) = rx.recv().await {
        println!("Processing job {}: {}", job.id, job.payload);
    }

    println!("All jobs processed");
}

Tokio also provides oneshot channels for single-value communication and broadcast channels for multi-consumer scenarios:

use tokio::sync::oneshot;

#[tokio::main]
async fn main() {
    let (tx, rx) = oneshot::channel();

    tokio::spawn(async move {
        // Do some work
        let result = expensive_computation().await;
        tx.send(result).unwrap();
    });

    // Wait for the result
    let value = rx.await.unwrap();
    println!("Received: {}", value);
}

async fn expensive_computation() -> String {
    tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
    String::from("computed value")
}

Building an Async TCP Echo Server

Here is a practical example — a TCP server that echoes back whatever clients send:

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!("Server listening on 127.0.0.1:8080");

    loop {
        let (mut socket, addr) = listener.accept().await?;
        println!("New connection from: {}", addr);

        tokio::spawn(async move {
            let mut buf = vec![0u8; 1024];

            loop {
                let n = match socket.read(&mut buf).await {
                    Ok(0) => {
                        println!("Client {} disconnected", addr);
                        return;
                    }
                    Ok(n) => n,
                    Err(e) => {
                        eprintln!("Error reading from {}: {}", addr, e);
                        return;
                    }
                };

                if let Err(e) = socket.write_all(&buf[..n]).await {
                    eprintln!("Error writing to {}: {}", addr, e);
                    return;
                }
            }
        });
    }
}

Each client connection runs in its own spawned task, so the server handles thousands of concurrent connections efficiently. You can test this with nc localhost 8080 or telnet localhost 8080.

Shared State with Mutex

When multiple tasks need to read and write shared state, use tokio::sync::Mutex:

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

type Db = Arc<Mutex<HashMap<String, String>>>;

#[tokio::main]
async fn main() {
    let db: Db = Arc::new(Mutex::new(HashMap::new()));

    let db1 = Arc::clone(&db);
    let writer = tokio::spawn(async move {
        for i in 0..5 {
            let mut lock = db1.lock().await;
            lock.insert(format!("key-{}", i), format!("value-{}", i));
            // Lock is dropped here at end of scope
        }
    });

    let db2 = Arc::clone(&db);
    let reader = tokio::spawn(async move {
        tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
        let lock = db2.lock().await;
        for (k, v) in lock.iter() {
            println!("{}: {}", k, v);
        }
    });

    writer.await.unwrap();
    reader.await.unwrap();
}

Tip: if your critical sections are short and do not contain .await calls, use std::sync::Mutex instead of tokio::sync::Mutex — it has less overhead. Only use the Tokio mutex when you need to hold the lock across an .await point.

Timeouts and Select

Tokio provides utilities for timeout handling and racing futures:

use tokio::time::{timeout, Duration};

async fn slow_operation() -> String {
    tokio::time::sleep(Duration::from_secs(10)).await;
    String::from("done")
}

#[tokio::main]
async fn main() {
    // Timeout after 2 seconds
    match timeout(Duration::from_secs(2), slow_operation()).await {
        Ok(result) => println!("Got result: {}", result),
        Err(_) => println!("Operation timed out"),
    }
}

Use tokio::select! to race multiple futures and act on whichever finishes first:

use tokio::sync::mpsc;
use tokio::time::{sleep, Duration};

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

    tokio::spawn(async move {
        sleep(Duration::from_secs(3)).await;
        tx.send("hello".to_string()).await.unwrap();
    });

    tokio::select! {
        val = rx.recv() => {
            println!("Received: {:?}", val);
        }
        _ = sleep(Duration::from_secs(5)) => {
            println!("Timed out waiting for message");
        }
    }
}

Common Pitfalls

Forgetting to .await: An async function call without .await does nothing. The compiler warns about unused futures, so pay attention to those warnings.

Blocking the runtime: Never call std::thread::sleep or perform CPU-heavy work directly in an async context. It blocks the entire worker thread. Use tokio::task::spawn_blocking for blocking operations:

let result = tokio::task::spawn_blocking(|| {
    // CPU-heavy or blocking I/O work here
    std::thread::sleep(std::time::Duration::from_secs(1));
    "done"
}).await.unwrap();

Holding a MutexGuard across .await: With std::sync::Mutex, holding the guard across an .await point can cause deadlocks because the task might be moved to a different thread. Either use tokio::sync::Mutex or restructure your code to drop the guard before the .await.

Send bounds: Futures spawned with tokio::spawn must be Send. If your future holds a non-Send type (like Rc or RefCell), it cannot be spawned. Use Arc and Mutex instead.

Wrapping Up

Async Rust with Tokio gives you high-performance concurrent I/O with the safety guarantees Rust is known for. The core concepts are straightforward:

  • async fn returns a lazy future; .await drives it to completion.
  • tokio::join! runs futures concurrently; tokio::spawn runs them in background tasks.
  • Channels (mpsc, oneshot, broadcast) enable communication between tasks.
  • Arc<Mutex<T>> provides shared mutable state across tasks.
  • Use timeout and select! for deadline handling and racing futures.

Start with join! for structured concurrency, graduate to spawn when you need independent tasks, and reach for channels when tasks need to communicate. The async ecosystem in Rust is mature and battle-tested — Tokio powers production systems at companies like Discord, Cloudflare, and AWS.