DEV Community

Cover image for Rust Concurrency: Threads, Channels, Mutex & Sync (Part 4)
mihir mohapatra
mihir mohapatra

Posted on

Rust Concurrency: Threads, Channels, Mutex & Sync (Part 4)

This is Part 4 of the Core Rust Concepts series.

  • Part 1 — Ownership, Borrowing, Lifetimes, Traits, Result/Option, Pattern Matching
  • Part 2 — Closures, Iterators, Generics, Enums, Smart Pointers, Async/Await
  • Part 3 — Macros, Modules, Testing, Unsafe Rust, FFI

Table of Contents

  1. Threads
  2. Message Passing with Channels
  3. Send and Sync Traits
  4. Mutex and RwLock
  5. Atomics
  6. Building a CLI Tool with clap

19. Threads

Rust's standard library provides OS threads via std::thread. The ownership and type system prevents data races at compile time — if your code compiles, it's free of data races. That's a guarantee no other systems language makes.

Spawning a thread

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

fn main() {
    let handle = thread::spawn(|| {
        for i in 1..=5 {
            println!("thread: {i}");
            thread::sleep(Duration::from_millis(50));
        }
    });

    for i in 1..=3 {
        println!("main:   {i}");
        thread::sleep(Duration::from_millis(80));
    }

    handle.join().unwrap(); // wait for the thread to finish
}
Enter fullscreen mode Exit fullscreen mode

Moving data into a thread

Use move to transfer ownership of data into the thread closure:

use std::thread;

fn main() {
    let data = vec![1, 2, 3, 4, 5];

    let handle = thread::spawn(move || {
        // data is moved into this thread
        let sum: i32 = data.iter().sum();
        println!("sum: {sum}");
    });

    // println!("{:?}", data); ← compile error: data was moved

    handle.join().unwrap();
}
Enter fullscreen mode Exit fullscreen mode

Spawning many threads

use std::thread;

fn main() {
    let handles: Vec<_> = (0..8)
        .map(|i| {
            thread::spawn(move || {
                println!("worker {i} done");
                i * i
            })
        })
        .collect();

    let results: Vec<_> = handles
        .into_iter()
        .map(|h| h.join().unwrap())
        .collect();

    println!("squares: {:?}", results);
}
Enter fullscreen mode Exit fullscreen mode

💡 thread::spawn returns a JoinHandle<T> where T is the return type of the closure. Always .join() your handles — a panicking thread silently fails otherwise.


20. Message Passing with Channels

Rust's standard library includes multiple-producer, single-consumer (mpsc) channels. The philosophy: "do not communicate by sharing memory; share memory by communicating."

Basic channel

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

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

    thread::spawn(move || {
        let msgs = vec!["hello", "from", "the", "thread"];
        for msg in msgs {
            tx.send(msg).unwrap();
        }
    });

    for received in rx {
        println!("got: {received}");
    }
}
Enter fullscreen mode Exit fullscreen mode

Multiple producers

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

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

    for id in 0..4 {
        let tx_clone = tx.clone(); // each thread gets its own sender
        thread::spawn(move || {
            tx_clone.send(format!("msg from worker {id}")).unwrap();
        });
    }

    drop(tx); // drop the original so rx knows when all senders are gone

    for msg in rx {
        println!("{msg}");
    }
}
Enter fullscreen mode Exit fullscreen mode

Synchronous channel (bounded)

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

fn main() {
    // Buffer of 2 — sender blocks when full
    let (tx, rx) = mpsc::sync_channel::<i32>(2);

    thread::spawn(move || {
        for i in 0..5 {
            println!("sending {i}");
            tx.send(i).unwrap(); // blocks at i=2 until receiver reads
        }
    });

    thread::sleep(std::time::Duration::from_millis(200));

    for val in rx {
        println!("received {val}");
    }
}
Enter fullscreen mode Exit fullscreen mode

💡 For more advanced channel patterns (broadcast, watch, oneshot), use the tokio::sync or crossbeam-channel crates.


21. Send and Sync Traits

These two marker traits are the foundation of Rust's fearless concurrency. You rarely implement them manually — the compiler derives them automatically.

Trait Meaning
Send Safe to transfer ownership to another thread
Sync Safe to share a reference across threads (&T is Send)
use std::thread;
use std::rc::Rc;
use std::sync::Arc;

