Skip to content

Rust - Concurrency

Concurrent programming, where different parts of a program execute independently, and parallel programming, where they execute at the same time, are increasingly important. Rust’s ownership and type system provide powerful compile-time guarantees against common concurrency bugs, like data races. This makes writing safe concurrent code significantly easier.

1. Creating Threads for Parallel Execution

Section titled “1. Creating Threads for Parallel Execution”

Operating systems manage processes, and within a process, you can have independent parts called threads that run simultaneously. You can create a new thread using the thread::spawn function, which takes a closure containing the code to run.

A new thread will stop when the main thread of the program exits, whether it has finished its work or not. To prevent this, thread::spawn returns a JoinHandle. Calling the join() method on this handle will block the current thread until the spawned thread has finished.

use std::thread;
use std::time::Duration;
fn main() {
let handle = thread::spawn(|| {
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));
}
// Block the main thread until the spawned thread finishes.
// unwrap() will panic if the child thread panics.
handle.join().unwrap();
println!("Main thread exiting after spawned thread has finished.");
}
Hi number 1 from the main thread!
Hi number 1 from the spawned thread!
Hi number 2 from the main thread!
Hi number 2 from the spawned thread!
Hi number 3 from the main thread!
Hi number 3 from the spawned thread!
Hi number 4 from the spawned thread!
Hi number 5 from the spawned thread!
Main thread exiting after spawned thread has finished.

Often, you’ll want to use data from the main thread within your spawned thread. To do this, you must use a move closure to force the closure to take ownership of the values it uses. This is a safety measure to prevent the main thread from invalidating data while the spawned thread is still using it.

use std::thread;
fn main() {
let v = vec![1, 2, 3];
// The 'move' keyword transfers ownership of 'v' to the new thread.
let handle = thread::spawn(move || {
println!("Here's a vector: {:?}", v);
});
// We can no longer use 'v' in the main thread because it has been moved.
// drop(v); // This would cause a compile-time error.
handle.join().unwrap();
}

A safe and effective way for threads to communicate is through channels. This follows the principle of “Do not communicate by sharing memory; instead, share memory by communicating.” A channel has a transmitter (tx) and a receiver (rx).

use std::sync::mpsc; // mpsc stands for multiple producer, single consumer
use std::thread;
fn main() {
// Create a new channel
let (tx, rx) = mpsc::channel();
// Spawn a thread to send a message
thread::spawn(move || {
let val = String::from("hi");
println!("Thread sending: {}", val);
tx.send(val).unwrap();
// 'val' is now owned by the channel, cannot use it here.
});
// The main thread receives the message. recv() blocks until a message is available.
let received = rx.recv().unwrap();
println!("Main got: {}", received);
}
Thread sending: hi
Main got: hi

For situations where you must share memory, Rust provides concurrency primitives like Mutex<T> (Mutual Exclusion). A Mutex allows only one thread to access some data at any given time. To share ownership of the Mutex across multiple threads, we wrap it in an Arc<T> (Atomically Reference Counted), a thread-safe version of Rc<T>.

use std::sync::{Mutex, Arc};
use std::thread;
fn main() {
// Arc<T> provides shared ownership of a value of type T, allocated on the heap.
let counter = Arc::new(Mutex::new(0));
let mut handles = vec![];
for _ in 0..10 {
let counter = Arc::clone(&counter);
let handle = thread::spawn(move || {
// lock() acquires the mutex, blocking the current thread until it's available.
// The lock is released when the MutexGuard returned by lock() goes out of scope.
let mut num = counter.lock().unwrap();
*num += 1;
});
handles.push(handle);
}
for handle in handles {
handle.join().unwrap();
}
println!("Result: {}", *counter.lock().unwrap());
}
Result: 10

For I/O-bound tasks (like network requests or file system operations), Rust has first-class support for asynchronous programming with async and await syntax. Instead of blocking a thread, async operations yield control, allowing other tasks to run. This allows a single thread to handle many concurrent operations efficiently.

To run async code, you need an async runtime, such as tokio or async-std. Here is a conceptual example of what it looks like:

// This example requires adding the `tokio` crate to your Cargo.toml
// [dependencies]
// tokio = { version = "1", features = ["full"] }
async fn say_hello() {
println!("Hello");
}
#[tokio::main]
async fn main() {
// Calling an async function returns a Future.
let future = say_hello();
// The `.await` keyword pauses execution until the Future is complete.
future.await;
println!("Async function finished.");
}

Key Takeaway: Rust’s Send and Sync traits are marker traits that the compiler uses to enforce thread safety at compile time, preventing entire classes of concurrency bugs.