---
title: 'Asynchronous Composition'
source: 'https://academia.sh/en/courses/jvm-and-concurrency/asynchronous-composition'
course: 'JVM, Concurrency and Performance'
language: en
updated: '2026-09-13T05:09:02+00:00'
license: 'CC BY-SA 4.0'
objectives:
    - 'Distinguishes, by running it, which side runs each stage in a future-based chain — the caller on a completed future, the completing side on a pending future, a pool we never created or a given one on an async-suffixed call'
    - 'Measures that when a stage drops, the following stages are skipped and the error reaches the caller wrapped at join'
    - 'Shows that the same chain written four different ways runs on four different sides, but the result stays the same in all four'
---

# Asynchronous Composition

Measures, in a future-based chain, which side runs each stage — the caller on a completed future, the completing side on a pending future, a pool we never created or a given one on an async-suffixed call — and that the same chain written four ways runs on four different sides while the result stays the same in all four. Shows that when a stage drops, the following stages are skipped and the error reaches the caller wrapped at join.

The previous lesson made independent tasks cheaper; this lesson strings them into a **chain**.
One stage's result becomes the next stage's input, and even though each link works correctly on
its own, stringing them into a chain raises a new question: which thread runs this stage?
Future-based chaining asks this question again at every binding point, and the four answers the
course has measured all show up together, on the same chain, right here.

## The Same Chain Runs on Four Different Sides in Four Different Forms

A stage binding to a `CompletableFuture` that is already completed runs **synchronously** on the
thread making the binding call; if the future is not yet completed, the stage runs on the
completing side's thread. An `Async`-suffixed binding opens a third path: on a common pool if no
executor is given, on the given executor if one is.

**CC26.** If the future a stage is bound to is already completed, the stage runs synchronously
on the thread making the binding call; if the future is not yet completed, the stage runs on the
thread that completes it.
**CC27.** An `Async`-suffixed stage runs, if no executor is given, on a common pool the program
never created (unknown); if an executor is given, on that executor's thread (created).

```java
// Run.java — which thread runs a step: the caller, a created thread, or a party we never created (helper source, no main)
import java.util.*;

class Run {
    static final Map<Thread, String> labels = Collections.synchronizedMap(new IdentityHashMap<>());

    static void announce(String label) { labels.put(Thread.currentThread(), label); }

    static String who() {
        String l = labels.get(Thread.currentThread());
        return l == null ? "unknown" : l;
    }

    static void observe(String point) {
        System.out.printf("%-32s -> %s%n", point, who());
    }
}
```

```java
// FourStyles.java — the same chain runs on four different sides in four different forms, the result the same in all four
import java.util.*;
import java.util.concurrent.*;

public class FourStyles {
    static int stageFunction(int v) { return v * 2 + 1; }

    public static void main(String[] args) throws Exception {
        Run.announce("caller");
        List<Integer> results = new ArrayList<>();

        // A. completed future: the stage runs on the caller
        int a = CompletableFuture.completedFuture(10)
                .thenApply(v -> { Run.observe("completed future stage"); return stageFunction(v); })
                .join();
        results.add(a);

        // B. pending future: the completing thread runs the stage
        CompletableFuture<Integer> pending = new CompletableFuture<>();
        CompletableFuture<Integer> chain = pending.thenApply(v -> {
            Run.observe("pending future stage"); return stageFunction(v);
        });
        Thread completer = new Thread(() -> { Run.announce("created"); pending.complete(10); });
        completer.start();
        completer.join();
        results.add(chain.join());

        // C. async on the default pool: on a thread we never created
        int c = CompletableFuture.completedFuture(10)
                .thenApplyAsync(v -> { Run.observe("async on default pool"); return stageFunction(v); })
                .join();
        results.add(c);

        // D. async on a given pool: on the thread of the executor we built
        ExecutorService given = Executors.newSingleThreadExecutor(
                task -> new Thread(() -> { Run.announce("created"); task.run(); }));
        int d = CompletableFuture.completedFuture(10)
                .thenApplyAsync(v -> { Run.observe("async on given pool"); return stageFunction(v); }, given)
                .join();
        results.add(d);
        given.shutdown();

        boolean allSame = results.stream().distinct().count() == 1;
        System.out.println("result of the four forms (10*2+1=21)  : " + results);
        System.out.println("all the same                          : " + allSame);
    }
}
```

```
completed future stage           -> caller
pending future stage             -> created
async on default pool            -> unknown
async on given pool              -> created
result of the four forms (10*2+1=21)  : [21, 21, 21, 21]
all the same                          : true
```

