Multithreading in Rust: Threads, Shared State, and Channels
The complete guide to multithreading in Rust: std::thread, Arc, Mutex, RwLock, channels, and the pitfalls that compile fine but blow up in production.
My name is Max and I'm the founder of Rustify.
I've been writing Rust professionally for 2 years and I've helped dozens of engineers make the switch to Rust professionally.
If you want to go further, here's how I can help:
- Fullstack Bootcamp: structured program with real projects, async support, and private community access.
- Blockchain Bootcamp: structured program with real projects, async support, and private community access.
- 1:1 Mentorship: personalized sessions to get you hired faster, with projects tailored to you and mock interviews.
And if you want more Rust content, I post regularly on my YouTube channel:
Rust's threading model is the reason engineers stop worrying about data races. Not because they write careful code. Because the compiler refuses to compile the unsafe version.
This guide covers the full threading stack: spawning threads, sharing data across them with Arc<Mutex<T>>, passing messages via channels, and the pitfalls that compile perfectly and only blow up at runtime. The compiler catches the crashes. You still have to catch the deadlocks.
Every client I work with who comes from Python or Node asks the same question early on: can I just use tokio for everything? The answer is no, and this guide explains exactly why.
If you're reading this, you probably have a complicated relationship with threads.
In Python, the GIL made them useless for CPU-bound work. In Node, the event loop made them unnecessary most of the time. You learned to stay away. You learned that threads are where race conditions live, where debugging sessions go to die, where the senior engineer says "let's not go there."
Then you started writing Rust. And you found out that Rust engineers don't stay away from threads. They use them routinely. Not because they're more careful. Because the compiler makes the unsafe version impossible to compile.
There are two kinds of developers who read this.
The first kind understands that Rust makes concurrency safer, nods, and continues avoiding threads wherever possible. They reach for async for everything and treat the threading primitives as something to look up only when forced.
The second kind internalizes the model. When the problem is CPU-bound and parallel, they reach for threads automatically. When a colleague asks why they're not using tokio for everything, they explain it precisely. They write concurrent code without the background anxiety that used to come with it.
This guide is built to put you in the second group.