fn main() {
    // Rc<T> is NOT Send — can't move across threads
    // let rc = Rc::new(42);
    // thread::spawn(move || println!("{}", rc)); ← compile error!

    // Arc<T> IS Send — atomic reference counting
    let arc = Arc::new(42);
    let arc_clone = Arc::clone(&arc);

    thread::spawn(move || {
        println!("thread sees: {}", arc_clone);
    }).join().unwrap();

    println!("main sees: {}", arc);
}
Enter fullscreen mode Exit fullscreen mode

Types and their Send/Sync status:

Type Send Sync
i32, f64, bool
String, Vec<T>
Rc<T>
Arc<T>
RefCell<T>
Mutex<T>
*mut T (raw pointer)

🦀 If you try to share a non-Sync type across threads, the compiler refuses to compile. No runtime surprises.


22. Mutex and RwLock

When multiple threads need to mutate shared data, use a Mutex (mutual exclusion lock). Wrap it in Arc to share ownership across threads.

Mutex

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

fn main() {
    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 || {
            let mut num = counter.lock().unwrap(); // blocks until lock acquired
            *num += 1;
        }); // lock is released here when `num` drops
        handles.push(handle);
    }

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

    println!("result: {}", *counter.lock().unwrap()); // 10
}
Enter fullscreen mode Exit fullscreen mode

RwLock — many readers, one writer

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

fn main() {
    let data = Arc::new(RwLock::new(vec![1, 2, 3]));

    // Spawn 4 reader threads
    let readers: Vec<_> = (0..4).map(|i| {
        let data = Arc::clone(&data);
        thread::spawn(move || {
            let r = data.read().unwrap(); // multiple readers allowed
            println!("reader {i}: {:?}", *r);
        })
    }).collect();

    // One writer thread
    let data_w = Arc::clone(&data);
    let writer = thread::spawn(move || {
        let mut w = data_w.write().unwrap(); // exclusive access
        w.push(4);
        println!("writer pushed 4");
    });

    for r in readers { r.join().unwrap(); }
    writer.join().unwrap();
}
Enter fullscreen mode Exit fullscreen mode

Mutex vs RwLock:

Mutex<T> RwLock<T>
Readers One at a time Many simultaneously
Writers One at a time One at a time (exclusive)
Best for Write-heavy workloads Read-heavy workloads
Overhead Lower Higher

Avoiding deadlocks

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

fn main() {
    let a = Arc::new(Mutex::new(1));
    let b = Arc::new(Mutex::new(2));

    // ✅ Always acquire locks in the same order
    {
        let _lock_a = a.lock().unwrap();
        let _lock_b = b.lock().unwrap();
        println!("safe");
    }

    // ❌ Acquiring in different orders across threads = deadlock risk
    // thread 1: lock a → lock b
    // thread 2: lock b → lock a  ← deadlock!
}
Enter fullscreen mode Exit fullscreen mode

23. Atomics

For simple counters and flags shared between threads, std::sync::atomic types are faster than a Mutex — they use hardware atomic instructions with no locking.

use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::thread;

fn main() {
    let counter = Arc::new(AtomicUsize::new(0));
    let mut handles = vec![];

    for _ in 0..8 {
        let counter = Arc::clone(&counter);
        handles.push(thread::spawn(move || {
            for _ in 0..1000 {
                counter.fetch_add(1, Ordering::Relaxed);
            }
        }));
    }

    for h in handles { h.join().unwrap(); }
    println!("total: {}", counter.load(Ordering::SeqCst)); // 8000
}
Enter fullscreen mode Exit fullscreen mode

Memory ordering cheat sheet

Ordering Use case
Relaxed Counters — no ordering guarantees needed
Acquire Load that synchronizes with a Release store
Release Store that publishes data to other threads
AcqRel Read-modify-write (e.g. fetch_add with sync)
SeqCst Strongest — total order across all threads
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::thread;

fn main() {
    let ready = Arc::new(AtomicBool::new(false));
    let ready_clone = Arc::clone(&ready);

    let worker = thread::spawn(move || {
        thread::sleep(std::time::Duration::from_millis(100));
        ready_clone.store(true, Ordering::Release);
        println!("worker: done");
    });

    // Spin-wait (not ideal in prod — use a Condvar or channel instead)
    while !ready.load(Ordering::Acquire) {
        thread::yield_now();
    }
    println!("main: worker finished");
    worker.join().unwrap();
}
Enter fullscreen mode Exit fullscreen mode

