Feedback on Structured Concurrency (JEP 525, 6th Preview)
Alan Bateman
alan.bateman at oracle.com
Sun Oct 12 19:41:33 UTC 2025
On 12/10/2025 06:32, Jige Yu wrote:
>
>
> Hi Project Loom.
>
>
> First and foremost, I want to express my gratitude for the
> effort that has gone into structured concurrency. API design in
> this space is notoriously difficult, and this feedback is
> offered with the greatest respect for the team's work and in the
> spirit of collaborative refinement.
>
> My perspective is that of a developer looking to use Structured
> Concurrency for common, IO-intensive fan-out operations. My focus is
> to replace everyday async callback hell, or reactive chains with
> something simpler and more readable.
>
> It will lack depth in the highly specialized concurrent programming
> area. And I acknowledge this viewpoint may bias my feedback.
>
Just a general point on providing feedback: The feedback that we most
value is feedback from people that have tried a feature or API in
earnest. We regularly have people showing up here with alternative APIs
proposals but it's never clear if they have the same goals, whether
they've tried the feature, or have considered many use cases. This isn't
a criticism of your proposal, it's just not clear if this is after
trying the feature or not.
> ------------------------------------------------------------------------
>
> 1.
>
> *Stateful and Imperative API:* The API imposes quite some "don't
> do this at time X" rules. Attempting to |fork()| after |join()|
> leads to a runtime error; forgetting to call join() is another
> error; and the imperative |fork|/|join| sequence is more
> cumbersome than a declarative approach would be. None of these are
> unmanageable though.
>
The API has 5 instance methods and isn't too hard to get wrong. Yes,
it's an exception at runtime if someone joins before forking, or
attempts to process the outcome before joining. With a few basic
recipes/examples then it should be possible for someone to get started
quickly. The issues dealing with cancellation and shutdown are difficult
to get right and we hope this API will help to avoid several of issues
with a relatively simple API.
> 1.
>
> *Challenging Exception Handling:* The exception handling model is
> tricky:
>
> *
>
> *Loss of Checked Exception Compile-Time Safety:*
> |FailedException| is effectively an unchecked wrapper that
> erases checked exception information at compile time.
> Migrating from sequential, structured code to concurrent code
> now means losing valuable compiler guarantees.
>
> *
>
> *No Help For Exception Handling: *For code that wants to catch
> and handle these exceptions, it's the same story of using
> /instanceof/ on the getCause(), again, losing all compile-time
> safety that was available in equivalent sequential code.
>
> *
>
> *Burdensome |InterruptedException| Handling:* The requirement
> for the caller to handle or propagate |InterruptedException|
> from |join()| will add room for error as handling
> InterruptedException is easy to get wrong: one can forget to
> call currentThread().interrupt(). Or, if the caller decides to
> declare /throws/ /InterruptedException/, the signature
> propagation becomes viral.
>
> *
>
> *Default Exception Swallowing:* The |AnySuccessOrThrow| policy
> *swallows all exceptions* by default, including critical ones
> like |NullPointerException|, |IllegalArgumentException|, or
> even an |Error|. This makes it dangerously easy to mask bugs
> that should be highly visible. There is no straightforward
> mechanism to inspect these suppressed exceptions or fail on
> specific, unexpected types.
>
We aren't happy with needing to wrap exceptions but it is no different
to other concurrent APIs, e.g. Future. Countless hours have been spent
on explorations to do better. All modelling of exceptions with type
parameters lead to cumbersome usage, e.g. a type parameter for the
exception thrown by subtasks and another type parameter for the
exception thrown by join. If there were union types for exceptions or
other changes to the language then we might do better.
On anySuccessfulOrThrow, then it's like invokeAny and similar
combinators in that it causes join to return a result from any subtasks
or throw if all subtasks fail. It would be feasible to develop a Joiner
that returns something like `record(Optional<T> result, Map<Subtask<T>,
Throwable> exceptions)` where the map contains the subtasks that failed
before the successful subtask. That would be harder to use than the
simpler built-in and users always have the option of logging in the
failed subtask.
> 1.
>
> *Conflated API Semantics:* The |StructuredTaskScope| API unifies
> two very different concurrency patterns—"gather all"
> (allSuccessfulOrThrow) and "race to first success"
> (|anySuccessfulResultOrThrow|)—under a single class but with
> different interaction models for the same method.
>
> *
>
> In the *"gather all"* pattern (|allSuccessfulOrThrow|),
> |join()| returns |void|. The callsite should use
> |subtask.get()| to retrieve results.
>
> *
>
> In the *"race"* pattern (|anySuccessfulResultOrThrow|),
> |join()| returns the result (|R|) of the first successful
> subtask directly. The developer should /not/ call |get()| on
> individual subtasks. Having the |join()+subtask.get()| method
> spec'ed conditionally (which method to use and how depends on
> the actual policy) feels like a minor violation of LSP and is
> a source of confusion. It may be an indication of premature
> abstraction.
>
join always returns something. For allSuccessfulOrThrow it returns a
stream of successful subtasks.
I think your comment is really about cases where the subtasks return
results of the same type vs. other cases where subtasks return results
of different types. This is an area where we need feedback. To date,
we've been assuming that the more common case is subtasks that return
results of different types (arms and legs in your example). For these
cases, it's more useful to keep a reference to the subtask so that you
don't have to cast when handling the results. It may be that we don't
have this right and the common case is homogeneous subtasks, in which
case the default Joiner should be allSuccessfulOrThrow so you don't need
to keep a reference to the subtasks.
> 1.
>
> *Overly Complex Customization:* The |StructuredTaskScope.Policy|
> API, while powerful, feels like a potential footgun. The powerful
> lifecycle callback methods like onFork(), onComplete(),
> onTimeout() may lower the barrier to creating intricate,
> framework-like abstractions that are difficult to reason about and
> debug.
>
Developing a Joiner for more advanced/expert developers. We have several
guidelines in the API docs, the more relevant here is that they aren't
the place for business logic, and should be designed to be as general
purpose as possible.
> ------------------------------------------------------------------------
>
>
> *Suggestions for a Simpler Model*
>
> My preference is that the API for the most common use cases should be
> more *declarative and functional*.
>
> 1.
>
> *Simplify the "Gather All" Pattern:* The primary "fan-out and
> gather" use case could be captured in a simple, high-level
> construct. An average user shouldn't need to learn the wide API
> surface of StructuredTaskScope + Joiner + the lifecycles. For example:
>
> Java
> |// Ideal API for the 80% use case Robot robot =
> Concurrently.call( () -> fetchArm(), () -> fetchLeg(), (arm, leg)
> -> new Robot(arm, leg) ); |
>
We've been down the road of combinator or utility methods a number of
times, and have decided not to propose that direction for this API. It's
not too hard to what create a method that does what you want, e.g.
<U, V, R> R callConcurrently(Callable<U> task1, Callable<V> task2,
BiFunction<U, V, R> combine) {
try (var scope = StructuredTaskScope.open()) {
Supplier<U> subtask1 = scope.fork(task1);
Supplier<V> subtask2 = scope.fork(task2);
scope.join();
return combine.apply(subtask1.get(), subtask2.get());
}
}
(there's a more general form of the example presented in the JEP),
> 1.
>
> *Separate Race Semantics into Composable Operations:* The "race"
> pattern feels like a distinct use case that could be implemented
> more naturally using composable, functional APIs like Stream
> gatherers, rather than requiring a specialized API at all. For
> example, if |mapConcurrent()| fully embraced structured
> concurrency, guaranteeing fail-fast and happens-before, a
> recoverable race could be written explicitly:
>
> Java
> |// Pseudo-code for a recoverable race using a stream gatherer <T>
> T race(Collection<Callable<T>> tasks, int maxConcurrency) { var
> exceptions = new ConcurrentLinkedQueue<RpcException>(); return
> tasks.stream() .gather(mapConcurrent(maxConcurrency, task -> { try
> { return task.call(); } catch (RpcException e) { if
> (isRecoverable(e)) { // Selectively recover exceptions.add(e);
> return null; // Suppress and continue } throw new
> RuntimeException(e); // Fail fast on non-recoverable } }))
> .filter(Objects::nonNull) .findFirst() // Short-circuiting and
> cancellation .orElseThrow(() -> new
> AggregateException(exceptions)); } |
>
> While this is slightly more verbose than the JEP example, it's
> familiar Stream semantics that people have already learned, and it
> offers explicit control over which exceptions are recoverable
> versus fatal. The boilerplate for exception aggregation could
> easily be wrapped in a helper method.
>
There are many use cases. Joiner defines a small set of static factory
for built-ins that we hope will cover most usages, equivalent to the
built-ins defined by Gatherers. The anySuccessfulOrThrow (which is
"race" in some Scala libraries) fits in well.
We do want to bring mapConcurrent (or a successor) into the structured
fold but don't have a good proposal at this time.
> 1.
>
> *Reserve Complexity for Complex Cases:* The low-level
> |StructuredTaskScope| and its policy mechanism are powerful tools.
> However, they should be positioned as the "expert-level" API for
> building custom frameworks. Or perhaps just keep them in the
> traditional ExecutorService API. The everyday developer experience
> should be centered around simpler, declarative constructs that
> cover the most frequent needs.
>
STS is intended to usable by average developers. Implementing Joiner is
more advanced/expert. Early exploration did propose additions to
ExecutorService, including a variant of inokveAll that short circuited
when a task failed, but just hides everything about structured concurrency.
-Alan
-------------- next part --------------
An HTML attachment was scrubbed...
URL: <https://mail.openjdk.org/pipermail/loom-dev/attachments/20251012/bc5c1a9d/attachment-0001.htm>
More information about the loom-dev
mailing list