S

Sajiron

34 min readPublished on Feb 24, 2025

Fearless Concurrency in Rust: A Guide to Threads

DALL·E 2025-02-24 21.03.28 - A dynamic and visually engaging representation of Rust concurrency_ multiple robotic arms assembling a large Rust logo, symbolizing safe and efficient.webp

1. Introduction

Related Post
📖 If you haven't read it yet, check out the previous blog: Mastering Rust: A Deep Dive into Traits

Concurrency is one of the most powerful features in modern programming, but it also introduces challenges like race conditions, deadlocks, and data corruption. Rust provides a fearless concurrency model, ensuring thread safety at compile-time while avoiding common pitfalls seen in other languages.

Rust's concurrency model is based on the ownership and type systems, which help manage memory safety and concurrency issues effectively. Many concurrency errors in Rust are detected at compile-time, preventing data races before execution. Instead of spending hours debugging runtime concurrency issues, Rust ensures that incorrect code won’t compile until it’s correct. This compile-time guarantee allows developers to write concurrent code that is easy to reason about and refactor without introducing subtle bugs.

1.1 Concurrency vs. Parallelism

Before diving in, it's important to distinguish between concurrency and parallelism:

Concurrency: Multiple tasks are logically executed at the same time but may not be executing simultaneously.

Parallelism: Multiple tasks are executed at the exact same time, often on multiple processors or cores.

Rust supports both models, providing multiple tools to handle concurrent execution efficiently.

In this guide, we'll explore:

Threads: Creating and managing multiple threads
Mutexes: Handling shared state safely
Atomic Reference Counting (Arc): Sharing data across threads
Message Channels: Communicate by sending each other messages between threads
Sync and Send Traits: Ensuring safe communication between threads

By the end, you'll have a solid understanding of how to write safe and efficient concurrent Rust programs. Let’s dive in! 🚀

2. Understanding Threads in Rust

A thread is the smallest unit of execution in a program. Rust allows creating multiple threads using the std::thread module.

Example: Spawning a New Thread

use std::thread;
use std::time::Duration;

fn main() {
let handle = thread::spawn(|| {
for i in 1..10 {
println!("hi number {i} from the spawned thread!");
thread::sleep(Duration::from_millis(1));
}
});

for i in 1..5 {
println!("hi number {i} from the main thread!");
thread::sleep(Duration::from_millis(1));
}

handle.join().unwrap();
}

📌 Key Takeaways:
thread::spawn creates a new thread and executes the given closure asynchronously.
✔ The spawned thread runs concurrently with the main thread.
✔ Without explicit synchronization, the order of execution between threads is unpredictable.

2.1 What thread::spawn Does

The function thread::spawn creates a new thread and runs the provided closure inside it. The spawned thread starts executing independently, meaning that the main thread does not wait for it to finish automatically. If the main thread completes before the spawned thread, the program may terminate before the spawned thread finishes its execution. To ensure the spawned thread completes, you need to explicitly wait for it using handle.join().unwrap();.

2.2 What handle.join().unwrap() Does

When a thread is spawned, Rust returns a JoinHandle<T>, which represents the running thread. Calling handle.join().unwrap(); blocks the main thread until the spawned thread completes execution. This prevents the main thread from exiting prematurely, ensuring all spawned tasks finish.

2.3 What is unwrap()?

In Rust, unwrap() is used on Result<T, E> or Option<T> types to extract their inner values. If the result is Ok(T) (for Result<T, E>) or Some(T) (for Option<T>), unwrap() returns T. However, if the result is an Err(E) or None, it panics and terminates the program. This is useful for debugging but should be handled carefully in production.

Example: Ensuring a Thread Completes Before Exiting

use std::thread;
use std::time::Duration;

fn main() {
let handle = thread::spawn(|| {
for i in 1..10 {
println!("hi number {i} from the spawned thread!");
thread::sleep(Duration::from_millis(1));
}
});

handle.join().unwrap(); // Ensures the spawned thread completes before the main thread exits.
println!("Main thread exiting after spawned thread completes.");
}

2.4 Handling Errors Instead of unwrap()

Instead of using unwrap(), which can cause the entire program to panic, you can handle errors gracefully:

use std::thread;

