[KongchangAI]
· 2 min read· 1,238 words

Why Tokio and Rayon Don't Mix Well: A Deep Dive into Rust's Async Blocking Pitfalls

Why Tokio and Rayon Don't Mix Well: A …

Why Tokio and Rayon clash in Rust async code, and how to use spawn_blocking to fix it.

Tokio and Rayon serve fundamentally different purposes: Tokio handles I/O concurrency via cooperative scheduling, while Rayon handles CPU parallelism via work-stealing. Running blocking compute tasks inside async functions starves Tokio's event loop. The fix is using spawn_blocking to isolate them, and understanding that concurrency and parallelism are not the same thing.

Concurrency Is More Complex Than It Seems

In the Rust community, async/await is celebrated as a powerful tool for high-performance concurrent programming, and Tokio has become the de facto standard runtime for this ecosystem. On the other side, Rayon is a go-to library for data-parallel computation in CPU-intensive scenarios. However, when developers try to combine the two, they often fall into a subtle trap — and that's exactly what this article is about: why async/await can "break down" in certain concurrency scenarios.

This problem touches on a long-underappreciated architectural risk in Rust async programming: I/O concurrency and compute parallelism are fundamentally different models, and forcing them together carries serious performance and correctness risks.

The Fundamental Difference Between Two Concurrency Models

Tokio: Built for I/O-Intensive Tasks

Tokio is designed for I/O-intensive workloads. It is built on an event loop and cooperative scheduling, using async/await to efficiently multiplex thousands of tasks across a small number of threads. When a task hits an I/O wait, it voluntarily yields execution, and the runtime immediately schedules another task.

Tokio's event loop is fundamentally built on top of the OS's I/O multiplexing mechanisms — epoll on Linux, kqueue on macOS, and IOCP on Windows. When an async task executes .await, the Rust compiler transforms it into a state machine, compiling the code around each suspension point into distinct state transition branches. The runtime maintains a ready queue, and a task is only re-enqueued when its underlying I/O event fires. This zero-cost abstraction allows tens of thousands of concurrent connections to run smoothly on just 4–8 worker threads, with far lower memory overhead than the "one thread per connection" model.

The critical assumption of this model is: tasks should yield control frequently. As long as tasks properly suspend while waiting on network, disk, or timers, the Tokio scheduler can maintain high throughput. The moment a task holds a thread for too long without yielding, the scheduler's fairness assumptions collapse.

Rayon: Built for CPU-Intensive Computation

Rayon embodies a completely different philosophy. It targets CPU-intensive tasks, using a work-stealing thread pool to distribute large computations across multiple physical cores. APIs like par_iter() let developers easily saturate multi-core CPUs.

The work-stealing algorithm was originally proposed by Blumofe and Leiserson at MIT in the 1990s and was later widely adopted by Java's ForkJoinPool and Intel TBB. The core idea is that each worker thread maintains a double-ended queue (deque); new tasks are pushed to the front and consumed by the local thread, while idle threads randomly "steal" tasks from the back of other threads' queues. This design automatically achieves dynamic load balancing under uneven workloads and offers better cache locality than a centralized task queue. Rayon's par_iter() recursively bisects the iteration range under the hood, generating a tree-shaped task structure that's ideal for map/reduce-style data-parallel computation.

The key point is that Rayon tasks are non-yielding — they hold threads continuously until the computation finishes. This is a fundamental conflict with Tokio's cooperative scheduling.

How the Blocking Trap Is Triggered

Running Blocking Computation Inside an async Function

The most common misuse pattern is calling CPU-intensive synchronous code (such as Rayon parallel computation or any long-running loop) directly inside an async fn.

async fn handle_request() {
    // Danger: this blocks an entire Tokio worker thread
    let result = heavy_computation();
    // ...
}

