How to Use Crossbeam for Advanced Concurrency in Rust

Install crossbeam-channel, crossbeam-deque, and crossbeam-utils via Cargo to enable advanced safe concurrency patterns in Rust.

When std channels aren't enough

You are building a system with multiple threads. A producer thread generates tasks. A pool of worker threads processes them. A coordinator thread collects results. You reach for std::sync::mpsc. It works for a single sender and a single receiver. Then you add a second producer. mpsc requires you to wrap the sender in an Arc and clone it, which feels clunky. Then you realize the coordinator needs to wait for results from three different worker channels at once. mpsc has no way to multiplex. You would need to spawn a thread per channel to poll them, which wastes resources. You need a channel that supports multiple producers, multiple receivers, and a select! macro to wait on several sources without blocking. You need a work-stealing deque for a custom parallel algorithm. That is what Crossbeam provides.

Crossbeam is a collection of crates focused on lock-free concurrency primitives. The ecosystem splits functionality into specific packages. crossbeam-channel handles message passing. crossbeam-deque provides work-stealing queues. crossbeam-utils offers low-level atomics and scoped threads. You do not need to use all of them. Most projects only need crossbeam-channel.

The channel family

Crossbeam channels come in three flavors. Bounded channels have a fixed capacity. Unbounded channels grow as needed. Tick channels deliver a message at regular intervals. The choice depends on your backpressure strategy.

Bounded channels enforce a limit on how many messages can sit in the buffer. When the buffer is full, the sender blocks until the receiver takes a message. This prevents the producer from overwhelming the consumer. It acts as a circuit breaker. If the consumer slows down, the producer pauses. This keeps memory usage predictable.

Unbounded channels never block the sender. They allocate more memory to store messages as they arrive. Use them when the producer is fast and the consumer is fast, or when you trust the message volume will not spike. If the consumer falls behind on an unbounded channel, your process will consume all available memory and the OS will kill it. The compiler cannot prevent this. You must size your system correctly.

Tick channels are a utility. They send a message every time a duration elapses. They are useful for heartbeats or timeouts without spawning a dedicated thread.

Minimal example: bounded channel

Start with a bounded channel. This is the safest default. It forces you to think about buffer size and backpressure.

use crossbeam_channel::bounded;

fn main() {
    // Create a bounded channel with capacity 10.
    // The sender will block if 10 messages are waiting.
    let (sender, receiver) = bounded(10);

    // Spawn a thread to send messages.
    // The sender is moved into the closure.
    std::thread::spawn(move || {
        for i in 0..5 {
            // send() blocks if the buffer is full.
            // It returns Err if the receiver is dropped.
            sender.send(i).unwrap();
        }
    });

    // Receive messages in the main thread.
    // recv() blocks until a message arrives or the channel closes.
    while let Ok(msg) = receiver.recv() {
        println!("Got: {}", msg);
    }
}

The bounded function returns a tuple of Sender and Receiver. Both types implement Clone. You can clone the sender to share it across multiple producer threads. You can clone the receiver to share it across multiple consumer threads. This makes crossbeam channels multi-producer multi-consumer (MPMC) by default. The standard library mpsc is multi-producer single-consumer. Cloning a crossbeam receiver is a common pattern for fan-out architectures.

Convention aside: always handle the Result from send in production code. unwrap() is fine for examples. In real systems, send returns Err(SendError) when all receivers are dropped. If you ignore this, your producer will keep trying to send into the void. Check the result and break the loop when the receiver is gone.

Walkthrough: what happens under the hood

When you call sender.send(value), the channel checks the buffer. If space is available, it writes the value and wakes a waiting receiver. If the buffer is full, the sender parks its thread. The OS puts the thread to sleep. It consumes no CPU. When the receiver calls recv, it takes a value and unparks a waiting sender. The sender wakes up and writes its value.

This parking mechanism is efficient. It avoids busy-waiting. The thread sleeps until there is actual work. Crossbeam uses atomic operations and futexes to coordinate this with minimal overhead. The implementation is lock-free for the single-producer case and uses fine-grained locking for multiple producers. This means contention is low even under heavy load.

When all senders are dropped, the channel closes. The receiver gets a RecvError on the next call. This signals that no more messages will arrive. You can use this to detect completion. When all receivers are dropped, the sender gets a SendError. This signals that no one is listening.

Realistic example: multiplexing with select!

The killer feature of crossbeam is the select! macro. It lets you wait on multiple channels simultaneously. The macro polls all channels and executes the branch for the first one that has data. This is essential for merging streams, implementing timeouts, or building event loops.

use crossbeam_channel::{bounded, select};
use std::time::{Duration, Instant};

