Enterprise Java

Flux vs ParallelFlux in Project Reactor

Project Reactor provides a reactive programming foundation on the JVM. Two common building blocks are Flux and ParallelFlux. While Flux represents an ordered asynchronous sequence of 0..N items, ParallelFlux provides a way to process items in parallel across multiple rails (logical parallel lanes). Let us delve into understanding reactor flux vs parallel flux and explore their differences, use cases, and best practices.

1. Introduction

1.1 Flux (reactor.core.publisher.Flux)

Flux<T> is the standard 0..N reactive sequence type in Reactor. It preserves the order of signals by default and integrates easily with operators like map, flatMap, filter, buffer, etc. A Flux runs on the thread that subscribes unless you shift it to another thread using operators like publishOn or subscribeOn.

1.2 ParallelFlux (reactor.core.publisher.ParallelFlux)

ParallelFlux<T> is created by calling flux.parallel(). It splits the upstream sequence into multiple parallel “rails” (the number of rails equals the parallelism). Each rail processes its subset of elements independently and potentially concurrently. After parallel processing you typically call sequential() (or flatMap / merge) to get back a Flux. Important: ParallelFlux does not guarantee original ordering across rails — only per-rail ordering.

1.3 Key differences

  • Ordering: Flux preserves order; ParallelFlux may reorder elements across rails.
  • Processing model: Flux is a single sequence of operators; ParallelFlux splits processing into multiple lanes that can run concurrently.
  • Backpressure: Both support backpressure; be mindful of downstream consumers when combining parallelism and buffering.
  • Use-case fit: Use Flux for ordered pipelines and when operators are cheap or IO-bound with proper schedulers. Use ParallelFlux for CPU-bound, independent, embarrassingly parallel tasks where ordering is either not required or can be restored later.

1.4 Pitfalls

  • Unexpected reordering: If your logic depends on global ordering, using parallel() can break correctness.
  • Shared mutable state: Don’t mutate shared objects across rails without proper synchronization — this leads to race conditions.
  • Scheduler misuse: Creating too many threads or using blocking calls on non-blocking schedulers can exhaust resources.
  • Error handling: Errors within a rail will affect that rail — ensure proper onError handling and consider onErrorContinue / onErrorResume where appropriate.

1.5 Best practices

  • Prefer flatMap with an explicit concurrency limit for simple parallelism when order doesn’t matter, and use flatMapSequential if you need to preserve order of emission while allowing concurrent inner publishers.
  • Use parallel() for CPU-bound tasks and set parallelism to Runtime.getRuntime().availableProcessors() or a tuned value.
  • Use appropriate schedulers (Schedulers.parallel() for CPU, Schedulers.boundedElastic() for blocking I/O).
  • Avoid blocking calls on Reactor’s default schedulers that are non-blocking.
  • Measure! Parallelizing adds complexity and may not always give throughput improvements due to coordination/overhead.

1.6 Use cases

  • Flux: ordered streams (event processing, time-series, operators that rely on sequence), composing non-blocking network calls.
  • ParallelFlux: CPU-heavy transformations (image processing, compression, cryptographic hashing), large independent data transformations that can be parallelized.

2. Code Example

The following example illustrates a simple Flux pipeline using a scheduler, then shows how the same flux can be converted into a ParallelFlux to execute CPU-bound mapping in parallel, followed by a comparison of their outputs with explanations.

// File: ParallelVsFluxExample.java

import reactor.core.publisher.Flux;
import reactor.core.publisher.ParallelFlux;
import reactor.core.scheduler.Scheduler;
import reactor.core.scheduler.Schedulers;

import java.time.Duration;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ThreadLocalRandom;

public class ParallelVsFluxExample {

  public static void main(String[] args) throws InterruptedException {
    // Ensure Reactor is on the classpath (reactor-core). This example is compatible with Java 8.
    int itemCount = 12;

    CountDownLatch latch = new CountDownLatch(2);

    // Example 1: plain Flux with publishOn (single threaded mapping unless scheduler has multiple threads)
    Flux.range(1, itemCount)
      .doOnSubscribe(s -> System.out.println("[Flux] subscribed on thread: " + Thread.currentThread().getName()))
      .map(i -> cpuBoundWork(i)) // CPU-bound work on the calling thread unless shifted
      .publishOn(Schedulers.parallel()) // shift downstream to parallel scheduler (but mapping already done)
      .doOnNext(i -> System.out.println("[Flux] item: " + i + " processed on " + Thread.currentThread().getName()))
      .doOnComplete(() -> {
        System.out.println("[Flux] complete");latch.countDown();
      })
      .subscribe();

    // Example 2: ParallelFlux
    ParallelFlux < Integer > parallel = Flux.range(1, itemCount)
      .parallel(Runtime.getRuntime().availableProcessors()) // split into N rails
      .runOn(Schedulers.parallel()) // run rails on parallel scheduler
      .map(i -> cpuBoundWork(i)) // this map runs concurrently on multiple threads
    ;

    parallel
      .sequential() // merge back into a Flux (ordering across rails is not guaranteed)
      .doOnSubscribe(s -> System.out.println("[ParallelFlux] subscribed on thread: " + Thread.currentThread().getName()))
      .doOnNext(i -> System.out.println("[ParallelFlux] item: " + i + " processed on " + Thread.currentThread().getName()))
      .doOnComplete(() -> {
        System.out.println("[ParallelFlux] complete");latch.countDown();
      })
      .subscribe();

    // Wait for both to complete
    latch.await();
  }

