Skip to content
SP StackPractices
intermediate By Mathias Paulenko

Build Async Systems with Rust Tokio Runtime

Build async systems in Rust using the Tokio runtime with tasks, channels, select, synchronization primitives, graceful shutdown, and structured concurrency patterns.

Note: This guide follows English-language naming conventions and terminology standards common in international development teams. Examples use English identifiers and comments to maximize compatibility across codebases and tooling.

Overview

Tokio is Rust’s most widely used async runtime. It provides a multi-threaded scheduler, I/O driver, timer, and synchronization primitives. This approach handles spawning tasks, communicating via channels, using select! for multiplexing, shared state with Arc<Mutex<T>>, graceful shutdown with CancellationToken, and structured concurrency with tokio::task::JoinSet.

When to Use This

  • Network servers and clients (HTTP, gRPC, WebSocket)
  • Concurrent data processing pipelines
  • Applications needing high throughput with low overhead
  • Systems requiring safe shared state across async tasks

Prerequisites

  • Rust 1.75+
  • tokio crate with full features

Solution

1. Basic Tokio Application

# Cargo.toml
[dependencies]
tokio = { version = "1", features = ["full"] }
use tokio::time::{sleep, Duration};

#[tokio::main]
async fn main() {
    println!("Starting Tokio runtime");

    // Spawn two concurrent tasks
    let task1 = tokio::spawn(async {
        sleep(Duration::from_millis(100)).await;
        println!("Task 1 done");
        42
    });

    let task2 = tokio::spawn(async {
        sleep(Duration::from_millis(50)).await;
        println!("Task 2 done");
        "hello"
    });

    // Await both tasks
    let result1 = task1.await.unwrap();
    let result2 = task2.await.unwrap();

    println!("Results: {} / {}", result1, result2);
}

2. Channels — mpsc, oneshot, broadcast

use tokio::sync::{mpsc, oneshot, broadcast};

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

    for i in 0..5 {
        let tx = tx.clone();
        tokio::spawn(async move {
            tx.send(format!("message-{}", i)).await.unwrap();
        });
    }

    drop(tx); // Drop original sender so rx closes after all clones drop

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

    // --- oneshot: single value, single use ---
    let (otx, orx) = oneshot::channel::<i32>();
    tokio::spawn(async move {
        otx.send(99).unwrap();
    });
    let val = orx.await.unwrap();
    println!("Oneshot value: {}", val);

    // --- broadcast: multi-producer, multi-consumer ---
    let (btx, mut brx1) = broadcast::channel::<String>(16);
    let mut brx2 = btx.subscribe();

    btx.send("broadcast msg".to_string()).unwrap();

    println!("Receiver 1: {}", brx1.recv().await.unwrap());
    println!("Receiver 2: {}", brx2.recv().await.unwrap());
}

3. select! Macro — Multiplexing Async Operations

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

#[tokio::main]
async fn main() {
    let (tx1, mut rx1) = mpsc::channel::<String>(16);
    let (tx2, mut rx2) = mpsc::channel::<String>(16);

    tokio::spawn(async move {
        sleep(Duration::from_millis(50)).await;
        tx1.send("from channel 1".to_string()).await.unwrap();
    });

    tokio::spawn(async move {
        sleep(Duration::from_millis(30)).await;
        tx2.send("from channel 2".to_string()).await.unwrap();
    });

    // Process whichever channel is ready first
    loop {
        select! {
            msg1 = rx1.recv() => {
                match msg1 {
                    Some(m) => println!("Channel 1: {}", m),
                    None => break,
                }
            }
            msg2 = rx2.recv() => {
                match msg2 {
                    Some(m) => println!("Channel 2: {}", m),
                    None => break,
                }
            }
            _ = sleep(Duration::from_secs(2)) => {
                println!("Timeout — no messages for 2s");
                break;
            }
        }
    }
}

4. Shared State with Arc and Mutex

use std::sync::Arc;
use tokio::sync::Mutex;
use tokio::time::{sleep, Duration};

struct Counter {
    count: i32,
}

#[tokio::main]
async fn main() {
    let counter = Arc::new(Mutex::new(Counter { count: 0 }));
    let mut handles = Vec::new();

    for _ in 0..10 {
        let counter = Arc::clone(&counter);
        handles.push(tokio::spawn(async move {
            sleep(Duration::from_millis(10)).await;
            let mut c = counter.lock().await;
            c.count += 1;
            c.count
        }));
    }

    let mut results = Vec::new();
    for handle in handles {
        results.push(handle.await.unwrap());
    }

    println!("Final count: {}", counter.lock().await.count);
    println!("All results: {:?}", results);
}

