Table of Contents
- Understanding Concurrency in Rust
- Threads: OS-Managed Concurrency
- Async Programming: Lightweight Concurrency
- Threads vs. Async: When to Use Which?
- Combining Threads and Async
- Common Pitfalls and How Rust Prevents Them
- Examples in Action
- Conclusion
- References
1. Understanding Concurrency in Rust
Concurrency is the ability to execute multiple tasks independently, potentially overlapping in time. Rust supports two primary concurrency models:
- Threads: Managed by the operating system (OS), threads are heavyweight and pre-emptively scheduled. The OS interrupts threads to switch between them, making them suitable for CPU-bound tasks.
- Async Programming: Managed by user-space executors, async tasks are lightweight and cooperatively scheduled. Tasks yield control explicitly (via
await), making them ideal for I/O-bound tasks with low overhead.
Rust’s concurrency story is built on its core principles: ownership, borrowing, and type safety. These ensure that even in complex concurrent code, data races (unsynchronized access to shared data) and other bugs are caught at compile time.
2. Threads: OS-Managed Concurrency
Threads are the most basic concurrency primitive in Rust, provided by the standard library (std::thread). They allow you to run code in parallel with the main thread, leveraging multiple CPU cores.
2.1 Spawning Threads
To create a thread, use std::thread::spawn, which takes a closure (or function) as its entry point. The spawned thread runs concurrently with the main thread.
use std::thread;
use std::time::Duration;
fn main() {
// Spawn a new thread
let handle = thread::spawn(|| {
for i in 1..=5 {
println!("Thread: Count {}", i);
thread::sleep(Duration::from_millis(500));
}
});
// Main thread work
for i in 1..=3 {
println!("Main: Count {}", i);
thread::sleep(Duration::from_millis(500));
}
// Wait for the spawned thread to finish (blocking)
handle.join().unwrap();
}
Key Points:
thread::spawnreturns aJoinHandle<T>, which allows the main thread to wait for the spawned thread viajoin().join()blocks the caller until the thread finishes and returns aResult<T, Box<dyn Any + Send>>(to capture panics).- Threads run independently: The output order of “Thread” and “Main” messages is non-deterministic.
2.2 Sharing Data Between Threads: Arc and Mutex
By default, Rust’s ownership system prevents sharing data between threads (since multiple threads can’t have mutable access to the same data simultaneously). To share data safely, we use:
Arc<T>(Atomic Reference Counting): A thread-safe reference-counted pointer. It allows multiple threads to share ownership of data by incrementing/decrementing a reference count atomically.Mutex<T>(Mutual Exclusion): Ensures only one thread can access the data at a time, preventing race conditions.
Example: Sharing a Counter Between Threads
use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
// Wrap the counter in Arc (for shared ownership) and Mutex (for exclusive access)
let counter = Arc::new(Mutex::new(0));
let mut handles = vec![];
for _ in 0..10 {
// Clone the Arc to share ownership with the new thread
let counter = Arc::clone(&counter);
let handle = thread::spawn(move || {
// Lock the Mutex to access the data (blocks until lock is acquired)
let mut num = counter.lock().unwrap();
*num += 1;
});
handles.push(handle);
}
// Wait for all threads to finish
for handle in handles {
handle.join().unwrap();
}
// Print the final count (should be 10)
println!("Final count: {}", *counter.lock().unwrap());
}
How It Works:
Arc<Mutex<i32>>allows multiple threads to share the counter.Arcensures the data isn’t dropped until all threads release their references.Mutex::lock()returns aMutexGuard, which acts as a RAII wrapper: when it goes out of scope, the lock is automatically released. This prevents forgotten unlocks (a common source of deadlocks).
2.3 Thread Safety: Send and Sync Traits
Rust uses two auto-traits to enforce thread safety at compile time:
Send: A type isSendif its ownership can be transferred across threads. Most types (e.g.,i32,String) areSend, but types with non-thread-safe interior mutability (e.g.,Rc<T>,RefCell<T>) are not.Sync: A type isSyncif references to it (&T) can be safely shared across threads. This meansTcan be accessed concurrently via shared references. Types likeMutex<T>areSync(since&Mutex<T>can be shared, and the mutex ensures exclusive access).
Why This Matters:
thread::spawnrequires the closure to beSend(since the closure’s environment is moved to the new thread).Arc<T>requiresT: Sync(sinceArc<T>allows shared references across threads).
Rust’s compiler checks Send/Sync bounds, preventing you from accidentally sharing non-thread-safe data across threads. For example, using Rc<T> (not Send) in a thread will fail to compile:
use std::rc::Rc;
use std::thread;
fn main() {
let rc = Rc::new(5);
thread::spawn(move || { // Compile error: `Rc<i32>` is not `Send`
println!("{}", rc);
});
}
3. Async Programming: Lightweight Concurrency
Async programming is designed for I/O-bound tasks (e.g., network requests, file I/O) where waiting for operations (e.g., a database query) dominates execution time. Instead of blocking a thread during waits, async tasks yield control, allowing other tasks to run.
3.1 Async/Await Syntax
Rust’s async/await syntax simplifies writing async code. An async function returns a Future—a value representing a computation that may not have completed yet. await pauses the current task until the Future is ready, without blocking the thread.
Example: A Simple Async Function
// Async function: returns a Future<Output = u32>
async fn async_add(a: u32, b: u32) -> u32 {
a + b // This runs when the Future is polled
}
#[tokio::main] // Tokio executor (more on this later)
async fn main() {
let result = async_add(2, 3).await; // Await the Future to get the result
println!("2 + 3 = {}", result); // Output: "2 + 3 = 5"
}
Key Points:
async fndoes not execute immediately; it returns aFuturethat must be executed by an async runtime (e.g., Tokio, async-std).awaitcan only be used inside anasyncfunction or block. It suspends the task until theFuturecompletes, allowing the executor to run other tasks in the meantime.
3.2 Futures: The Building Blocks of Async
A Future is a type that implements the std::future::Future trait, which has a single method:
trait Future {
type Output;
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>;
}
poll: Attempts to resolve the future. ReturnsPoll::Ready(output)if done, orPoll::Pendingif it needs to wait (e.g., for I/O).Pin: Ensures the future isn’t moved in memory, which is critical for futures with self-referential data (e.g., a future that holds a reference to its own state).Context: Contains aWaker, which the future uses to notify the executor when it’s ready to be polled again (e.g., when data arrives on a socket).
3.3 Executors: Running Async Code
Futures don’t run on their own—they need an executor to manage scheduling. Executors handle polling futures, waking them when ready, and multiplexing tasks onto OS threads.
Popular Rust async runtimes (which include executors) are:
- Tokio: The most widely used runtime, with support for networking, timers, and more.
- async-std: A batteries-included runtime with a standard-library-like API.
Using Tokio
To use Tokio, add it to Cargo.toml:
[dependencies]
tokio = { version = "1.0", features = ["full"] }
Then, annotate main with #[tokio::main] to use Tokio’s executor:
use tokio::time::{sleep, Duration};
#[tokio::main]
async fn main() {
println!("Start");
sleep(Duration::from_secs(1)).await; // Async sleep (non-blocking)
println!("After 1 second");
}
How It Works:
#[tokio::main]transforms themainfunction into an async entry point, starting Tokio’s executor.sleep(Duration::from_secs(1)).awaityields control to the executor, which runs other tasks (if any) during the 1-second wait.
3.4 Async Primitives: Timers, Channels, and More
Async runtimes provide primitives for common concurrency patterns:
- Timers:
tokio::time::sleepfor delays. - Channels:
tokio::sync::mpscfor message passing between async tasks. - Locks:
tokio::sync::Mutex(async-aware mutex, non-blocking lock acquisition).
Example: Async Channel
use tokio::sync::mpsc;
use tokio::time::{sleep, Duration};
#[tokio::main]
async fn main() {
// Create a channel with a buffer of 3 messages
let (tx, mut rx) = mpsc::channel(3);
// Spawn a task to send messages
tokio::spawn(async move {
for i in 1..=5 {
tx.send(i).await.unwrap(); // Send message (blocks if buffer is full)
sleep(Duration::from_millis(500)).await;
}
});
// Receive messages in the main task
while let Some(msg) = rx.recv().await {
println!("Received: {}", msg);
}
}
Key Difference from Thread Channels:
tokio::sync::mpsc::channelis async:sendandrecvreturn futures that await until the operation can complete (e.g.,sendwaits if the buffer is full,recvwaits for a message).
4. Threads vs. Async: When to Use Which?
| Factor | Threads | Async |
|---|---|---|
| Use Case | CPU-bound tasks (e.g., data processing). | I/O-bound tasks (e.g., HTTP servers, DB calls). |
| Overhead | High (OS-managed, stack per thread). | Low (user-space scheduling, shared stack). |
| Scheduling | Pre-emptive (OS interrupts threads). | Cooperative (tasks yield via await). |
| Scalability | Limited by OS thread limits (e.g., 1000s). | High (100k+ tasks per thread). |
| Complexity | Simpler model (familiar to most developers). | Steeper learning curve (futures, executors). |
Rule of Thumb:
- Use threads for CPU-bound work (they leverage multiple cores).
- Use async for I/O-bound work (minimize idle time and maximize task density).
5. Combining Threads and Async
Sometimes, you need both models. For example:
- An async web server handling I/O (async tasks) but offloading CPU-heavy work (e.g., image processing) to a thread pool.
Tokio provides a blocking pool for this:
use tokio::task;
use std::thread;
use std::time::Duration;
#[tokio::main]
async fn main() {
// Spawn an async task
tokio::spawn(async {
println!("Async task started");
// Offload blocking work to Tokio's blocking pool
let result = task::spawn_blocking(|| {
thread::sleep(Duration::from_secs(2)); // Simulate CPU work
42
}).await.unwrap();
println!("Blocking task result: {}", result);
}).await.unwrap();
}
Why This Works:
task::spawn_blockingruns the closure on a dedicated thread pool, avoiding blocking the async executor (which would starve other async tasks).
6. Common Pitfalls and How Rust Prevents Them
Deadlocks
Problem: Threads waiting indefinitely for locks held by each other (e.g., Thread A holds Lock 1 and waits for Lock 2; Thread B holds Lock 2 and waits for Lock 1).
Rust’s Help: Mutex uses RAII guards to ensure locks are released, but deadlocks still happen if locks are acquired in the wrong order. Tools like parking_lot (a faster mutex implementation) or tokio::sync::RwLock can help, but Rust can’t catch logical deadlocks at compile time.
Forgetting to await Futures
Problem: An async function returns a future, but you forget to await it. The task never runs, leading to silent failures.
async fn do_work() { /* ... */ }
#[tokio::main]
async fn main() {
do_work(); // Bug: future is created but never polled!
}
Rust’s Help: The compiler warns about unused futures (since do_work() returns a Future that’s not used).
Blocking the Async Executor
Problem: Running blocking code (e.g., thread::sleep) in an async task blocks the executor, preventing other tasks from running.
Fix: Use task::spawn_blocking (Tokio) or async_std::task::spawn_blocking to offload blocking work.
7. Examples in Action
Example 1: Threads for CPU-Bound Work
use std::thread;
// Simulate CPU-bound work (sum 1..n)
fn cpu_intensive(n: u64) -> u64 {
(1..=n).sum()
}
fn main() {
let handles = (0..4).map(|i| {
thread::spawn(move || {
let start = i * 25_000_000 + 1;
let end = (i + 1) * 25_000_000;
cpu_intensive(end) - cpu_intensive(start - 1)
})
});
let total: u64 = handles.map(|h| h.join().unwrap()).sum();
println!("Total: {}", total); // Sum 1..100_000_000 = 5000000050000000
}
Example 2: Async for I/O-Bound Work (HTTP Requests)
Using reqwest (an async HTTP client) to fetch multiple URLs concurrently:
Add to Cargo.toml:
reqwest = { version = "0.11", features = ["json"] }
tokio = { version = "1.0", features = ["full"] }
use reqwest::Client;
use tokio;
async fn fetch_url(client: &Client, url: &str) -> Result<u16, reqwest::Error> {
let response = client.get(url).send().await?;
Ok(response.status().as_u16())
}
#[tokio::main]
async fn main() -> Result<(), reqwest::Error> {
let client = Client::new();
let urls = [
"https://httpbin.org/status/200",
"https://httpbin.org/status/404",
"https://httpbin.org/status/500",
];
// Spawn a task for each URL and collect futures
let tasks: Vec<_> = urls.iter()
.map(|url| fetch_url(&client, url))
.collect();
// Await all tasks concurrently
let results = futures::future::join_all(tasks).await;
for (url, status) in urls.iter().zip(results) {
println!("{}: {:?}", url, status);
}
Ok(())
}
8. Conclusion
Rust’s concurrency model empowers developers to write safe, efficient code for both CPU-bound and I/O-bound tasks. Threads provide a straightforward way to leverage multiple cores, while async programming minimizes overhead for I/O-heavy workloads.
By combining compile-time safety (via Send/Sync, ownership) with flexible runtime options (threads, async executors), Rust stands out as a language where concurrency is not just possible but safe by design.
Whether you’re building a high-performance server or a responsive application, Rust’s threads and async tools have you covered.