How to use crossbeam crate in Rust concurrency

Add crossbeam crates to Cargo.toml and import specific modules like crossbeam-channel or crossbeam-deque to enable safe parallelism.

When standard channels hit a wall

You are writing a concurrent image processor. One thread fetches URLs, another decodes images, a third saves them. You need pipes between them. The standard library gives you std::sync::mpsc, which works for simple cases. It falls apart when you need a worker to listen to multiple channels at once and grab whichever message arrives first. Or when you are building a thread pool and need idle threads to steal tasks from busy ones without locking a mutex.

That is where crossbeam lives. It is a family of crates providing high-performance concurrency primitives that the standard library leaves out. crossbeam-channel gives you channels with a select! macro and efficient backpressure. crossbeam-deque gives you lock-free work-stealing deques for task distribution.

Crossbeam is not a replacement for std. It is the power tool you reach for when the basic toolkit lacks the features you need.

The crossbeam family

Crossbeam is split into focused crates. You rarely import crossbeam as a whole. You pick the module that solves your problem.

crossbeam-channel handles communication between threads. It supports bounded and unbounded channels, multiple senders and receivers, and the select! macro for multiplexing. The implementation is lock-free and highly optimized. It avoids the overhead of mutex contention that can plague naive channel implementations.

crossbeam-deque handles work distribution. It provides an Injector for pushing tasks and Worker structs for popping them. Workers can pop from their own deque or steal from others. This pattern is the backbone of efficient thread pools like rayon.

Think of channels as conveyor belts. Standard channels are single-lane belts. Crossbeam channels have sensors and traffic lights that let a worker stand at a junction and grab the first package that arrives from any belt. Deques are like a shared pile of dishes. Workers grab from the top of their own pile or steal from the bottom of another worker's pile. The structure ensures minimal contention.

Minimal channel setup

Add crossbeam-channel to your dependencies. You can add other modules later as needed.

[dependencies]
crossbeam-channel = "0.5"

Create a bounded channel. Bounded channels enforce backpressure. If the sender pushes faster than the receiver pulls, the sender blocks until space opens up. This prevents memory exhaustion from unbounded message accumulation.

use crossbeam_channel::{bounded, unbounded};

fn main() {
    // Bounded channel with capacity 10.
    // The sender blocks if the buffer is full.
    let (tx, rx) = bounded::<String>(10);

    // Clone the sender to allow multiple producers.
    // Each clone is independent and can be moved to a thread.
    let tx_clone = tx.clone();

    std::thread::spawn(move || {
        // Send blocks if the channel is full.
        // Returns Err if the receiver is dropped.
        tx_clone.send("hello from thread").unwrap();
    });

    // Receive blocks until a message is available.
    // Returns Err if all senders are dropped.
    let msg = rx.recv().unwrap();
    println!("{msg}");
}

The bounded function creates a channel with a fixed capacity. The unbounded function creates a channel that grows dynamically. Unbounded channels allocate a linked-list node for every message. They are convenient but dangerous. A fast producer can fill the heap before the consumer notices. Prefer bounded channels unless you have a specific reason to allow unlimited buffering.

Multiplexing with select!

The killer feature of crossbeam-channel is the select! macro. It lets you wait on multiple receivers simultaneously. The macro expands to efficient polling code that blocks until at least one channel is ready.

use crossbeam_channel::{bounded, select};

fn main() {
    let (tx1, rx1) = bounded(1);
    let (tx2, rx2) = bounded(1);

    std::thread::spawn(move || {
        tx1.send("from one").unwrap();
    });

    std::thread::spawn(move || {
        tx2.send("from two").unwrap();
    });

    // select! blocks until one branch is ready.
    // It picks a ready branch and executes it.
    loop {
        select! {
            recv(rx1) -> msg => {
                // msg is Result<T, RecvError>.
                // Handle the message or break on error.
                match msg {
                    Ok(value) => {
                        println!("Got: {value}");
                        break;
                    }
                    Err(_) => {
                        println!("Channel one closed");
                        break;
                    }
                }
            }
            recv(rx2) -> msg => {
                match msg {
                    Ok(value) => {
                        println!("Got: {value}");
                        break;
                    }
                    Err(_) => {
                        println!("Channel two closed");
                        break;
                    }
                }
            }
        }
    }
}

The select! macro takes branches. Each branch starts with an operation like recv(rx) or default. The operation evaluates to a value bound to a variable. If the operation is ready, the branch executes. If no operation is ready, the macro blocks.

Add a default branch to make the select non-blocking. The default branch runs immediately if no other branch is ready. This is useful for polling loops or combining blocking waits with timeout logic.

use crossbeam_channel::{bounded, select};