fn main() {
    // Create two channels for different data sources.
    let (send_a, recv_a) = bounded(10);
    let (send_b, recv_b) = bounded(10);

    // Producer A sends messages every 50ms.
    std::thread::spawn(move || {
        for i in 0..3 {
            std::thread::sleep(Duration::from_millis(50));
            send_a.send(format!("A-{}", i)).unwrap();
        }
    });

    // Producer B sends messages every 80ms.
    std::thread::spawn(move || {
        for i in 0..3 {
            std::thread::sleep(Duration::from_millis(80));
            send_b.send(format!("B-{}", i)).unwrap();
        }
    });

    // Consumer waits on both channels.
    // select! blocks until any channel has data.
    loop {
        select! {
            // Check channel A.
            recv(recv_a) -> msg => {
                match msg {
                    Ok(v) => println!("Received: {}", v),
                    Err(crossbeam_channel::RecvError) => {
                        println!("Channel A closed");
                        break;
                    }
                }
            },
            // Check channel B.
            recv(recv_b) -> msg => {
                match msg {
                    Ok(v) => println!("Received: {}", v),
                    Err(crossbeam_channel::RecvError) => {
                        println!("Channel B closed");
                        break;
                    }
                }
            },
        }
    }
}

The select! macro takes a list of branches. Each branch specifies a channel operation and a pattern. The macro evaluates all operations. If multiple channels have data, it picks one fairly. The order is not guaranteed. You must handle both cases. The macro expands to efficient polling code. It does not spawn extra threads. It uses the same parking mechanism as recv.

You can also use select! for timeouts. Crossbeam provides a timeout helper. You can wait for a message or a duration. If the duration expires first, the timeout branch executes. This is cleaner than spawning a timer thread and sending a message.

Convention aside: keep select! branches simple. The macro expands to complex code. If you put heavy logic inside a branch, it can obscure errors. Extract logic into helper functions. Keep the branch focused on receiving and dispatching.

Work-stealing deques

Crossbeam includes crossbeam-deque for work-stealing algorithms. A work-stealing deque is a queue where each thread has its own deque. Threads push tasks to their own deque and pop from the front. If a thread runs out of work, it can steal tasks from the back of another thread's deque. This balances load automatically. Busy threads keep working. Idle threads find work.

Work-stealing deques are not for general messaging. They are for parallel algorithms. Use them when you are implementing a custom scheduler, a parallel map-reduce, or a tree traversal. The deque provides Injector for global work and StealingDeque for per-thread work. The API is low-level. You manage the stealing logic yourself.

Most Rust developers do not need crossbeam-deque. Crates like rayon use it internally. If you are writing a parallel algorithm from scratch, reach for it. Otherwise, stick to channels.

Pitfalls and compiler errors

Crossbeam channels are safe, but concurrency bugs still happen. Deadlocks are the most common. A deadlock occurs when threads wait for each other indefinitely. If a sender blocks on a full buffer and the receiver never runs, the system hangs. This happens when the receiver is blocked on another channel that the sender owns. Always check for circular dependencies.

Another pitfall is dropping senders. If you clone a sender and drop the original, the channel stays open. If you drop all clones, the channel closes. Be careful with scopes. If a sender goes out of scope unexpectedly, the receiver will see an error. Use Arc to share senders if you need them to live longer than the current scope.

Compiler errors catch some mistakes. If you try to send a type that is not Send, the compiler rejects it with E0277. Channels require Send because data crosses thread boundaries. Rc is not Send. Use Arc instead. If you try to move a value out of a borrowed context, you get E0507. This happens when you try to send a value that is still borrowed. Clone the value or restructure the borrow.

Crossbeam channels do not prevent logical errors. They prevent data races. You still need to reason about thread lifetimes and synchronization. The borrow checker helps, but it does not solve all concurrency problems.

Decision: when to use crossbeam

Use crossbeam-channel when you need to wait on multiple channels with select!. The standard library lacks this feature. Crossbeam provides it with a clean macro.

Use crossbeam-channel when you need an unbounded channel with higher throughput than std::sync::mpsc. Crossbeam channels are optimized for performance. Benchmarks show lower latency and higher throughput under contention.

Use std::sync::mpsc when you have a single producer and do not need select!. The standard library is sufficient for simple cases. It avoids adding a dependency.

Use tokio::sync::mpsc when you are writing async code inside a Tokio runtime. Tokio channels integrate with the async executor. They yield control instead of blocking threads. Do not use crossbeam channels in async code. They block the thread and hurt scalability.

Use crossbeam-deque when you are implementing a custom work-stealing parallel algorithm. Reach for it when you need fine-grained control over task distribution.

Use std::thread::scope for scoped threads in modern Rust. crossbeam-utils::thread::scope is largely superseded by the standard library. The standard scope is stable and sufficient for most use cases.

Backpressure is your friend. Size your buffers based on your latency budget, not your memory limit. A bounded channel stops the producer when the consumer chokes. An unbounded channel hides the problem until your process crashes. Pick the tool that matches your flow.

Where to go next