A thread is a sequence of work scheduled by the operating system inside a process. Concurrency means work can make progress over overlapping periods; parallelism means work actually executes at the same time, usually on different cores. Threads can provide both, but scheduling and hardware decide what overlaps.
Rust's contribution is to make ownership rules cross thread boundaries. You must move suitable owned data into an independent worker or prove that borrowed data outlives a scoped worker. A channel then moves messages between workers without giving each one direct mutable access to the same collection.
Read smart pointers first. This lesson uses only the standard library; shared mutable data comes in the next lesson.
Run the examples
cargo new threads-lab --edition 2024
cd threads-lab
Each Rust block with fn main independently replaces src/main.rs; run it with cargo run. Assertions check results without depending on which worker runs first.
Spawn work and join its result
thread::spawn starts a new thread and returns a JoinHandle. Joining waits for termination and returns the worker's result, or an error if it panicked.
use std::thread;
fn main() {
let values = vec![2_u64, 4, 6];
let worker = thread::spawn(move || values.into_iter().sum::<u64>());
let local = 3_u64 * 3;
let total = worker.join().expect("sum worker panicked");
assert_eq!(total + local, 21);
println!("combined: {}", total + local);
}
Output: combined: 21.
move transfers values into the closure. It does not clone the vector. Trying to use values in the parent afterwards is a moved-value error. Capturing a reference with move would only move that reference; it would not extend the referenced value's lifetime.
The normal spawn API requires the closure and its result to be Send + 'static. Send means ownership can safely transfer between threads. The 'static bound means there can be no borrowed data that becomes invalid while the independent thread is running; it does not require a heap allocation or a thread that lives forever. Owned String and Vec values can satisfy it.
Dropping a handle detaches its thread; it does not join or cancel it. When the main thread exits, other threads do not keep the process alive. Keep and join handles for work whose completion matters. See spawn and the Book's thread examples.
Scoped threads can borrow their inputs
If workers are part of one bounded operation, thread::scope guarantees that its scoped threads finish before the scope returns. They can therefore borrow local data that lives outside the scope.
use std::thread;
fn main() {
let numbers = [1_u64, 2, 3, 4, 5, 6];
let total = thread::scope(|scope| {
let (left, right) = numbers.split_at(3);
let a = scope.spawn(|| left.iter().sum::<u64>());
let b = scope.spawn(|| right.iter().sum::<u64>());
a.join().expect("left worker panicked") + b.join().expect("right worker panicked")
});
assert_eq!(total, 21);
assert_eq!(numbers[0], 1);
println!("sum: {total}");
}
Output: sum: 21. Neither worker owns the array. The scope joins any remaining scoped threads automatically; here explicit joins also retrieve their results. Unhandled scoped-thread panics cause the scope to panic. See thread::scope.
For six numbers, spawning threads costs far more than the addition saves. Use small input for a readable example, then measure realistic workloads before parallelizing them.
Channels move messages
std::sync::mpsc means multiple producer, single consumer. You can clone the sender, but the standard receiver is not a clonable work-stealing queue.
use std::sync::mpsc;
use std::thread;
fn main() {
let (tx, rx) = mpsc::channel::<String>();
let worker = thread::spawn(move || {
let message = String::from("report ready");
tx.send(message).expect("receiver disconnected");
});
let message = rx.recv().expect("sender disconnected");
assert_eq!(message, "report ready");
worker.join().expect("worker panicked");
println!("{message}");
}
Output: report ready.
send consumes the message. After a successful send, the producer no longer owns the string. If sending fails because the receiver is gone, SendError<T> contains the unsent value. A successful send does not prove that the receiver processed the message; add an acknowledgement message if your protocol needs that guarantee.
recv() blocks until a message arrives or all senders disconnect. try_recv() returns immediately and distinguishes an empty connected channel from a disconnected one. A receiving loop ends only after all senders are dropped and buffered messages have been drained. See message passing and the mpsc module.
A bounded result pipeline
An ordinary channel has a conceptually unbounded buffer. Producers can outrun a slow consumer and accumulate messages. sync_channel(capacity) bounds the buffer: send waits when it is full. This is backpressure, making producers slow down when downstream work cannot keep up.
Here two workers each summarize their own owned batch. The main thread alone owns the final result vector.
use std::sync::mpsc;
use std::thread;
#[derive(Debug, PartialEq, Eq)]
struct Summary {
batch: usize,
words: usize,
}
fn main() {
let batches = [
vec!["rust has ownership", "channels move values"],
vec!["threads can overlap", "join waits"],
];
let (tx, rx) = mpsc::sync_channel::<Summary>(1);
let mut workers = Vec::new();
for (batch, lines) in batches.into_iter().enumerate() {
let sender = tx.clone();
workers.push(thread::spawn(move || {
let words = lines
.iter()
.map(|line| line.split_whitespace().count())
.sum();
sender
.send(Summary { batch, words })
.expect("collector disconnected");
}));
}
drop(tx);
let mut summaries: Vec<Summary> = rx.into_iter().collect();
for worker in workers {
worker.join().expect("summary worker panicked");
}
summaries.sort_by_key(|summary| summary.batch);
assert_eq!(
summaries,
[
Summary { batch: 0, words: 6 },
Summary { batch: 1, words: 5 },
]
);
for summary in summaries {
println!("batch {}: {} words", summary.batch, summary.words);
}
}
Output:
batch 0: 6 words
batch 1: 5 words
Four details make shutdown predictable:
- Each producer owns one sender clone and drops it when its closure finishes.
- The parent drops its original
tx; otherwise the receiving iterator would keep waiting after every worker finished. - The parent receives before joining workers. Joining first could deadlock if a worker waits to send into a full buffer.
- The collector sorts by batch number. Arrival order across independent producers is not a stable output order.
Capacity bounds queued messages, not total application memory. The batches and in-progress worker results still occupy memory. Capacity zero creates a rendezvous: sending must meet a receiving operation. See sync_channel.
Plan failure and shutdown as a protocol
For a small program, expect makes a worker panic visible at the join point. A reusable service should decide which failures are recoverable and send Result<JobOutput, JobError> values, or return errors through join handles.
| Event | What the API reports | What your design must decide |
|---|---|---|
| Producer finishes normally | Its sender drops | Whether more producers remain |
| All senders drop | recv errors after buffered messages drain | Whether this means successful completion |
| Receiver drops | Subsequent sends fail | Whether producers stop or save unsent data |
| Worker panics | join returns Err under unwinding | Whether to fail the whole operation or retry |
| Worker waits forever | A join can wait forever | A timeout, cancellation signal or bounded work policy |
recv_timeout limits a receive wait, not the lifetime of the worker. Rust has no safe general-purpose API to forcibly kill an arbitrary thread. Cooperative cancellation requires workers to check a signal at appropriate points, and blocking operations need their own wakeup or timeout strategy.
Avoid starting one operating-system thread per small record. Bound the number of workers, split work into meaningful batches, or use a suitable worker-pool library when scheduling becomes a real requirement.
Idioms and pitfalls
- Sleeping is not synchronization. Use joins, channels or synchronization primitives to establish when work is finished.
- A
moveclosure can still contain a short-lived reference. Use owned values or scoped threads to satisfy the lifetime requirement. - Never rely on interleaved
println!output having a fixed order. Assert final data, or sort collected messages. - Keep sender clones only as long as the protocol needs them. One forgotten clone can keep a receiver blocked.
- Receive while bounded-channel producers are running. A full buffer plus a parent waiting in
joincan stop all progress. - Channels transfer ownership but do not automatically bound CPU work, implement retries or guarantee delivery processing.
Exercises
1. Borrow disjoint mutable slices
Use thread::scope and split_at_mut to double the two halves of [1, 2, 3, 4] in separate threads. Assert the final array without cloning it or using a mutex.
2. Collect squared values
Start three workers for 2, 3 and 4. Send their squares through a bounded channel of capacity one. Close the original sender, collect and sort the results, join the workers, and assert [4, 9, 16].
3. Find the shutdown bug
A program clones a sender into a worker, then calls rx.iter().collect() while keeping the original sender in scope. The worker sent its only message and returned. Why does the parent still wait? Would calling sleep solve it?
Solutions
1. Separate exclusive regions
use std::thread;
fn main() {
let mut values = [1, 2, 3, 4];
thread::scope(|scope| {
let (left, right) = values.split_at_mut(2);
scope.spawn(move || {
for value in left {
*value *= 2;
}
});
scope.spawn(move || {
for value in right {
*value *= 2;
}
});
});
assert_eq!(values, [2, 4, 6, 8]);
}
The slices do not overlap, and the scope ends before the parent reads the array. Both conditions matter.
2. Drain, then join
use std::sync::mpsc;
use std::thread;
fn main() {
let (tx, rx) = mpsc::sync_channel(1);
let mut handles = Vec::new();
for number in [2, 3, 4] {
let sender = tx.clone();
handles.push(thread::spawn(move || {
sender.send(number * number).expect("receiver disconnected");
}));
}
drop(tx);
let mut squares: Vec<i32> = rx.into_iter().collect();
for handle in handles {
handle.join().expect("worker panicked");
}
squares.sort_unstable();
assert_eq!(squares, [4, 9, 16]);
}
3. Close the channel
The original sender still represents a live producer, so the receiver cannot know that no more messages will arrive. Drop that sender before collecting. Waiting longer does not disconnect it; the problem is ownership, not timing.
Key takeaways
- Independent threads need transferable data without short-lived borrows; scoped threads can safely borrow from their surrounding operation.
- A join retrieves completion and failure. Dropping a handle only detaches it.
- Message passing moves ownership, and bounded channels add backpressure.
- Correct shutdown requires closing every sender and draining results without creating a wait cycle.
Next lesson
Continue with Shared-state concurrency in Rust to coordinate mutation with Arc, mutexes and atomics.

