[External] : Re: Problem report on the usage of Structured Concurrency (5th preview)

Adam Warski adam at warski.org
Tue Sep 30 08:57:28 UTC 2025


Hello

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

Yes, there’s multiple ways to implement this, each with their tradeoffs. I’m not saying that shared memory / connection pool are bad in all cases, just that an actor-like / central "coordinator" should also be possible, when using STS. With the same error handling properties, as the concurrency scopes generally offer.

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

Not quite: it depends where this "complex" or "correct" code has to be implemented - is it by the user, or offered by a library? Quite often the value proposition of libraries is to hide some ugly implementation details and offer a nice abstraction for the user. If the "lifting" of the main block onto a VT is done by the library, we can implement this once (correctly, with appropriate testing etc.), and then only expose to the users the "safe" variant. That is, users would be able to define the code that runs in the "main block", but not in the orchestrator.

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

If we allow forks-from-forks, then we can keep the main-scope logic dead simple, with just a couple of scope.fork, followed by a scope.join.
All the complex coordination logic might then go into a "main" fork. Such "main" fork might then start further forks, fully participating in the error handling / cancellation protocol, just as all other forks. 

Not sure if it’s the best idea, it’s just an idea ;)

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

Yes, sure. But then submitting an empty task, just to let the joiner "discover" that the flag has changed, and so that it might return false from onFork - well to me this look suspicious.

> >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() :)

Well, it’s in the name of the class ;). `Option` (or `Supplier`) doesn’t suggest anything related to concurrency. So no - I’m not saying that `.get()` is now forever doomed to only mean "block this thread until a result is available". Also, `Option` is an inspectable data structure, unlike futures etc.
`Subtask` is simply a name which (at least to me) is immediately associated with a `Future`. But again, that’s more of a nitpick, then any fundamental problem.

Adam

-- 
Adam Warski

https://warski.org



More information about the loom-dev mailing list