[Pitch #2] Structured Concurrency

I still haven't got an answer to that question. I find it strange that group.add requires an await but async let (which is supposed to be sugar over it) doesn't.

Yes, I set up a complex scenario intentionally because I’m trying to understand the behavior of cancellation handlers.

So the cancellation handler does not is not executed by the executor of the task that registered it, right? Is the expectation that a cancellation handler be thread safe then? If so, should the method be called withUnsafeCancellationHandler? I think this detail could surprise a lot of people the way it is written right now.

I see handler: /* @concurrent */ () -> Void in the signature in the proposal. I think I understand why /* @concurrent */ is included now but it is not explained clearly.

Also, can you please update the proposal to provide an example of intended usage of cancellation handlers? This seems like an important topic that deserves at least one example.

Finally, I will ask again for more explanation of the behavior of non-exclusive executors.

There's no API for this. A function "knowing" its executor means that it can hop back to that executor after (e.g.) calling out to another async function that might change executors (say, because it runs on an actor).

We haven't exposed the ability to provide a custom executor beyond the enqueue operation of actors. Some day, probably, but I don't expect it will be part of this round of proposals.

I see your point here, but highest, lowest, etc. are almost entirely devoid of meaning. Having the UI metaphor at least gives some sense of when to use the priorities, even if it's not exactly the domain.

The actor's enqueue is the only mechanism was have for this. Custom executors don't have API yet.

Doug

Ok, so task group & async let does await for their child tasks (subtasks) before exiting the scope (as it should, to be structural). It's actually pretty clear from the draft, and got repeated a few time. It just somehow went over my head during the reading...

One more question, it does seem that the task groups (and async let) cancels all of their subtasks only when they exit the scope by throwing an error. Is there a reason we don't want to cancel if the scope exits normally (by reaching the end or using return)?

I don't want the nuance of why the scope ends to differentiate the cancellation behaviour, especially when "error reporting" doesn't limit to throwing errors, but also returning Bool or Optional.

Thanks! AIUI Swift will need to add a new version of withoutActuallyEscaping to handle async closures, or the existing implementation needs to add the proposed reasync feature.

I guess it's explicitly mentioned in the Detailed design section:

The answer is: await is implicit when using async let and it isn't when using group.add (at the moment at least).

Note that group.add has two awaits: one before group.add, and one inside the closure:

await group.add { 
   DinnerChild.chopVegetables(await chopVegetables())
}

Both awaits are made implicit by async let.

It's fine to omit the one in the closure since you have to await on it anyway when referencing the variable (as stated in the text you quoted). But the await before group.add would introduce a suspension point on the async let declaration line, which I think will surprise most people when using async let:

async let x = somethingAsync()
// suspension point here
async let y = somethingAsync()
// suspension point here
async let z = somethingAsync()
// suspension point here
...
await (x, y, z)
// suspension point here, obvious because of `await`

It's clever that group.add can introduce back-pressure by suspending the caller in the middle of adding a subtask. But most people won't expect a suspension point to exist on the async let declaration line. The await should be made explicit to make that clear.

And you can't make this explicit by adding an await after the "=" since it'll appear to be part of the expression executed concurrently on the other thread/queue/executor. There is not much choice:

await async let x = somethingAsync()

A bit verbose, but at least now it's clear that the function can be suspended on this line.

Okay, so in this piece of code

func foo() async -> Void {
    // [A]: running on executor X
    await bar()
    // [B]: which executor am I on now?
}

are you saying that [A] and [B] are always executed on the same executor? My understanding was that this isn't always the case.

Hmm, so how would I implement a thread pool that has N threads (N >= 1 and decided at runtime) giving me N executors?

It's also very surprising to me that the proposal reads "For the most part, programmers should not have to work directly with partial tasks unless they are implementing a custom executor." but it's not actually possible to implement a custom executor from what I understand.

I'm very open to different naming suggestions. But how is it obvious for example that utility is a higher priority than background? Do you think that if somebody had asked you, say 10 years ago, to order default, utility,userInteractive, userInitiated, and background by priority you'd have come up with the "right" answer? I would've probably guessed that the user* ones are higher than default and that utility/background are lower but apart from that I don't think I would've necessarily picked the "correct" order.

8 Likes

You might have created those child tasks for their effects. An earlier draft of the proposal actually had an example of this (create child tasks to go confirm a bunch of orders; there’s no value to return on success); perhaps I should bring it back.

Doug

The difference between a return and a thrown error is quite significant in Swift; cancelling on thrown error but not a normal return matches well with the intended use of error handling.

Doug

1 Like

[Nit]: The link to the discussion in the draft still points to the old discussion [Concurrency] Structured concurrency which is not wrong per se but I think a link to this discussion here should be added.

I don’t think we want “always” here, no. If foo were within an actor we guarantee that we’d jump back to the actor.

We should strike this sentence until we’re ready to define custom executors. Even if we do custom executors now, it would be a different proposal.

The names don’t have to be perfectly self-evident, but communicating some meaning behind the intent in the range of “a human is waiting on this” vs. “do this when you have idle cycles” seems beneficial. If we’re to only consider the ordering without meaning, then we might as well have an Int rather than an enum.

Doug

The intent of the proposal is that the async let does not introduce a suspension point, for the reasons you’ve described. This is a difference in the desugarinf that we forgot to mention in the desugaring example. I’ll update it.

Doug

1 Like

I see, so the asynchrony (and concurrency) model is meant to be used with effects, not just for calculation. That part of the decision has been eluding me for quite some time.

I still have some uneasy feelings about some actor optimization like combining partial tasks of the same executor or skipping an empty partial task if we're going for effectful programming. Though I'll need to think about it some more (and it's irrelevant here anyway).

But while significant, throwing errors isn't the only way in Swift to propagate errors. Even TaskGroup.add, like many Cocoa imports, returns Bool to indicate its success/failure. Not to mention failable initializers and other Cocoa imports that return nil to indicate failure.

Plus, opting-out of cancellation is easier, too:

// Opt-out of cancellation
Task.withGroup {
  while await group.get() { }
}

do {
  async let foo = _

  _ = await foo
}

Opting-in for child task requires you to throw a dummy error regardless of the success/failure condition:

// Opt-in for cancellation
Task.withGroup {
  group.cancelAll()
}

try {
  do {
    async let foo = _

    throw CancellationError()
  }
} catch {
  // Might actually succeed, but we want
  // to cancel long-running unused tasks.
}
1 Like

Ok, thanks. I think this should definitely be spelled out explicitly in the proposal because I think this matters a lot.

I think we absolutely need the ability to create custom executors from the get go but I do agree that the sentence should be removed from this proposal. And if we were to actually punt on custom executors, this should be spelled out very explicitly too. For the evolution process to work properly, I do think that we should be as explicit as possible so that people really understand what'll be possible and what not.

Without custom executors, is it correct that I can't make async functions run on a specific OS thread (pthread_t or similar)? Because I think this ability is crucial.

Just like UIKit and friends will need to run the UI code on the main thread, other systems will need to run their code on specific threads too. And I don't think we can get away with mandating 2 thread hops (to and from the specific OS thread) for every async function if its implementation requires going to a specific thread. For server systems that would for example mean sacrificing a lot of performance for async/await which would be more than sad.

Am I understanding correctly that in the current version of the proposal, the UIActor is special and cannot be replicated in user code (say for another UI framework that also requires the UI code to run on a specific thread)?

I'm totally on board with an Int (or an enum backed by an Int). I would still offer names like high/low etc because it's not always obvious if a higher number means higher or lower priority.

If the names aren't self-evident, I don't really see why we should have the names as the canonical way of specifying these priorities. Of course, it makes a lot of sense to also have names like userInteractive but IMHO they should map to the canonical spelling which would not hard-code the UI metaphor. If I may take the server example again, what the word user represents for example is probably quite unclear.

13 Likes
extension Task {
  static func withGroup<TaskResult, BodyResult>(
    resultType: TaskResult.Type,
    returning returnType: BodyResult.Type = BodyResult.self,
    body: (inout Task.Group<TaskResult>) async throws -> BodyResult
  ) async throws -> BodyResult
}
  • The resultType: TaskResult.Type could become elementType: Element.Type, especially if the task group will conform to AsyncSequence.

  • Why is the returning returnType parameter needed?

  • The body is @escaping in the current implementation.


extension Task.Group { 
  mutating func add(
    overridingPriority: Priority? = nil,
    operation: @escaping () async throws -> TaskResult
  ) async -> Bool
}
  • The operation: @escaping could become element: @autoclosure @escaping.

  • The return type is Task.Handle with @discardableResult in the current implementation.

Edit: My question was already answered:

Original question

I understand this differently: The implicit await is placed after the =. Can someone please confirm this?

Otherwise, this could be a very subtle bug (assuming interleaving is possible):

self.balance = 42
async let valueNeededLater = otherInstance.doSomething()
print(self.balance) // Can this be something else than 42, even if doSomething() does not touch balance?

In other words: Is there a potential suspension point between self.balance = 42 and print(self.balance)?

I think this is very subtle behavior. In my opinion, this will lead to performance bugs because people will assume that all remaining tasks are cancelled.

I would feel better if the body of the task group must either

return .afterAwaitingRemainingTasks(result)

or

return .afterCancellingRemainingTasks(result)

This would be the most explicit solution. However, I am open to other solutions, which are more expilicit than the current one.

@ktoso: I believe you wrote something about this issue in another thread.

They are cancelled. Cancellation does not itself force child tasks to immediately complete, and structured concurrency does mean that we can't just leave cancelled tasks out there to complete in their own time; thus, we have to wait for them.

So even without calling group.cancelAll() explicitly, remaining tasks are automatically cancelled when the body returns? The proposal says otherwise.

The group cancels if withGroup body throws. It won't cancel if the block exits via normal return.

The three ways in which a task group can be cancelled are:
1. When an error is thrown out of the body of withGroup ,
2. When the task in which the task group itself was created is cancelled, or
3. When the cancelAll() operation is invoked.

For child task, it's cancel if the block surrounding it throws (I think?)

Terms of Service

Privacy Policy

Cookie Policy