Because Tokio has a limited number of worker threads (typically equal to the number of CPU cores), if a task occupies one of those threads for a long time without yielding, it will starve other async tasks waiting to be scheduled. The entire event loop gets stuck, causing latency spikes, throughput drops, and even "pseudo-deadlock" behavior.

Why async/await Can't Rescue You Automatically

async/await is fundamentally a cooperative mechanism — it relies on developers inserting .await yield points at appropriate locations. But CPU-intensive computation has no natural yield points, and the compiler won't automatically insert scheduling opportunities inside a compute loop. This is the deep reason behind the saying "async/await can't solve compute concurrency": async syntax expresses only I/O concurrency; it cannot automatically enforce fair scheduling for compute parallelism.

The Right Solution

Isolate Blocking Tasks with spawn_blocking

Tokio provides spawn_blocking, which is specifically designed to offload blocking or CPU-intensive operations to a separate thread pool, preventing them from contaminating the async worker threads:

let result = tokio::task::spawn_blocking(|| {
    heavy_computation()
}).await?;

It's worth noting that spawn_blocking dispatches to Tokio's blocking thread pool, which is completely separate from the async worker thread pool. It defaults to a cap of 512 threads, creates threads on demand, and recycles idle ones automatically. However, in high-concurrency scenarios where many blocking tasks arrive simultaneously, the thread count can grow quickly. With each thread defaulting to roughly 8MB of stack space, this creates non-trivial memory pressure. In such cases, a better approach is to use a dedicated Rayon thread pool and communicate with the async context via tokio::sync::oneshot or mpsc channel, giving you fine-grained control over parallelism.

This way, the async event loop continues scheduling I/O tasks uninterrupted, while compute tasks run independently in their dedicated threads — the two never interfere.

Bridging Tokio and Rayon at the Right Boundary

For scenarios that genuinely require Rayon's parallel capabilities, the recommended approach is to use spawn_blocking or a dedicated channel mechanism to "hand off" compute tasks from the async context to Rayon's thread pool, then asynchronously return the results. The core principle is: let I/O belong to Tokio, let computation belong to Rayon, and establish a clean boundary between them rather than nesting one inside the other.

A Deeper Architectural Lesson: Concurrency vs. Parallelism

This trap reveals a universal engineering truth: concurrency and parallelism are fundamentally distinct concepts. Rob Pike systematically articulated this distinction in his 2012 talk at Heroku, Concurrency is not Parallelism, which profoundly influenced Go's design philosophy. His classic statement — "Concurrency is about dealing with lots of things at once; parallelism is about doing lots of things at once" — is perfectly illustrated by the conflict between Tokio and Rayon.

Concurrency is a program structure property: the ability to decompose a problem into independently progressing parts, which can exist even on a single core. Parallelism is an execution property: multiple computations physically happening at the same time, requiring multi-core hardware. Conflating the two is costly — using concurrency tools to solve parallelism problems leaves CPU cores idle; using parallelism tools to solve concurrency problems can cause thread explosion and context-switching storms.

  • Tokio solves concurrency problems: how to manage a large number of I/O tasks simultaneously under limited resources.
  • Rayon solves parallelism problems: how to split a single large task across multiple cores for faster execution.

async/await is syntactic sugar for concurrency, not a silver bullet for parallelism. When developers confuse these two layers, no matter how modern the language or how efficient the runtime, performance pitfalls are inevitable.

Summary

Understanding the distinct roles of Tokio and Rayon, and avoiding implicit blocking computation inside async contexts, is key to writing high-performance, predictable Rust services.

No tool is inherently better or worse — only appropriate or inappropriate for the job. Truly mature engineering practice isn't about blindly chasing the "elegance" of a particular syntax. It's about understanding the execution model behind every abstraction — from the OS's I/O multiplexing to work-stealing algorithms, from cooperative scheduling to preemptive parallelism — and making the right choice in the right context.

#Tokio#Rayon#Rust async#spawn_blocking#concurrency vs parallelism#async blocking pitfall#Rust performance
Share:

Related articles