Rust Concurrency & Async/Await Explained â Threads, Tokio & the Async Runtime
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
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]
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
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 ArcArc<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
Related Concepts
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