  // Simulated CPU-bound work — deterministic-ish but with a tiny random sleep to make thread interleaving visible
  private static int cpuBoundWork(int i) {
    try {
      // Simulate some CPU work
      long iterations = 20_000 L * (i % 3 + 1);
      double x = 0;
      for (long k = 0; k < iterations; k++) {
        x += Math.sin(k + i);
      }
      // small random sleep to emphasize concurrency switching (DO NOT use sleeps in real CPU benchmarks)
      Thread.sleep(ThreadLocalRandom.current().nextInt(5, 25));
    } catch (InterruptedException e) {
      Thread.currentThread().interrupt();
    }
    return i * 10;
  }
}

2.1 Code Explanation

This Java 8 example using Reactor Core demonstrates the practical difference between a regular Flux and a ParallelFlux when handling CPU-bound work. In the first part, a simple Flux pipeline is created with Flux.range, applying a CPU-intensive mapping function (cpuBoundWork) on the calling thread. Although publishOn(Schedulers.parallel()) shifts subsequent operations to the parallel scheduler, the CPU-bound map operation still executes on a single thread before the shift, meaning work is not parallelized. The log output shows all items processed sequentially on the same thread after mapping. In contrast, the second part converts the flux to a ParallelFlux by calling .parallel(Runtime.getRuntime().availableProcessors()), splitting the workload into multiple rails, and then running them concurrently using runOn(Schedulers.parallel()). Here, the map function itself executes in parallel across multiple threads, allowing true concurrent execution of the CPU-bound logic. After processing, the results are merged back into a sequential flux using sequential(), though ordering across rails is not guaranteed. A CountDownLatch is used to keep the program alive until both pipelines complete, and log statements highlight thread usage and concurrency differences. This code illustrates a key distinction: while Flux with publishOn shifts execution context without parallelizing work, ParallelFlux enables actual concurrent processing, making it suitable for CPU-heavy workloads.

2.2 Code Run and Output

The code produces the following output when executed.

[Flux] subscribed on thread: main
[Flux] item: 10 processed on parallel-1
[Flux] item: 20 processed on parallel-1
[Flux] item: 30 processed on parallel-1
[Flux] item: 40 processed on parallel-1
[Flux] item: 50 processed on parallel-1
[Flux] item: 60 processed on parallel-1
[Flux] item: 70 processed on parallel-1
[Flux] item: 80 processed on parallel-1
[Flux] item: 90 processed on parallel-1
[Flux] item: 100 processed on parallel-1
[Flux] item: 110 processed on parallel-1
[Flux] item: 120 processed on parallel-1
[Flux] complete

[ParallelFlux] subscribed on thread: main
[ParallelFlux] item: 40 processed on parallel-2
[ParallelFlux] item: 20 processed on parallel-1
[ParallelFlux] item: 10 processed on parallel-3
[ParallelFlux] item: 30 processed on parallel-4
[ParallelFlux] item: 60 processed on parallel-2
[ParallelFlux] item: 50 processed on parallel-1
[ParallelFlux] item: 70 processed on parallel-3
[ParallelFlux] item: 80 processed on parallel-4
[ParallelFlux] item: 90 processed on parallel-2
[ParallelFlux] item: 100 processed on parallel-1
[ParallelFlux] item: 110 processed on parallel-3
[ParallelFlux] item: 120 processed on parallel-4
[ParallelFlux] complete

3. Executing Schedulers in Project Reactor

Schedulers control where work is executed. Common Reactor schedulers:

  • Schedulers.parallel() — a fixed-size thread pool designed for CPU-bound work. Default size equals Runtime.getRuntime().availableProcessors() (can be tuned via reactor.schedulers.defaultParallelism).
  • Schedulers.boundedElastic() — a scheduler for blocking or I/O-bound work. It grows threads up to a bounded maximum and reuses idle threads to avoid unbounded thread creation.
  • Schedulers.single() — a single-threaded scheduler for serialization of tasks.
  • Schedulers.immediate() — runs tasks on the calling thread immediately.
  • Schedulers.fromExecutorService(ExecutorService) — create custom schedulers from executor services for fine-grained control.

3.1 Code Example

Let’s explore this with an example.

// File: SchedulerExamples.java

