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
- Threads
- Message Passing with Channels
- Send and Sync Traits
- Mutex and RwLock
- Atomics
- 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.
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::spawnreturns aJoinHandle<T>whereTis 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 channeluse 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::syncorcrossbeam-channelcrates.
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-
Synctype 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.
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
clapwithanyhowfor error handling,indicatiffor progress bars, andcoloredorowo-colorsfor terminal color output.
Wrapping Up
The full series so far
What's in Part 5?
- Trait objects and dynamic dispatch (
dyn Trait) - The
DerefandDroptraits - 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.