I encourage everyone to reread the original JEP on structured concurrency - a simple and effective solution for a common problem.
I read many of these comments and I shudder to think of how complex their codebases must be - and so instead they are trying to change STS to fit their messes rather than fix their messes. > On Sep 29, 2025, at 12:58 PM, Viktor Klang <[email protected]> wrote: > > > Hi Adam, > > Responses inline below: > > > > where tasks are generated as results of other tasks. > > Yes, and this is only an "issue" if you want those tasks to be siblings to > the tasks whose result yielded their existence. (Nesting a new scope in task > is still possible) > > >Continuing the example of the crawler. A reasonable requirement is to add a > >per-domain limit on how many connections can be opened to a given domain at > >any time, plus a global limit on connections. > > The question whose responsibility defending those boundaries—one could > (equally successfully) argue that total limits and per-domain limits could be > ensured by the logic which performs the outbound calls (similar to how > connection pools for databases enforce a total limit of outbound database > connections.) > > >I suppose the point here is that when using nested scopes, for problems > >where some communication between the processes needs to happen, you have to > >rely on shared (mutable) memory, and use synchronization tools such as > >concurrent maps, semaphores etc. > > The challenge is (mainly) when you need bidirectional communication, possibly > even with a dynamic number of participants, or as a part of a larger > orchestration of communication between participants. With that said, it's a > much larger topic than an email would do justice. > > >An alternative (one which I think you’ve exercised to the extreme ;) ) is to > >use something more like an actor: a centralised process-coordinator which > >manages the whole computation. > > Heh, I might have a smidge of experience in that department, yes. ;) > > >We can still use STS, but instead of a tree of scopes, we flatten this to a > >single scope, which creates crawler-forks in accordance with the various > >rate-limiting restrictions. The logic in the coordinator is definitely > >non-trivial, but it’s now single-threaded and we don’t have to worry about > >atomic updates and the like. But, we still need to communicate somehow with > >the child processes, and that’s where queues or channels come in. > > From my perspective, there's no obvious advantage to "flattening the > tree"—the risk is that you're embedding a lot more complexity into a single > place, leading to hard to diagnose issues, and a rather high barrier to entry. > > >So now to the crux of the problem: using blocking operations in forks has > >different error handling than using blocking operations in the body of the > >scope. The first will be interrupted when there’s an exception in any of the > >forks (under the default Joiner). The second will not - it will just hang. I > >think it does put a dent in the otherwise "let it crash" philosophy that you > >might employ when working with STS. That is, when an exception occurs, you > >can be sure that everything will be cleaned up properly, and the exception > >is propagated. With a caveat: only when the scope’s main body is blocked on > >scope.join(), not some other operation. > > I understand what you're saying, yet what I hear that you're proposing is > merely moving the target one step further. «Quis custodiet ipsos custodes» — > even if the main block were to be "lifted" into a Virtual Thread and be > "symmetric" or equivalent in its behavior as compared to its tasks, the code > which creates the block would not, and so now you've "only" managed to move > the sameness in behavior a step further, and now you have a dissimilarity > between that and the "coordinator" and the cycle repeats. > > >Well if scope.join() waits until all forks complete, it will only do so when > >there are no forks left, and then nobody can create new forks? So I don’t > >think there’s a race here? In other words, forks can only be created from > >live forks, before they complete. > > I believe I misunderstood, thanks for clarifying, Adam. So you're saying > you're scope.fork():ing concurrently to scope.join(), and the "benefit" to > that is that the body of the scope can perform other responsibilities because > it has outsourced the forking? > > >Yes, sure, the design here can be cleaner. But I still think the idea of > >using an AtomicBoolean to signal completion smells like a work-around, not a > >"proper" way to use Joiners. But maybe I’m too picky? > > In my Joiner example there'd be no need to expose the AtomicBoolean, so it > would be an internal implementation detail to the Joiner in question. It is > definitely within the area of responsibilities for a Joiner to decide when > the scope is "done". > > >Sure, and we might not want to represent numbers in terms of tuples and > >units :). As I said, it’s more a nitpick, but for me a timeout(Duration, > >Runnable) method seems more general and light-weight. That said, I’m not too > >worried about nested scopes, as they seem really light-weight, but maybe the > >current solution has potential for more optimization. > > As a side-note: An interesting, recent, development is that ForkJoinPool now > implements ScheduledExecutorService. > > >Ah, you see, I didn’t even notice the Supplier there. I’d still argue, > >though, that when people see a Subtask<T>, they would rather think of it in > >terms of analogies with a Future<T> ("a computation for which a result will > >be available in the future"), rather than a supplier. Especially that > >Subtask.get() can only be called under specific circumstances - after > >scope.join() has completed. So I’m not sure if the contract of Supplier even > >fits here? But maybe I’m too biased by working with Future-like things for a > >longer time. > > As I have dabbled with Futures a little bit, I can absolutely see why one'd > think it is related to Future, and in this case I think it is important to > judge things based on what they claim that they are (Subtask is not a Future) > rather than what one believes them to be (Future). Just like Optional.get() > wouldn't want to have to live up to the expectations of Future.get() :) > > >Thank you for the answers! > > You're most welcome! > > Cheers, > √ > > > Viktor Klang > Software Architect, Java Platform Group > Oracle > From: Adam Warski <[email protected]> > Sent: Monday, 29 September 2025 12:15 > To: Viktor Klang <[email protected]> > Cc: [email protected] <[email protected]> > Subject: [External] : Re: Problem report on the usage of Structured > Concurrency (5th preview) > > Good morning! > > > >1) SCS works great when tasks are independent, and known upfront; that is, > > >when tasks aren’t dynamically generated based on computations that are > > >part of the scope. > > > > I think I understand what you intend to say, but I think more specifically > > you're referring to when tasks are generated as a result of *other tasks*, > > not the scope body itself. > > > > Case in point: > > > > try(var scope = …) { > > while(dynamicCondition) // Dynamic number of tasks > > scope.fork(…); > > scope.join(); > > } > > > > >someone on Reddit already pointed out that a better implementation using > > >SCS nested scopes exists. > > > > When in doubt, consider if nested scopes could make the design clearer. > > This is analoguous to breaking a large, complex, method-body down into > > multiple smaller ones. > > > > >Still, if we add requirements around rate limiting, per-domain connection > > >pooling etc., a solution with a centralised coordinator becomes more > > >viable. > > > > Not sure how that conclusion was derived. Could you explain further? > > > > >Other examples might include implementing an actor-like component, where > > >the actor’s body becomes the scope's body, handles some private (mutable) > > >state, and communicates with child processes (forks created in the scope) > > >using queues. > > > > Inter-task communication channels are not part of Structure Concurrency at > > this point in time. It is however important to note that > > StructuredTaskScope is not the end state of Structured Concurrency. > > > > >If the main scope body includes any blocking logic, it might end up > > >hanging indefinitely, while all the other forks have been cancelled. > > > > That statement is true by definition—any code which is blocking > > indefinitely and is not interrupted, is by definition blocking indefinitely. > > > > >The main scope’s body awaits for data from either of them (on a queue), > > >and when an element is produced, sends it downstream. Now, if we’re not > > >careful with error handling, an exception in one of the substreams will > > >cancel the scope, but the main scope will indefinitely wait on data, not > > >aware of the error. > > > > This sounds, to me, like another issue with an absent feature—Inter-task > > communication channels. > > I’ll try to clarify those together. > > So first of all yes, you’re right, the problematic scenario isn’t dynamic > task creation in general, but a subset, where tasks are generated as results > of other tasks. And yes, if there are equivalent solutions using nested > scopes or a centralised coordinator, then going with the less-centralized > option, and leveraging nested scopes is definitely the way to go. I won’t > contend anything here :). But - the scenarios I’m referring to are when > nested scopes either aren’t possible to use (because of the nature of the > problem), or would complicate the overall code. > > Continuing the example of the crawler. A reasonable requirement is to add a > per-domain limit on how many connections can be opened to a given domain at > any time, plus a global limit on connections. One way to implement this on > top of STS is to use nested scopes, as pointed out on reddit > (https://urldefense.com/v3/__https://www.reddit.com/r/java/comments/1nq25yr/comment/ng3yk7b/__;!!ACWV5N9M2RV99hQ!L9F3IT3OW3MJ1Tw3IwbQL99I7_CqQJGWxghNCf8IGjLR6TNVCjae_D1Ih2AHPHSsCrOKebwR9qucoOM$ > ). However, to maintain the limits, you’d then need a ConcurrentHashMap > mapping domains to per-domain Semaphores, plus a global Semaphore to enforce > the global limit. And now this becomes tricky to implement correctly: which > semaphore do you acquire first? What if there’s a concurrent update to the > map? You’ll need to use CAS and the like to ensure correctness of the > process. Doable, but not necessarily the cleanest solution. > > I suppose the point here is that when using nested scopes, for problems where > some communication between the processes needs to happen, you have to rely on > shared (mutable) memory, and use synchronization tools such as concurrent > maps, semaphores etc. > > An alternative (one which I think you’ve exercised to the extreme ;) ) is to > use something more like an actor: a centralised process-coordinator which > manages the whole computation. We can still use STS, but instead of a tree of > scopes, we flatten this to a single scope, which creates crawler-forks in > accordance with the various rate-limiting restrictions. The logic in the > coordinator is definitely non-trivial, but it’s now single-threaded and we > don’t have to worry about atomic updates and the like. But, we still need to > communicate somehow with the child processes, and that’s where queues or > channels come in. > > (And using STS might not be just a whim, we might **have to** to use it, as > we might need ScopedValues propagation, or we want the guarantee, that no > thread leaks are possible) > > Of course, the crawler is just one example, with which I hope to illustrate a > point: that there’s a class of problems where some coordination between forks > is crucial, and while some of these can be solved using shared memory and > java.util.concurrent, it’s not always the best approach. I understand that > inter-task communication is out of scope of the JEP, but since we are > designing the future of Java’s IO-bound concurrency (or at least, that’s my > perception of the goal of the JEP), it would be good to at least know that > STS is extendible so that it might accommodate to these requirements in the > future. Or integrate with third-party solutions. I’m quite sure people will > end up just using LinkedBlockingQueues (or something like Jox’s Channels, > which are completable & error-aware) as soon as the JEP becomes final. > > So now to the crux of the problem: using blocking operations in forks has > different error handling than using blocking operations in the body of the > scope. The first will be interrupted when there’s an exception in any of the > forks (under the default Joiner). The second will not - it will just hang. I > think it does put a dent in the otherwise "let it crash" philosophy that you > might employ when working with STS. That is, when an exception occurs, you > can be sure that everything will be cleaned up properly, and the exception is > propagated. With a caveat: only when the scope’s main body is blocked on > scope.join(), not some other operation. > > > >Moreover, I allow creating forks-in-forks, so that the main logic can > > >create forks at will. > > > > As this is described, this means that you could have a race condition > > between forking in the same scope and the call to scope.join(), or did I > > misunderstand? > > Well if scope.join() waits until all forks complete, it will only do so when > there are no forks left, and then nobody can create new forks? So I don’t > think there’s a race here? In other words, forks can only be created from > live forks, before they complete. > > > >My work-around here is to create a `Joiner` which monitors an `isDone` > > >flag, and submit an empty task after the work is determined to be done: > > > > Since Joiners are one-shot, and are created before the scope is opened, it > > would seem more logical(?) to embed that flag into the joiner and have it > > be set by the scope body by referring to the joiner: > > > > var myJoiner = new MyJoiner(…); > > try(var scope = StructuredTaskScope.open(myJoiner)) { > > … > > myJoiner.signalDone() > > scope.join(); > > } > > Yes, sure, the design here can be cleaner. But I still think the idea of > using an AtomicBoolean to signal completion smells like a work-around, not a > "proper" way to use Joiners. But maybe I’m too picky? > > > >Special-casing for this seems odd, as timeout is only one example from a > > >family of "resiliency" methods, others including retries, repeats etc. > > >These as well, could be implemented on top of virtual threads and SCS as > > >methods, without special support from the SCS API itself. > > > > While it is true that the I combinator, in SKI calculus, is not primitive > > because it can be implemented in terms of the S and K combinators, that > > doesn't necessarily mean that we should strive to distill the absolute > > primitives. There also exists a scientific paper on the Turing Completeness > > of the x86 MOV instruction—but it's Turing Completeness does not rule out > > the value of having specialized instructions. > > > > Putting a deadline on a concurrent operation is established "good practice" > > to ensure liveness, and the duration for that timeout is most useful if it > > is provided by the caller, so creating a standardized configuration option > > for this common operation was deemed to be worth it, since we do not need > > to either create a scope-within-a-scope by default or filter out the > > Subtask handling the timeout. > > Sure, and we might not want to represent numbers in terms of tuples and units > :). As I said, it’s more a nitpick, but for me a timeout(Duration, Runnable) > method seems more general and light-weight. That said, I’m not too worried > about nested scopes, as they seem really light-weight, but maybe the current > solution has potential for more optimization. > > > > The Subtask.get() method is confusing > > > > «public static sealed interface StructuredTaskScope.Subtask<T> > > extends Supplier<T>» - > > https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/util/concurrent/StructuredTaskScope.Subtask.html > > Ah, you see, I didn’t even notice the Supplier there. I’d still argue, > though, that when people see a Subtask<T>, they would rather think of it in > terms of analogies with a Future<T> ("a computation for which a result will > be available in the future"), rather than a supplier. Especially that > Subtask.get() can only be called under specific circumstances - after > scope.join() has completed. So I’m not sure if the contract of Supplier even > fits here? But maybe I’m too biased by working with Future-like things for a > longer time. > > Thank you for the answers! > > -- > Adam Warski > > https://urldefense.com/v3/__https://warski.org__;!!ACWV5N9M2RV99hQ!L9F3IT3OW3MJ1Tw3IwbQL99I7_CqQJGWxghNCf8IGjLR6TNVCjae_D1Ih2AHPHSsCrOKebwROYMY51A$ >
