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.

Max Wells

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.

No spam. Unsubscribe any time.

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 context
QUICK 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 -> channels

1. 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 faster

Resize 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 external

The 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 max

This 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 / simulation

LAYER 1: STD THREADS

2. std::thread::spawn

Key idea: thread::spawn hands a closure to the OS and says "run this on another core." It returns a JoinHandle: 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 finish

Without 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: 43
JoinHandle<i32> -- what .join() returns
 
   thread panicked?

        ├── Err(_)   <- thread crashed, you get the panic payload

        └── Ok(43)   <- thread finished normally, you get the value

move 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 safety

Fix: 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 Rc and Arc allow multiple owners of the same value. Rc is single-threaded only. Arc is thread-safe. When threads are involved, always use Arc.

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 enough

The 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 already

You 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 drops

Works 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 Send

Why? 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)); // OK
Arc<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 safely
Rc<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 threads

Rule: 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 here

4. 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 automatically

The 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 forever

Fix: 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: Send and Sync are 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 guarantees

When 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: Arc gives shared ownership across threads. Mutex gives 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 hand
Arc<T>          -> multiple owners, read-only (&T)
Mutex<T>        -> exclusive access (one thread at a time)
Arc<Mutex<T>>   -> multiple owners + read/write access

The 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()); // 4

Every 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 token

7. RwLock<T>: multiple readers OR one writer

Key idea: RwLock is like Mutex but 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 other

The 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 consumer
use 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 naturally

Unbounded 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 first

The 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" direction

Rule: 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 forever

Fix: 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-THREADED

Fixes:

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 process

Pitfall 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 stalls

Fix: 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_blocking

Quick 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                             tokio

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

Book a session →


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.

No spam. Unsubscribe any time.

Student Success Stories

Hear from engineers who built real Rust projects with Rustify

Arik Dutta

Arik Dutta

Technical Lead · Low-code & Python → Rust

Tiago Afonso

Tiago Afonso

Fullstack Developer

Ugo Tiberto

Ugo Tiberto

Rust Engineer · Fullstack Developer

Your Future Awaits

Join our 9-week self-paced bootcamp and go from one language to production Rust. Learn through hands-on projects and daily async support.