import reactor.core.publisher.Flux;
import reactor.core.scheduler.Schedulers;

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class SchedulerExamples {
    public static void main(String[] args) throws InterruptedException {

        System.out.println("=== parallel() - CPU-bound work ===");
        Flux.range(1, 5)
            .publishOn(Schedulers.parallel())
            .map(i -> {
                System.out.println("[parallel] value: " + i + " on " + Thread.currentThread().getName());
                return i * 10;
            })
            .blockLast(); // wait for completion

        System.out.println("\n=== boundedElastic() - I/O or blocking tasks ===");
        Flux.range(1, 5)
            .publishOn(Schedulers.boundedElastic())
            .map(i -> {
                System.out.println("[boundedElastic] value: " + i + " on " + Thread.currentThread().getName());
                try {
                    Thread.sleep(100); // simulate blocking call
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                }
                return i * 20;
            })
            .blockLast();

        System.out.println("\n=== single() - serialized execution on one thread ===");
        Flux.range(1, 5)
            .publishOn(Schedulers.single())
            .map(i -> {
                System.out.println("[single] value: " + i + " on " + Thread.currentThread().getName());
                return i * 30;
            })
            .blockLast();

        System.out.println("\n=== immediate() - same calling thread ===");
        Flux.range(1, 5)
            .publishOn(Schedulers.immediate())
            .map(i -> {
                System.out.println("[immediate] value: " + i + " on " + Thread.currentThread().getName());
                return i * 40;
            })
            .blockLast();

        System.out.println("\n=== fromExecutorService() - custom executor ===");
        ExecutorService customExecutor = Executors.newFixedThreadPool(2);
        Flux.range(1, 5)
            .publishOn(Schedulers.fromExecutorService(customExecutor))
            .map(i -> {
                System.out.println("[fromExecutorService] value: " + i + " on " + Thread.currentThread().getName());
                return i * 50;
            })
            .blockLast();
        customExecutor.shutdown();
    }
}

3.1.1 Code Run and Output

This Java example demonstrates different Reactor Schedulers in action using Flux. First, Schedulers.parallel() executes CPU-bound work across multiple threads, so each item in Flux.range(1,5) is mapped concurrently on threads like parallel-1, parallel-2, etc. Next, Schedulers.boundedElastic() is used for blocking or I/O-bound tasks, simulating a delay with Thread.sleep; this scheduler dynamically grows threads up to a limit and reuses idle threads, allowing concurrent execution of blocking operations. Schedulers.single() runs all tasks serially on one dedicated thread, ensuring ordered execution, while Schedulers.immediate() executes tasks on the calling thread itself, here main. Finally, Schedulers.fromExecutorService() demonstrates creating a custom scheduler from an ExecutorService, allowing fine-grained control over threads; in this example, a fixed thread pool of two threads executes the mapping concurrently. Each Flux uses blockLast() to wait for completion, and System.out.println logs both the processed value and the thread name to illustrate how each scheduler affects concurrency and threading.

=== parallel() - CPU-bound work ===
[parallel] value: 1 on parallel-1
[parallel] value: 2 on parallel-2
[parallel] value: 3 on parallel-3
[parallel] value: 4 on parallel-4
[parallel] value: 5 on parallel-1

=== boundedElastic() - I/O or blocking tasks ===
[boundedElastic] value: 1 on boundedElastic-1
[boundedElastic] value: 2 on boundedElastic-2
[boundedElastic] value: 3 on boundedElastic-3
[boundedElastic] value: 4 on boundedElastic-4
[boundedElastic] value: 5 on boundedElastic-1

=== single() - serialized execution on one thread ===
[single] value: 1 on single-1
[single] value: 2 on single-1
[single] value: 3 on single-1
[single] value: 4 on single-1
[single] value: 5 on single-1

=== immediate() - same calling thread ===
[immediate] value: 1 on main
[immediate] value: 2 on main
[immediate] value: 3 on main
[immediate] value: 4 on main
[immediate] value: 5 on main

=== fromExecutorService() - custom executor ===
[fromExecutorService] value: 1 on pool-1-thread-1
[fromExecutorService] value: 2 on pool-1-thread-2
[fromExecutorService] value: 3 on pool-1-thread-1
[fromExecutorService] value: 4 on pool-1-thread-2
[fromExecutorService] value: 5 on pool-1-thread-1

4. Conclusion

Flux and ParallelFlux are powerful concepts in Project Reactor. Use Flux for ordered, sequential reactive pipelines and for simple concurrency using flatMap or publishOn. Use ParallelFlux when you have truly independent, CPU-bound tasks that benefit from parallel processing across multiple rails. Always mind ordering guarantees, shared state, scheduler choice, and backpressure. Measure and test — parallelism adds overhead and complexity, and is not always faster.

Yatin Batra

An experience full-stack engineer well versed with Core Java, Spring/Springboot, MVC, Security, AOP, Frontend (Angular & React), and cloud technologies (such as AWS, GCP, Jenkins, Docker, K8).
Subscribe
Notify of
guest

This site uses Akismet to reduce spam. Learn how your comment data is processed.

0 Comments
Oldest
Newest Most Voted
Back to top button