SE-0304: Structured Concurrency

This is how libdill and Venice works. It can throw cancelation errors on most of its APIs, including while creating new Coroutines and when yielding, but more importantly it is guaranteed to throw on any IO operation through its polling mechanisms, which I know I are even lower level in Swift's concurrency proposal.

I'm curious though, how would that translate into what's being designed for Swift. Where would the low level IO implementation live? In the executor? Would this IO level be aware of tasks and wether they are canceled or not? Would this IO layer be able to throw a cancelation error?

The proposal is really not clear where else cancelation errors could be thrown besides manual checking. It would be nice to clarify all the places where cancelation errors would occur, even if they are to occur in lower level features out of the scope of structured concurrency.

While this works, I might not want to cancel the task that is running the group, just the group. I know the example below creates a detached task to do just that, but then I lose structured concurrency. What if I also want to cancel the group if the task which is running the group is canceled? How would I propagate that?

Like I said in that big post, I still have some questions/suggestions. Specific to this API:

extension Task.Group { 
  /// Add a child task to the group.
  ///
  /// Returns true if the task was successfully added, false otherwise.
  mutating func add(
      overridingPriority: Priority? = nil,
      operation: @concurrent @escaping () async throws -> TaskResult
  ) async -> Bool
}

Instead of returning a Bool it could return an optional wrapping a type that can be used to cancel the child task from outside.

 extension Task.Group { 
  /// Add a child task to the group.
  ///
  /// Returns a cancelable if the task was successfully added, nil otherwise.
  mutating func add(
      overridingPriority: Priority? = nil,
      operation: @concurrent @escaping () async throws -> TaskResult
  ) async -> Cancelable?
}

Cancelable is a placeholder here, but could be that creating a protocol to serve as an opaque cancelable is useful for other things. I think this would solve the problem by allowing cancelation from outside without needing to resort to detached tasks and still maintaing structured concurrency and the following.

Also about the following quote.

Why do you say this is out of the scope for structured concurrency? Is it because the solution you imagine can be built on top of it or is it because it has to, conceptually, in your view? I personally think that "allowing cancelation from outside" does fit structured concurrency, conceptually.

While such a thing is appealing, I'm personally skeptical that it will ever be practically possible/useful in a Swift-like language. Such "effect" markers have been proposed for other things, e.g. side-effect-free or doesn't-touch-global-variables. The problem is that these markers become pervasive through the codebase, and such things have large-scale impact on software engineering - e.g. what evolution is possible of an API, so we have to be very careful about them.

We now have two effects in Swift (throws and async), so it isn't /impossible/. The payoff just needs to be carefully considered and the tradeoffs balanced, and I'm personally skeptical that it will make the cut.

-Chris

4 Likes

I’ve been hesitating to bring this up, but I worry that not having a “detached group”, with a carefully considered relationship to structured concurrency, undermines the whole model.

If we approach SC from the “go statement considered harmful” perspective, runDetached is a go statement which ideally shouldn’t be used, or even exposed. Nevertheless, there are many obvious cases where lexically scoped asynchrony is insufficient, especially in interactive applications: tasks need to be associated with the lifetimes of documents, windows, and pages in browser-like apps, and potentially with service-like objects such as hardware integrations and network services.

As far as I can see, such tasks will need to be detached in the proposed design, unless event loop frameworks are updated to somehow associate a lexical scope with each “lifetimed object”, whatever that is. Retrofitting this kind of design seems difficult given the basic uncomposability of event loop systems.

A “detached group”, or the ability to associate a task with a “lifecycle” object, seems like a natural solution to these kinds of situations, and it isn’t obvious to me that “detached tasks” and “detached groups” both need to exist.

3 Likes

About cancelation errors for IO operations:

About canceling a child task:

@ktoso can you, please, address the two topics I mentioned above? I know you must be very busy, and I don't want to be annoying. Just checking in case you meant to reply later and forgot or something. I appreciate your hard, amazing, work. I just would love to get some clarification on those issues. Thanks!

That's a task handle.

Yeah returning some struct TaskGroup.Spawned { var successfully: Bool {...}; let handle: Task.Handle<> } could be doable. I'd need to some input from the core team about that though.

We had iterations of this API that included group.spawnWithHandle we can add it again very easily.


I played around with it and quite like it, introduced TaskGroup.Spawned and gave a shoutout to Paulo: Structured concurrency: Updates from review #1 by ktoso · Pull Request #1311 · apple/swift-evolution · GitHub

If Spawned.succeed matches the nil-ness of Spawned.handle. Maybe we can just return Task.Handle?, and have nil means failure.

I don't think imbuing a lot of meanings onto nil-ness without spelling it out is a good pattern, it's almost as bad as magic values sprinkled around in the code.

