Feedback on Structured Concurrency (JEP 525, 6th Preview)

Jige Yu yujige at gmail.com
Sun Oct 12 21:56:18 UTC 2025


On Sun, Oct 12, 2025 at 12:53 PM Alan Bateman <alan.bateman at oracle.com>
wrote:

> 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.
>

 Yeah. I've learned that feedbacks from tried, real earnest users would be
more useful, which sadly I'm not.

 The exception handling part of it was enough for me to want to try
something different and this is the angle I came in. I know my feedback is
generally negative but they are honest.

I did try to use mapConcurrent() and tried it out from the structured
concurrency aspect. And I've then realized that it doesn't entirely have
the two most important properties: fail-fast and happens-before. It does
however provide two-way cancellation and task interruptions.

I've also gotten my feet wet in trying to implement what I had proposed,
making sure at least I know what I'm talking about, fwiw.

>
> ------------------------------
>
>    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.
>

I understand that. And I'm not proposing to add exception type parameters.
Those aren't gonna work.

I was hoping Java could add some help to make exception tunneling easier (I
had some detailed clarification in my reply to Remi),

But even failing that, 3 points are orthogonal to adding type parameters:

   1. Should the callback be Callable or Supplier? With Callable (and with
   FailedException being unchecked), it's essentially a sneaky exception
   unchecker. Whereas Supplier would be more like Stream, still not going to
   make everyone happy, but it's at least honest: won't silently uncheck-ify
   exceptions.
   2. Forcing callers to catch or handle InterruptedException is not
   helpful. mapConcurrent() on the other hand doesn't, which I believe is a
   better model.
   3. anySuccessfulResultThrow() swallows runtime exceptions and errors.
   This to me seems like an anti-pattern.


> 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.
>
> I know. But the thought that a standard JDK API would silently swallow *by
default* still feels scary.

>
>
>
>    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.
>

I guess my feedback was at a higher level than the details in the Joiner
API. My question was: is the Joiner/STS API even the right API that pulls
this weight? If the STS team only needed to make mapConcurrent() fully
structured-concurrency, and it only needed to provide a simple, functional
API, the API would be a lot simpler and all of these extra imperative
concepts like subtasks, joiners, lifecycle callbacks etc. might not even
need to exist.

It's quite likely that the Loom team had already discussed and reached the
conclusion that a functional API similar to what I had described, despite
being simpler, would not be sufficient, and the extra weight in the current
STS is worth it (for reasons X, Y and Z).  If that's the case, then
consider my questions dismissed.

Otherwise, I just want to make sure the unpopular question (*is it worth it
to build the imperative, complex API?*) is on the table.

>
>
>
>
>    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.
>

I guess I got my impression from recent online discussions that people
can be keen on using these lifecycle callbacks to bake in business-specific
needs.

It's the thing with these generic libraries though: they can be used, and
they can be abused. And imho "how can it avoid being abused" should also be
a critical part of designing an API.

>
>
> ------------------------------
> *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),
>

Yes. I understand it can be built on top of STS. But my point is to ask: *could
it be that the simpler API is all that most people need?*

There's immense power in the *default option* provided by the standard JDK.
If STS is the default provided by Loom, I'm sure it'll be what majority of
people use, even if technically one can build a simpler wrapper - it takes
an extra dependency, or it takes extra work, and all the documents are
about the default option, so in the end, the theoretical simpler
alternative wrapper may not get a chance.

But there are two potential downsides:

   1. It changes the perception from SC being really easy in Java to
   something less punchy. The ease-of-use of an API is imho much more
   important than its raw power.
   2. The overly powerful STS API, with its sharp edges (e.g.
   anySuccessfulOrThrow swallows exceptions) can be abused, generating code
   that's less maintainable in the long run.

And by asking that question, I guess my daring proposal (out of my
average-user naivety) is to decouple the two:

   - Provide a simple, functional API for the 90% users to enjoy SC in the
   simplest possible way. *Forget about power and max coverage in this
   phase*.
   - Take the meaty STS API as an "advanced, follow-up project" and
   evaluate the ROI, given 90% use cases already satisfied by the functional
   API.


>
>
>    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/6f8f7409/attachment-0001.htm>


More information about the loom-dev mailing list