Updated Jul 30, 2026

17. Threads and Channels

📋 Jump to Takeaways

🎁 In most languages, concurrency bugs hide until production. In Rust, data races are impossible, the type system catches them at compile time. No runtime cost, no sanitizer flags, no prayer. The compiler simply refuses to build code that could corrupt shared memory. This is "fearless concurrency."

To be precise: Rust eliminates data races — the kind where two threads read and write shared memory simultaneously without synchronization. Deadlocks and starvation are still possible and remain your responsibility.

In practice, whether you use threads depends on the work:

  • CPU-bound work (image processing, compression, data crunching) — threads or rayon are the right tool. You want real parallelism across cores.
  • I/O-bound work (web servers, HTTP clients, databases, file reads) — almost nobody uses raw threads. You use async/await with a runtime like tokio. Spawning a thread per connection doesn't scale; tokio can handle thousands of concurrent connections on a handful of threads. This is not single-threaded like Node.js — tokio runs a thread per CPU core by default, tasks are distributed across all of them.

If you're coming from Go: Go's runtime scheduler handles both transparently. It uses a pool of OS threads (controlled by GOMAXPROCS) and preemptively switches goroutines — so CPU-bound work gets time-sliced and I/O-bound work parks automatically while waiting. You never think about which kind of work you're doing. Rust made a different tradeoff: no built-in runtime, explicit choice between threads and async.

In terms of raw performance, tokio tasks and goroutines are comparable for most workloads. Go's GC has improved significantly over the years — pauses are typically under 1ms. Go 1.25 introduced the Green Tea GC (page-based scanning instead of object-by-object), reducing GC CPU time by 10-40% depending on workload, with plans to make it the default in Go 1.26. Rust's advantage is that it has no GC at all, so there are zero pauses by design. For most backend services the difference is negligible. Rust wins in the most latency-sensitive systems (real-time, audio, HFT) where even sub-millisecond pauses are unacceptable, and in environments where a runtime isn't allowed (embedded, WebAssembly, kernel modules).

This lesson covers threads and channels. Next: shared state with Arc and Mutex.

Spawning Threads

Use std::thread::spawn to create a new OS thread. It takes a closure that becomes the thread's entry point.

use std::thread;
use std::time::Duration;

fn main() {
    let handle = thread::spawn(|| {
        for i in 1..=3 {
            println!("spawned thread: {}", i);
            thread::sleep(Duration::from_millis(100));
        }
    });

    for i in 1..=3 {
        println!("main thread: {}", i);
        thread::sleep(Duration::from_millis(100));
    }

    handle.join().unwrap();
    // Output interleaves — both threads run concurrently
}

thread::spawn returns a JoinHandle. Calling .join() blocks until that thread completes and returns its result. If the spawned thread panicked, .unwrap() re-panics in the current thread.

Move Closures for Threads

Spawned threads might outlive the scope that created them. Rust forces you to move data into the thread closure so ownership is clear.

use std::thread;

fn main() {
    let data = vec![1, 2, 3];

    let handle = thread::spawn(move || {
        // data is now owned by this thread
        let sum: i32 = data.iter().sum();
        println!("sum: {}", sum); // sum: 6
    });

    // println!("{:?}", data); // ❌ ERROR: data was moved
    handle.join().unwrap();
}

Without move, the compiler rejects the code because it can't guarantee data lives long enough. This is the ownership system preventing a use-after-free at compile time.

One common question: if main returns while a thread is still running, does the thread keep going? No — when main exits, all threads are killed immediately, just like goroutines in Go. move has nothing to do with this. It's purely about the borrow checker: it can't prove at compile time whether the spawning scope will outlive the thread, so it rejects any reference that crosses the boundary. move sidesteps the question by making the thread own its data outright.

Always .join() threads you care about before main returns, otherwise they're silently killed mid-run.

Channels: Message Passing

Channels let threads communicate by sending messages. Rust's standard library provides mpsc, multiple producer, single consumer.

use std::sync::mpsc;
use std::thread;

fn main() {
    let (tx, rx) = mpsc::channel(); // tx = transmitter (sender), rx = receiver

    thread::spawn(move || {
        let messages = vec!["hello", "from", "thread"];
        for msg in messages {
            tx.send(msg).unwrap(); // returns Err if receiver was dropped
        }
    });

    // rx.recv() blocks until a message arrives
    for received in rx {
        println!("got: {}", received);
    }
    // got: hello
    // got: from
    // got: thread
}

The rx iterator ends when all senders are dropped. You can clone tx to have multiple producers sending to the same receiver.

use std::sync::mpsc;
use std::thread;

fn main() {
    let (tx, rx) = mpsc::channel();
    let tx2 = tx.clone();

    thread::spawn(move || { tx.send("from thread 1").unwrap(); });
    thread::spawn(move || { tx2.send("from thread 2").unwrap(); });

    for msg in rx {
        println!("{}", msg);
    }
    // Order depends on scheduling — both messages arrive
}

In this example both tx and tx2 are moved into threads, so they're dropped automatically when those threads finish. But when you keep the original tx in the main thread and only clone it for workers, you must drop it manually:

