thread::spawn is more than a request to run a function elsewhere. It creates a boundary where the compiler checks whether a closure and its return value can safely cross into another thread, and whether borrowed data can remain valid for the worker's entire lifetime. Rust expresses those checks through ownership, lifetimes, Send, and Sync.
This chapter targets Rust 2024 edition with rustc and Cargo 1.98.1. The example moves three fixed endpoints into OS threads, performs blocking checks, joins every JoinHandle, and prints results in input order. It makes no network requests, sleeps, or runtime measurements.
1. Move Ownership into a Worker Thread
The type boundary of ordinary thread::spawn is:
pub fn spawn<F, T>(f: F) -> JoinHandle<T>
where
F: FnOnce() -> T + Send + 'static,
T: Send + 'static,
The closure crosses into the new thread, while its return value crosses back through join. Both must therefore be Send. Ordinary spawn also requires the closure and return value to be 'static because the worker may outlive its caller. Here, 'static does not require an owned String or Endpoint to remain allocated until the process exits. It means the value cannot contain borrowed data that expires too soon; an owned value may be dropped as soon as the worker finishes with it.
The example's one-item path moves an Endpoint into a move closure without cloning it:
pub fn run_one(endpoint: Endpoint, checker: Arc<ImmediateChecker>) -> CheckResult {
thread::spawn(move || {
let _checker = checker;
CheckResult {
endpoint_id: endpoint.id,
health: Health::Healthy,
status: 200,
}
})
.join()
.expect("the one-worker fixture must complete")
}
move makes the closure capture surrounding values by value rather than borrow them. Once an owned non-Copy value moves into the closure, the parent thread cannot use it again. If the parent still needs an identifier, the worker can return that identifier as part of its result, as this example does. But move does not grant Send to a type, nor can it turn a short-lived reference into an owned 'static value.
A shown borrow in ordinary spawn demonstrates the distinction. This is an excerpt from the rustc 1.98.1 diagnostic:
error[E0373]: closure may outlive the current function, but it borrows `endpoints`, which is owned by the current function
--> tests/compile_fail/borrowed_spawn.rs:5:32
|
5 | let worker = thread::spawn(|| endpoints.len());
| ^^ --------- `endpoints` is borrowed here
| |
| may outlive borrowed value `endpoints`
|
note: function requires argument type to outlive `'static`
--> tests/compile_fail/borrowed_spawn.rs:5:18
|
5 | let worker = thread::spawn(|| endpoints.len());
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
help: to force the closure to take ownership of `endpoints` (and any other referenced variables), use the `move` keyword
|
5 | let worker = thread::spawn(move || endpoints.len());
| ++++
When parallel work needs to borrow local data, thread::scope is the alternative. Every thread created inside the scope is joined before that scope returns, so workers may borrow non-'static local references. If an automatically joined worker panics, scope panics too; code that needs to handle the panic can explicitly join the scoped handle. This example intentionally uses owned work items because ownership transfer is the point being tested.
2. Keep Handles and Join Every Worker
A JoinHandle<T> is the uniquely owned permission to join one thread. Its join(self) method consumes the handle, waits for termination, and returns std::thread::Result<T>. After a successful join, operations performed by the worker are guaranteed to happen before operations that follow the join. Dropping the handle detaches the worker and permanently gives up the ability to join it. Calling spawn alone does not make the parent wait.
For three endpoints, the parent retains each (input index, identifier, handle) tuple. Workers return values instead of mutating a shared result vector:
pub fn run_fixture_checks<C>(endpoints: [Endpoint; 3], checker: Arc<C>) -> RunReport
where
C: BlockingChecker + Send + Sync + 'static,
{
let handles: Vec<_> = endpoints
.into_iter()
.enumerate()
.map(|(inventory_index, endpoint)| {
let checker = Arc::clone(&checker);
let endpoint_id = endpoint.id;
let handle = thread::spawn(move || {
let result = checker.check(endpoint);
(inventory_index, endpoint_id, result)
});
(inventory_index, endpoint_id, handle)
})
.collect();
let mut indexed_results = Vec::with_capacity(3);
let mut indexed_failures = Vec::new();
for (inventory_index, endpoint_id, handle) in handles {
match handle.join() {
Ok((returned_index, returned_id, result)) => {
debug_assert_eq!(
(returned_index, returned_id),
(inventory_index, endpoint_id)
);
indexed_results.push((returned_index, result));
}
Err(_) => {
indexed_failures.push((inventory_index, WorkerFailure::Panicked { endpoint_id }))
}
}
}
indexed_results.sort_by_key(|(inventory_index, _)| *inventory_index);
indexed_failures.sort_by_key(|(inventory_index, _)| *inventory_index);
RunReport {
results: indexed_results
.into_iter()
.map(|(_, result)| result)
.collect(),
failures: indexed_failures
.into_iter()
.map(|(_, failure)| failure)
.collect(),
}
}
Joining the retained handles and sorting by the saved index makes collection and display deterministic. It says nothing about execution order. A thread spawned first is not guaranteed to start or finish first.
3. Send: Values Crossing a Thread Boundary
Send is an unsafe auto trait with no methods. It states that a value of a type may be transferred to another thread. The closure passed to spawn is itself a value that contains its captures, so the entire closure must be Send. The result returned through JoinHandle has the same requirement.
Structs, enums, unions, and tuples generally receive an auto trait when every field satisfies it. A closure's auto-trait implementation depends on the types and capture modes of its captures. Generic wrappers may instead have conditional implementations, and explicit implementations or negative implementations can override automatic derivation. Ordinary stable user code also cannot add arbitrary negative implementations. An unsafe impl Send places a contract beyond the compiler's checks in the author's hands, so the example does not use one.
Rc<T> has explicit negative implementations for Send and Sync, and its reference count is not updated atomically. The following source uses move and still fails to compile:
use std::rc::Rc;
use std::thread;
fn main() {
let endpoint = Rc::new(String::from("home"));
let worker = thread::spawn(move || endpoint.len());
let _ = worker.join();
}
This rustc 1.98.1 excerpt connects the failure to the Send bound introduced by spawn:
error[E0277]: `Rc<String>` cannot be sent between threads safely
--> tests/compile_fail/rc_not_send.rs:6:32
|
6 | let worker = thread::spawn(move || endpoint.len());
| ------------- -------^^^^^^^^^^^^^^^
| | |
| | `Rc<String>` cannot be sent between threads safely
| | within this `{closure@tests/compile_fail/rc_not_send.rs:6:32: 6:39}`
| required by a bound introduced by this call
|
= help: within `{closure@tests/compile_fail/rc_not_send.rs:6:32: 6:39}`, the trait `Send` is not implemented for `Rc<String>`
note: required because it's used within this closure
--> tests/compile_fail/rc_not_send.rs:6:32
|
6 | let worker = thread::spawn(move || endpoint.len());
| ^^^^^^^
note: required by a bound in `spawn`
move and Send answer separate questions. The first chooses how a closure captures a value; the second states whether that value may cross the thread boundary. When ownership is not shared, moving an ordinary owned value into one worker is simpler than replacing every Rc with Arc.
4. Sync: References Shared Across Threads
T: Sync holds exactly when &T: Send. In this example, several workers invoke the same checker through &self, so C must be Sync. Each closure also receives an Arc<C>, which brings the relevant Send requirement into play. Arc<T> implements Send and Sync only when its payload meets the documented bounds.
The required traits follow the operations. Moving one Endpoint into one worker requires Endpoint: Send. Cloning Arc<C> across workers so they can call check(&self, ...) requires the shared C: Sync plus the bounds needed to transfer the Arc. Returning a CheckResult through a handle requires CheckResult: Send.
Arc does not make its payload thread-safe. The compiler also rejects this Arc<RefCell<_>>:
use std::cell::RefCell;
use std::sync::Arc;
use std::thread;
fn main() {
let results = Arc::new(RefCell::new(Vec::<u16>::new()));
let worker_results = Arc::clone(&results);
let worker = thread::spawn(move || worker_results.borrow_mut().push(200));
let _ = worker.join();
}
The rustc 1.98.1 excerpt is explicit about the missing trait and its effect on Arc:
error[E0277]: `RefCell<Vec<u16>>` cannot be shared between threads safely
--> tests/compile_fail/arc_refcell_not_sync.rs:8:32
|
8 | let worker = thread::spawn(move || worker_results.borrow_mut().push(200));
| ------------- ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `RefCell<Vec<u16>>` cannot be shared between threads safely
| |
| required by a bound introduced by this call
|
= help: the trait `Sync` is not implemented for `RefCell<Vec<u16>>`
= note: if you want to do aliasing and mutation between multiple threads, use `std::sync::RwLock` instead
= note: required for `Arc<RefCell<Vec<u16>>>` to implement `Send`
Arc<Mutex<T>> combines shared ownership with synchronized mutation, subject to its actual generic bounds. The example needs neither that combination nor an Arc<Mutex<Vec<_>>>: each worker returns its own result through its handle, so no worker mutates a shared result collection.
5. Panic Becomes Policy at the Join Boundary
A Rust panic that unwinds to the worker root appears at join as Err(Box<dyn Any + Send + 'static>). It does not automatically panic the joining thread. Calling .unwrap() or .expect() on that result causes a new panic there, while std::panic::resume_unwind deliberately resumes the shown panic. Mapping it to a domain failure isolates that worker's failure. The caller must choose the policy.
The example joins every handle and records only the endpoint identifier, not arbitrary panic payload text as a stable contract. In this test, all three workers reach a rendezvous before the middle worker panics. The test passes only when the later success and the middle failure both survive collection:
#[test]
fn every_handle_is_joined_when_one_worker_panics() {
let checker = Arc::new(PanickingChecker {
barrier: Barrier::new(3),
});
let report = run_fixture_checks(fixture_endpoints(), checker);
assert_eq!(
report
.results
.iter()
.map(|result| result.endpoint_id)
.collect::<Vec<_>>(),
vec![EndpointId::Home, EndpointId::Metrics]
);
assert_eq!(
report.failures,
vec![WorkerFailure::Panicked {
endpoint_id: EndpointId::Health,
}]
);
}
This boundary does not promise general fault isolation. A panic may already have changed external state or damaged application invariants. Process-abort configurations do not unwind, and foreign unwinding has separate restrictions.
6. Verify Blocking Work Without Timing or Order Assumptions
FixtureChecker owns a Barrier::new(3). Each call to check waits once and then returns an entry from a fixed table. The test relies only on the fact that no worker passes the rendezvous until all three arrive. It proves nothing about start order, fairness, core count, or throughput.
pub struct FixtureChecker {
barrier: Barrier,
}
impl FixtureChecker {
pub fn new() -> Self {
Self {
barrier: Barrier::new(3),
}
}
}
impl Default for FixtureChecker {
fn default() -> Self {
Self::new()
}
}
impl BlockingChecker for FixtureChecker {
fn check(&self, endpoint: Endpoint) -> CheckResult {
self.barrier.wait();
match endpoint.id {
EndpointId::Home => CheckResult {
endpoint_id: endpoint.id,
health: Health::Healthy,
status: 200,
},
EndpointId::Health => CheckResult {
endpoint_id: endpoint.id,
health: Health::Unhealthy,
status: 503,
},
EndpointId::Metrics => CheckResult {
endpoint_id: endpoint.id,
health: Health::Healthy,
status: 204,
},
}
}
}
Compile-time assertions also check the inferred auto traits. They do not prove performance, fairness, or the absence of logical races.
fn assert_send<T: Send>() {}
fn assert_sync<T: Sync>() {}
fn assert_checker_contract<C: BlockingChecker>() {
assert_send::<C>();
assert_sync::<C>();
}
Run the complete example checks from the project directory:
cd examples/article-21-threads-send-sync
cargo fmt --all -- --check
cargo check --all-targets --all-features
cargo clippy --all-targets --all-features -- -D warnings
cargo test --all-features
cargo run --quiet
With Rust 1.98.1 and Cargo 1.98.1, all five commands should return status 0. The suite contains five behavior tests and three compile-fail tests. Program output is fixed:
home: healthy (200)
health: unhealthy (503)
metrics: healthy (204)
summary: checked=3 healthy=2 unhealthy=1 worker_panics=0
Safe Rust, together with valid Send and Sync implementations, prevents invalid cross-thread references and data races. It does not prevent deadlock, starvation, duplicate work, stale business decisions, incorrect aggregation, or scheduling mistakes. Threads alone do not guarantee a speedup. Unsafe trait implementations and unsafe or foreign code remain responsible for every invariant they declare.
The fixed three-item example is a teaching example, not production advice to create one thread per arbitrary endpoint. Article 22 covers channels, bounded queues, worker pools, backpressure, shutdown, work distribution, and the trade-off between shared state and message passing.
Full source code
The complete runnable source for this article is available in the Chapter 21 project on GitHub.
Sources
- The Rust Programming Language 1.98.1: Using Threads to Run Code Simultaneously
- The Rust Programming Language 1.98.1: Extensible Concurrency with Send and Sync
- Rust standard library 1.98.1:
thread::spawn - Rust standard library 1.98.1:
JoinHandle - Rust standard library 1.98.1:
thread::scope - Rust standard library 1.98.1:
Send - Rust standard library 1.98.1:
Sync - The Rust Reference 1.98.1: Auto traits
- Rust standard library 1.98.1:
Rc - Rust standard library 1.98.1:
Arc - Rust standard library 1.98.1:
Barrier - Rust standard library 1.98.1:
thread::Result
Leave a Reply