Big Picture
CPU-bound, parallel work → std threads (this guide)
I/O-bound, concurrent → tokio async (see guide-async-rust)
spawn_blocking() = bridge: run blocking/sync code FROM async contextQUICK DECISION TREE
Need async I/O?
yes -> tokio async
no -> keep going
Need to use multiple CPU cores?
yes -> std threads or rayon
Need multiple threads to mutate the same data?
yes -> Mutex / RwLock
Need threads to send results/events/work to each other?
yes -> channels1. Why Threads
Key idea: threads exist for one reason: to do multiple things at the same time on multiple CPU cores. Not to wait faster. To compute faster.
The problem threads solve
Your CPU has 8, 16, maybe 32 cores. By default, your program uses exactly one.
WITHOUT threads WITH threads
──────────────── ────────────────────────────────
Core 1: ████████████████ Core 1: ████████ (task A)
Core 2: ░░░░░░░░░░░░░░░░ Core 2: ████████ (task B)
Core 3: ░░░░░░░░░░░░░░░░ Core 3: ████████ (task C)
Core 4: ░░░░░░░░░░░░░░░░ Core 4: ████████ (task D)
time: 4x longer time: 4x fasterResize 1000 images? Encode 50 videos? Parse 10GB of logs? Without threads, you process them one by one. With threads, you split the work across every core you have. For CPU-bound work, this is the only way to go faster.
Threads vs async: when to reach for which
This is the question I get most from my clients who are coming to Rust from Node or Python. The answer is simpler than people think.
THREADS ASYNC (tokio)
──────────────────────────── ──────────────────────────────
CPU-bound work I/O-bound work
image processing HTTP servers
video encoding database queries
cryptography file reads/writes
parsing large files network requests
scientific computation WebSockets
rayon data parallelism anything that waits on externalThe rule:
your program COMPUTES → threads (multiple cores, real parallelism)
your program WAITS → async (one thread, cooperative scheduling)Mixing them? Use spawn_blocking. It runs blocking or CPU-heavy code on a thread pool from inside an async context. That's the bridge between the two worlds.
The cost of a thread
Threads are not free. Each one costs:
memory: ~8MB stack (default on Linux/macOS)
startup time: ~50-100 microseconds to create
context switch: OS kernel involvement, ~1-10 microseconds
limit: OS caps at a few thousand threads maxThis is why you don't spawn 10,000 threads for 10,000 tasks. Ten thousand threads is eighty gigabytes of stack memory before you do any actual work. You spawn N threads (N = CPU cores) and distribute work across them. That's exactly what rayon does automatically.
USE THREADS FOR: DON'T USE THREADS FOR:
image/video processing HTTP server → use tokio
ML inference DB queries → use sqlx + tokio
compression (gzip, zstd) file I/O at scale → use tokio::fs
parsing large datasets anything network → use async
cryptographic operations
game physics / simulationLAYER 1: STD THREADS
2. std::thread::spawn
Key idea:
thread::spawnhands a closure to the OS and says "run this on another core." It returns aJoinHandle: your only way to wait for the result.
The point of spawning threads is to do work in parallel instead of one by one.
Imagine resizing 4 images of different sizes. Bigger file = more CPU work = takes longer.
SEQUENTIAL - one by one total: sum of all files
thread 0 (8MB): [████████████████████]
thread 1 (4MB): [████████████]
thread 2 (2MB): [████████]
thread 3 (1MB): [████]
PARALLEL - all at once total: time of largest file
thread 0 (8MB): [████████████████████] ──┐
thread 1 (4MB): [████████████] │
thread 2 (2MB): [████████] │
thread 3 (1MB): [████] │
│
join() ──────┘ <- main blocks here
│ until ALL finish
↓
[all done, continue]The simplest thread
use std::thread;
let handle = thread::spawn(|| {
println!("hello from another thread!");
});
handle.join().unwrap(); // wait for it to finishWithout join(), main could exit before the thread finishes and the thread gets killed immediately. Always call it unless you genuinely want fire-and-forget.
Getting a return value
spawn returns JoinHandle<T> where T is whatever the closure returns.
let handle = thread::spawn(|| {
42 + 1 // no semicolon = return value
});
let result = handle.join().unwrap(); // result: 43JoinHandle<i32> -- what .join() returns
thread panicked?
│
├── Err(_) <- thread crashed, you get the panic payload
│
└── Ok(43) <- thread finished normally, you get the valuemove closures: why you need them
Try to capture a local variable without move:
let name = String::from("Alice");
let handle = thread::spawn(|| {
println!("{}", name); // ERROR: may outlive borrowed value
});The compiler refuses. The problem: Rust can't know at compile time how long the thread will run. The thread might outlive the scope that created it. A borrow would dangle.
case 1: thread finishes before main -> safe... but Rust can't guarantee it
case 2: thread finishes after main -> name dropped, thread reads freed memory
Rust can't tell the difference at compile time
-> refuses both cases to guarantee safetyFix: move transfers ownership into the closure. No borrow, no problem.
let name = String::from("Alice");
let handle = thread::spawn(move || {
println!("{}", name); // OK: thread owns name now
});WITHOUT move WITH move
"Alice" [owner: main] "Alice" [owner: main]
│ │
borrows &name move
│ ↓
main ends "Alice" [owner: thread]
│ │
"Alice" DELETED thread uses it, drops when done
│ ↓
thread reads garbage 💥 safe ✓Rule: almost every thread::spawn closure needs move. I have yet to see a client codebase where this wasn't the case.
Spawning multiple threads
use std::thread;
let files = vec!["large.jpg", "medium.jpg", "small.jpg", "tiny.jpg"];
let handles: Vec<_> = files.into_iter().map(|path| {
thread::spawn(move || resize_image(path))
}).collect();
let results: Vec<_> = handles.into_iter()
.map(|h| h.join().unwrap())
.collect();All 4 threads run in parallel. collect() gathers handles first, then join() waits for each in order.
Thread naming
let handle = thread::Builder::new()
.name("image-resizer".to_string())
.spawn(|| { /* ... */ })
.unwrap();Name shows up in panic messages and debuggers. Useful in production when you need to know which thread crashed and not just that something did.
LAYER 2: SHARED STATE
3. Rc vs Arc: ownership across threads
Key idea: both
RcandArcallow multiple owners of the same value.Rcis single-threaded only.Arcis thread-safe. When threads are involved, always useArc.
Why you can't just borrow across threads
thread::spawn requires the closure to be 'static: it cannot capture ordinary references.
let data = vec![1, 2, 3];
thread::spawn(|| println!("{:?}", data)); // ERROR: borrowed value does not live long enoughThe thread can outlive the scope that created it. A reference would dangle. So thread::spawn closes this class of bugs at compile time: no borrows allowed, only owned values.
The fix for sharing across threads: Arc. Each thread gets its own owned handle to the same allocation.
The problem: you can't move data to two threads
With move, ownership goes to exactly one thread. But what if multiple threads need the same data?
let data = vec![1, 2, 3];
let h1 = thread::spawn(move || println!("{:?}", data));
let h2 = thread::spawn(move || println!("{:?}", data)); // ERROR: data moved alreadyYou need shared ownership: multiple owners pointing to the same value in memory.
Rc: shared ownership, single-threaded
Rc<T> (Reference Counted) gives you multiple owners via .clone():
use std::rc::Rc;
let data = Rc::new(vec![1, 2, 3]);
let data2 = Rc::clone(&data); // clone the Rc, not the Vec
// Vec freed when LAST Rc dropsWorks great in single-threaded code. But try to send Rc to another thread:
let data = Rc::new(vec![1, 2, 3]);
thread::spawn(move || println!("{:?}", data)); // ERROR: Rc is not SendWhy? Rc increments and decrements its counter with plain integer operations, not atomic. Two threads doing this simultaneously = counter corruption = either a memory leak or a use-after-free.
Arc: shared ownership, thread-safe
Arc<T> (Atomic Reference Counted) is identical to Rc but uses atomic operations for the counter. Safe across threads.
use std::sync::Arc;
let data = Arc::new(vec![1, 2, 3]);
let data1 = Arc::clone(&data);
let data2 = Arc::clone(&data);
let h1 = thread::spawn(move || println!("{:?}", data1)); // OK
let h2 = thread::spawn(move || println!("{:?}", data2)); // OKArc<Vec> (ref count = 3, atomic)
│
├── data (main)
├── data1 (thread 0) all point to the SAME Vec in memory
└── data2 (thread 1)
last Arc drops -> ref count = 0 -> Vec freed safelyRc<T> Arc<T>
──────────────────────── ────────────────────────
single-threaded only multi-threaded safe
slightly faster slightly slower (atomic ops)
can't cross thread boundary Send + Sync
use when: no threads use when: anything with threadsRule: if threads are involved, always Arc. The performance difference is negligible.
One important limit: Arc<T> gives you &T, shared immutable access. To mutate shared data, you need Arc<Mutex<T>>. That's the next section.
Arc<T> -> multiple owners, read-only (&T)
Arc<Mutex<T>> -> multiple owners, read + write (with lock)Shortcut for small scopes: thread::scope (Rust 1.63+) guarantees threads finish before the scope exits, so borrows are safe without Arc:
let data = vec![1, 2, 3];
thread::scope(|s| {
s.spawn(|| println!("{:?}", data)); // borrow OK
s.spawn(|| println!("{:?}", data)); // borrow OK
}); // guaranteed: both threads done here4. Mutex<T>: exclusive access
Key idea:
Mutex<T>protects one value and guarantees only one thread can access it at a time. The lock gives temporary exclusive access. When the guard drops, access is released automatically.
Mutex does not mean "fast". It means "correct exclusive access". If 8 threads all need the same value, the Mutex serializes them: one gets in, the other 7 wait. This is intentional. This is the point.
Mental image: one room, one key. Whoever holds the key gets in. Everyone else waits outside.
Mutex<T> -> one holder of the lock at a time
lock() -> wait until the lock becomes available
MutexGuard -> temporary exclusive access to T
drop(guard) -> unlock automaticallyThe basic API
use std::sync::Mutex;
let counter = Mutex::new(0);
{
let mut guard = counter.lock().unwrap(); // acquire lock
*guard += 1; // guard derefs to &mut i32
} // guard drops here -> lock released
println!("{}", *counter.lock().unwrap());MutexGuard is the whole trick. While the guard exists, you have exclusive access to the inner value. When the guard drops, the lock releases. You cannot forget to unlock: the compiler enforces it via Drop.
How the lock behaves under contention
MUTEX STATE
unlocked: [ ] <- any thread can lock it
locked: [thread 2] <- only thread 2 can access
other threads BLOCK until released
thread 0: lock() -> [thread 0] mutates drop(guard) -> [ ]
thread 1: lock() -----------------------------------------> [thread 1] ...
thread 2: lock() ---------------------------------------------------> ....lock() blocks until the Mutex becomes free. That is why Mutex is safe, and also why it becomes a bottleneck when too many threads contend for the same one.
Deadlock
Two threads each hold a lock and wait forever for the other. The program hangs. No panic, no error, just silence.
thread 0: holds A, waiting for B
thread 1: holds B, waiting for A
-> both wait foreverFix: always acquire multiple locks in the same order across all threads.
Deadlock is the most common threading bug I see in client codebases. It shows up as a service that stops responding under load with no error in the logs. It can take hours to diagnose the first time.
Lock poisoning
If a thread panics while holding a lock, the Mutex becomes "poisoned". Future .lock() calls return Err. Most code uses .unwrap(): if the mutex is poisoned, something already went seriously wrong and a panic is appropriate.
5. Send & Sync
Key idea:
SendandSyncare Rust's compile-time contracts for thread safety. They answer two questions: can you MOVE this value to another thread? Can you SHARE it across threads?
Send = can I MOVE this value to another thread?
one thread gets it, you lose it
like passing a baton
Sync = can I SHARE this value with multiple threads?
many threads read &T at the same time
like a whiteboard everyone can see
SEND SYNC
───────────────────── ──────────────────────────
thread 0 ──── T ────> thread 0 ──┐
(moves away) thread 1 ──┤──> &T (all read same value)
thread 2 ──┘You never implement them manually. Rust derives them automatically based on what your type contains. But you need to understand them to read compiler errors without panicking.
Send: can this value be moved to another thread?
Most types are Send. The notable exception: Rc<T>. Plain integer ref count, not atomic. Two threads decrementing simultaneously = corruption.
Rc<T> -> !Send (plain ref count, not thread-safe)
Arc<T> -> Send (atomic ref count, thread-safe)Sync: can this value be shared across threads?
Sync means: it is safe to access &T from multiple threads simultaneously.
The notable exception: RefCell<T>. Its borrow tracking uses a plain integer, not atomic. Two threads calling borrow_mut() simultaneously = data race.
RefCell<T> -> !Sync (runtime borrow check, not thread-safe)
Mutex<T> -> Sync (OS-level lock, thread-safe)The full picture
TYPE SEND SYNC WHY
────────── ────── ────── ──────────────────────────────────────
i32, bool yes yes plain data, no shared state
String yes yes owned, no ref count
Arc<T> yes yes atomic ref count
Mutex<T> yes yes OS lock protects access
Rc<T> no no plain ref count, not atomic
RefCell<T> yes no borrow tracking not thread-safe
*mut T no no raw pointer, no safety guaranteesWhen you see error[E0277]: Rc<i32> cannot be sent between threads safely, the fix is almost always: Rc to Arc, RefCell to Mutex, raw pointer to a safe abstraction.
6. Arc<Mutex<T>>: shared mutable state
Key idea:
Arcgives shared ownership across threads.Mutexgives exclusive access to the inner value. Together,Arc<Mutex<T>>is the standard shared mutable state pattern in Rust.
Arc = who is allowed to reach the box
Mutex = who is allowed to open the box right now
Guard = the key currently in handArc<T> -> multiple owners, read-only (&T)
Mutex<T> -> exclusive access (one thread at a time)
Arc<Mutex<T>> -> multiple owners + read/write accessThe full pattern in practice
use std::sync::{Arc, Mutex};
let counter = Arc::new(Mutex::new(0));
let handles: Vec<_> = (0..4).map(|_| {
let counter = Arc::clone(&counter);
thread::spawn(move || {
let mut guard = counter.lock().unwrap();
*guard += 1;
})
}).collect();
for h in handles { h.join().unwrap(); }
println!("{}", *counter.lock().unwrap()); // 4Every client I work with uses this pattern within the first week. It feels verbose the first time. By the second week it reads naturally. By the third week you miss it in every other language.
Arc clones -> who is allowed to reach the shared value
Mutex lock -> who is allowed to touch it RIGHT NOW
MutexGuard -> the temporary exclusive access token7. RwLock<T>: multiple readers OR one writer
Key idea:
RwLockis likeMutexbut allows multiple threads to read simultaneously. Only writing requires exclusive access. Use it when reads dominate.
Mental image: a library. Many visitors can read the same book at once. But when the archivist updates the catalog, everyone else must wait.
Mutex: one thread at a time (read OR write)
RwLock: many readers at once OR one writer (never both)use std::sync::{Arc, RwLock};
let data = Arc::new(RwLock::new(vec![1, 2, 3]));
let r1 = data.read().unwrap();
let r2 = data.read().unwrap(); // OK: two readers at once
let mut w = data.write().unwrap(); // blocks until all readers release
w.push(4);Use RwLock when reads are frequent and writes are rare: config data, lookup tables, caches. If you write as often as you read, Mutex is simpler and fast enough. RwLock has more overhead on the write path.
LAYER 3: MESSAGE PASSING
8. Channels (std::sync::mpsc)
Key idea: instead of sharing memory and locking it, send values through a channel. One end sends, the other receives. No shared state, no locks, no data races.
Mental image: a conveyor belt. Producers drop items on one end. The consumer picks them up in order.
The two mental models for sharing data between threads
OPTION 1: Shared memory (Arc<Mutex<T>>)
┌──────────┐ ┌──────────┐
│ thread A │ ── lock() ──► │ Mutex │
│ │ │ <data> │
│ thread B │ ── lock() ──► │ │
└──────────┘ └──────────┘
both fight for the same lock
OPTION 2: Message passing (channel)
┌──────────┐ ┌──────────┐
│ thread A │ ── send ──►│ channel │
│ │ │ buffer │──► thread C
│ thread B │ ── send ──►│ │
└──────────┘ └──────────┘
senders never block each otherThe channel approach: data travels in one direction. Senders never see each other. The receiver processes one value at a time with no lock needed.
mpsc: multiple producers, single consumer
mpsc = Multiple Producers, Single Consumer
┌────────────┐
│ sender 0 │ ──┐
├────────────┤ │ ┌──────────────────────────┐ ┌────────────┐
│ sender 1 │ ──┼────►│ [ msg ][ msg ][ msg ] ··· │───►│ receiver │
├────────────┤ │ └──────────────────────────┘ └────────────┘
│ sender 2 │ ──┘ channel buffer (FIFO queue)
└────────────┘
you can clone Sender as many times as you want
but Receiver is unique: only ONE consumeruse std::sync::mpsc;
let (tx, rx) = mpsc::channel();
let tx1 = tx.clone();
let tx2 = tx.clone();
thread::spawn(move || tx1.send("hello from thread 1").unwrap());
thread::spawn(move || tx2.send("hello from thread 2").unwrap());
drop(tx); // drop the original so rx knows when all senders are gone
while let Ok(msg) = rx.recv() {
println!("{msg}");
}
// rx.recv() returns Err when ALL senders are dropped -> loop ends naturallyUnbounded vs bounded channel
UNBOUNDED (channel)
no limit -- sender never blocks
risk: if receiver is slow, buffer grows forever -> out of memory
BOUNDED (sync_channel)
buffer full -> sender BLOCKS until receiver makes room
natural backpressure: fast senders automatically slow down
safe memory: buffer can't grow past the limit// unbounded -- send never blocks
let (tx, rx) = mpsc::channel::<String>();
// bounded -- buffer of 4 messages, sender blocks when full
let (tx, rx) = mpsc::sync_channel::<String>(4);Collecting results from worker threads
Channels are perfect for gathering work back from parallel tasks:
let (tx, rx) = mpsc::channel();
let files = vec!["large.jpg", "medium.jpg", "small.jpg"];
for file in files {
let tx = tx.clone();
thread::spawn(move || {
let result = resize_image(file);
tx.send((file, result)).unwrap();
});
}
drop(tx);
for (file, result) in rx {
println!("{file}: {:?}", result);
}
// results arrive in COMPLETION order -- fastest thread firstThe fastest thread sends first. You process results as they arrive, not in spawn order. Several of my clients have replaced Vec<JoinHandle> loops with this pattern and found it cleaner for anything beyond simple parallel mapping.
When channels beat shared state
USE CHANNELS WHEN: USE Arc<Mutex<T>> WHEN:
data flows in one direction data read/written in place
producer -> consumer pipeline shared counter, shared cache
results from worker threads multiple threads update same struct
event streams no clear "sender/receiver" directionRule: if your threads COMMUNICATE (send results, events, work items), use channels. If they COLLABORATE on the same data structure, use Arc<Mutex<T>>.
CROSS-CUTTING
9. Common Pitfalls
The compiler prevents data races. It cannot prevent logic errors. These four pitfalls compile perfectly and only bite you at runtime, usually during a production incident.
Pitfall 1: Deadlock
Two threads each hold a lock and wait for the other. Both block forever. The program hangs with no panic, no error, no log.
thread 0: holds A, waiting for B ──┐
thread 1: holds B, waiting for A ──┘
-> both wait foreverFix: always acquire multiple locks in the same order across ALL threads.
// DEADLOCK -- different acquisition order
thread::spawn(|| { let _a = mutex_a.lock(); let _b = mutex_b.lock(); });
thread::spawn(|| { let _b = mutex_b.lock(); let _a = mutex_a.lock(); });
// SAFE -- same order in both threads
thread::spawn(|| { let _a = mutex_a.lock(); let _b = mutex_b.lock(); });
thread::spawn(|| { let _a = mutex_a.lock(); let _b = mutex_b.lock(); });Deadlock is the pitfall I see most often in code reviews with my clients who are new to concurrency. It shows up as a server that stops responding under load with nothing in the logs. The first time takes hours to diagnose. The fix is one line.
Pitfall 2: Lock Contention
Too many threads fighting for the same Mutex. They serialize. You get no parallelism. Adding more threads makes it worse.
8 threads, 1 Mutex:
ideal (no contention):
T0: ████████ T1: ████████ T2: ████████ T3: ████████
all 8 threads run simultaneously -> 8x throughput
reality (heavy contention):
T0: [lock████unlock]
T1: [lock████unlock]
T2: [lock████unlock]
T3: [lock████unlock]
...
one thread at a time -> effectively SINGLE-THREADEDFixes:
PROBLEM FIX
───────────────────────── ─────────────────────────────────────
all threads lock same Mutex shard into N Mutexes
(HashMap<Key> -> 16 shards, each own Mutex)
reads dominate Mutex -> RwLock
(many readers can hold lock simultaneously)
simple counter/flag Mutex -> Atomic
(no lock at all, CPU handles it)
lock held too long minimize time inside lock
clone data out, release lock, then processPitfall 3: Spawning Too Many Threads
Spawning one thread per task does not scale. The OS has hard limits and your RAM has harder ones.
1 thread per job -- what happens at 10k jobs:
memory: 10,000 threads x 8MB stack = 80GB RAM
OS: Linux default limit ~4,000 threads per process
beyond that: thread::spawn() returns Err// one thread per item -- works fine at 100, falls apart at 100,000
for item in million_items {
thread::spawn(move || process(item)); // spawns 1,000,000 threads
}
// rayon -- N threads, N = CPU cores, work stolen automatically
million_items.par_iter().for_each(|item| process(item));Rule: the number of threads should match the number of CPU cores, not the number of tasks.
// how many cores do you have?
let cores = thread::available_parallelism().unwrap().get();Pitfall 4: Blocking Inside tokio
If you use threads and tokio together, never call blocking code directly inside a tokio task. It blocks the tokio thread. No other task can run on that thread until the blocking call returns.
BLOCKING inside tokio task:
tokio thread 0: [ task A ][ BLOCKED 2s on heavy_compute() ][ task B ]
^
no other tasks run on this thread for 2 seconds
if all threads are blocked -> entire runtime stallsFix: spawn_blocking moves the blocking call onto a separate thread pool that tokio maintains for exactly this purpose. That pool is allowed to block. The async thread pool is not.
// WRONG: blocks tokio thread
async fn handle_request() {
let result = heavy_compute(); // CPU-bound, takes 2s
respond(result).await;
}
// CORRECT: runs on blocking thread pool
async fn handle_request() {
let result = tokio::task::spawn_blocking(|| {
heavy_compute()
}).await.unwrap();
respond(result).await;
}This is how you take down a tokio HTTP server without any obvious errors. I have seen it happen more than once. The server handles low traffic fine, then someone runs a batch job and every request starts timing out. spawn_blocking is the fix. Three lines.
Symptom table
SYMPTOM LIKELY CAUSE
────────────────────────────────────────────────────────────
program hangs forever deadlock: two locks, wrong order
adding threads makes it slower lock contention: too many threads
fighting for same Mutex
crash / out of memory too many threads spawned
tokio server stops responding blocking call inside async task
under load -> use spawn_blockingQuick chooser
Need... Use...
many owners, read only Arc<T>
many owners, mutate safely Arc<Mutex<T>>
mostly reads, rare writes Arc<RwLock<T>>
send work/results between threads mpsc channel
parallelize a large iterator rayon
async I/O tokioThe threading bugs that take services down are not exotic. Deadlocks, contention, and blocking inside tokio are the usual suspects. If you want me to audit your concurrent code before it becomes an incident, that's what 1:1 mentorship sessions are for. Book a session →
You've read this far. That puts you in a specific group.
Most developers who hit the concurrency section of their Rust learning stop there. They understand the theory. They add async wherever they can, treat Arc<Mutex<T>> as something to look up when forced, and tell themselves they'll come back to it.
The developers who internalize this material end up being the ones who can actually debug a hung server under load. Who see a deadlock pattern in a code review and flag it in seconds. Who know when to use channels and when to use shared state, and can explain the difference without looking it up.
That's a learnable position to be in. Not from reading more guides. From writing concurrent code with someone who can catch the deadlock pattern before it ships.
If that's the version of you you're building toward, that's what 1:1 mentorship is for.
If you want to go from understanding threading to writing production-grade concurrent Rust, with code review, real projects, and personalized feedback:
- Fullstack Bootcamp: structured program with real projects, async support, and private community access.
- Blockchain Bootcamp: structured program with real projects, async support, and private community access.
- 1:1 Mentorship: personalized sessions to get you hired faster, with projects tailored to you and mock interviews.

Student Success Stories
Hear from engineers who built real Rust projects with Rustify

Arik Dutta
Technical Lead · Low-code & Python → Rust

Tiago Afonso
Fullstack Developer

Ugo Tiberto
Rust Engineer · Fullstack Developer