use std::sync::mpsc;
use std::thread;

fn main() {
    let (tx, rx) = mpsc::channel();

    for i in 0..3 {
        let tx = tx.clone();
        thread::spawn(move || { tx.send(i).unwrap(); });
    }

    drop(tx); // ❌ without this, rx loops forever — the original tx is still alive

    for msg in rx {
        println!("{}", msg);
    }
}

drop(tx) explicitly destroys the original sender. The rx iterator only stops when every sender is gone — including the original. If you forget drop(tx), the loop blocks forever waiting for a message that will never come.

Buffered and Unbuffered Channels

mpsc::channel() is unbounded — the sender never blocks regardless of how fast the receiver processes. That's fine until a fast producer floods memory because the receiver can't keep up.

For backpressure, use mpsc::sync_channel(n) with a capacity:

use std::sync::mpsc;
use std::thread;

fn main() {
    let (tx, rx) = mpsc::sync_channel(2); // buffer holds up to 2 messages

    thread::spawn(move || {
        for i in 1..=4 {
            tx.send(i).unwrap(); // blocks when buffer is full
            println!("sent: {}", i);
        }
    });

    for msg in rx {
        println!("received: {}", msg);
    }
}

The sender will block on the third send until the receiver consumes a slot — the producer automatically slows down when the consumer is behind.

Pass 0 for rendezvous behavior: the sender blocks until the receiver is ready to accept, with no buffer in between.

let (tx, rx) = mpsc::sync_channel(0); // sender always waits for receiver

The same rule as Go's unbuffered channel applies: the receiver must already be waiting (in a spawned thread) before the sender calls send, otherwise both sides block forever. The difference is how each language handles it:

// ❌ deadlock — rx.recv() is never reached because tx.send() blocks forever
let (tx, rx) = mpsc::sync_channel(0);
tx.send(1).unwrap(); // hangs here silently
let _ = rx.recv();

// ✅ correct — receiver is already waiting in a thread
let (tx, rx) = mpsc::sync_channel(0);
thread::spawn(move || { let _ = rx.recv(); });
tx.send(1).unwrap(); // proceeds once the thread is ready

Go detects this at runtime and panics with "fatal error: all goroutines are asleep - deadlock!". Rust has no runtime scheduler, so the program hangs silently with no message. Use sync_channel(0) only when the receiver is always in a spawned thread.

For Go readers, the mapping is direct:

Go Rust Behavior
make(chan T) sync_channel(0) Sender blocks until receiver is ready
make(chan T, n) sync_channel(n) Sender blocks when buffer is full
(no equivalent) channel() Sender never blocks — unbounded

Use sync_channel when you want the producer to slow down naturally instead of queuing unbounded work.

Channels vs Shared State: When to Use Which

Use Case Prefer
One thread produces, another consumes Channel
Multiple writers updating shared state Arc<Mutex<T>>
Pipeline / streaming data Channel
Shared config or cache Arc<Mutex<T>> or Arc<RwLock<T>>
Simple coordination (done signal) Channel

Channels enforce a "share by communicating" philosophy. Mutexes enforce a "communicate by sharing" model. When in doubt, start with channels — they're harder to deadlock.

Practical Example: Parallel Worker Pattern

Here's a pattern combining channels and threads for concurrent work — spawn one thread per item, collect results back through a channel:

use std::sync::mpsc;
use std::thread;
use std::time::Duration;

fn process_item(id: u32) -> String {
    thread::sleep(Duration::from_millis(50)); // simulate work
    format!("result-{}", id)
}

fn main() {
    let (tx, rx) = mpsc::channel();
    let items: Vec<u32> = (1..=4).collect();

    for item in items {
        let tx = tx.clone();
        thread::spawn(move || {
            let result = process_item(item);
            tx.send(result).unwrap();
        });
    }

    drop(tx); // drop original sender so the receiver iterator ends

    let results: Vec<String> = rx.into_iter().collect();
    println!("{:?}", results); // ["result-1", "result-3", "result-2", "result-4"] (order varies)
}

Each thread gets a cloned tx, does its work, and sends the result back. The main thread collects everything. Order is not guaranteed — whichever thread finishes first sends first.

Key Takeaways

  • thread::spawn creates OS threads; use move closures to transfer ownership into the thread
  • JoinHandle::join() waits for a thread to finish and propagates panics
  • mpsc::channel() is unbounded — the sender never blocks
  • mpsc::sync_channel(n) adds backpressure — sender blocks when the buffer is full
  • sync_channel(0) is rendezvous — like Go's unbuffered channel, but Rust hangs silently on deadlock instead of panicking
  • Clone tx to have multiple producers; the receiver iterator ends when all senders are dropped
  • Channels suit producer/consumer and pipeline patterns; mutexes suit shared caches and counters

🎁 Channels are great for passing data between threads, but what if multiple threads need to read and write the same value directly? Next up: Arc, Mutex, and how Rust enforces thread safety through the type system.

📝 Ready to test your knowledge?

Answer the quiz below to mark this lesson complete.

Spot something off? Report an issue
© 2026 ByteLearn.dev. Free courses for developers. · Privacy