> From: "Jige Yu" <[email protected]>
> To: "Remi Forax" <[email protected]>
> Cc: "loom-dev" <[email protected]>
> Sent: Sunday, October 12, 2025 6:49:19 PM
> Subject: Re: Feedback on Structured Concurrency (JEP 525, 6th Preview)

> Thanks for the quick reply, Remi!
> I'll focus on discussing alternatives, which hopefully should also help 
> clarify
> my concerns of the current API.

> On Sun, Oct 12, 2025 at 6:43 AM Remi Forax < [ mailto:[email protected] |
> [email protected] ] > wrote:

>>> From: "Jige Yu" < [ mailto:[email protected] | [email protected] ] >
>>> To: "loom-dev" < [ mailto:[email protected] | [email protected] ] >
>>> Sent: Sunday, October 12, 2025 7:32:33 AM
>>> Subject: Feedback on Structured Concurrency (JEP 525, 6th Preview)

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

>> [...]

>>> 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)
>>> );

>> I'm curious how you want to type that API, does it work only for two tasks, 
>> do
>> you have an overload for each arity (2 tasks, 3 tasks, etc).
>> And how exceptions are supposed to work given that the type system of Java is
>> not able to merge type variable representing exceptions correctly.

> Just a handful of overloads. Looking from Google's internal code base, up to 5
> concurrent fanout probably covers 95% of use cases. The other 5% can either
> build their own helpers like:

> // MoreConcurrency
> <T1, T2, ..., T10, R> R concurrently(
>     Supplier<T1>, ..., Supplier<T10>,
>     Function10<T1, T2, ..., T10, R> combiner) {
>   return concurrently(  // just nest some concurrent calls
>      () -> concurrently(task1, task2, ..., task5, Tuple5::new),
>      () -> concurrently(task6, ..., task10, Tuple5::new),
>      (tuple1, tuple2) -> combiner.apply(tuple1.a(), tuple1.b(), ..., 
> tuple2.e());
> }
> Or, they can use the homogeneous mapConcurrent() gatherer, and deal with some
> type casting.

> In terms of exceptions, directly propagating checked exception across threads
> may not always be desirable because their stack trace will be confusing. This
> is why traditionally Future throws ExecutionException with the stack traces
> chained together. It should be a conscious choice of the developer if they
> don't mind losing the extra stack trace.

> I was thinking of one of Google's internal compile-time plugins to help with
> exception propagation. But before I dive into the details, allow me to clarify
> the principle that I implicitly adheres to:

> Any Checked Exception Must Be Explicitly Caught or Declared As Throws

> There must be no secret pathway where it can become unchecked without the
> developer's explicit acknowledgement.

> And that is why I'm concerned about the current SC API, where the checked
> exception can be thrown in the Callable lambda, not have to be caught. And 
> then
> at the call site it has become unchecked.

> (well, except maybe InterruptedException, which probably shouldn't have 
> required
> the developer to catch and handle)

> Now I'll explain what the Google's internal plugin does, it's called
> TunnedException, which is an unchecked exception. For streams, it's used like:

> try {
>   return list.stream().map(v -> tunnel(() -> process(v))).toList();
> } catch (TunnelException e) {
>   try {
>     // If you forgot a checked exception, compilation will FAIL
> throw e.rethrow(IOException.class, InvalidSyntaxException.class);
>   } catch (IOExeption e) {
>     ...
>   } catch (InvalidSyntaxException e) {
>      ...
>   }
> }

> At the javac level, tunnel() expects a Callable, which does allow checked
> exceptions to be magically "unchecked" as TunnelException. And at runtime, the
> TunnelException will be thrown as is by Stream.

> But in the ErrorProne plugin, it will recognize that the special tunnel() call
> has suppressed a few checked exception types (in this case, IOException and
> InvalidSyntaxException). And then the plugin will validate that within the 
> same
> lexical scope, rethrow() with the two exception types must be called. Thus
> compile-time enforcement of checked exceptions remains. And at the catch site
> we still have the compiler-check about which checked exception that we have
> forgotten to catch, or the checked exception type cannot possibly be thrown.

> I played with this idea inside Google, using it for this functional
> concurrently() flavor of structured concurrency. And it worked out ok:
> try {
>   return Concurrently.call(
>       () -> tunnel(() -> fetchArm()),
>       () -> tunnel(() -> fetchLeg()),
>       (arm, leg) -> new Robot(arm, leg)
>   );
> } catch (TunnelException e) {
>   throw e.rethrow(RpcException.class);
>   // or wrap it in an appropriate application-level exception
> }
> I'm not saying that the Google's ErrorrProne plugin be adopted verbatim by 
> Loom.
> I actually had hoped that the Java team, being the god of Java, can do more,
> giving us a more systematic solution to checked exceptions in structured
> concurrency. Google's ErrorProne plugin can be considered a baseline, that at
> worst, this is what we can do.

> That said, it's understandable that this whole
> checked-exception-does-not-work-across-abstractions problem is considered an
> orthogonal issue and Loom decides it's not in scope.

> But even then, it's probably prudent to use Supplier instead of Callable for
> fork(), or in this hypothetical functional SC.

> The reason I prefer Supplier is that it's consistent with the established
> checked exception philosophy, and will force the developer to handle the
> checked exceptions. Even if you do want to propagate it in unchecked, it 
> should
> be an explicit choice. Either by using plain old try-catch-rethrow, or the
> developer (or Project Loom) can provide an explicit "unchecker" helper to help
> save boilerplate:
> public static <T> Supplier<T> unchecked(Callable<T> task) {
>   return () -> {
>     try {
>       return task.call();
>     } catch (RuntimeException e) {
>       throw e;
>     } catch (Exception e) {
>       throw new UncheckedExecutionException(e);
>     }
>   };
> }
> Then it's only a matter of changing the call site to the following:
> return Concurrently.call(
>       unchecked(() -> fetchArm()),
>       unchecked(() -> fetchLeg()),
>       (arm, leg) -> new Robot(arm, leg));

Exceptions management is really really hard in Java, mostly because of checked 
exceptions and IDE failing to implement the fact that exception should be catch 
as late as possible. 

You can use a Supplier or any other functional interfaces of java.util.function 
to force users to manually deal with exceptions, sadly what i'm seeing is that 
my students write code that shallow exceptions or throw everything as a 
RuntimeException (the default behavior of Eclipse and IntelliJ respectively). 

We have already a way to deal with exceptions in Executor/Callable/Future, the 
default behavior wraps every exceptions, 
Yes, you get only one part of the tunneling, you have to write the rethrowing 
part yourself, but at least that default behavior is better than letting users 
to deal with exceptions. 

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

>> Several points :
>> - I believe the current STS API has no way to deal with if the exception is
>> recoverable or not because it's far easier to do that at the end of the
>> callable.
>> Your example becomes :
>> sts.fork(() -> {
>> try {
>> taskCall();
>> } catch(RPCException e) {
>> ...
>> }
>> });

> Yes. Though my point is that this now becomes an opt-in . It should be an
> opt-out. Swallowing exceptions should not be the default behavior.

> And for the anySuccessfulOrThrow() joiner, I don't know it helps much because
> even if it's not recoverable,you'd still throw in the lambda, and it will 
> still
> be swallowed by the joiner.

anySuccessfulResultOrThrow() has the semantics of stopping the STS when one 
result is found. 
So you may never run some callables, so you may never know if a Callable fails 
or not. 

Given that semantics, not propagating the exceptions through the joiner seems 
the right thing to do, 
again, you are not even sure that all callables will run. 

>> - You do not want to post the result/exception of a task into a concurrent 
>> data
>> structure, i think the idea of the STS API in this case is to fork all the
>> tasks and then take a look to all the subtasks.

> It probably is. What I was trying to say is that the mapConcurrent() approach
> feels more natural, and safer.

>> I believe it's more efficient because there is no CAS to be done if the main
>> thread take a look to the subtasks afterward than if the joiner tries to
>> maintain a concurrent data structure.

> This may be my blind spot. I've always assumed that structured concurrency 
> where
> I need to fan out IO-blocking tasks isn't usually the hot path. Even with
> virtual threads, context switching still isn't cheap enough to worry about
> low-level micro optimizations ?

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

>> For me, that's why you have an open Joiner interface for expert and already
>> available Joiner (like all.../any...) that are more for everyday developers.

> Yeah. My point is the current Joiner interface looks too much like an inviting
> couch that an average developer would immediately start to think: "oh I have a
> use case I may be able to implement by overriding onComplete()!". But you 
> don't
> really need it .

> In an analogy, there is Stream API. Most of us would just use the Steam API,
> passing in lambdas, collectors etc. We would not think of implementing our own
> BaseStream, which imho would have been an unfortunate distraction.

Wrong guy, i've implemented quite a lot of spliterators (the abstraction used 
by the Stream implementation). 