fn main() {
let handle = thread::spawn(|| {
panic!("Something went wrong in the thread!");
});

match handle.join() {
Ok(_) => println!("Thread completed successfully."),
Err(e) => println!("Thread panicked: {:?}", e),
}

println!("Program completed.");
}

✅ This prevents the entire program from crashing if a thread fails.

3. Using move Closures with Threads

When spawning threads, you may need to transfer ownership of variables to the new thread. This is because Rust’s ownership model does not allow threads to access variables that might be modified or dropped in the main thread while the spawned thread is still running. Rust enforces ownership rules to prevent data races, requiring the use of the move keyword when capturing data from the main thread. The move keyword ensures that the variables used within the thread’s closure are moved into the closure’s scope, effectively transferring ownership to the new thread. This prevents issues where a variable might be accessed after it has been deallocated, ensuring memory safety at compile time.

use std::thread;

fn main() {
let message = String::from("Hello, Rust!");

let handle = thread::spawn(move || {
println!("Thread: {}", message);
});

handle.join().unwrap();
}

3.1 What Happens If You Don’t Use move?

If you don’t use move, the closure will attempt to borrow the variables instead of taking ownership. However, this often leads to compiler errors because Rust prevents dangling references in threads.

3.2 Can You Still Use a Moved Variable in the Main Thread?

No! Once a variable is moved into the closure, it cannot be accessed in the main thread anymore.

Example: Using a Moved Variable (Fails)

use std::thread;

fn main() {
let message = String::from("Hello, Rust!");

let handle = thread::spawn(move || {
println!("Thread: {}", message);
});

// println!("Main thread: {}", message); // ERROR: `message` was moved

handle.join().unwrap();
}

3.3 When is move Not Needed?

If all variables inside the closure have a 'static lifetime (e.g., constants or static variables), move is not required.

use std::thread;

fn main() {
static MESSAGE: &str = "Hello, world!"; // 'static lifetime

let handle = thread::spawn(|| { // No `move` needed
println!("{}", MESSAGE);
});

handle.join().unwrap();
}

✅ Works because MESSAGE has a 'static lifetime and never gets dropped.

4. Sharing Data Between Threads: Using Arc and Mutex

Rust provides two main ways to manage concurrency when multiple threads need access to shared data:

Message Passing (Channels) - Each thread communicates by sending messages instead of sharing memory. We will cover this in detail in the next section.

Shared-State Concurrency (Mutex<T> and Arc<T>) - Multiple threads share memory but use synchronization mechanisms to prevent race conditions.

While message passing is recommended in many scenarios, shared-state concurrency can be necessary when multiple threads need simultaneous read and write access to data. Rust ensures safety through strict ownership and borrowing rules, preventing data races at compile time.

4.1 Understanding Mutex<T> for Safe Shared Data Access

A mutex (Mutual Exclusion) ensures that only one thread can modify the data at a time. It prevents race conditions by requiring a thread to lock the mutex before accessing the data.

Example: Using Mutex<T> in a Single Thread

use std::sync::Mutex;

fn main() {
let counter = Mutex::new(0); // Wraps counter in a Mutex

{
let mut num = counter.lock().unwrap(); // Lock before modifying
*num += 1;
} // Lock is automatically released when `num` goes out of scope

println!("Counter: {}", *counter.lock().unwrap());
}

Why This Works:

Mutex<T> guarantees only one thread can access the data at a time.

The lock is automatically released when num goes out of scope (RAII pattern).

lock().unwrap() ensures the thread panics if the lock is poisoned due to another thread's panic.

4.2 Why Mutex<T> Alone Is Not Enough for Multi-Threading

If you try to use Mutex<T> with multiple threads without Arc<T>, you will run into ownership issues.

Example: This Compiles but Has Limitations

use std::sync::Mutex;
use std::thread;

fn main() {
let counter = Mutex::new(0);

let handle = thread::spawn(move || {
let mut num = counter.lock().unwrap(); // Lock before modifying
*num += 1;
});

handle.join().unwrap();
}

Why This Works:

The move keyword transfers ownership of counter to the spawned thread.

The main thread does not attempt to access counter afterward, preventing a use-after-move error.

🚨 But This Has a Limitation:

The main thread loses access to counter once it is moved to the spawned thread.

If you try to use counter after handle.join().unwrap(), Rust will throw borrow of moved value error.