💡 Available atomic types: AtomicBool, AtomicI8/16/32/64, AtomicU8/16/32/64, AtomicUsize, AtomicIsize, AtomicPtr<T>.


24. Building a CLI Tool with clap

clap is the standard Rust library for building command-line interfaces. It gives you argument parsing, help text, and subcommands with minimal boilerplate.

# Cargo.toml
[dependencies]
clap = { version = "4", features = ["derive"] }
Enter fullscreen mode Exit fullscreen mode

Basic CLI

use clap::Parser;

/// A simple file word counter
#[derive(Parser, Debug)]
#[command(name = "wordcount")]
#[command(about = "Count words in a string or file", long_about = None)]
struct Cli {
    /// The text to count words in
    #[arg(short, long)]
    text: Option<String>,

    /// Show character count too
    #[arg(short, long, default_value_t = false)]
    chars: bool,

    /// Verbosity level
    #[arg(short, long, action = clap::ArgAction::Count)]
    verbose: u8,
}

fn main() {
    let cli = Cli::parse();

    let input = cli.text.unwrap_or_else(|| String::from("hello world"));
    let words = input.split_whitespace().count();

    if cli.verbose > 0 {
        println!("Input: {:?}", input);
    }

    println!("Words: {words}");

    if cli.chars {
        println!("Chars: {}", input.chars().count());
    }
}
Enter fullscreen mode Exit fullscreen mode

Running it:

$ cargo run -- --text "the quick brown fox" --chars -v
Input: "the quick brown fox"
Words: 4
Chars: 19

$ cargo run -- --help
Count words in a string or file

Usage: wordcount [OPTIONS]

Options:
  -t, --text <TEXT>   The text to count words in
  -c, --chars         Show character count too
  -v, --verbose...    Verbosity level
  -h, --help          Print help
Enter fullscreen mode Exit fullscreen mode

Subcommands

use clap::{Parser, Subcommand};

#[derive(Parser)]
#[command(name = "mytool", about = "A multi-command CLI")]
struct Cli {
    #[command(subcommand)]
    command: Commands,
}

#[derive(Subcommand)]
enum Commands {
    /// Add two numbers
    Add { a: f64, b: f64 },
    /// Multiply two numbers
    Mul { a: f64, b: f64 },
    /// Greet someone
    Greet {
        name: String,
        #[arg(short, long, default_value = "Hello")]
        greeting: String,
    },
}

fn main() {
    let cli = Cli::parse();

    match cli.command {
        Commands::Add { a, b } => println!("{} + {} = {}", a, b, a + b),
        Commands::Mul { a, b } => println!("{} × {} = {}", a, b, a * b),
        Commands::Greet { name, greeting } => println!("{greeting}, {name}!"),
    }
}
Enter fullscreen mode Exit fullscreen mode

Running subcommands:

$ cargo run -- add 3.5 2.5
3.5 + 2.5 = 6

$ cargo run -- greet Alice --greeting "Hey"
Hey, Alice!

$ cargo run -- mul 6 7
6 × 7 = 42
Enter fullscreen mode Exit fullscreen mode

💡 For a full production CLI, combine clap with anyhow for error handling, indicatif for progress bars, and colored or owo-colors for terminal color output.


Wrapping Up

Concept What it gives you
Threads OS-level parallelism, compile-time data race prevention
Channels Safe message passing between threads (mpsc)
Send & Sync Compiler-enforced thread-safety contracts
Mutex & RwLock Shared mutable state without data races
Atomics Lock-free counters and flags
clap Production-grade CLI argument parsing

The full series so far

Part Topics
Part 1 Ownership, Borrowing, Lifetimes, Traits, Result/Option, Pattern Matching
Part 2 Closures, Iterators, Generics, Enums, Smart Pointers, Async/Await
Part 3 Macros, Modules, Cargo, Testing, Unsafe, FFI
Part 4 Threads, Channels, Send/Sync, Mutex, Atomics, clap

What's in Part 5?

  • Trait objects and dynamic dispatch (dyn Trait)
  • The Deref and Drop traits
  • Custom iterators
  • Building a real REST API with axum

Found this helpful? Drop a ❤️ and follow for Part 5!

Top comments (0)