Last updated: April 13, 2025
Table of Contents
- 1. Introduction: Fearless Concurrency
- 2. Using Threads for Parallel Execution
- 3. Message Passing for Thread Communication
- 4. Shared-State Concurrency (Briefly)
- 5. Asynchronous Programming with
async
/.await
- 6. Compile-Time Guarantees:
Send
andSync
Traits - 7. Conclusion: Choosing the Right Approach
- 8. Additional Resources
1. Introduction: Fearless Concurrency
Concurrency (allowing multiple computations to happen in overlapping time periods) and parallelism (multiple computations happening simultaneously) are crucial for building performant applications, especially network services and CPU-intensive tasks. However, concurrent programming is notoriously difficult and prone to subtle bugs like data races and deadlocks.
Rust aims to make concurrent programming safer and more manageable. Its ownership and type system play a
crucial role in preventing many common concurrency errors at **compile time**. This allows developers to write
concurrent code with greater confidence – often referred to as "fearless concurrency"
.
Rust offers several approaches to concurrency:
- Using OS threads directly.
- Message passing between threads using channels.
- Shared-state concurrency using primitives like Mutexes.
- Asynchronous programming using
async
/.await
and an async runtime.
This guide explores the main approaches provided by the standard library and the popular Tokio
runtime.
Familiarity with Rust's Ownership and Borrowing rules is essential for understanding concurrency safety in Rust.
2. Using Threads for Parallel Execution
The standard library provides the std::thread
module for creating native OS threads. Each thread
runs independently and potentially in parallel on multi-core processors.
2.1 Spawning Threads with thread::spawn
The thread::spawn
function takes a closure (an anonymous function) containing the code the new
thread should run.
use std::thread;
use std::time::Duration;
fn main() {
thread::spawn(|| { // Spawn a new thread
for i in 1..=5 {
println!("hi number {} from the spawned thread!", i);
thread::sleep(Duration::from_millis(1));
}
});
// Code in the main thread continues concurrently
for i in 1..=3 {
println!("hi number {} from the main thread!", i);
thread::sleep(Duration::from_millis(1));
}
// Note: The main thread might finish before the spawned thread completes!
}
Running this might produce interleaved output, and the spawned thread might not finish if the main thread exits first.
2.2 Waiting for Threads with join
Handles
thread::spawn
returns a JoinHandle
. Calling the join()
method on this
handle blocks the current thread until the spawned thread finishes execution.
use std::thread;
use std::time::Duration;
fn main() {
let handle = thread::spawn(|| { // Store the handle
for i in 1..=5 {
println!("hi number {} from the spawned thread!", i);
thread::sleep(Duration::from_millis(1));
}
});
for i in 1..=3 {
println!("hi number {} from the main thread!", i);
thread::sleep(Duration::from_millis(1));
}
// Wait for the spawned thread to finish
handle.join().unwrap(); // join returns a Result, unwrap panics on error
println!("Spawned thread finished.");
}
Now the main thread waits, ensuring the spawned thread completes.
2.3 Moving Ownership to Threads
Closures passed to thread::spawn
often need to capture variables from their environment. To
ensure safety, Rust usually requires you to explicitly move ownership of captured variables into the thread's
closure using the move
keyword.
use std::thread;
fn main() {
let v = vec![1, 2, 3];
// Use `move` before the closure || to force it to take ownership of `v`
let handle = thread::spawn(move || {
println!("Here's a vector: {:?}", v);
// `v` is now owned by this thread's closure
});
// drop(v); // Error! `v` was moved and cannot be used here anymore.
handle.join().unwrap();
}
The move
keyword ensures the spawned thread has its own copy or ownership, preventing potential
use-after-free errors if the main thread were to drop v
before the spawned thread finished.
3. Message Passing for Thread Communication
A common and often safer way for threads to communicate is via message passing, avoiding the complexities of shared memory and mutexes. Rust's standard library provides channels.
3.1 Channels (MPSC)
std::sync::mpsc
provides Multi-Producer, Single-Consumer (MPSC) channels. You create a channel,
which yields a transmitter (Sender
) and a receiver (Receiver
). Multiple threads can
hold clones of the Sender
, but only one thread can hold the Receiver
.
3.2 Example: Sending Data Between Threads
use std::sync::mpsc; // Multi-producer, single-consumer
use std::thread;
use std::time::Duration;
fn main() {
// Create a channel
let (tx, rx) = mpsc::channel(); // tx: Sender, rx: Receiver
let tx1 = tx.clone(); // Clone the sender for another thread
// Thread 1: Send multiple messages
thread::spawn(move || {
let vals = vec![
String::from("hi"),
String::from("from"),
String::from("the"),
String::from("thread"),
];
for val in vals {
tx1.send(val).unwrap(); // Send ownership of val
thread::sleep(Duration::from_millis(200));
}
});
// Thread 2: Send one message
thread::spawn(move || {
tx.send(String::from("more messages")).unwrap();
});
// Main thread: Receive messages
// rx.recv() blocks until a message arrives. rx.try_recv() is non-blocking.
// The receiver can also be used as an iterator which ends when all senders drop.
println!("Waiting for messages...");
for received in rx { // Iterate over received messages
println!("Got: {}", received);
}
println!("Channel closed.");
}
Channels provide a thread-safe way to transfer ownership of data between threads.
4. Shared-State Concurrency (Briefly)
While message passing is often preferred, sometimes threads need to access the same shared data. Rust
provides synchronization primitives like Mutex
for this, but they must be used carefully with
Rust's ownership rules.
4.1 Mutexes (Mutex<T>
)
A Mutex (Mutual Exclusion) allows only one thread at a time to access the data it protects. Attempting to
acquire the lock blocks other threads until the lock is released. Rust's Mutex
ensures data
access is synchronized, preventing data races.
// Simplified concept - requires Arc for multi-threading
use std::sync::Mutex;
let m = Mutex::new(5); // Create a mutex protecting the value 5
{
let mut num = m.lock().unwrap(); // Acquire the lock (blocks if held)
*num = 6; // Modify the data (requires dereferencing *)
} // Lock is automatically released when `num` goes out of scope (RAII)
println!("m = {:?}", m);
4.2 Atomic Reference Counting (Arc<T>
)
To share ownership of data (like a Mutex
) across multiple threads safely, Rust uses
Arc<T>
(Atomically Reference Counted pointer). Arc
keeps track of how many
threads own a reference to the data and ensures the data is deallocated only when the last reference is
dropped.
5. Asynchronous Programming with async
/.await
For I/O-bound tasks (like network requests or file operations), creating an OS thread for each task can be inefficient. Asynchronous programming allows handling many concurrent I/O operations on a small number of threads.
5.1 Core Concepts: Futures and Runtimes
async fn
: Defines an asynchronous function that returns aFuture
. Futures represent computations that might not be complete yet..await
: Used inside anasync fn
to pause execution until aFuture
is ready, allowing other tasks to run in the meantime without blocking the thread.- Async Runtime: A library (like
Tokio
orasync-std
) that manages and executes asynchronous tasks (Futures). It typically includes an event loop and task scheduler. Common crates like web frameworks often dictate which runtime to use. See our Common Rust Crates guide.
5.2 Example with Tokio
use tokio::time::{sleep, Duration};
async fn task_one() {
println!("Task one starting...");
sleep(Duration::from_millis(200)).await; // Pause, allowing other tasks to run
println!("Task one finished.");
}
async fn task_two() {
println!("Task two starting...");
sleep(Duration::from_millis(100)).await;
println!("Task two finished.");
}
#[tokio::main] // Tokio's main macro sets up the runtime
async fn main() {
println!("Starting main task.");
// Spawn tasks to run concurrently
let join1 = tokio::spawn(task_one());
let join2 = tokio::spawn(task_two());
// Wait for both tasks to complete
let _ = tokio::join!(join1, join2);
println!("All tasks finished.");
}
Tokio runs these async tasks concurrently, potentially on a single thread, efficiently handling the waiting periods.
5.3 Async vs. Threads
- Threads: Good for CPU-bound tasks that benefit from true parallelism on multiple cores. Context switching between OS threads has overhead.
- Async/Await: Excellent for I/O-bound tasks involving lots of waiting. Much lower overhead per task compared to threads, allowing potentially millions of concurrent async tasks. Requires an async runtime.
See also: Rust vs. Go Comparison (discusses Go's goroutines).
6. Compile-Time Guarantees: Send
and Sync
Traits
Rust's safety extends to concurrency through two marker traits:
Send
: A type isSend
if it's safe to transfer ownership of it to another thread. Most primitive types and types composed only ofSend
types areSend
.Sync
: A type isSync
if it's safe to have references to it accessed from multiple threads simultaneously (i.e.,&T
isSend
). Types likeMutex<T>
andArc<T>
(ifT
isSend
andSync
) areSync
.
The compiler checks these traits automatically. If you try to send non-Send
data across threads
or share non-Sync
data, you'll get a compile-time error, preventing data races before the code
runs.
7. Conclusion: Choosing the Right Approach
Rust provides a rich set of tools for concurrent and parallel programming, all underpinned by strong compile-time safety guarantees.
- Use threads (
std::thread
) for CPU-bound tasks requiring parallel execution. - Use message passing (
std::sync::mpsc
) for safe communication and data transfer between threads. - Use shared-state primitives (
Mutex
,Arc
) carefully when threads truly need to access the same mutable data concurrently. - Use
async
/.await
with a runtime like Tokio for highly concurrent I/O-bound tasks (like web servers and network clients).
The best approach depends on the specific problem you're solving. Rust's "fearless concurrency" comes from the compiler's ability to catch many potential issues, allowing you to build complex concurrent systems with greater confidence.
8. Additional Resources
Related Articles
- Getting Started with Rust
- Rust Ownership and Borrowing Explained
- Common Rust Crates for Web Development
- Rust vs. Go Comparison