Returning a Spawned has: no more runtime cost than the optional; allows us to spell out if/while spawned.successfully and also offers space for future API evolution; Locking the return type into just an optional does not.

I have spent some time with Philippe of Combine designing such abstraction, and as I said here: we agree it is useful. It is not structured though, adding tasks "from the side" without any bound on having to await them, as that is the purpose of such thing, is not really the structured "task group / nursery"-style structured concurrency that this proposal is focused on.

And do keep in mind that you can cancel a group from the outside - by cancelling the task it runs in. So this is about adding tasks to some "container", not just cancelling from the outside.

What I'm saying saying is that this is not something small enough that we'll be able to just tag onto this in-flight proposal but will likely be another new proposal, adding to the set of existing concurrency primitives. That's at least my personal read on it.

1 Like

But returning nil upon failure is exactly a Swift pattern... And if we extend any of it's capabilities, it's more likely to be added to Task.handle.

EDIT: correct links

2 Likes

Awesome! Thank you. About throwing cancelation errors when doing IO; I know that it sits in a level below structured concurrency, but it is nevertheless related to the concept of structured concurrency. As far as I understand, most iOS or macOS apps are IO bound not CPU bound. The proposal only mentions mechanisms for cancelation checking and throwing for compute intensive operations with yield(), which only a few people will ever use. On the other hand, IO operations will be where most of cancelation errors will be thrown and this is not mentioned at all in the proposal. I think it makes the whole concept of structured concurrency a bit more difficult to understand as, IMO, that's where it really shines.

This is all related to deadlines. It's not clear to me where deadlines fit. As far as I remember, deadline APIs are not mentioned in the proposal, but I think I've seen something like task.deadline or Task.deadline somewhere. Even if deadlines and IO is out of scope for structured concurrency, I think it should be properly mentioned in the proposal. I also think that the proposal should mention in which proposal deadlines and IO will be properly presented and discussed.

group as AsyncSequence doesn't feel exactly useful. There's a recent pitch about end-of-iteration behaviour of AsyncSequence. If we ended up adopting this, this means we can do for ... in group only once at the end. If we want to iterate through the group multiple times, we need to fallback to group.nextResult.

What if we use group.currentResults() for AsyncSequence instead? See below.


On a more holistic look on spawn vs add, looking at the PR:

  • I think it's a shame that we remove back-pressure, it could be useful when scheduling a lot of subtasks. Though it's not necessary since we can achieve it manually:
    for ... in 0..<10 {
      group.spawn(...)
    }
    for result in group {
      // handle `result`
      group.spawn(...)
    }
    
  • group.spawn and group.nextResult feels like they're at odd with each other. Task.Spawned exactly identifies one particular handler for one specific spawn, while nextResult anonymizes the origin. If, for example, I can't cancel one particular Spawn because it's already completed, I have no idea which one in the group.nextResult is the corresponding result.

The mesh of proposals is interconnected enought already... :wink:

We are very interested in deadlines and have made some initial work towards them however they are out scope in the near term. I don’t think it changes much in terms of evaluation of this proposal that in future work includes deadlines. I can add a mention in Future Work, but it shouldn’t impact the review of the core primitives here IMHO.

Deadlines are difficult because Swift has no types to express time concepts in, so we’ll have to tackle that hairy topic along with deadlines. (No, using Foundation types is not an option because of layering).

— edit: one too many negation sneaked in „not out of scope” -> „out of scope”

Nice! I guess the main point I'm trying to make is that mentioning deadlines and IO will make the motivation for structured concurrency easier to understand. Specially when placed alongside yield and the whole IO bound CPU bound dichotomy.

No, that is not true.

You are misinterpreting the linked proposal. It specifically discusses the AsyncIteratorProtocol type.

Each for await ... in ... gets a new AsyncIterator and as such „ends once” is perfectly natural and normal. In fact, with you cannot even observe the weird behavior the proposal is aiming to fix with async for loop — you don’t have the iterator in hand to iterate over it multiple times — you would only be able to do so if you manually grabbed the iterator and manually invoked iteration functions on it.

You are absolutely correct :woman_facepalming:.

I still think returning nil on failure is perfectly fine and a recommended Swift way for simple domain error, though. :stuck_out_tongue:

1 Like

Review Conclusion

The period for this review has ended. In response to feedback during the first review, the proposal authors have revised the proposal and a second review will start shortly. Please direct further comments there.

Thanks!
Ben

3 Likes

Apologies, there was a slight hiccup in launching the second review, as the proposal just has some last-minute revisions. I'll post back here once it kicks back off.

6 Likes

Second review thread has now been kicked off.

1 Like
Terms of Service

Privacy Policy

Cookie Policy