Concurrency, Testing & Cargo
Fearless Concurrency — What This Module Covers
Section titled “Fearless Concurrency — What This Module Covers”One of Rust’s boldest claims is fearless concurrency: writing multi-threaded programs without the data races that plague C, C++, or even Java. The compiler enforces the same ownership rules at thread boundaries, turning an entire class of bugs into compile-time errors rather than hard-to-reproduce runtime failures.
This module explores four pillars of production Rust:
| Lesson | Topic | Runnable |
|---|---|---|
| Threads | std::thread, Arc<Mutex<T>>, joining handles | Yes — browser Playground |
| Channels | std::sync::mpsc, send/receive, ownership transfer | Yes — browser Playground |
| Async Intro | async/.await, futures, tokio runtime | Cargo project only |
| Cargo & Tests | cargo new, #[test], cargo test | Cargo project only |
The Two Concurrency Models
Section titled “The Two Concurrency Models”Rust standard library gives you OS threads via std::thread. Each thread is a real OS-level thread with its own stack. Shared state between threads is protected by Arc<Mutex<T>> — the compiler will not let you pass a non-Send type across a thread boundary.
For I/O-bound workloads — network servers, file operations, many concurrent lightweight tasks — the async/.await model is more efficient. It uses a single-threaded or multi-threaded event loop (usually tokio) to schedule thousands of tasks without the overhead of an OS thread per task.
Why Rust Concurrency Is Different
Section titled “Why Rust Concurrency Is Different”In most languages, data races are a runtime problem. You write the code, run it, and occasionally it crashes or returns wrong results under load. In Rust:
- A type is
Sendif it is safe to move to another thread. - A type is
Syncif it is safe to share a reference across threads. - The compiler rejects code that violates these contracts — no sanitizers, no runtime checks needed.
flowchart TD
A["What kind of work am I scheduling?"] --> B{"CPU-bound, or I/O-bound?"}
B -->|CPU-bound| C["std::thread — real OS thread, ~8 MB stack"]
B -->|I/O-bound, many at once| D["async/.await — lightweight task, needs an executor"]
C --> E["Share state with Arc + Mutex/RwLock"]
D --> F["Share state the same way, but never hold a sync lock across .await"] Quick Preview
Section titled “Quick Preview”Here is the smallest meaningful concurrent Rust program — spawn a thread, wait for it, continue in main.
use std::thread;
fn main() { let handle = thread::spawn(|| { println!("Hello from a spawned thread!"); }); handle.join().unwrap(); println!("Main thread done.");}Compiling…
What the Compiler Says: Using a Value After It Was Moved Into a Thread
Section titled “What the Compiler Says: Using a Value After It Was Moved Into a Thread”thread::spawn requires its closure to own everything it captures (via move), because the spawned thread might still be running after the function that created it returns. That ownership transfer is real — the value is genuinely gone from the caller, not just borrowed:
// @expect-error E0382use std::thread;
fn main() { let data = vec![1, 2, 3]; let handle = thread::spawn(move || { println!("sum from thread: {}", data.iter().sum::<i32>()); }); handle.join().unwrap(); println!("data length: {}", data.len()); // ERROR: data was moved into the thread above}error[E0382]: borrow of moved value: `data` --> src/main.rs:9:33 |4 | let data = vec![1, 2, 3]; | ---- move occurs because `data` has type `Vec<i32>`, which does not implement the `Copy` trait5 | let handle = thread::spawn(move || { | ------- value moved into closure here6 | println!("sum from thread: {}", data.iter().sum::<i32>()); | ---- variable moved due to use in closure...9 | println!("data length: {}", data.len()); | ^^^^ value borrowed here after move |help: consider cloning the value before moving it into the closure |5 ~ let value = data.clone();6 ~ let handle = thread::spawn(move || {7 ~ println!("sum from thread: {}", value.iter().sum::<i32>()); |The compiler’s own suggestion is .clone() — and that is a legitimate fix here, since main genuinely needs data in two places. But treat that suggestion as a last resort, not a reflex: the better fix is usually to have the thread compute and return the value main needs (via JoinHandle::join()) instead of a second copy existing on both sides.
Mental Model
Section titled “Mental Model”Every value in Rust has exactly one owner at a time, and thread::spawn’s closure becomes a new owner the moment you write move. Nothing “magic” happens at the thread boundary — it is the same move-semantics rule that governs passing a String to a function, just enforced against the extra constraint that the closure (and everything it captures) must be 'static and Send, because the compiler cannot know when the spawned thread will finish relative to the rest of the program.
Trade-offs
Section titled “Trade-offs”OS threads give you true parallelism and a simple mental model (each thread just runs), at the cost of a real OS stack (~8 MB) and a hard ceiling around hundreds to low thousands of threads. async/.await tasks are ~cheap — you can have thousands to millions of them — but every one of them needs an executor like tokio to actually run, and CPU-bound work on that executor blocks every other task sharing its thread.
Common Misconceptions
Section titled “Common Misconceptions”- “Fearless concurrency means Rust prevents all concurrency bugs.” It prevents data races (two threads touching the same memory unsynchronized, at least one write) at compile time. It does not prevent deadlocks, logic races, or lock poisoning after a panic — those are still your responsibility, covered later in this module.
- “
SendandSyncare things you implement yourself for most types.” Almost every type gets both automatically (they are auto traits) as long as every field isSend/Sync. You only think about them when the compiler tells you a bound is not satisfied, usually because ofRc<T>,RefCell<T>, or a raw pointer somewhere inside. - “async is always faster than threads.” For CPU-bound work,
asyncadds overhead (polling, state-machine allocation) with no benefit — threads orrayon-style parallelism are the right tool there.
Reading AI-generated Code
Section titled “Reading AI-generated Code”AI-generated concurrent Rust reaches for thread::spawn and pushes the returned JoinHandle into a Vec, but sometimes drops the handle immediately (or never collects the Vec at all) because the surrounding code “looked done” without it.
🐛 Spot the bug
use std::thread;
fn process_all(items: Vec<i32>) { for item in items { thread::spawn(move || { println!("processed: {}", item * 2); }); } println!("all items processed");}Show the answer
thread::spawn returns a JoinHandle, and this code never stores it — the handle is dropped immediately, which detaches the thread but does not wait for it. process_all can print "all items processed" and return before any spawned thread has run at all; if main exits right after, the process can terminate mid-work with some (or all) of the println!("processed: ...") lines never printed. The fix: collect every JoinHandle into a Vec and call .join().unwrap() on each one before returning.