Concurrency in Rust - Creating Threads
Concurrency is one of the standout pillars of Rust, enabling developers to write high-performance programs that execute multiple tasks independently. Grounded in Rust's strict type system and compile-time ownership model, the language catches subtle bugs before code ever reaches production—a philosophy famously referred to as Fearless Concurrency.
1. Why Do We Need Concurrency? (The Problem Space)
At the operating system level, an executed program runs inside a process. Within that single process, modern applications often need to perform multiple computations simultaneously to maximize CPU utilization and throughput.
However, introducing multiple execution pathways within a program drastically increases architectural complexity. When multiple computations run concurrently or in parallel, developers face three major challenges:
- Race Conditions: Threads access shared data or resources in an unpredictable, inconsistent order.
- Deadlocks: Two or more threads are permanently blocked, each waiting for a resource held by the other.
- Non-Deterministic Bugs: Because thread execution interleaving is controlled by the operating system scheduler, certain bugs appear only under specific conditions, making them extremely difficult to reproduce and fix reliably.
Note: Concurrent vs. Parallel Programming: Concurrent programming means different parts of a program execute independently over overlapping time periods. Parallel programming means different parts execute at the exact same physical instant (on multi-core hardware). For the remainder of these notes, "concurrent" encompasses both concepts.
2. Core Mental Model: Operating System Threads vs. Green Threads
Before writing code, it is vital to understand how different programming languages manage threads of execution. There are two primary thread models:
Why Rust Chooses One-to-One Threads
Rust is engineered to be a low-level systems programming language with an extremely small runtime—effectively no runtime at all. Because green threads require a heavy language runtime to manage scheduling and context-switching, Rust's standard library implements only one-to-one (OS) threads.
If an application specifically requires green threads, developers can pull in external community crates, accepting the trade-off of a larger binary footprint.
3. Spawning Your First OS Thread in Rust
To execute tasks concurrently in Rust, we import the thread module from the standard library and call thread::spawn(), passing in a closure containing the code the new thread should execute.
use std::thread;
use std::time::Duration;
fn main() {
// Spawn a new concurrent OS thread
thread::spawn(|| {
for i in 1..10 {
println!("hi number {} from the spawned thread!", i);
thread::sleep(Duration::from_millis(1));
}
});
// Code executed by the primary main thread
for i in 1..5 {
println!("hi number {} from the main thread!", i);
thread::sleep(Duration::from_millis(1));
}
}
Execution Behavior and Non-Determinism
When running this program, you will notice that the output alternates unpredictably between the main thread and the spawned thread. This occurs because thread scheduling is non-deterministic and handled dynamically by the operating system.
Furthermore, running the code above reveals a critical catch: when the main thread terminates, all spawned threads are abruptly shut down, regardless of whether they finished executing or not. In the snippet above, the spawned thread (counting up to 10) typically halts prematurely when the main thread finishes its loop up to 5.
4. Waiting for Threads to Finish Using JoinHandle
To guarantee that a spawned thread completes its execution before the program exits, we capture the return value of thread::spawn—which is a JoinHandle type—and invoke the .join() method on it.
use std::thread;
use std::time::Duration;
fn main() {
let handle = thread::spawn(|| {
for i in 1..10 {
println!("hi number {} from the spawned thread!", i);
thread::sleep(Duration::from_millis(1));
}
});
for i in 1..5 {
println!("hi number {} from the main thread!", i);
thread::sleep(Duration::from_millis(1));
}
// Block the main thread until the spawned thread terminates
handle.join().unwrap();
}
Understanding .join() and Blocking
handle.join(): Blocks the currently running thread (in this case, themainthread) from doing further work or exiting until the thread associated withhandlefinishes running..unwrap(): Because.join()returns aResult(which fails if the spawned thread panics), calling.unwrap()propagates any panic that occurred inside the thread.
Placement Matters: Concurrent vs. Sequential Execution
Where you place the .join() call completely changes how your program executes:
// SCENARIO A: Join called at the VERY END of main
let handle = thread::spawn(|| { /* ... */ });
// Main thread runs its loop concurrently with the spawned thread
handle.join().unwrap();
// SCENARIO B: Join called IMMEDIATELY after spawn
let handle = thread::spawn(|| { /* ... */ });
handle.join().unwrap(); // Main thread BLOCKS immediately!
// Main thread waits for spawned thread to finish ENTIRELY before continuing
5. Handling Data Ownership and the move Closure
When spawning threads, closures often need to reference data defined outside the thread in the parent function (such as main). However, Rust's strict compile-time borrow checker prevents unsafe memory access across thread boundaries.
The Borrow Checker Compile-Time Error
Consider the following naive attempt to pass a vector into a spawned thread:
use std::thread;
fn main() {
let v = vec![1, 2, 3];
let handle = thread::spawn(|| {
println!("Here's a vector: {:?}", v); // COMPILE ERROR!
});
handle.join().unwrap();
}
Why does this fail at compile time?
- Rust infers that the closure borrows
vby reference becauseprintln!only needs read access. - However, Rust does not know how long the spawned thread will run.
- Imagine if the main thread dropped or mutated
vimmediately after spawning the thread while the background thread was still reading it. That would result in a dangerous use-after-free or data race vulnerability.
Forcing Ownership with the move Keyword
To guarantee safety, Rust requires us to explicitly transfer ownership of captured variables into the spawned thread using the move keyword before the closure.
use std::thread;
fn main() {
let v = vec![1, 2, 3];
// Use 'move' to transfer ownership of 'v' into the thread closure
let handle = thread::spawn(move || {
println!("Here's a vector: {:?}", v);
});
handle.join().unwrap();
// ERROR: If we tried to use 'v' here, the compiler would reject it
// because ownership was moved into the spawned thread!
// println!("{:?}", v);
}
Note: By prefixing the closure with
move, we tell Rust: "Do not borrow these variables; take full ownership and move them inside the closure's stack frame." Once moved, the main thread can no longer access or dropv, completely eliminating data races by design.
6. Summary and Key Takeaways
- Fearless Concurrency: Rust uses its compile-time ownership and type system to catch concurrency bugs (like data races) before code runs in production.
- OS Threads (1-to-1 Model): Rust's standard library implements one-to-one operating system threads to maintain an extremely lightweight binary with almost no runtime overhead.
- Non-Deterministic Execution: Thread scheduling is controlled by the operating system, meaning execution order is non-deterministic and varies between runs.
- Premature Program Termination: When the primary
mainthread terminates, all spawned child threads are instantly halted regardless of their completion state. - Thread Joining: Use
let handle = thread::spawn(...)combined withhandle.join().unwrap()to block the parent thread and ensure child threads finish executing. - Join Placement Impact: Calling
.join()immediately after spawning forces sequential execution, whereas placing it at the end of the parent block allows true concurrent execution. - Ownership & Move Closures: Because threads can outlive the current scope, closures must use the
movekeyword to take absolute ownership of captured variables, preventing invalid memory references and use-after-free bugs.