Why does a task sometimes run in the main thread although it was submitted to the common ForkJoinPool?

Viewed 320

Can someone explain me why some tasks sometimes run in the main thread although they were submitted to the common ForkJoinPool before?

Here is an example in Java 16 (adopt-openjdk-16.0.1):

import java.util.concurrent.ExecutionException;
import java.util.concurrent.ForkJoinPool;
import java.util.concurrent.Future;

public class StrangeUseOfThreads {
    public static void main(String[] args) throws ExecutionException, InterruptedException {
        int mainThreadCounter = 0;
        int forkJoinPoolThreadCounter = 0;
        for (int i = 0; i < 100; i++) {
            Future<String> future = ForkJoinPool.commonPool().submit(() -> Thread.currentThread().getName());
            String threadName = future.get();
            if(threadName.equals("main")) {
                mainThreadCounter++;
            } else {
                forkJoinPoolThreadCounter++;
            }
        }
        System.out.println("mainThreadCounter=" + mainThreadCounter);
        System.out.println("forkJoinPoolThreadCounter=" + forkJoinPoolThreadCounter);

        System.out.println("------------------------------------");

        mainThreadCounter = 0;
        forkJoinPoolThreadCounter = 0;
        for (int i = 0; i < 100; i++) {
            Future<String> future = ForkJoinPool.commonPool().submit(() -> Thread.currentThread().getName());
            ForkJoinPool.commonPool().submit(Thread::yield); // let's disturb here
            String threadName = future.get();
            if(threadName.equals("main")) {
                mainThreadCounter++;
            } else {
                forkJoinPoolThreadCounter++;
            }
        }
        System.out.println("mainThreadCounter=" + mainThreadCounter);
        System.out.println("forkJoinPoolThreadCounter=" + forkJoinPoolThreadCounter);
    }
}

That's the output for me:

mainThreadCounter=80
forkJoinPoolThreadCounter=20
------------------------------------
mainThreadCounter=0
forkJoinPoolThreadCounter=100

While the main thread is often used in the first loop, it is not used at all in the second loop because I "disturbed" the sequence of

Future<String> future = ForkJoinPool.commonPool().submit(() -> Thread.currentThread().getName());
String threadName = future.get();

by adding

ForkJoinPool.commonPool().submit(Thread::yield);

between those two lines which is, of course, somehow useless but it cannot obviously removed by the dead code elimination optimization of the JIT compiler, I guess.

I suspect the JIT compiler optimizes here something with the ForkJoinPool because the get()-call on the future follows directly after the submission of the task. However, I have desperately googled for such an optimzation and have not found any.
What's going on here "under the hood"?

EDIT1:
tgdavies inspired me to modify the example to get stack traces.
That's my example now (it's ugly but it works!):

import java.util.concurrent.ExecutionException;
import java.util.concurrent.ForkJoinPool;
import java.util.concurrent.Future;

public class StrangeUseOfThreads {
    public static void main(String[] args) throws ExecutionException, InterruptedException {
             int mainThreadCounter = 0;
        int forkJoinPoolThreadCounter = 0;
        for (int i = 0; i < 100; i++) {
            Future<Throwable> future = ForkJoinPool.commonPool().submit(
                    () -> new Throwable(Thread.currentThread().getName()));
            Throwable throwable = future.get();
            if(throwable.getMessage().equals("main")) {
                if(mainThreadCounter==0) {
                    throwable.printStackTrace();
                }
                mainThreadCounter++;
            } else {
                if(forkJoinPoolThreadCounter==0) {
                    throwable.printStackTrace();
                }
                forkJoinPoolThreadCounter++;
            }
        }
        System.out.println("mainThreadCounter=" + mainThreadCounter);
        System.out.println("forkJoinPoolThreadCounter=" + forkJoinPoolThreadCounter);
    }
}

That's the output now:

java.lang.Throwable: main
    at net.mirwaldt.completable.futures.examples.StrangeUseOfThreads.lambda$main$0(StrangeUseOfThreads.java:13)
    at java.base/java.util.concurrent.ForkJoinTask$AdaptedCallable.exec(ForkJoinTask.java:1458)
    at java.base/java.util.concurrent.ForkJoinTask.doExec$$$capture(ForkJoinTask.java:295)
    at java.base/java.util.concurrent.ForkJoinTask.doExec(ForkJoinTask.java)
    at java.base/java.util.concurrent.ForkJoinTask.tryExternalHelp(ForkJoinTask.java:386)
    at java.base/java.util.concurrent.ForkJoinTask.externalInterruptibleAwaitDone(ForkJoinTask.java:356)
    at java.base/java.util.concurrent.ForkJoinTask.get(ForkJoinTask.java:1009)
    at net.mirwaldt.completable.futures.examples.StrangeUseOfThreads.main(StrangeUseOfThreads.java:14)
java.lang.Throwable: ForkJoinPool.commonPool-worker-19
    at net.mirwaldt.completable.futures.examples.StrangeUseOfThreads.lambda$main$0(StrangeUseOfThreads.java:13)
    at java.base/java.util.concurrent.ForkJoinTask$AdaptedCallable.exec(ForkJoinTask.java:1458)
    at java.base/java.util.concurrent.ForkJoinTask.doExec$$$capture(ForkJoinTask.java:295)
    at java.base/java.util.concurrent.ForkJoinTask.doExec(ForkJoinTask.java)
    at java.base/java.util.concurrent.ForkJoinPool$WorkQueue.topLevelExec(ForkJoinPool.java:1016)
    at java.base/java.util.concurrent.ForkJoinPool.scan(ForkJoinPool.java:1665)
    at java.base/java.util.concurrent.ForkJoinPool.runWorker(ForkJoinPool.java:1598)
    at java.base/java.util.concurrent.ForkJoinWorkerThread.run(ForkJoinWorkerThread.java:183)
mainThreadCounter=91
forkJoinPoolThreadCounter=9

That's interesting: While the task in the main thread was effectively triggered by the get()-call on the future, the task in the ForkJoinPool thread was submitted to the ForkJoinPool.
However, I still don't know why. I can't imagine that's a bug.

1 Answers

TL;DR:
It's not the JIT Compiler but the code of the class ForkJoinPoolTask itself which optimizes the special situation of a call of get() on a future whose task has not been started yet.

I have started debugging the code and noticed this:
ForkJoinPool.commonPool().submit() creates a ForkJoinPoolTask which implements Future.
When get() on the returned future is called, it first checks the status in tryExternalHelp() which is called by externalInterruptibleAwaitDone().
If tryExternalHelp() notices the task has not been started yet by managing to "unpush" (i.e. to remove) the task from the right worker-queue, it executes the task in the caller-thread in which get() was called which is the main-thread in my example. Then tryExternalHelp() returns the completed (or failed) status to externalInterruptibleAwaitDone().
If tryExternalHelp() notices the task has been started yet by failing to remove the task from the right worker-queue, it just returns the current status to externalInterruptibleAwaitDone() which waits until the task has been completed (or failed).
Then the result - if it exists - is returned by get() anyway.
CompletableFuture doesn't behave like this.
It always runs the task in a worker-thread and never in the caller-thread.

Consequently, it depends on how small the time between the call of submit() on the ForkJoinPool and get() on the future is whether the caller-thread or a worker-thread of the ForkJoinPool is used for the task. And it also depends on how full the worker-queues are.

Related