Example: This Will NOT Compile

use std::sync::Mutex;
use std::thread;

fn main() {
let counter = Mutex::new(0);

let handle = thread::spawn(move || {
let mut num = counter.lock().unwrap();
*num += 1;
});

handle.join().unwrap();

println!("Result: {}", *counter.lock().unwrap()); // Will throw compile error
}

Fix: Use Arc<T> to enable multiple ownership.

4.3 Using Arc<T> to Share a Mutex<T> Across Threads

Rust’s Arc<T> (Atomic Reference Counting) allows multiple threads to share ownership of a Mutex<T> safely. In a multi-threaded environment, Rust enforces strict ownership rules to prevent data races. However, Mutex<T> alone is not sufficient for sharing data across multiple threads because Mutex<T> cannot be copied or cloned. This is where Arc<T> comes in.

Arc<T> enables multiple threads to hold references to the same Mutex<T> while ensuring that the underlying data is only dropped when the last reference goes out of scope. Unlike Rc<T>, which is only for single-threaded reference counting, Arc<T> is thread-safe and uses atomic operations to manage reference counts across multiple threads.

Wrapping Mutex<T> inside Arc<T> allows multiple threads to safely read and modify the shared resource without violating Rust’s ownership model.

Example: Safe Multi-Threaded Counter Using Arc<Mutex<T>>

use std::sync::{Arc, Mutex};
use std::thread;

fn main() {
let counter = Arc::new(Mutex::new(0)); // Use Arc for shared ownership
let mut handles = vec![];

for _ in 0..10 {
let counter = Arc::clone(&counter); // Clone Arc reference for each thread

let handle = thread::spawn(move || {
let mut num = counter.lock().unwrap(); // Lock before modifying
*num += 1;
println!("Thread incremented counter to: {}", *num);
});

handles.push(handle);
}

for handle in handles {
handle.join().unwrap(); // Ensure all threads finish
}

println!("Final count after all threads: {}", *counter.lock().unwrap()); // ✅ Safe access to shared data
}

🚨 Note: This example does not guarantee the order in which threads modify the counter. Since threads execute independently, the operating system schedules them in an arbitrary order, meaning their execution order may change on different runs.

5. Using Message Passing with Channels

Another approach to ensuring safe concurrency is message passing, where threads communicate by sending each other messages using channels instead of sharing memory. A channel is a simple and safe way to send data between threads. It consists of two parts:

Transmitter (tx): Used to send messages.

Receiver (rx): Used to receive messages.

Think of a channel as a pipeline where one thread places messages into the pipeline, and another thread picks them up. This ensures safe communication without requiring shared memory.

Rust's standard library provides channels through the std::sync::mpsc module:

mpsc::channel() creates a channel where multiple producers (threads) can send messages, but only a single consumer can receive them.

5.1 Blocking vs. Non-Blocking Receiving

rx.recv() → Blocks execution until a message arrives.

rx.try_recv() → Returns immediately, either with a message or an error if no message is available.

Example: Blocking Behavior with recv()

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

fn main() {
let (tx, rx) = mpsc::channel();

thread::spawn(move || {
thread::sleep(Duration::from_secs(3)); // Simulate delay
tx.send("Message from thread").unwrap();
});

println!("Waiting for a message...");

let received = rx.recv().unwrap(); // This blocks until the message arrives

println!("Received: {}", received);
}

When using recv(), the receiver blocks execution until a message arrives. This means the main thread will pause and wait for the message before proceeding.

Example: Using try_recv() for Non-Blocking Receiving

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

fn main() {
let (tx, rx) = mpsc::channel();

thread::spawn(move || {
thread::sleep(Duration::from_secs(3)); // Simulate delay
tx.send("Message from thread").unwrap();
});

loop {
match rx.try_recv() {
Ok(msg) => {
println!("Received: {}", msg);
break; // ✅ Exit the loop once a message is received
}
Err(_) => {
println!("No message yet...");
thread::sleep(Duration::from_millis(500));
}
}
}

println!("Exiting loop after receiving the message.");
}

When using try_recv(), the receiver does not block execution. Instead, it checks for messages and continues executing if none are available.

5.2 When to Use Blocking (recv()) vs. Non-Blocking (try_recv())