Four lines gather the course's four separate answers into a single chain. On a completed future,
`thenApply` runs the moment it is bound, because there is already something to run — the stage
runs on whichever thread made the binding call: the **caller** (CC26). On a pending future, the
stage stays waiting; it does not run on any thread until `pending.complete(10)` is called, and
when that call comes, it runs on the thread that **made** it — here that side is the thread we
built named `completer`, that is, **created** (CC26). On the third line, since no executor is
given to the added suffix, the stage runs on a common pool; we neither built nor named this
pool, which is why it is **unknown** (CC27). On the fourth line, the same suffix is this time
given an executor, and the stage runs on that executor's — our own, built by us — thread:
**created** (CC27).

## Adding the Suffix Changes Which Side Runs It, Not the Speed

All four forms apply exactly the same operation: `v -> v * 2 + 1`, on the same starting value.
The result list is `[21, 21, 21, 21]` — all four reach the same number. This is the lesson's
limiting measurement: the difference between `thenApply` and `thenApplyAsync` is not in the
computed **value**, it is in **who** computes that value. Adding the suffix did not speed
anything up — no duration was measured in this lesson, nor can it be — the only thing that
changed was which side the call syntax chooses. The reason to write a stage with the `Async`
suffix is not speed, it is to **separate** that stage from the caller's own thread — for
example, if the caller is a user-interface thread, or does not want to hold a potentially
long-running task on itself. The decision itself is not this lesson's subject; only its
**result**, which side ran it, is measured.

## When a Stage Drops, the Following Ones Are Skipped

What happens if a stage in the chain throws an exception? The following stages are never called
at all, and the error is carried all the way to the `join()` call at the end of the chain.

**CC28.** In a `thenApply` chain, every stage runs synchronously on the thread the previous one
completed on; this chain has no `Async` suffix at all, so which thread the error path runs on
needs no separate modeling — on an already-completed future, that thread is already the caller.

```java
// ErrorPath.java — when a stage drops, the following stages are skipped, the error reaches the caller wrapped at join
import java.util.concurrent.*;
import java.util.concurrent.atomic.*;

public class ErrorPath {
    public static void main(String[] args) throws Exception {
        AtomicInteger ranStages = new AtomicInteger(0);

        CompletableFuture<Integer> chain = CompletableFuture.completedFuture(10)
                .thenApply(v -> { ranStages.incrementAndGet(); return v + 1; })
                .thenApply(v -> { if (v > 0) throw new RuntimeException("stage error"); return v; })
                .thenApply(v -> { ranStages.incrementAndGet(); return v + 1; })
                .thenApply(v -> { ranStages.incrementAndGet(); return v + 1; });

        try {
            chain.join();
            System.out.println("chain did not drop");
        } catch (CompletionException e) {
            System.out.printf("%-32s -> %s wrapping: %s%n", "error caught at join",
                    e.getClass().getSimpleName(), e.getCause().getClass().getSimpleName());
        }

        int stagesDefinedAfterError = 2;
        int stagesRanAfterError = ranStages.get() - 1;
        System.out.printf("%-32s -> %d%n", "stage ran before error", 1);
        System.out.printf("%-32s -> %d%n", "stages defined after error", stagesDefinedAfterError);
        System.out.printf("%-32s -> %d%n", "stages skipped after error",
                stagesDefinedAfterError - stagesRanAfterError);
    }
}
```

```
error caught at join             -> CompletionException wrapping: RuntimeException
stage ran before error           -> 1
stages defined after error       -> 2
stages skipped after error       -> 2
```

The chain was built from four stages: the first ran normally, the second threw an exception, the
third and fourth never ran at all. The counter confirms this — **one** stage ran before the
error, and of the **two** stages defined after it, **both** were skipped. A `thenApply` chain
treats the rest as automatically **dropped** once one stage drops; every stage assumes the
previous one completed successfully, and once that assumption breaks, the rest of the chain is
never evaluated at all. The error is carried silently all the way to the `join()` call, and there
it reaches the caller wrapped inside a `CompletionException`; the original exception can be
recovered with `getCause()`. This behavior points the same way as the previous lesson's
`submit`/`get` pair: an exception can stay silent up to the first point the form it was given in
allows.

The two lessons' way of carrying an exception is close but not identical. The previous lesson
wrapped the exception in a checked `ExecutionException` when `Future.get()` was called; this
lesson does the same job with an unchecked `CompletionException` when `join()` is called. Both
give back the real exception with `getCause()`, both cross from the side the task/stage ran on
to the caller's side with a wrapping — the only difference is which call (`get` or `join`) chose
which wrapping (checked or unchecked).

## Summary

- If the future a stage is bound to is already completed, it runs synchronously on the thread
  making the binding call; if the future is not yet completed, the stage runs on the thread
  that completes it.
- An `Async`-suffixed stage runs, if no executor is given, on a common pool the program never
  created; if one is given, on that given executor's thread.
- When the same chain is built in four separate forms, four separate sides run it, but since the
  operation applied is the same, the result stays exactly the same across all four; adding the
  suffix changes which side runs it, not the speed.
- When a stage throws an exception, every following stage in the chain is skipped; the error is
  carried silently to the `join()` call and reaches the caller there, wrapped inside a
  `CompletionException`.
