0
votes

I am trying to understand the behaviour of blocking functions in Reactor, but something else has completely thrown me off my study. Here is the code:

  public static void main(String[] args) throws InterruptedException {
    Flux.range(1, 100_000)
        .doOnNext(a -> System.out.println(a + ", thread: " + Thread.currentThread().getName()))
        .flatMap(a -> Mono.fromCallable(() -> blockingMethod(a)).subscribeOn(Schedulers.elastic()))
        .subscribe();
    System.out.println("Here");
    Thread.sleep(Integer.MAX_VALUE);
  }

  private static int blockingMethod(int s) {
    try {
      Thread.sleep(100_000);
    } catch (InterruptedException e) {
      e.printStackTrace();
    }
    return s;
  }

Here's a summary of what happens AFAIK:

  1. Subscription happens on the main thread.

  2. main becomes free inside the flatMap to bring the next element from upstream. Therefore, doOnNext should always print main.

  3. After processing 100_000 elements, main would become free and print here.

Instead, this is what happens:

  1. The first 256 elements are printed on main (in doOnNext) as expected.

  2. After around 1 second, the next 256, then the next and so on. Elements from the second batch onwards are printed on elastic threads.

Here are my questions:

  1. Why are elements being processed in batch of 256? Schedulers.elastic() should create threads on demand, so ideally there should always be a thread available to take the request from main (ignoring JVM restrictions on the number of threads that I can create).

  2. Why are elements in the second 'batch' (and beyond) being printed on elastic threads? I expect them to be published on main. In fact, this is what happens when you remove the blocking call as

       public static void main(String[] args) throws InterruptedException {
           Flux.range(1, 100_000)
               .doOnNext(a -> System.out.println(a + ", thread: " + Thread.currentThread().getName()))
               .flatMap(a -> Mono.just(a).subscribeOn(Schedulers.elastic()))
               .subscribe();
          System.out.println("Here");
          Thread.sleep(Integer.MAX_VALUE);
      }
    

Here, all elements print main in doOnNext and here is printed only when the stream finishes (freeing the main thread).

Am I missing something?

2
By default, max concurrence of flatMap operator is 256. So it can merge only 256 upstream concurrently. - Mister_Jesus
And subscribeOn affects to all chain of operators (No matter where was the call - inside other nested operator or before subscribe). You can see that the first bundle of doOnNext callbacks was performed in main thread because subscribeOn was not called yet. If you will print values in subscriber, we will see that its all expected and all call will be in elastic. That why you shouldn't use doOn* operators with heavy operations. - Mister_Jesus
@Mister_Jesus thanks for the answer. Why does the example without the blocking call emit all items on the main thread then? (in doOnNext)? - Prashant Pandey
The answer is above - Mister_Jesus
@Mister_Jesus not sure how it answers my question. In the example without the blocking call (the bottom-most chunk of code), all items are printed on the main thread. This means subscribeOn didn't change the source of the top-level stream right? - Prashant Pandey

2 Answers

2
votes

You need to understand that the entire reactor is built on the reactive streams spec. Every operator above is thus a publisher and subscriber combo.

In scenario 2

   public static void main(String[] args) throws InterruptedException {
       Flux.range(1, 100_000)
           .doOnNext(a -> System.out.println(a + ", thread: " + Thread.currentThread().getName()))
           .flatMap(a -> Mono.just(a).subscribeOn(Schedulers.elastic()))
           .subscribe();
      System.out.println("Here");
      Thread.sleep(Integer.MAX_VALUE);
  }

The publisher starts on the main thread (Flux.Range) the subscriber (doOnNext) is subscribing (invoking the onNext call on the main Thread) and it is delegated to an elastic thread which is consuming faster than the subscriber can publish. So the entire Flux.Range gets invoked on the main thread and gets distributed to the elastic thread pool to process

In scenario 1

  public static void main(String[] args) throws InterruptedException {
    Flux.range(1, 100_000)
        .doOnNext(a -> System.out.println(a + ", Publisher: " + Thread.currentThread().getName()))
        .flatMap(a -> Mono.fromCallable(() -> blockingMethod(a)).subscribeOn(Schedulers.elastic())).doOnNext(a -> System.out.println(a + ", Subscriber: " + Thread.currentThread().getName()))
        .subscribe();
    System.out.println("Here");
    Thread.sleep(Integer.MAX_VALUE);
  }

  private static int blockingMethod(int s) {
    try {
      Thread.sleep(100_000);
    } catch (InterruptedException e) {
      e.printStackTrace();
    }
    return s;
  }

The first 256 calls of Flux.Range happen on the main thread. Please remember that reactive streams is a back pressure driven programming and since the flat map can only merge 256 upstreams, the flat map operator (which is a subscriber) does not invoke the onNext call after 256 events, since they are all blocked/waiting to complete.

The main thread now is freed up, since it finished it responsibility of taking part in the reactive pipeline and steps out of the conveyer belt. i.e basically goes on to execute the next line of code. Which is why you see "Here" printed after the first 256 elements. Then the main thread goes on to get blocked indefinitely as per the code.

Once one of the blocking threads complete the job, they would trigger the subscriber to invoke the onNext on the thread that completed the earlier request, which is the elastic thread. The elastic thread would now delegate the call to another elastic thread in the inner reactor pipeline.

Which means for the same item i emitted, the thread that prints "Publisher" & the thread that prints "Subscriber" is not the same, even though they are elastic thread. Feel free to verify the below code

2
votes

Ok. Lets modify your example by adding a new statement:

public static void main(String[] args) throws InterruptedException {
       Flux.range(1, 100_000)
           .doOnNext(a -> System.out.println(a + ", thread: " + Thread.currentThread().getName())) // #4
           .flatMap(a -> Mono.just(a)
               .subscribeOn(Schedulers.elastic())) // #2
           .subscribeOn(Schedulers.immediate()) // NEW, #1
           .subscribe(); // #3
      System.out.println("Here");
      Thread.sleep(Integer.MAX_VALUE);
  }

By default, this subscription chain will be executed in current thread. Doc says about Mono::subscribeOn(Scheduler):

Run subscribe, onSubscribe and request on a specified Scheduler's Scheduler.Worker. As such, placing this operator anywhere in the chain will also impact the execution context of onNext/onError/onComplete signals from the beginning of the chain up to the next occurrence of a publishOn.

And reactor reference:

subscribeOn applies to the subscription process, when that backward chain is constructed. As a consequence, no matter where you place the subscribeOn in the chain, it always affects the context of the source emission.

And with this knowledge we can read out statements:

  1. In line #3 we subscribe to out Flux;
  2. In line #1 we successfully change the context of numbers emission;
  3. In line with flatMap we do not any visible work, because it will be work after numbers emission;
  4. Also flatMap requests Integer.MAX_VALUE (By default) to drain elements from Flux::range and Flux::range starts to emit to 256 at once (Because flatMap has backpressure);
  5. Callback in line #4 will be use current thread because subscribeOn in line #2 was not executed yet;
  6. On a new value in line #2 subscribeOn will be invoked for each element and pick up one thread per value. Since this moment all work will be in Schedulers::elastic. Because #2 is more close to publisher than #1, #2 will change the context of source emission and all upstream work will be use this scheduler instead #1;
  7. Exit from subscribe.

We can extract two rules:

  1. If subscribeOn is performed later than every other subscribeOn in time - all upstream will be use it;
  2. If subscribeOn is performed before than every other in time - it can be be replaced by hidden transformation with other subscribeOn.