Is CompletableFuture guaranteed to run un a new thread?

Viewed 1724

Given some arbitrary context (e.g. Junit unit test, not specifically just not necessarily the "main" thread).

Would code like this have to introduce at least 2 threads ?

public static void main (String[] args)
{
    CompletableFuture<Void> s = new CompletableFuture<>();
    CompletableFuture<Void> f = new CompletableFuture<>();
    
    CompletableFuture<Void> someContext =  CompletableFuture.supplyAsync(() ->
    {
        try{    
          System.out.println(Thread.currentThread().getId());
          CompletableFuture<String> update =
          CompletableFuture.supplyAsync(
            () -> {
              String ans = null;
              try {
                System.out.println(Thread.currentThread().getId());
                ans = "Hello";
              } catch (Exception e) {
                ans = e.toString();
              } finally {
                s.complete(null);
                return ans;
              }
            });
          s.get();
          System.out.println(s.isDone());
        } catch (Exception e) {
            System.out.println("Some error");
            return null;
        }
        return null;
    });
    
    System.out.println(f.isDone());
}

When we get to s.get() in someContext can the JVM detect that it's waiting -> context switch to update complete it, and switch back to someContext?

When running it in ideone it consistently runs them in two diffrent threads but that's just single observation.

I would like to understand what guarantees the language/runtime provide.

2 Answers

No, this guarantee does not exist.

What you are doing is unsafe.

If you look of the docs of CompletableFuture.supplyAsync(Supplier):

Returns a new CompletableFuture that is asynchronously completed by a task running in the ForkJoinPool.commonPool() with the value obtained by calling the given Supplier.

you can see that it uses a ForkJoinPool obtained from ForkJoinPool.commonPool().

From the docs of ForkJoinPool:

all threads in the pool attempt to find and execute tasks submitted to the pool and/or created by other active tasks (eventually blocking waiting for work if none exist). This enables efficient processing when most tasks spawn other subtasks (as do most ForkJoinTasks), as well as when many small tasks are submitted to the pool from external clients. Especially when setting asyncMode to true in constructors, ForkJoinPools may also be appropriate for use with event-style tasks that are never joined. All worker threads are initialized with Thread.isDaemon() set true. A static commonPool() is available and appropriate for most applications. The common pool is used by any ForkJoinTask that is not explicitly submitted to a specified pool. Using the common pool normally reduces resource usage (its threads are slowly reclaimed during periods of non-use, and reinstated upon subsequent use). For applications that require separate or custom pools, a ForkJoinPool may be constructed with a given target parallelism level; by default, equal to the number of available processors

This means that tasks submitted may be executed in an arbituary amount of threads (number of processors by default) and those threads are re-used. If all those threads are busy, the execution may wait for previous executions to finish.

As the common pool may also be used by other parts of the application, submitted tasks should run shortly and should not block so that other tasks can be executed quickly.

While OpenJDK has a special handling for ForkJoinPool in CompletableFuture#get that makes sure that other tasks can be executed during that time, other JDKs might not provide this.

Alternative: Asynchronous handling

Instead of blocking using .get(), you may want to use methods like CompletableFuture#thenAcceptAsync(Consumer). This runs the Consumer after the future finishes.

Also, you can use CompletionStage#exceptionally to handle exceptions in an asynchronous manner.

public static void main (String[] args) throws java.lang.Exception
{
    CompletableFuture<Void> s = new CompletableFuture();
    CompletableFuture<Void> f = new CompletableFuture();
    
    CompletableFuture<Void> someContext =  CompletableFuture.supplyAsync(() ->
    {
        
          System.out.println(Thread.currentThread().getId());
          CompletableFuture<String> update =
          CompletableFuture.supplyAsync(
            () -> {
              String ans = null;
              try {
                System.out.println(Thread.currentThread().getId());
                ans = "Hello";
              } catch (Exception e) {
                ans = e.toString();
              } finally {
                s.complete(null);
                return ans;
              }
            });
          s thenSupplyAsync(result->{
              System.out.println(s.isDone());
          }).exceptionally(e->{
              System.out.println("Some error");
              return null;
          });
        
        return null;
    });
    
    System.out.println(f.isDone());
}

The answer is a little involved, as the correct one is - "it depends". In the configuration that you currently have, you will always get two threads; but that is an artifact of ForkJoinPool.

When a certain thread from ForkJoinPool blocks, internally a new one will be created, so that parallelism stays correct.

That is provable if we change your example to:

public static void main (String[] args) {

    ForkJoinPool pool = new ForkJoinPool(1);

    CompletableFuture<Void> s = new CompletableFuture<>();
    CompletableFuture<Void> f = new CompletableFuture<>();

    CompletableFuture<Void> someContext =  CompletableFuture.supplyAsync(() ->
    {
        try{
            System.out.println("someContextName : " + Thread.currentThread().getName());
            CompletableFuture<String> update =
                    CompletableFuture.supplyAsync(
                            () -> {
                                String ans = null;
                                try {
                                    System.out.println("update name : " + Thread.currentThread().getName());
                                    ans = "Hello";
                                } catch (Exception e) {
                                    ans = e.toString();
                                } finally {
                                    s.complete(null);
                                    return ans;
                                }
                            }, pool);
            s.get();
            System.out.println(s.isDone());
        } catch (Exception e) {
            System.out.println("Some error");
            return null;
        }
        return null;
    }, pool);

    System.out.println(f.isDone());

    LockSupport.parkNanos(TimeUnit.SECONDS.toNanos(1));
}

Notice that even if ForkJoinPool is created with a single thread, this will still finish, as a new thread will be created.

On the other hand, if you change :

ExecutorService pool = Executors.newFixedThreadPool(1);

your code will block.

Related