Feedback on Structured Concurrency (JEP 525, 6th Preview)
Jige Yu
yujige at gmail.com
Sun Oct 12 16:49:19 UTC 2025
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 <forax at univ-mlv.fr> wrote:
>
>
> ------------------------------
>
> *From: *"Jige Yu" <yujige at gmail.com>
> *To: *"loom-dev" <loom-dev at openjdk.org>
> *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));
> 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.
> - 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.
------------------------------
*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.
regards,
> Rémi
>
>
>
-------------- next part --------------
An HTML attachment was scrubbed...
URL: <https://mail.openjdk.org/pipermail/loom-dev/attachments/20251012/dbf27aaf/attachment-0001.htm>
More information about the loom-dev
mailing list