Writing Concurrent Applications in Rust

Last updated: Apr 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 usingasync/.awaitand 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 withthread::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 withjoinHandles

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)

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)

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 withasync/.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 fnto pause execution until aFutureis ready, allowing other tasks to run in the meantime without blocking the thread.

  • Async Runtime:A library (likeTokioorasync-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 ourCommon Rust Cratesguide.

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:SendandSyncTraits

Rust’s safety extends to concurrency through two marker traits:

  • Send:A type isSendif it’s safe to transfer ownership of it
    to another thread. Most primitive types and types composed only ofSendtypes areSend.

  • Sync:A type isSyncif it’s safe to have references to it
    accessed from multiple threads simultaneously (i.e.,&TisSend). Types likeMutexandArc(ifTisSendandSync) 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.

  • Usethreads(std::thread) for CPU-bound tasks requiring parallel execution.

  • Usemessage passing(std::sync::mpsc) for safe communication and data
    transfer between threads.

  • Useshared-state primitives(Mutex,Arc) carefully when threads
    truly need to access the same mutable data concurrently.

  • Useasync/.awaitwith a runtime likeTokiofor
    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.

Additional Resources

Related Articles on InfoBytes.guru

External Resources