skip to content

Why is Schedulers.parallel() the wrong place for blocking calls, and boundedElastic the wrong place for tight CPU-bound work? What can go wrong?

level: seniorimportance: should knowfreq 55%

answer

  1. parallel = #cores; blocking it starves/deadlocks
  2. boundedElastic = many threads; CPU loops oversubscribe
  3. cores limit parallelism; threads limit waiting concurrency
  4. split: boundedElastic fetch → publishOn parallel compute
  5. BlockHound allows boundedElastic, blocks parallel/event-loop

basics

~20 s

parallel() has only one thread per CPU core, so blocking those threads starves the pool and can deadlock. boundedElastic can hold many threads, so putting CPU work there causes oversubscription and context-switching instead of speedup.

solid answer

~40 s

`Schedulers.parallel()` is a **fixed** pool sized to CPU cores for **short non-blocking** work. Blocking a parallel thread (JDBC, sleep) removes a core-sized slice of capacity; under concurrency you exhaust all of them and everything scheduled there stalls — potentially a **deadlock** if the blocked task waits on work that needs the same pool. `Schedulers.boundedElastic()` is tuned for **blocking I/O**: its thread cap (default 10×cores) means running heavy CPU-bound loops there **oversubscribes the CPU** — many compute threads fighting for the same cores cause context-switch thrashing and no throughput gain, plus it steals threads meant for I/O. Match the Scheduler to the work: CPU-bound non-blocking → parallel (≈#cores threads = ≈#cores of parallelism); blocking/waiting I/O → boundedElastic (threads mostly parked, so more than #cores is fine). BlockHound can flag blocking on parallel/event-loop threads.

code

java · 19 lines
java
import reactor.core.publisher.Mono;
import reactor.core.scheduler.Schedulers;

// WRONG: blocking on the fixed CPU pool — starves cores, risks deadlock
Mono.fromCallable(() -> jdbc.query(...))     // blocking!
    .subscribeOn(Schedulers.parallel())      // only #cores threads
    .subscribe();

// WRONG: tight CPU loop on the I/O pool — oversubscription, steals I/O threads
Mono.fromSupplier(() -> mineCryptoHash(data)) // pure CPU
    .subscribeOn(Schedulers.boundedElastic()) // up to 10x cores compute threads
    .subscribe();

// RIGHT: blocking fetch on boundedElastic, CPU transform on parallel
Mono.fromCallable(() -> jdbc.load(id))         // blocking
    .subscribeOn(Schedulers.boundedElastic())  // I/O pool
    .publishOn(Schedulers.parallel())          // hop to CPU pool
    .map(row -> heavyCompute(row))             // CPU-bound, non-blocking
    .subscribe();

go deeper

for a junior

Know 'CPU → parallel, blocking → boundedElastic' and that blocking parallel is bad.

for a middle

Explain pool sizes (cores vs 10×cores) and the starvation risk on parallel.

for a senior

Articulate cores-limit-parallelism vs threads-limit-waiting, oversubscription, and splitting stages with publishOn.

for a principal

Reason about deadlock topologies, coherent flatMap concurrency + Scheduler sizing, and system-wide impact of shared singletons.

## The core principle: threads vs. cores Parallelism is limited by **CPU cores**; concurrency of *waiting* work is limited by **threads**. The two Schedulers embody the two regimes. ### `Schedulers.parallel()` — CPU-bound, fixed to cores It has **one thread per core** (`Runtime.availableProcessors()`). That's optimal for **CPU-bound, non-blocking** tasks: running more compute threads than cores wouldn't speed anything up (the CPU is the bottleneck), so a fixed core-sized pool is exactly right. **Why blocking on it is bad:** a blocked thread isn't doing CPU work — it's *waiting* — yet it occupies one of your very few pool threads. With only N (=cores) threads, a handful of concurrent blocking tasks **exhausts the pool**. New tasks queue behind the blocked ones and the whole pool stalls. Worse, if a blocked task is (directly or transitively) **waiting on another task that also needs a parallel thread**, you get a **thread-starvation deadlock** — nothing can progress. This is why `flatMap`/CPU pipelines on parallel must stay non-blocking. ### `Schedulers.boundedElastic()` — blocking/waiting I/O, many threads It's designed so a thread can **park while waiting** on I/O. Since parked threads consume almost no CPU, having far more than #cores threads (default cap 10×cores) is fine — they're mostly idle-waiting, and you get high I/O concurrency. **Why tight CPU loops on it are bad:** if you run genuinely CPU-hungry work on many boundedElastic threads, you now have (potentially) 10×cores threads all *actively* competing for #cores of CPU. That's **oversubscription**: heavy context switching, cache thrashing, and **no throughput improvement** over #cores of real parallelism — often it's slower. You also **consume threads reserved for real blocking I/O**, so genuine I/O work backs up in the queue. ## Rules of thumb - **Non-blocking + CPU-bound → `parallel()`.** - **Blocking / waiting on I/O → `boundedElastic()`.** - **No thread switch needed → don't add a Scheduler at all** (or `immediate()`). - Don't put `Thread.sleep`, JDBC, `RestTemplate`, or `block()` on parallel or the event loop. ## Edge cases & tooling - **BlockHound** throws when a blocking JDK call executes on a Scheduler marked non-blocking (event loop, parallel). boundedElastic threads are *allowed* to block. - Mixed work: split stages — do the blocking fetch on boundedElastic (`subscribeOn`), then `publishOn(Schedulers.parallel())` for the CPU-heavy transform. - Don't over-parallelize `flatMap` concurrency beyond the pool's usefulness; concurrency knob + Scheduler must be coherent. - The globals are shared singletons; misusing one Scheduler affects the whole app, not just your chain.

  • How can blocking on Schedulers.parallel() cause a deadlock, not just slowness?
    With only #cores threads, if all are blocked waiting on results that themselves require a parallel thread to produce, no thread is free to run those producers — a thread-starvation deadlock where nothing progresses.
  • Is it ever fine to do a little CPU work on boundedElastic?
    Short, incidental CPU work is fine — it's parked-thread-friendly. The problem is sustained, tight CPU loops across many boundedElastic threads, which oversubscribe the CPU and starve real I/O tasks.

saying these in an interview costs you the question

  • 'parallel() grows to handle blocking load' — it's fixed to cores
  • 'more threads = more CPU throughput' — bounded by cores
  • Putting CPU-heavy work on boundedElastic for 'more parallelism'
  • Assuming boundedElastic threads are cheap enough to run compute loops on

context