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

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

DEV Community: rust (mihir mohapatra)

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
}

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();
}

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);
}

💡 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}");
}
}

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}");
}
}

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}");
}
}

💡 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.


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);
}

Types and their Send/Sync status:


🦀 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
}

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();
}

Mutex vs RwLock:


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!
}

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
}

Memory ordering cheat sheet

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();
}

💡 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"] }

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());
}
}

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

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}!"),
}
}

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

💡 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


The full series so far

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!

Generated by RSStT. The copyright belongs to the original author.

Source

Report Page