Skip to content

Rust Concurrency & Async/Await Explained — Threads, Tokio & the Async Runtime

DodaTech Updated 2026-06-23 8 min read

In this tutorial, you'll learn about Rust Concurrency & Async/Await Explained. We cover key concepts, practical examples, and best practices.

Master Rust concurrency: std::thread, channels, Arc, Send and Sync traits, async/await with Tokio, and choosing between thread-based and async concurrency.

What You'll Learn

In this tutorial, you'll master Rust concurrency — spawning OS threads with std::thread, message passing with channels, shared state with Arc<Mutex>, the Send and Sync marker traits that enforce thread safety at compile time, and async/await with the Tokio runtime for I/O-bound workloads.

Why It Matters

Concurrency is where Rust truly shines. The type system catches data races at compile time — no segfaults, no race conditions, no deadlocks from forgotten locks (though logic-level deadlocks are still possible). This compile-time guarantee is transformative for server software, embedded systems, and desktop applications where correctness under concurrency is critical.

Real-World Use

The Tokio runtime powers Discord, AWS Lambda, and Cloudflare Workers. Firecracker microVMs use threads for hardware emulation and async for I/O. Durga Antivirus Pro uses a thread pool for parallel file scanning with async channels for progress reporting, ensuring no file is scanned twice and no race condition corrupts the threat database.

flowchart TD
    subgraph "Thread-Based"
        A[std::thread::spawn] --> B[OS thread]
        B --> C[Separate stack, preemptive]
        C --> D[Sync via channels / Arc]
    end
    subgraph "Async"
        E[async fn / .await] --> F[Future]
        F --> G[Runtime polls]
        G --> H[Cooperative, single-threaded or pooled]
    end
    I[Use threads when:] --> J[CPU-bound work]
    I --> K[Blocking I/O]
    L[Use async when:] --> M[Many I/O connections]
    L --> N[High-level concurrency]
â„šī¸ Info

Prerequisites: Concurrency basics and Error Handling. Understanding ownership and smart pointers is essential.

Thread Pools and Scoped Threads

std::thread::scope (Rust 1.63+) lets you borrow data without Arc, and thread pools manage resource usage:

use std::thread;

fn main() {
    // Scoped threads: borrow local variables without Arc
    let numbers = vec![1, 2, 3, 4, 5, 6, 7, 8];
    let mut results = vec![0u64; numbers.len()];
    
    thread::scope(|s| {
        for (i, chunk) in numbers.chunks(2).enumerate() {
            s.spawn(move || {
                let mut sum = 0u64;
                for &x in chunk {
                    sum += x as u64;
                }
                results[i] = sum;
            });
        }
    });
    // All threads guaranteed joined here
    
    println!("Chunk sums: {:?}", results);
    println!("Total: {}", results.iter().sum::<u64>());
}

Expected output:

Chunk sums: [3, 7, 11, 15]
Total: 36

Channels for Communication

Multiple-producer, multiple-consumer channels with backpressure:

use std::sync::mpsc;
use std::thread;
use std::time::Duration;

fn main() {
    let (tx, rx) = mpsc::channel();
    let tx2 = tx.clone();
    
    // Producer 1
    thread::spawn(move || {
        for i in 1..=5 {
            tx.send(format!("Producer A: {}", i)).unwrap();
            thread::sleep(Duration::from_millis(50));
        }
    });
    
    // Producer 2
    thread::spawn(move || {
        for i in 1..=5 {
            tx2.send(format!("Producer B: {}", i)).unwrap();
            thread::sleep(Duration::from_millis(80));
        }
    });
    
    // Consumer receives all messages
    for received in rx {
        println!("{}", received);
    }
}

Expected output: (order may vary)

Producer A: 1
Producer B: 1
Producer A: 2
Producer B: 2
Producer A: 3
Producer A: 4
Producer B: 3
Producer A: 5
Producer B: 4
Producer B: 5

Send and Sync: Compile-Time Thread Safety

Send means a type can be transferred across threads. Sync means a type can be shared across threads (accessed via reference). The compiler derives them automatically when all fields are Send/Sync:

use std::marker::{Send, Sync};
use std::sync::{Arc, Mutex};
use std::thread;

// Custom type that is NOT Sync
struct UnsyncCounter {
    count: i32,
}

// This would NOT compile if we tried to share &UnsyncCounter across threads
// because UnsyncCounter does not implement Sync
struct SafeCounter {
    count: Mutex<i32>,
}
// SafeCounter IS Sync because Mutex<i32> is Sync

fn main() {
    let counter = Arc::new(SafeCounter {
        count: Mutex::new(0),
    });
    
    let mut handles = vec![];
    for _ in 0..10 {
        let c = Arc::clone(&counter);
        handles.push(thread::spawn(move || {
            let mut count = c.count.lock().unwrap();
            *count += 1;
        }));
    }
    
    for h in handles {
        h.join().unwrap();
    }
    
    println!("Final count: {}", *counter.count.lock().unwrap());
}

Expected output:

Final count: 10

Async/Await with Tokio

Tokio is the de facto async runtime, providing an event-driven model for I/O-bound workloads:

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

async fn fetch_url(id: u32) -> String {
    println!("Fetching URL {} started", id);
    // Simulate network I/O
    sleep(Duration::from_millis(100 * id as u64)).await;
    format!("Response from URL {}", id)
}

#[tokio::main]
async fn main() {
    let mut handles = vec![];
    
    // Spawn concurrent tasks
    for i in 1..=5 {
        handles.push(tokio::spawn(fetch_url(i)));
    }
    
    // Await all tasks concurrently
    for handle in handles {
        let result = handle.await.unwrap();
        println!("Got: {}", result);
    }
}