Choosing between recv() and try_recv() depends on the use case. Here’s how to decide which one to use:

Method

Behavior

Best Used For

rx.recv()

Blocks execution until a message arrives.

When the thread must wait for messages and has nothing else to do.

rx.try_recv()

Returns immediately with a message or an error if none is available.

When the thread has other work to do while waiting for messages.

Example: Multiple producers with single consumer

use std::sync::mpsc;
use std::thread;

fn main() {
let (tx1, rx) = mpsc::channel();

// Clone the transmitter so multiple threads can send messages
let tx2 = tx1.clone();

// First producer
thread::spawn(move || {
tx1.send("Hello from thread 1").unwrap();
});

// Second producer
thread::spawn(move || {
tx2.send("Hello from thread 2").unwrap();
});

// Single Consumer: Receives messages from both producers
for received in rx {
println!("Received: {}", received);
}
}

6. The Sync and Send Traits

Rust’s concurrency model ensures that data can be safely transferred or shared between threads using two special marker traits:

Send: Allows ownership transfer between threads.

Sync: Allows shared access from multiple threads.

These traits help Rust enforce thread safety at compile time, preventing data races and unsafe memory access.

6.1 What is Send?

The Send trait in Rust ensures that a type's ownership can be safely moved between threads. A type implementing Send can be transferred to another thread without causing data races or undefined behavior.

When a type is moved into a new thread, the original thread can no longer access it, preventing potential concurrent modifications.

📌 Key Properties of send

If a type implements Send, it can be moved between threads.

Almost all types are Send.

Rc<T> is not Send because it is not safe for concurrent access.

Arc<T> is Send because it uses atomic reference counting, ensuring safe ownership transfer across threads. A type is Send if it can be moved to another thread safely.

Example: send in Action

use std::thread;

fn main() {
let message = String::from("Hello from main!");

let handle = thread::spawn(move || {
println!("{}", message); // `message` is moved to the new thread
});

handle.join().unwrap();
}

Example: This Will NOT Compile

use std::rc::Rc;
use std::thread;

fn main() {
let data = Rc::new(42); // Rc<T> is NOT thread-safe!

let handle = thread::spawn(move || {
println!("Value: {}", data);
});

handle.join().unwrap();
}

Fix: Rc<T> (Reference Counted Smart Pointer) is NOT Send because it isn't thread-safe. Use Arc<T> (Atomic Reference Counted) instead of Rc<T>.

6.2 What is Sync?

The Sync trait ensures that multiple immutable references (&T) to a type can be safely shared across multiple threads. If T is Sync, it means &T (a reference to T) can be sent to another thread and accessed concurrently.

📌 Key Properties of sync

If a type is Sync, multiple threads can have immutable references to it at the same time.

All primitive types (i32, bool, f64) are Sync.

Rc<T> is not Sync, as it does not support atomic reference counting.

Arc<T> is Sync, making it safe for shared references across threads.

Mutex<T> and RwLock<T> can be used to enable safe mutable access in multi-threaded scenarios.

Example: sync in Action

use std::sync::Arc;
use std::thread;

fn main() {
let data = Arc::new(String::from("Shared data"));

let handles: Vec<_> = (0..3).map(|_| {
let data = Arc::clone(&data);
thread::spawn(move || {
println!("Thread received: {}", data);
})
}).collect();

for handle in handles {
handle.join().unwrap();
}
}

Example: This Will NOT Compile

use std::cell::RefCell;
use std::sync::Arc;
use std::thread;

fn main() {
let data = Arc::new(RefCell::new(42)); // Wrapping in Arc for shared ownership

let data_clone = Arc::clone(&data);
let handle = thread::spawn(move || {
*data_clone.borrow_mut() += 1; // ❌ PANIC: Borrowing across threads is unsafe!
});

handle.join().unwrap();
}

Fix: RefCell<T> is NOT Sync because it enforces borrowing rules at runtime, not compile-time. Use Mutex<T> or RwLock<T> for safe mutable access in multiple threads.

7. Next Steps

Rust provides fearless concurrency by ensuring thread safety at compile-time, avoiding issues like race conditions and data corruption.

Stay tuned for the next blog on exploring Async Programming in Rust for high-performance, non-blocking applications! 🚀

💡 If you found this helpful, please remember to leave a like! 👍