fn main() {
    let (tx, rx) = bounded(1);

    // Non-blocking select.
    select! {
        recv(rx) -> msg => {
            println!("Message: {msg:?}");
        }
        default => {
            println!("No message yet");
        }
    }
}

Convention aside: The community treats select! as the standard way to handle multiple channels. Do not write manual loops checking try_recv on each channel. The macro is optimized and handles edge cases like spurious wakeups.

Work-stealing deques

Use crossbeam-deque when you need to distribute tasks across threads with minimal contention. The pattern involves an Injector for pushing tasks and Worker structs for consuming them.

use crossbeam_deque::{Injector, Steal, Worker};

fn main() {
    // Injector holds the shared task queue.
    // It is safe to share across threads.
    let injector = Injector::unbounded();

    // Push tasks onto the injector.
    for i in 0..10 {
        injector.push(i);
    }

    // Create a worker attached to the injector.
    // Each worker has its own local deque.
    let worker = Worker::new(injector.clone()).unwrap();

    // Pop tasks from the worker's local deque.
    while let Some(task) = worker.pop() {
        println!("Processing {task}");
    }
}

Workers maintain a local deque. When you push to the injector, tasks are distributed to workers. Workers pop from their own deque first. If their deque is empty, they can steal from other workers.

Stealing is explicit. You call steal on a worker, passing references to other workers. The steal operation returns a Steal enum.

use crossbeam_deque::{Injector, Steal, Worker};
use std::thread;

fn main() {
    let injector = Injector::unbounded();
    
    // Push initial tasks.
    for i in 0..20 {
        injector.push(i);
    }

    let mut workers = vec![];
    let mut handles = vec![];

    // Create workers.
    for _ in 0..4 {
        let inj = injector.clone();
        let worker = Worker::new(inj).unwrap();
        workers.push(worker);
    }

    // Spawn threads, each owning a worker.
    for worker in workers {
        let handle = thread::spawn(move || {
            loop {
                // Try to pop from self.
                if let Some(task) = worker.pop() {
                    println!("Thread {:?} processing {task}", thread::current().id());
                    continue;
                }

                // If empty, we would steal from others here.
                // In a real pool, you keep references to other workers.
                // For this example, we break if empty.
                break;
            }
        });
        handles.push(handle);
    }

    for handle in handles {
        handle.join().unwrap();
    }
}

The Steal enum has three variants. Success(value) means a task was stolen. Empty means the target deque had no tasks. Retry means the deque was being modified concurrently. You must retry the steal on Retry.

Convention aside: Keep unsafe blocks out of deque usage. The API is safe. You only need unsafe if you are building a custom abstraction on top of the deque. The community calls this the "minimum unsafe surface" rule.

Pitfalls and compiler errors

Channels block. If you send to a bounded channel that is full, the thread blocks. If you receive from a channel with no senders, the thread blocks forever. This leads to deadlocks.

If you move the sender into a thread without cloning, the compiler rejects you with E0382 (use of moved value). You need tx.clone() for each producer.

use crossbeam_channel::bounded;

fn main() {
    let (tx, rx) = bounded(1);

    // This fails to compile.
    // tx is moved into the closure.
    // std::thread::spawn requires 'static lifetime.
    // std::thread::spawn(move || {
    //     tx.send(1).unwrap();
    // });

    // Fix: Clone the sender.
    let tx_clone = tx.clone();
    std::thread::spawn(move || {
        tx_clone.send(1).unwrap();
    });
}

If you send a value of the wrong type, the compiler rejects you with E0308 (mismatched types). Channels are strongly typed. You cannot send an integer to a string channel.

Runtime errors occur when channels close. If all senders are dropped, recv returns Err(RecvError). If the receiver is dropped, send returns Err(SendError). Check the result. Using unwrap assumes the channel stays open. In production code, handle the error. Log it or shut down gracefully.

select! is not strictly fair. If one channel is always ready, the macro may prefer it over others. This can starve slower channels. If fairness matters, add a default branch with a small sleep or rotate the order of branches.

Don't reach for unbounded channels unless you trust the producer. Backpressure is your friend. Bounded channels force the system to slow down when the consumer falls behind. Unbounded channels hide the problem until the heap fills up.

When to use crossbeam

Use crossbeam-channel when you need select! to multiplex multiple receivers. Use crossbeam-channel when you need try_send with timeouts or non-blocking checks. Use crossbeam-channel when you need high-performance lock-free channels with minimal overhead. Use std::sync::mpsc when you only have one sender and one receiver and do not need advanced features. Use crossbeam-deque when you are building a work-stealing thread pool. Use crossbeam-deque when you need lock-free task distribution across threads.

Treat select! as your traffic cop. It keeps your threads from spinning uselessly. Trust the borrow checker. It usually has a point.

Where to go next