- In a cut-off chain, the stages before the error ran normally; the number of skipped stages
  equals the length of the rest of the chain.

## Course Wrap-Up

**CC29.** The running-side column in the wrap-up table takes only three values: caller, created,
unknown — these are the three answers the course's shared definition builds, and they stay fixed
throughout the course.

The course opened with a single question: which thread runs this step, and who created that
thread? Eleven lessons asked this question across eleven separate mechanisms, and each time got
the answer by running at least two ways of doing it side by side. The first claim was confirmed
and shown once more in this lesson: the running thread does not show up in the call syntax —
the difference between `thenApply` and `thenApplyAsync` is a suffix, but the running side splits
three ways. The second claim — that a thread the program never created can run its own code —
was exemplified once more here, with the common pool. The third claim, K04's own distinction
between "who runs it" and "who sees it," was measured in earlier lessons through the memory model
and `ThreadLocal`; this lesson did not test that distinction, it only tested the running side.
The course's boundary was also the same from the start, and it was confirmed once more here:
adding a suffix, growing a pool, using a virtual thread — none of these is a speed claim on its
own; everything measured was a **count**, never a **duration**.

| Lesson | Behavior measured | Running side (caller · created · unknown) | Limiting measurement |
|---|---|---|---|
| Class Loading | Three of four triggers (a new instance, a static method, reading a static field) run class initialization; when two threads touch the same class at the same time, only one of them runs the initialization block | caller (four triggers) · created (winner in concurrent touch) | Reading a compile-time constant never initializes the class at all; a blank line means "not triggered," not "did not happen" |
| Memory Areas | Of six values, three (a local variable, a parameter, a thread-local value) never leave the writing thread at all, three (an object field, an array element, a static field) do | created (writer) · caller (reader, after join) | Being shared is not the same as being visible; without an ordering tool like join, the same read is undefined |
| Garbage Collectors | With a strong reference standing, cleanup runs in none of three collection requests; once the reference is removed, it runs. A weak and a phantom reference land in the queue, a soft one resists | unknown (the cleanup action) | System.gc() is a request; the measurement determines not when cleanup will run, only which side runs it |
| Just-in-Time Compilation | After the same method is called three hundred thousand times, the instruction list in the class file stays exactly the same; once the number of receiver types passing through a call site rises from one to two, the monomorphic assumption breaks | caller (producing the profile data) · unknown (making the decision) | Speed is not measured and cannot be; the only things counted are instruction count and receiver type count, and deoptimization is modeled |
| Threads and Lifecycle | The same `Runnable` body runs on the caller with `run()`, on a created thread with `start()`; of three interruption setups, only one fails to finish its body without an interruption request | caller (`run`) · created (`start`) | `start()` returning does not show that the body has run; order is built by hand with `CountDownLatch` and `join` |
| Java Memory Model | Of eight publication mechanisms, four ask both the writer and the reader to act; a `final` field puts the load on the writer alone, `join` on the reader alone; two (a field without synchronization, `ThreadLocal`) fall outside the visibility question | created (writer) · caller (reader) | The visibility defect of a field without synchronization is not shown by running it, it is modeled as a rule; `volatile` gives no atomicity |
| Synchronization | Re-entry depth rises from `0` to `2` and back down; only one of three lock setups shares a single lock, the other two raise the count of separate locks to two | caller (holding the lock) · created (waiting) | A lock gives mutual exclusion and visibility, not order among waiters; the fair-lock option is modeled |
| Concurrency Utilities | Compare-and-set reaches its result in three attempts under a modeled contention and never leaves the caller; four snapshots see, in order, 0, 1, 2, and 3 elements — the writer produces a new array on every change | caller (compare-and-set, traversal) · created (dropping the count, releasing the permit) | An atomic type gives atomicity only to a single variable; keeping two variables consistent together is explained by rule, not by running it |
| Executor Services | While idle, the pool's own thread runs it; while full, the giving side runs it; the rejection policy is a parameter chosen by the caller | caller (rejected in a full pool) · created (task given to the pool) | Only one of the two ways of giving a task (`execute`) shows the exception on its own; `submit` only shows it if `get` is called |
| Virtual Threads | For the same task set, distinct identity stays bounded at four in a fixed pool, but grows with the task count in a virtual executor; `ThreadLocal` producing a separate value per task | created (in both) | The growth of distinct identity count is not a speed claim, it is a fact; the carrier cannot be observed |
| Asynchronous Composition | The caller runs it on a completed future, the completing side on a pending future, an unknown or a given pool on a suffixed call; the stages after an error are skipped | caller (completed) · created (pending, given pool) · unknown (default pool) | Adding the suffix does not speed anything up, it only changes the running side; the result of the four forms stays exactly the same |

JVM, Concurrency and Performance closes here. The question the course asked — which thread runs
this step — sits under every line of a Java program at all times; the call syntax never answers
this question on its own.