Expected output: (timestamps may vary, order is by completion)

Fetching URL 1 started
Fetching URL 2 started
Fetching URL 3 started
Fetching URL 4 started
Fetching URL 5 started
Got: Response from URL 1
Got: Response from URL 2
Got: Response from URL 3
Got: Response from URL 4
Got: Response from URL 5
âš ī¸ Warning

Blocking in async: Never call blocking functions (like std::thread::sleep or Mutex::lock) inside async code. They block the entire runtime thread. Use tokio::task::spawn_blocking for CPU-bound or blocking work.

Common Errors

1. Sending non-Send types across threads

let rc = Rc::new(5);
thread::spawn(move || { println!("{}", rc); }); // ERROR: Rc is not Send

2. Deadlock with mutex

let m = Mutex::new(());
let _a = m.lock().unwrap();
let _b = m.lock().unwrap(); // Deadlock! Same thread locking twice

3. Holding a MutexGuard across .await

async fn bad() {
    let m = Mutex::new(0);
    let _guard = m.lock().unwrap();
    some_future().await; // ERROR: MutexGuard is not Send across .await
}

4. Forgetting to clone Arc

let data = Arc::new(vec![1, 2, 3]);
for _ in 0..3 {
    thread::spawn(|| { println!("{:?}", data); }); // ERROR: data moved
}

5. Unbounded channel memory growth

let (tx, rx) = mpsc::channel();
// Producer faster than consumer → memory grows unbounded

Practice Questions

1. What is the difference between Send and Sync? Send allows ownership transfer across threads. Sync allows shared reference access across threads (T: Sync means &T: Send). Most types are both, but Rc<T> is neither, and Mutex<T> is Sync while RefCell<T> is not.

2. When should you use channels vs Arc? Channels for communicating ownership (one-shot or streaming data). Arc<Mutex> for shared mutable state with fine-grained access. Channels are more idiomatic in Rust and avoid lock contention.

3. What is the difference between threads and async tasks? Threads are OS-managed, preemptively scheduled, have per-thread stacks, and are good for CPU-bound work. Async tasks are runtime-managed, cooperatively scheduled, share a stack, and are good for I/O-bound workloads with many concurrent operations.

4. How does Tokio's work-stealing scheduler work? Tokio uses a multi-threaded work-stealing scheduler. Each thread has its own task queue. When a thread is idle, it steals tasks from other threads' queues. This balances load without a central scheduler bottleneck.

5. Challenge: Build a concurrent web scraper that fetches 10 URLs concurrently using Tokio, processes responses, and writes results to a shared HashMap<String, usize> protected by an Arc<RwLock>.

Mini Project: Concurrent File Search Engine

use std::sync::{Arc, Mutex};
use std::thread;
use std::path::Path;
use std::fs;

fn search_file(path: &Path, pattern: &str) -> Vec<(String, usize)> {
    let mut results = Vec::new();
    if let Ok(content) = fs::read_to_string(path) {
        for (lineno, line) in content.lines().enumerate() {
            if line.contains(pattern) {
                results.push((path.to_string_lossy().into(), lineno + 1));
            }
        }
    }
    results
}

fn main() {
    let pattern = "unsafe";
    let results = Arc::new(Mutex::new(Vec::new()));
    let mut handles = vec![];
    
    let paths: Vec<_> = fs::read_dir("/home/admin1/projects/website/dodatech-tutorials/content/rust-systems")
        .unwrap()
        .filter_map(|e| e.ok())
        .map(|e| e.path())
        .filter(|p| p.extension().map_or(false, |e| e == "md"))
        .collect();
    
    for path in paths {
        let results = Arc::clone(&results);
        let pattern = pattern.to_string();
        handles.push(thread::spawn(move || {
            let found = search_file(&path, &pattern);
            let mut r = results.lock().unwrap();
            r.extend(found);
        }));
    }
    
    for h in handles {
        h.join().unwrap();
    }
    
    let final_results = results.lock().unwrap();
    for (file, line) in final_results.iter() {
        println!("Match in {} at line {}", file, line);
    }
}

Expected output: (varies by file content)

Match in ...unsafe-rust.md at line ...
Match in ...rust-unsafe-code.md at line ...
...

FAQ

What is the difference between async/.await and threads?

Threads are OS-level, preemptively scheduled, with per-thread stacks (1-8 MB each). Async tasks are user-space, cooperatively scheduled, with small state machines. Threads scale to thousands, async tasks scale to millions. Use threads for CPU-bound work, async for I/O-bound work.

Why can't I hold a MutexGuard across an .await point?

MutexGuard from std::sync::Mutex is not Send. If a future holding a MutexGuard is sent to another thread after .await, the guard would be on the wrong thread. Use tokio::sync::Mutex for async code, or scope the lock so it drops before .await.

How do I choose between Tokio, async-std, and smol?

Tokio is the most mature, best documented, and has the largest ecosystem. async-std provides a stdlib-like API. smol is minimal and lightweight. For production systems, Tokio is the safe choice. The ecosystem (hyper, axum, tonic, reqwest) all build on Tokio.

Concurrency Basics
Async/Await Basics
Unsafe Rust
Performance Optimization

What's Next

Dive into Unsafe Rust for low-level concurrency primitives, or optimize your concurrent code with Performance Optimization techniques.

Built by the developers of Doda Browser, DodaZIP, and Durga Antivirus Pro.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro