Skip to content
Structured Concurrency · Part 4 of 9

Two workflow shapes that show up after fork-and-wait

Streaming results as subtasks finish suits user-facing flows; nested scopes mirror the service tree. What each costs.

J
Jagdish Salgotra
·12 min read·~1,900 words

All code in this series targets Java 21 preview APIs. Part 9 covers migration to Java 25.


After fork-and-wait, users ask for progress

A caller may not want to wait silently while five independent sections load. A CLI may want to print completed steps. A UI may want to stream finished sections as they arrive. A batch job may need to record which child phase finished before a later phase failed.

Structured concurrency can support that, but progress has to stay inside the ownership boundary. The mistake is to treat progress as an excuse to detach work from its parent. Once a task is detached, the parent no longer knows whether it is still running, failed, cancelled, or leaking capacity.

The central tension is that a progress API usually needs at least two concepts: terminal progress, where the task is no longer running, and successful output, where the task produced usable data. Treat those as the same thing and the code will eventually report "completed" for work that actually failed.

This article is still learning material. The examples are small, structured concurrency is still preview, and the measurements below are local checks from the companion repository. The goal is to make the workflow shape visible, not to claim production performance.


Progress is a contract, not a spinner

Progressive results do not mean "start work somewhere and hope updates arrive."

They mean the parent still owns the scope, while each child reports a terminal state as it finishes.

In Java 21 preview syntax, the core shape looks like this:

java
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
    List<StructuredTaskScope.Subtask<String>> subtasks = new ArrayList<>();

    for (int i = 0; i < tasks.size(); i++) {
        int taskIndex = i;
        subtasks.add(scope.fork(() -> {
            String result = tasks.get(taskIndex).call();
            progress.accept(new ProgressUpdate<>(taskIndex, result, null));
            return result;
        }));
    }

    Instant deadline = Instant.now().plus(timeout);
    boolean[] observed = new boolean[subtasks.size()];
    int observedCount = 0;

    while (observedCount < subtasks.size() && Instant.now().isBefore(deadline)) {
        try {
            scope.joinUntil(Instant.now().plusMillis(50));
        } catch (TimeoutException ignored) {
            // The short join is used only to avoid a hot polling loop.
        }

        for (int i = 0; i < subtasks.size(); i++) {
            if (!observed[i] && subtasks.get(i).state() != Subtask.State.UNAVAILABLE) {
                observed[i] = true;
                observedCount++;
            }
        }
    }

    if (observedCount < subtasks.size()) {
        scope.shutdown();
    }

    scope.join();
    scope.throwIfFailed();
}

The important idea is not the exact polling interval. The important idea is that the parent remains the owner. The parent starts the tasks, observes terminal states, joins the scope, and closes the scope.

This article is learning material. The main branch now builds with OpenJDK 25.0.2 and uses the Java 25 preview structured-concurrency API, with the Java 21 version separately managed in the feature/java-21 branch. The Article 4 measurements below were generated from the current Java 25 code. The Java 21 preview syntax shown here remains valid for learning purposes, and Part 9 covers the migration details. In that migrated implementation, HierarchicalProgressiveHandler.executeProgressive() publishes progress from inside each subtask and only joins the scope from the owner. That matters because the Java 25 preview API enforces a stricter rule: the owner must join after forking, and consuming subtask results before the owner has joined is not valid.

The measured Article 4 paths use timeouts that are longer than the simulated work. They exercise progress observation and nested ownership, not the hard-timeout branch from Part 2.

That rule is not just API ceremony. It protects the same idea this whole series is about: the parent owns the lifecycle of the work it started.


What the progressive demo showed

The first demo used five simulated services:

TaskSimulated serviceDelay
0UserService200ms
1InventoryService300ms
2PaymentService400ms
3ShippingService250ms
4NotificationService150ms

The run completed in this order:

text
Task 4 completed: NotificationService response (processed in 150ms)
Task 0 completed: UserService response (processed in 200ms)
Task 3 completed: ShippingService response (processed in 250ms)
Task 1 completed: InventoryService response (processed in 300ms)
Task 2 completed: PaymentService response (processed in 400ms)

Progressive execution completed: 5/5 tasks in 444ms

That is the behavior progressive results are supposed to expose. The parent did not have to wait until the 400ms payment task finished before learning that notification, user, shipping, and inventory work had already completed.

The total duration was 444ms, not exactly 400ms. That is expected in this implementation. The Java 25 demo polls every 50ms, logs heavily, and records progress callbacks. The teaching point is that the whole scope finished near the slowest child, while progress arrived in the order children actually completed.

That order also gives you a useful test target. The exact wall-clock numbers will move from run to run, but the fast 150ms task should be observed before the 400ms task unless the machine is under unusual load.


Nested ownership is not the same thing as nested code

The next shape is hierarchy.

Hierarchy is not just "this method calls another method." That is ordinary call structure. Structured hierarchy means each layer that starts concurrent work also owns the scope for that work.

A Java 21 preview nested-scope example looks like this:

java
public String executeHierarchical() throws Exception {
    try (var parentScope = new StructuredTaskScope.ShutdownOnFailure()) {
        var group1 = parentScope.fork(() -> executeChildTasks("Group-1"));
        var group2 = parentScope.fork(() -> executeChildTasks("Group-2"));
        var group3 = parentScope.fork(() -> executeChildTasks("Group-3"));

        parentScope.join();
        parentScope.throwIfFailed();

        return "Parent completed: [%s, %s, %s]"
                .formatted(group1.get(), group2.get(), group3.get());
    }
}

private String executeChildTasks(String group) throws Exception {
    try (var childScope = new StructuredTaskScope.ShutdownOnFailure()) {
        var first = childScope.fork(() -> callFirstChild(group));
        var second = childScope.fork(() -> callSecondChild(group));

        childScope.join();
        childScope.throwIfFailed();

        return "%s: [%s, %s]".formatted(group, first.get(), second.get());
    }
}

There are two ownership boundaries here. The parent owns the group tasks. Each group owns its own child tasks. A failure in a child group can be handled at the group boundary or allowed to fail the parent, but the decision is local and visible.

The standalone Article 4 demo has a simple three-level hierarchical sequence:

text
Level 1 (Data Gathering) completed
Level 2 (Business Logic) completed
Level 3 (Finalization) completed

Hierarchical[DataGathered -> BusinessProcessed -> OrderFinalized]
Duration: 1742ms

That specific demo is intentionally sequential. Its simulated sleeps add up to about 1710ms before overhead, so the measured 1742ms is exactly what the code suggests. It is useful for teaching phase ownership, not for demonstrating parallel speedup.

The nested-scope benchmark code in ProgressiveResultsBenchmark is the better example of hierarchical fan-out. Its HierarchicalTaskManager starts a parent scope, then starts child scopes for user data and business logic. That is the shape to look for when reviewing real code: every layer that forks work must also join and close that work.


Parallel levels make completion semantics visible

The combined demo runs named levels concurrently, and each level uses progressive tracking for its own children.

In one local run, the three levels reported:

text
DataPreparation: 209ms
BusinessProcessing: 260ms
OrderFinalization: 421ms
Total duration: 425ms
Completed levels: [DataPreparation, BusinessProcessing, OrderFinalization]

The important number is the total duration. The levels add up to 890ms if you sum them, but the parent finished in 425ms because the levels ran as siblings and the slowest level was 421ms.

That is a good place to be precise about language. The run also included two simulated temporary failures inside non-terminal child tasks:

text
DataPreparation: Task 0 failed
OrderFinalization: Task 1 failed

The parent still reported the three levels as completed because the current handler treats "every child reached a terminal state" as completion. That is not the same thing as "every child produced business data."

The repository code exposes both pieces through ProgressiveResult.getResults() and ProgressiveResult.getErrors(). An article or UI should not collapse those into one green checkmark unless the product contract really allows partial output.


The e-commerce workflow shows the shape more clearly

The larger Article 4 demo uses four order-processing phases:

PhaseSimulated child workMeasured duration
UserDataCollectionprofile, preferences, payment methods, addresses208ms
ProductInventoryProcessingavailability, pricing, discounts, inventory reservation259ms
PaymentShippingProcessingpayment, shipping, delivery scheduling361ms
OrderFinalizationconfirmation, email, inventory update, analytics206ms

The parent result was:

text
Processing Time: 362ms
Completed Phases: [UserDataCollection, ProductInventoryProcessing, PaymentShippingProcessing, OrderFinalization]

Again, the parent duration tracks the slowest phase, not the sum of all phase durations.

That is useful for learning because it makes the ownership tree visible:

text
Order
  UserDataCollection
    profile
    preferences
    payment methods
    addresses
  ProductInventoryProcessing
    availability
    pricing
    discounts
    inventory reservation
  PaymentShippingProcessing
    payment
    shipping
    delivery scheduling
  OrderFinalization
    confirmation
    email
    inventory update
    analytics

This is not a recommendation to parallelize every real order-processing phase. A real payment flow often has hard dependencies: you may not be allowed to reserve inventory, charge payment, and send confirmation independently. The demo is a controlled concurrency shape, not a business-process template.

The lesson is narrower and more useful: if a workflow has independent phases, structure the code so the parent owns the phases and each phase owns its children.


What the benchmark actually compared

The dedicated benchmark class runs four synthetic approaches for 30 seconds each with 100 concurrent users:

  • a sequential traditional path on a fixed thread pool,
  • a progressive path on virtual threads,
  • a hierarchical path on virtual threads,
  • a combined progressive-plus-hierarchical path on virtual threads.

The raw result from this run was:

ApproachRequestsSuccess rateAverage latencyThroughputProgress updates
Traditional3,246100.0%634ms104/s0
Progressive12,094100.0%124ms399/s96,752
Hierarchical16,755100.0%89ms554/s0
Combined19,495100.0%94ms645/s272,930

The memory summary at the end of the run was:

text
Total Memory: 64 MB
Used Memory: 17 MB
Free Memory: 46 MB
Max Memory: 12288 MB

These numbers are useful, but only if you read the benchmark code.

The traditional path is intentionally sequential and includes sleeps between service calls. The structured paths run independent simulated work on virtual threads. The combined path also uses shorter request think times between iterations than the traditional path. So this table does not prove that "structured concurrency is 6x faster" in general.

What it does prove is smaller and more defensible:

The benchmark generated real progress events, and it generated a lot of them. The progressive path recorded 96,752 progress updates. The combined path recorded 272,930 progress updates. That gives the article a concrete grounding point: progress is not a decorative callback in this code; it is exercised heavily under concurrent load.

The final task counters also came from the benchmark run:

text
shipping tasks: 19495
profile tasks: 19495
payment tasks: 19495
inventory tasks: 19495
confirmation tasks: 0

Those counters are for the last scenario only because the benchmark resets metrics before each scenario. The confirmation counter is zero in the combined scenario because that code path does not call createOrderConfirmation(). That is another reminder to cite raw benchmark output carefully. A counter is only meaningful after you know which scenario produced it.


The Java 25 migration detail that mattered

During this pass, the first Article 4 demo run failed before producing usable measurements.

The failure was not a flaky benchmark. It was an ownership bug in the migrated Java 25 code:

text
java.lang.IllegalStateException: join not called
java.lang.IllegalStateException: Owner did not join after forking

The handler was observing subtask states and then calling Subtask.get() before the owner had joined the scope. Java 25 rejected that.

The fix was small: each subtask stores or publishes its own result before returning, the parent observes terminal states without calling get(), and the owner calls scope.join() before the scope closes.

That fix is worth mentioning because it reinforces the central rule. Progressive reporting is fine. Reading child results outside the ownership protocol is not.

For Java 21 preview readers, the equivalent discipline is to keep joinUntil(...), join(), throwIfFailed(), and result access in a clear order. Progress can be emitted while children finish, but the parent still owns the final join.


What to test

Progressive and hierarchical flows need tests that check state transitions, not just final strings.

For progressive execution, check that each task produces at most one progress update, that the final result separates successful outputs from failures, and that a timeout or failure does not leave child work running outside the scope. The order of progress updates should usually be treated as data, not as a fixed assertion, unless the delays are controlled and deterministic.

For hierarchical execution, check the boundary where a child scope fails. A child failure may fail the parent, degrade one level, or return partial data, but the code should make that policy explicit. The parent should not accidentally call a partially successful level "fully successful" just because all children reached terminal state.

For benchmark evidence, prefer raw per-scenario tables over generated improvement summaries. The raw table tells you requests, latency, throughput, and progress-update count. Percentage improvement labels are easy to misread unless the benchmark code and scenario timings are included.


What comes next

Part 5 moves from workflow shape to capacity shape. Once a parent can fork many children and nested levels, the next question is how many downstream calls should be allowed at the same time.

The companion repository at github.com/salgotraja/project-loom has runnable examples for every post in this series. The README covers build setup and the scripts used to reproduce the checks.


Resources

Series navigation

Code repositoryproject-loom
#structured-concurrency
share
J

Written by

Jagdish Salgotra

Distributed systems, cloud-native architecture, and the JVM. mostly shipping, occasionally reading.

Related series
Project Loom

Nine parts on Java virtual threads: I/O scalability, web services, microservices, performance, production debugging, and moving to Java 25.

Was this article helpful? or email →
anonymous · no account needed