Writing Concurrent Applications in Rust

Last updated: April 13, 2025

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 a Future. Futures represent computations that might not be complete yet.
  • .await: Used inside an async fn to pause execution until a Future is ready, allowing other tasks to run in the meantime without blocking the thread.
  • Async Runtime: A library (like Tokio or async-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 is Send if it's safe to transfer ownership of it to another thread. Most primitive types and types composed only of Send types are Send.
  • Sync: A type is Sync if it's safe to have references to it accessed from multiple threads simultaneously (i.e., &T is Send). Types like Mutex<T> and Arc<T> (if T is Send and Sync) are Sync.

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

External Resources