More seriously, yes you may implement onComplete or the Predicate of allUntil() 
when you should not, but it's like implementing a Spliterator, not a lot of 
people will do it anyway, it's clearly marked for expert. 

> InterruptedException
> Lastly, my view of InterruptedException is like what you've said: it being a
> checked exception is unfortunate. It forces people to catch it, which then
> makes it easier to make the mistake of forgetting to re-interrupt the thread.
> And actually, few people even understand it (where it comes from, what 
> triggers
> it,what needs to be done).

> Even if you do painstakingly declare throws InterruptedException all the way 
> up
> the call stack, as the usual best practice suggests, the end result is still
> just as if it were unchecked in the first place, only that way it wouldn't 
> have
> mandated so much maintenance effort of the developers: the top-level handler
> catch and handle it once and for all.

> So I'd consider it a plus if the SC API hides away InterruptedException. Heck,
> mapConcurrent() already hides it away without forcing users to catch it.

> If you expect average users to mis-handle it, the better alternative may be to
> handle it for them already, including perhaps re-interrupting the thread, and
> turning it into an UncheckedInterruptedException, so that most developers 
> won't
> be given the chance to make the mistake.

Again, you can think that InterruptedException should not be a checked 
exception, i will go even further saying Java should not have checked 
exceptions, 
but this is not the kind of fix you should do in an API, it should be done at 
the language level. 
It's more important to have an API that integrate seamlessly with the rest of 
Java, hence using InterruptedException when a blocking join() is interrupted. 

regards, 
Rémi 

Reply via email to