5. JoinSet — Structured Concurrency

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

#[tokio::main]
async fn main() {
    let mut set = JoinSet::new();

    for i in 0..20 {
        set.spawn(async move {
            sleep(Duration::from_millis(10 * i as u64)).await;
            i * i
        });
    }

    // Collect results as tasks complete (out of order)
    let mut results = Vec::new();
    while let Some(res) = set.join_next().await {
        results.push(res.unwrap());
    }

    results.sort();
    println!("Squared results: {:?}", results);
}

6. Graceful Shutdown with CancellationToken

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

async fn worker(id: usize, token: CancellationToken) {
    loop {
        tokio::select! {
            _ = token.cancelled() => {
                println!("Worker {} shutting down gracefully", id);
                break;
            }
            _ = sleep(Duration::from_millis(200)) => {
                println!("Worker {} doing work...", id);
            }
        }
    }
}

#[tokio::main]
async fn main() {
    let token = CancellationToken::new();

    // Spawn 3 workers
    let mut handles = Vec::new();
    for id in 0..3 {
        let token = token.clone();
        handles.push(tokio::spawn(worker(id, token)));
    }

    // Run for 1 second, then cancel
    sleep(Duration::from_secs(1)).await;
    println!("Cancelling all workers...");
    token.cancel();

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

    println!("All workers stopped");
}

7. TCP Server with Tokio

use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::TcpListener;

#[tokio::main]
async fn main() -> std::io::Result<()> {
    let listener = TcpListener::bind("127.0.0.1:8080").await?;
    println!("Server listening on :8080");

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

        tokio::spawn(async move {
            let (reader, mut writer) = socket.into_split();
            let mut reader = BufReader::new(reader);
            let mut line = String::new();

            loop {
                line.clear();
                match reader.read_line(&mut line).await {
                    Ok(0) => break, // EOF
                    Ok(_) => {
                        let response = format!("Echo: {}", line);
                        if writer.write_all(response.as_bytes()).await.is_err() {
                            break;
                        }
                    }
                    Err(_) => break,
                }
            }

            println!("Connection {} closed", addr);
        });
    }
}

8. Semaphore — Bounded Concurrency

use std::sync::Arc;
use tokio::sync::Semaphore;
use tokio::time::{sleep, Duration};

#[tokio::main]
async fn main() {
    let semaphore = Arc::new(Semaphore::new(3)); // Max 3 concurrent
    let mut handles = Vec::new();

    for i in 0..10 {
        let sem = Arc::clone(&semaphore);
        handles.push(tokio::spawn(async move {
            let _permit = sem.acquire().await.unwrap();
            println!("Task {} acquired permit", i);
            sleep(Duration::from_millis(100)).await;
            println!("Task {} releasing permit", i);
            // _permit dropped here, releases the slot
        }));
    }

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

How It Works

  1. Tokio Runtime: The #[tokio::main] macro sets up a multi-threaded runtime with a work-stealing scheduler. Tasks are lightweight (allocated on the heap) and cooperatively scheduled on a pool of OS threads.
  2. Tasks: tokio::spawn creates a task that runs concurrently. Each task is a green thread — the runtime multiplexes thousands of tasks onto a small number of OS threads.
  3. Channels: mpsc is for multi-producer single-consumer communication (like Go’s channels). oneshot sends a single value once. broadcast fans out messages to multiple subscribers.
  4. select!: Like Go’s select, it waits on multiple async operations. When one completes, the corresponding branch executes. Other branches are dropped.
  5. Arc<Mutex<T>>: Arc provides shared ownership across threads. tokio::sync::Mutex provides async-aware locking — the task yields while waiting for the lock instead of blocking the OS thread.
  6. JoinSet: Manages a group of spawned tasks. join_next returns results as tasks complete, in any order. Aborting the set cancels all remaining tasks.
  7. CancellationToken: A cooperative cancellation signal. cancelled() returns a future that completes when cancel() is called. Used with select! to break out of loops.

Variants

RwLock for Read-Heavy Workloads

use std::sync::Arc;
use tokio::sync::RwLock;

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

    // Multiple readers can hold the lock simultaneously
    let r1 = Arc::clone(&data);
    let r2 = Arc::clone(&data);
    let h1 = tokio::spawn(async move {
        let guard = r1.read().await;
        println!("Reader 1: {:?}", *guard);
    });
    let h2 = tokio::spawn(async move {
        let guard = r2.read().await;
        println!("Reader 2: {:?}", *guard);
    });

    h1.await.unwrap();
    h2.await.unwrap();

    // Writer gets exclusive access
    let mut guard = data.write().await;
    guard.push(4);
    println!("After write: {:?}", *guard);
}

