Beyond thread::spawn
The first time you see thread::spawn, the lesson is straightforward: hand it a closure, get back a JoinHandle, call .join() to wait. That's the basic mechanic. But once you start writing real services, you notice a problem. The JoinHandle represents "this thread, finishing." It doesn't represent "the thing the thread is doing." If you spawn a worker and you want to send it commands, ask it questions, or cleanly shut it down, the raw JoinHandle doesn't help.
The handle pattern is the Rust answer. You wrap your background work (a thread, a task, a connection) and return a small struct that owns the interface to that work. Outside callers hold the struct and use it to talk to the worker. When the struct is dropped, the worker shuts down. The pattern shows up everywhere: tokio::sync::mpsc::Sender, JoinSet, every actor crate, the standard library's Child for subprocesses. Once you see it, you'll see it in every codebase.
Think of it like a TV remote. Behind the scenes, the TV is doing complicated electrical things. You don't carry the TV around. You carry the remote, which is small, gives you a curated set of buttons, and the TV trusts whoever holds the remote to drive it.
The shape
A handle is just a struct with two ingredients: something to send commands to the worker (usually a channel sender), and optionally something to wait for the worker to finish (usually a JoinHandle). Public methods translate into messages.
use std::sync::mpsc::{self, Sender};
use std::thread;
// The internal protocol. Outside callers never see this enum.
enum Cmd {
Greet(String),
Shutdown,
}
// The handle. Small, cheap to clone if we want, owns the channel sender
// and the JoinHandle for the worker thread.
pub struct Greeter {
tx: Sender<Cmd>,
// Option<JoinHandle> so Drop can take it.
join: Option<thread::JoinHandle<()>>,
}
impl Greeter {
/// Spawn the worker and return a handle to it.
pub fn new() -> Self {
let (tx, rx) = mpsc::channel::<Cmd>();
// The worker thread loops over commands until told to stop or until
// the sender side is dropped (rx.recv() returns Err).
let join = thread::spawn(move || {
for cmd in rx {
match cmd {
Cmd::Greet(name) => println!("hello, {name}"),
Cmd::Shutdown => break,
}
}
});
Greeter { tx, join: Some(join) }
}
/// Public API: callers say what they want, we translate to a Cmd.
pub fn greet(&self, name: &str) {
// If the worker has already exited, send returns Err. We ignore it
// here; a real handle might propagate that.
let _ = self.tx.send(Cmd::Greet(name.to_string()));
}
}
impl Drop for Greeter {
fn drop(&mut self) {
// Politely ask the worker to stop, then wait for it.
let _ = self.tx.send(Cmd::Shutdown);
if let Some(j) = self.join.take() {
let _ = j.join();
}
}
}
Three things to notice. First, the Cmd enum is private. Callers don't construct messages, they call methods. The handle is a little API translator. Second, the worker stops when it receives Shutdown or when the channel's sender side is dropped (because then rx returns Err from recv and the for-loop ends). That second escape hatch is what makes the handle drop-safe: even if you forget to call a shutdown method, dropping the handle will close the channel and the worker will exit. Third, the Drop impl waits for the thread to finish. If you don't, your worker can still be running when main exits, which causes its own kind of headache.
What the caller sees
fn main() {
// One line to spawn and wire up.
let g = Greeter::new();
// Talk to the worker through the public API. No threads, channels,
// or commands visible to the caller.
g.greet("alice");
g.greet("bob");
// When `g` goes out of scope at the end of main, Drop runs.
// It sends Shutdown and joins the thread. No leaks, no orphans.
}
For the caller, Greeter is just an object with a method. They never see mpsc, never see thread::spawn, never see JoinHandle. They get a small, focused interface, and the cleanup happens automatically when the handle's lifetime ends. That's the win.
Async version with Tokio
The shape is identical when the worker is a Tokio task. Replace std::thread::spawn with tokio::spawn, the std channel with tokio::sync::mpsc, and use .await inside the worker.
use tokio::sync::mpsc;
use tokio::task::JoinHandle;
enum Cmd {
Process { id: u64, reply: tokio::sync::oneshot::Sender<String> },
}
pub struct Worker {
tx: mpsc::Sender<Cmd>,
join: Option<JoinHandle<()>>,
}
impl Worker {
pub fn new() -> Self {
// Bounded channel so a slow worker pushes back on senders.
let (tx, mut rx) = mpsc::channel::<Cmd>(32);
let join = tokio::spawn(async move {
while let Some(cmd) = rx.recv().await {
match cmd {
Cmd::Process { id, reply } => {
// Pretend to do real work.
let answer = format!("processed {id}");
// Send the answer back. If the caller dropped the
// oneshot receiver, this errors and we move on.
let _ = reply.send(answer);
}
}
}
});
Worker { tx, join: Some(join) }
}
/// Process a job and return the result. The caller awaits the result
/// without ever knowing a channel was involved.
pub async fn process(&self, id: u64) -> Result<String, &'static str> {
let (reply_tx, reply_rx) = tokio::sync::oneshot::channel();
// If send fails, the worker is gone.
self.tx.send(Cmd::Process { id, reply: reply_tx }).await.map_err(|_| "worker gone")?;
// If recv fails, the worker died before answering.
reply_rx.await.map_err(|_| "worker died")
}
}
Notice the oneshot channel. That's how a handle returns a value from the worker, not just a fire-and-forget command. The caller awaits the oneshot. The worker fills it. This is exactly how tokio::task::JoinHandle::await is implemented under the hood.
Why not just expose the channel?
You could. You'd hand callers a Sender<Cmd> and call it a day. But there are real reasons to wrap:
- Hide the protocol. If you decide tomorrow to switch from
mpsctobroadcast, or from a channel to aMutex<Vec<_>>, callers don't have to change. - Method-based ergonomics.
g.greet("alice")reads better thantx.send(Cmd::Greet("alice".into())).unwrap(). - Centralized errors. A single place that knows what to do when the worker is gone.
- Type safety. The
Cmdenum is private. Callers can't smuggle in an unsupported command. - Drop semantics. A wrapper struct lets you implement
Dropto coordinate shutdown.
It's roughly the same reason you wrap Vec<u8> in a Bytes or wrap a raw TcpStream in a request struct: control over the interface, freedom to change the implementation.
Cloneable handles
If several callers need to talk to the same worker, derive Clone on the handle. Channel senders are designed to be cheaply cloned: cloning a Sender just bumps a refcount. The JoinHandle is not cloneable, so you typically only put it in one "primary" handle and have the clones hold senders only.
A common shape: an outer Arc-style "manager" struct holds the join handle and is dropped last. Smaller "client" handles, freely cloneable, hold only the sender. When the manager is dropped, it signals shutdown and joins. When clients are dropped, the senders eventually close and the worker exits naturally.
Common pitfalls
You forgot the Option<JoinHandle> and tried to call self.join.join() from Drop. The compiler will reject it because join takes self by value, not &mut self. The standard fix is Option<JoinHandle> plus take():
error[E0507]: cannot move out of `self.join` which is behind a mutable reference
You panicked inside the worker and now the channel send returns Err forever. The handle should detect this. One option is to expose a method like is_alive() that checks self.join.as_ref().map(|j| !j.is_finished()). Another is to propagate worker panics in Drop by joining and re-panicking, though that hides bugs in tests.
You hit a deadlock where the worker is waiting on a reply channel and the caller is waiting on the worker. Usually because the worker calls back into something that calls back into the worker. The fix is to give the worker its own dedicated send-only channel for outbound notifications, never let it hold a handle to itself.
You used std::sync::mpsc in an async context and called .recv() from inside an async fn. That blocks the runtime thread. Use tokio::sync::mpsc (or flume, which has both sync and async variants) for async workers.
Your handle is !Send because of something inside the worker. If callers want to share the handle across threads, the handle itself has to be Send. The worker's internals don't have to be Send (the worker thread owns them), only the channel sender does. Channel senders are Send for Send payloads.
When to reach for the handle pattern
Reach for the handle pattern any time you have long-lived background work and you want callers to interact with it through a small, well-typed surface. Background workers, connection managers, GUI animation drivers, periodic tasks, anything that sits there "running" and needs to be controlled.
For a one-shot piece of work that you spawn, await once, and never talk to again, you don't need a handle: a plain tokio::spawn(...).await or thread::spawn(...).join() is enough.
For request/response with no shared state, an async fn is simpler than a worker thread. The handle pattern earns its keep when the worker has state (open connections, caches, accumulated metrics) and you want clear ownership of when that state is created and dropped.
For multi-process orchestration or deeply structured concurrency, look at structured-concurrency crates like tokio::task::JoinSet, which is itself a handle.
Where to go next
How to Implement the Strategy Pattern in Rust