<div dir="ltr"><div dir="ltr"><br></div><br><div class="gmail_quote gmail_quote_container"><div dir="ltr" class="gmail_attr">On Mon, Oct 13, 2025 at 10:37 AM <<a href="mailto:forax@univ-mlv.fr">forax@univ-mlv.fr</a>> wrote:<br></div><blockquote class="gmail_quote" style="margin:0px 0px 0px 0.8ex;border-left:1px solid rgb(204,204,204);padding-left:1ex"><div><div style="font-family:arial,helvetica,sans-serif;font-size:12pt;color:rgb(0,0,0)"><div><br></div><div><br></div><hr id="m_9151287118338983300zwchr"><div><blockquote style="border-left:2px solid rgb(16,16,255);margin-left:5px;padding-left:5px;color:rgb(0,0,0);font-weight:normal;font-style:normal;text-decoration:none;font-family:Helvetica,Arial,sans-serif;font-size:12pt"><b>From: </b>"Jige Yu" <<a href="mailto:yujige@gmail.com" target="_blank">yujige@gmail.com</a>><br><b>To: </b>"Remi Forax" <<a href="mailto:forax@univ-mlv.fr" target="_blank">forax@univ-mlv.fr</a>><br><b>Cc: </b>"loom-dev" <<a href="mailto:loom-dev@openjdk.org" target="_blank">loom-dev@openjdk.org</a>><br><b>Sent: </b>Sunday, October 12, 2025 6:49:19 PM<br><b>Subject: </b>Re: Feedback on Structured Concurrency (JEP 525, 6th Preview)<br></blockquote></div><div><blockquote style="border-left:2px solid rgb(16,16,255);margin-left:5px;padding-left:5px;color:rgb(0,0,0);font-weight:normal;font-style:normal;text-decoration:none;font-family:Helvetica,Arial,sans-serif;font-size:12pt"><div dir="ltr"><div dir="ltr">Thanks for the quick reply, Remi!<br><div>I'll focus on discussing alternatives, which hopefully should also help clarify my concerns of the current API.</div></div><br><div class="gmail_quote"><div dir="ltr" class="gmail_attr">On Sun, Oct 12, 2025 at 6:43 AM Remi Forax <<a href="mailto:forax@univ-mlv.fr" target="_blank">forax@univ-mlv.fr</a>> wrote:<br></div><blockquote class="gmail_quote" style="margin:0px 0px 0px 0.8ex;border-left:1px solid rgb(204,204,204);padding-left:1ex"><div><div style="font-family:arial,helvetica,sans-serif;font-size:12pt;color:rgb(0,0,0)"><br><br><hr id="m_9151287118338983300m_8301531414636395411zwchr"><div><blockquote style="border-left:2px solid rgb(16,16,255);margin-left:5px;padding-left:5px;color:rgb(0,0,0);font-weight:normal;font-style:normal;text-decoration:none;font-family:Helvetica,Arial,sans-serif;font-size:12pt"><b>From: </b>"Jige Yu" <<a href="mailto:yujige@gmail.com" target="_blank">yujige@gmail.com</a>><br><b>To: </b>"loom-dev" <<a href="mailto:loom-dev@openjdk.org" target="_blank">loom-dev@openjdk.org</a>><br><b>Sent: </b>Sunday, October 12, 2025 7:32:33 AM<br><b>Subject: </b>Feedback on Structured Concurrency (JEP 525, 6th Preview)<br></blockquote></div><div><blockquote style="border-left:2px solid rgb(16,16,255);margin-left:5px;padding-left:5px;color:rgb(0,0,0);font-weight:normal;font-style:normal;text-decoration:none;font-family:Helvetica,Arial,sans-serif;font-size:12pt"><div dir="ltr"><h3><span style="font-size:small;font-weight:normal">Hi Project Loom.</span></h3><h3><span style="font-size:small;font-weight:normal">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.</span></h3><p>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 <font face="monospace">async callback hell,</font> or reactive chains with something simpler and more readable.</p><p>It will lack depth in the highly specialized concurrent programming area. And I acknowledge this viewpoint may bias my feedback.</p><hr></div></blockquote><div>[...]</div><br><blockquote style="border-left:2px solid rgb(16,16,255);margin-left:5px;padding-left:5px;color:rgb(0,0,0);font-weight:normal;font-style:normal;text-decoration:none;font-family:Helvetica,Arial,sans-serif;font-size:12pt"><div dir="ltr"><h2><b>Suggestions for a Simpler Model</b></h2><p>My preference is that the API for the most common use cases should be more <b>declarative and functional</b>.</p><ol start="1"><li><p><b>Simplify the "Gather All" Pattern:</b> 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:</p><span><span><div><div><span>Java</span></div><div><div><pre><code><span>// Ideal API for the 80% use case</span>
Robot robot = Concurrently.call(
() -> fetchArm(),
() -> fetchLeg(),
(arm, leg) -> <span>new</span> Robot(arm, leg)
);</code></pre></div></div></div></span></span></li></ol></div></blockquote><br><div>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).</div><div>And how exceptions are supposed to work given that the type system of Java is not able to merge type variable representing exceptions correctly. </div></div></div></div></blockquote><br><br><div>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:</div><br><div><pre style="color:rgb(0,0,0);font-size:16px"><code>// 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());
}</code></pre></div><div> </div><div>Or, they can use the homogeneous mapConcurrent() gatherer, and deal with some type casting.</div><br><div>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.</div><br><div>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:</div><br><div><b style="color:rgb(0,0,0);font-family:Helvetica,Arial,sans-serif;font-size:16px">Any Checked Exception Must Be Explicitly Caught or Declared As Throws</b></div><br><div>There must be no secret pathway where it can become unchecked without the developer's explicit acknowledgement.</div><br><div>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.</div><br><div>(well, except maybe InterruptedException, which probably shouldn't have required the developer to catch and handle)</div><br><div>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:</div><br><div><pre style="color:rgb(0,0,0);font-size:16px">try {
return list.stream().map(v -> tunnel(() -> process(v))).toList();
} catch (TunnelException e) {
try {
// If you forgot a checked exception, compilation will FAIL</pre><pre style="color:rgb(0,0,0);font-size:16px"> throw e.rethrow(IOException.class, InvalidSyntaxException.class);
} catch (IOExeption e) {
...
} catch (InvalidSyntaxException e) {
...
}
}</pre></div><br><div>At the javac level, <span style="color:rgb(0,0,0);font-size:16px">tunnel()</span> 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.<br></div><br><div>But in the ErrorProne plugin, it will recognize that the special <span style="color:rgb(0,0,0);font-size:16px">tunnel() </span>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.</div><br><div>I played with this idea inside Google, using it for this functional <span style="color:rgb(0,0,0);font-size:16px">concurrently()</span> flavor of structured concurrency. And it worked out ok:</div><div><pre style="color:rgb(0,0,0);font-size:16px"><pre><code>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
}
</code></pre></pre></div><div>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.</div><br><div>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.</div><br><div>But even then, it's probably prudent to use Supplier instead of Callable for fork(), or in this hypothetical functional SC. </div><br><div>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:</div><div><pre style="color:rgb(0,0,0);font-size:16px"><code>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);
}
};
}</code></pre></div><div>Then it's only a matter of changing the call site to the following:</div><div><pre style="color:rgb(0,0,0);font-size:16px"><code> return Concurrently.call(
unchecked(() -> fetchArm()),
unchecked(() -> fetchLeg()),
(arm, leg) -> new Robot(arm, leg));</code></pre></div></div></div></blockquote><div><br></div><div>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.</div><div><br></div><div>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).</div><div><br></div><div>We have already a way to deal with exceptions in Executor/Callable/Future, the default behavior wraps every exceptions,</div><div>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.</div></div></div></div></blockquote><div><br></div><div>Agreed that checked exception management is hard. But by using Function/Supplier there is at least a consistency card: it will be the same user experience as Stream.</div><div><br></div><div>Users still have to catch checked exceptions in the lambdas, but they have to do that already with streams anyways.</div><div><br></div><div>And as you said: it's important to have the API integrate seamlessly with the rest of Java. So sticking to the same precedent of Stream, and not allow checked exceptions to sneakily become unchecked, would seem like a safe bet.</div><div> </div><blockquote class="gmail_quote" style="margin:0px 0px 0px 0.8ex;border-left:1px solid rgb(204,204,204);padding-left:1ex"><div><div style="font-family:arial,helvetica,sans-serif;font-size:12pt;color:rgb(0,0,0)"><div><div><br></div><div><br></div><blockquote style="border-left:2px solid rgb(16,16,255);margin-left:5px;padding-left:5px;color:rgb(0,0,0);font-weight:normal;font-style:normal;text-decoration:none;font-family:Helvetica,Arial,sans-serif;font-size:12pt"><div dir="ltr"><div class="gmail_quote"><blockquote class="gmail_quote" style="margin:0px 0px 0px 0.8ex;border-left:1px solid rgb(204,204,204);padding-left:1ex"><div><div style="font-family:arial,helvetica,sans-serif;font-size:12pt;color:rgb(0,0,0)"><div><blockquote style="border-left:2px solid rgb(16,16,255);margin-left:5px;padding-left:5px;color:rgb(0,0,0);font-weight:normal;font-style:normal;text-decoration:none;font-family:Helvetica,Arial,sans-serif;font-size:12pt"><div dir="ltr"><ol start="1"><li><p><b>Separate Race Semantics into Composable Operations:</b> 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 <code>mapConcurrent()</code> fully embraced structured concurrency, guaranteeing fail-fast and happens-before, a recoverable race could be written explicitly:</p><span><span><div><div><span>Java</span></div><div><div><pre><code><span>// Pseudo-code for a recoverable race using a stream gatherer</span>
<T> <span>T <span>race</span><span>(Collection<Callable<T>> tasks, <span>int</span> maxConcurrency)</span></span>{
<span>var</span> exceptions = <span>new</span> ConcurrentLinkedQueue<RpcException>();
<span>return</span> tasks.stream()
.gather(mapConcurrent(maxConcurrency, task -> {
<span>try</span> {
<span>return</span> task.call();
} <span>catch</span> (RpcException e) {
<span>if</span> (isRecoverable(e)) { <span>// Selectively recover</span>
exceptions.add(e);
<span>return</span> <span>null</span>; <span>// Suppress and continue</span>
}
<span>throw</span> <span>new</span> RuntimeException(e); <span>// Fail fast on non-recoverable</span>
}
}))
.filter(Objects::nonNull)
.findFirst() <span>// Short-circuiting and cancellation</span>
.orElseThrow(() -> <span>new</span> AggregateException(exceptions));
}
</code></pre></div></div></div></span></span><p>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.</p></li></ol></div></blockquote><br><div>Several points :</div><div>- 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.</div><div> Your example becomes : </div><div> </div><div> sts.fork(() -> {</div><div> try { </div><div> taskCall();</div><div> } catch(RPCException e) {</div><div> ...</div><div> }</div><div> });</div><br></div></div></div></blockquote><div>Yes. Though my point is that this now becomes an <b>opt-in</b>. It should be an opt-out. Swallowing exceptions should not be the default behavior.</div><br><div>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.</div></div></div></blockquote><div><br></div><div>anySuccessfulResultOrThrow() has the semantics of stopping the STS when one result is found.</div><div>So you may never run some callables, so you may never know if a Callable fails or not.</div><div><br></div><div>Given that semantics, not propagating the exceptions through the joiner seems the right thing to do,</div><div>again, you are not even sure that all callables will run.</div></div></div></div></blockquote><div><br></div><div>This is inconsistent with the fail-fast semantics we get from Streams though.</div><div><br></div><pre style="color:rgb(0,0,0);font-size:16px"><code>list.parallelStream()
.map(item -> processAndMayThrow(item))
.findAny();</code></pre><div><br></div><div>It still truthfully throws whatever exception that was thrown before the first successful item was found. Sure, you can't predict which item will be processed. But failures that have already happened cannot be ignored. </div><div><br></div><div>Otherwise, unexpected fatal errors won't be reported until a success is found. If the success takes a long time, or if it blocks and waits for things, it can defeat fail fast, or even hang the program. A throttled error won't stop the program from flooding the server; a security audit error won't stop the subtasks from doing whatever bad things they would be doing.</div><div><br></div><blockquote class="gmail_quote" style="margin:0px 0px 0px 0.8ex;border-left:1px solid rgb(204,204,204);padding-left:1ex"><div><div style="font-family:arial,helvetica,sans-serif;font-size:12pt;color:rgb(0,0,0)"><div><div></div><blockquote style="border-left:2px solid rgb(16,16,255);margin-left:5px;padding-left:5px;color:rgb(0,0,0);font-weight:normal;font-style:normal;text-decoration:none;font-family:Helvetica,Arial,sans-serif;font-size:12pt"><div dir="ltr"><div class="gmail_quote"><div> </div><blockquote class="gmail_quote" style="margin:0px 0px 0px 0.8ex;border-left:1px solid rgb(204,204,204);padding-left:1ex"><div><div style="font-family:arial,helvetica,sans-serif;font-size:12pt;color:rgb(0,0,0)"><div><div>- 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.</div></div></div></div></blockquote><br><div>It probably is. What I was trying to say is that the mapConcurrent() approach feels more natural, and safer.</div><div> </div><blockquote class="gmail_quote" style="margin:0px 0px 0px 0.8ex;border-left:1px solid rgb(204,204,204);padding-left:1ex"><div style="font-family:arial,helvetica,sans-serif;font-size:12pt;color:rgb(0,0,0)"><div> 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.</div><br></div></blockquote><div>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 ?</div><br><blockquote class="gmail_quote" style="margin:0px 0px 0px 0.8ex;border-left:1px solid rgb(204,204,204);padding-left:1ex"><div style="font-family:arial,helvetica,sans-serif;font-size:12pt;color:rgb(0,0,0)"><blockquote style="border-left:2px solid rgb(16,16,255);margin-left:5px;padding-left:5px;color:rgb(0,0,0);font-weight:normal;font-style:normal;text-decoration:none;font-family:Helvetica,Arial,sans-serif;font-size:12pt"><div dir="ltr"><ol start="1"><li><p><b>Reserve Complexity for Complex Cases:</b> The low-level <code>StructuredTaskScope</code> 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.</p></li></ol></div></blockquote><br><div>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.</div><br></div></blockquote><br><div>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 <b>you don't really need it</b>.</div><br><div>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.</div></div></div></blockquote><div><br></div><div>Wrong guy, i've implemented quite a lot of spliterators (the abstraction used by the Stream implementation).</div><div><br></div><div>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.</div><div><br></div><blockquote style="border-left:2px solid rgb(16,16,255);margin-left:5px;padding-left:5px;color:rgb(0,0,0);font-weight:normal;font-style:normal;text-decoration:none;font-family:Helvetica,Arial,sans-serif;font-size:12pt"><div dir="ltr"><div class="gmail_quote"><br><br><div><hr><h2><b>InterruptedException</b></h2></div><div>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).</div><br><div>Even if you do painstakingly declare <span style="color:rgb(0,0,0);font-size:16px">throws InterruptedException </span>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.</div><br><div>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. </div><br><div>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.</div></div></div></blockquote><div><br></div><div>Again, you can think that InterruptedException should not be a checked exception, i will go even further saying Java should not have checked exceptions,</div><div>but this is not the kind of fix you should do in an API, it should be done at the language level.</div><div>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.</div></div></div></div></blockquote><div><br></div><div>Personally, I like mapConcurrent()'s model. It doesn't throw InterruptedException, but it interrupts the subtask threads, and leaves it to the subtask lambdas to catch and respond to IE. That to me seems to be reasonable. </div><blockquote class="gmail_quote" style="margin:0px 0px 0px 0.8ex;border-left:1px solid rgb(204,204,204);padding-left:1ex"><div><div style="font-family:arial,helvetica,sans-serif;font-size:12pt;color:rgb(0,0,0)"><div><div><br></div><div>regards,</div><div>Rémi</div><div><br></div></div></div></div></blockquote></div></div>