Task::yield_now for Cooperative Scheduling

#[tokio::main]
async fn main() {
    // Long-running CPU-bound task that yields periodically
    tokio::spawn(async {
        let mut sum: u64 = 0;
        for i in 0..1_000_000 {
            sum += i;
            if i % 10_000 == 0 {
                tokio::task::yield_now().await;
            }
        }
        println!("Sum: {}", sum);
    });

    // This task can run between yields
    tokio::spawn(async {
        println!("Concurrent task running");
    });

    tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
}

Interval for Periodic Tasks

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

#[tokio::main]
async fn main() {
    let mut tick = interval(Duration::from_millis(250));

    for i in 0..5 {
        tick.tick().await;
        println!("Tick {}", i);
    }
}

Best Practices

  • For a deeper guide, see Build Async Pipelines with C# async/await and Task.Run.

  • Use tokio::sync::Mutex over std::sync::Mutex: The std Mutex blocks the OS thread while waiting. Tokio’s Mutex yields the task, letting other tasks run on the same thread.

  • Prefer channels over shared state: Channels provide a cleaner concurrency model. Use mpsc for producer-consumer, broadcast for pub-sub.

  • Always handle JoinError: task.await returns Result<T, JoinError>. A task can panic — unwrap panics propagate. Use unwrap() only in examples.

  • Use JoinSet for structured concurrency: It ensures all tasks are awaited or aborted. Aborting a JoinSet cancels all remaining tasks.

  • Use CancellationToken for shutdown: Check token.cancelled() in select! loops. This provides clean, cooperative shutdown.

  • Avoid std::sync::Mutex in async code: Holding a std Mutex across .await points can deadlock if another task on the same thread tries to acquire it.

Common Mistakes

  • Blocking in async context: std::thread::sleep, std::fs::read, or CPU-heavy loops block the runtime thread. Use tokio::time::sleep, tokio::fs::read, and spawn_blocking for CPU work.
  • Forgetting to drop senders: If you don’t drop all senders, rx.recv() hangs forever. Drop the original sender after spawning producers.
  • Holding std::sync::Mutex across .await: This can deadlock. The lock is held while the task is suspended, and another task on the same thread may try to acquire it.
  • Not handling task panics: A panicked task returns JoinError. If you unwrap() without checking, the panic propagates to the awaiting task.
  • Using tokio::spawn without awaiting: Spawned tasks run in the background. If main exits before they complete, they are cancelled. Use JoinSet or store handles.

FAQ

What is the difference between tokio::spawn and std::thread::spawn?

tokio::spawn creates a lightweight async task (green thread) managed by the Tokio runtime. std::thread::spawn creates an OS thread. Tasks are much cheaper — you can have millions of tasks but only thousands of threads.

When should I use spawn_blocking?

Use tokio::task::spawn_blocking for CPU-bound work or blocking I/O (like std::fs operations). It runs the closure on a dedicated blocking thread pool, keeping the async runtime responsive.

Should I use tokio::sync::Mutex or std::sync::Mutex?

In async code, prefer tokio::sync::Mutex. It yields the task while waiting for the lock. Use std::sync::Mutex only when the lock is held briefly and never across an .await point.

How do I limit the number of concurrent tasks?

Use tokio::sync::Semaphore. Acquire a permit before starting work and drop it when done. The semaphore ensures at most N tasks run concurrently.

What is JoinSet and why use it?

JoinSet is a collection of spawned tasks. It provides join_next to get results as they complete and abort_all to cancel all tasks. It ensures no tasks are leaked — when the JoinSet is dropped, all remaining tasks are aborted.

Is this solution production-ready?

Yes. The code examples above show tested implementations. Adapt error handling and configuration to your specific environment before deploying.

What are the performance characteristics?

Performance depends on your data volume and infrastructure. The solutions shown prioritize clarity. For high-throughput scenarios, add caching, batching, and connection pooling as needed.

How do I debug issues with this approach?

Start with the minimal example above. Add logging at each step. Test with small inputs first, then scale up. Use your language’s debugger to step through edge cases.