Data Parallelism with Rayon

Concurrency in Practice covered spawning threads by hand, the right tool when tasks are heterogeneous and long-lived. But a huge share of real parallelism is simpler: you have a big collection and the same independent work to do on every element. Hand-rolling a thread pool, chunking the data, and joining for that is a lot of error-prone plumbing. rayon collapses it to a one-word change: turn .iter() into .par_iter() and the work spreads across all cores, with the library handling the pool, the splitting, and the load balancing. This tutorial covers when that swap is a free win, when it isn't, and the rules that keep it correct.


The Core Move: .iter().par_iter()

Rayon's parallel iterators mirror the standard Iterator API from Iterators Over Loops almost method-for-method. A sequential chain becomes parallel by changing how the chain starts, the .map()/.filter()/.sum() adapters you already know stay the same:

use rayon::prelude::*;

// sequential: one core, element by element
let total: u64 = inputs.iter().map(|x| expensive_hash(x)).sum();

// parallel: same chain, spread across every core
let total: u64 = inputs.par_iter().map(|x| expensive_hash(x)).sum();

That single edit (iterpar_iter, plus use rayon::prelude::*) is the entire change. Rayon splits inputs into chunks, runs expensive_hash on multiple threads via a work-stealing pool, and recombines the results for .sum(). You wrote no thread, no channel, no join, and the result is identical to the sequential version, just faster when the work is heavy enough.

The same applies to consuming and collecting: into_par_iter() (owns the items), par_iter_mut() (mutates in place), and .collect() into a Vec all work as the parallel mirror of their sequential forms.


When It's a Win, and When It Isn't

Parallelism is not free, splitting work, dispatching to threads, and recombining all cost something. That overhead is fixed per parallel operation, so the swap pays off only when the per-element work, times the element count, dwarfs it.

// BAD: trivial work, tiny payoff — parallel overhead likely exceeds the savings
let doubled: Vec<i32> = v.par_iter().map(|x| x * 2).collect();

// GOOD: each element does real work — parallelism earns its keep
let hashes: Vec<_> = files.par_iter().map(|f| sha256(f)).collect();

Gotcha: par_iter() is not a blanket "make it faster" button. On cheap operations (x * 2) or small collections, the coordination overhead can make the parallel version slower than sequential, and you've added a dependency and a thread pool for a regression. The performance rule from Performance and Avoiding Allocations applies in full: measure the real workload (with criterion) before assuming par_iter helps. Reach for it when each element does substantial work or the collection is large, not reflexively.


join and scope: Parallelism Beyond Iterators

Not all parallel work is a uniform map over a collection. For two independent computations, rayon::join runs both, potentially in parallel, and returns when both finish, the natural fit for divide-and-conquer recursion:

use rayon::join;

fn sum_tree(node: &Node) -> u64 {
    match node {
        Node::Leaf(v) => *v,
        Node::Branch(left, right) => {
            // recurse into both halves, possibly on different threads
            let (l, r) = join(|| sum_tree(left), || sum_tree(right));
            l + r
        }
    }
}

join is potentially parallel, not guaranteed: rayon only farms the second closure to another thread if a core is actually idle (work-stealing). That's what makes deep recursion safe, when every core is already busy, join just runs both closures inline with no overhead, so it never oversubscribes. For a dynamic number of parallel tasks sharing borrowed data, rayon::scope gives a spawning API whose tasks are guaranteed to finish before the scope returns.


Sharing State: The Borrow Rules Still Hold

Rayon runs your closures on multiple threads, so the closures must be Send and anything they share must be Sync, the exact rules from the concurrency tutorial, now enforced by rayon's signatures. The clean approach is to avoid shared mutable state entirely: have each element produce a value and let rayon recombine, rather than reaching into one shared accumulator.

// idiomatic: no shared mutable state — map to values, reduce them
let total: u64 = data.par_iter().map(|x| process(x)).sum();

// when you must share mutable state, it needs synchronization
use std::sync::atomic::{AtomicU64, Ordering};
let errors = AtomicU64::new(0);
data.par_iter().for_each(|x| {
    if validate(x).is_err() {
        errors.fetch_add(1, Ordering::Relaxed);  // atomic — safe across threads
    }
});

Gotcha: you can't just += 1 into a captured variable inside a par_iter().for_each(), the closure runs on many threads at once, so a plain shared counter is a data race the compiler rejects. Use an atomic (cheap for counters), a Mutex (from Interior Mutability, for richer state but it serializes access), or better, restructure to .map().sum()/.reduce() so there's no shared mutation at all. Wrapping everything in a Mutex inside a hot parallel loop often serializes the work back to single-threaded speed, defeating the point.

For folding into a single value with an identity and a combine step, .reduce(identity, op) is the parallel-safe equivalent of fold, and .par_sort_unstable() gives a parallel sort that's a drop-in for .sort_unstable() on large slices.


When to Use Rayon vs Manual Threads

SituationReach for
Same independent work over a big collectionpar_iter() / into_par_iter()
Divide-and-conquer recursionrayon::join
Dynamic tasks sharing borrowed datarayon::scope
Parallel sort of a large slicepar_sort_unstable()
Heterogeneous, long-lived, or I/O-bound tasksmanual threads / async (Tokio)
Cheap per-element work or small nstay sequential — measure first

Rayon owns the data-parallel niche: CPU-bound, uniform work over a dataset. It is not a replacement for async (which targets I/O-bound concurrency) or for hand-spawned threads (which fit irregular, long-running tasks). Match the tool to the shape of the work.


Key Takeaways

  • Rayon turns a sequential iterator chain parallel by swapping .iter() for .par_iter() (with use rayon::prelude::*); the .map()/.filter()/.sum() adapters are unchanged and the result is identical.
  • Parallelism has fixed overhead, so it only wins when per-element work × element count clearly exceeds it. par_iter on cheap ops or small collections can be slower, measure the real workload first.
  • rayon::join runs two closures potentially in parallel (only if a core is idle), making divide-and-conquer recursion safe without oversubscription; rayon::scope handles a dynamic set of tasks sharing borrowed data.
  • The Send/Sync rules still apply: prefer .map().sum()/.reduce() with no shared mutable state; when you must share, use atomics or a Mutex, but a Mutex in a hot loop can serialize the work away.
  • Rayon is for CPU-bound data parallelism. Use async for I/O-bound concurrency and manual threads for heterogeneous, long-lived tasks.