[Pitch #3] Async Let

I can't help but notice we're missing a term for those do-not-say-attached async{} tasks. Could we call them "related" tasks?

Edit: perhaps a better idea:

  • scoped tasks
  • unscoped tasks
  • detached tasks
1 Like

Here is quite a different approach to express structured concurrency. So, instead of:

func makeDinner() async throws -> Meal {
  async let veggies = chopVegetables()
  async let meat = marinateMeat()
  async let oven = preheatOven(temperature: 350)

  let dish = Dish(ingredients: await [try veggies, meat])
  return try await oven.cook(dish, duration: .hours(3))
}

one would write something like this:

func makeDinner() async throws -> Meal {
  let veggies: [Vegetable]
  let meat: Meat
  let oven: Oven

  parallel {
    spawn { veggies = chopVegetables() }
    spawn { meat = marinateMeat() }
    spawn { oven = preheatOven(temperature: 350) }
  }

  let dish = Dish(ingredients: [veggies, meat])
  return try await oven.cook(dish, duration: .hours(3))
}

The advantage is that you directly see the parallel and (implicit) sequential code sections.
Also, tasks without return value don't require a construct like async let _ = someProc().

parallel also indicates quite well that tasks are worked on in parallel and are not just interleaved on the same execution context.

In addition, you could mark some of the parallel tasks as weak so that they are implicitly canceled when the other (strong) parallel tasks have finished:

func makeDinner() async throws -> Meal {
  let veggies: [Vegetable]
  let meat: Meat
  let oven: Oven

  parallel {
    spawn { veggies = chopVegetables() }
    spawn { meat = marinateMeat() }
    spawn { oven = preheatOven(temperature: 350) }
    spawn(cancelOnGroupEnd: true) { playMusic() }
  }

  let dish = Dish(ingredients: [veggies, meat])
  return try await oven.cook(dish, duration: .hours(3))
}```
--Marc
1 Like

To me this looks like a renaming of withTaskGroup?

While looking nice, I'm not really fond of that syntax, as it looks like the assignments are taking place in another task/thread, which is a data race. E.g. it looks like you could do something like this

spawn { sleep(seconds: 5); meat = marinateMeat() }

which would seem wrong.

2 Likes

And throwing child tasks don't cancel their parent task unless explicitly overwritten?

Cancellation signals flow “down”, not up.

If a throw (up) of a child causes a parent task to also exit early it would complete as well. If exits a scope by throwing, that’s the same as ending it’s scope and that cancels all other child tasks (down) it had and awaits them.

So in a way it’s similar to supervision trees from other actor runtimes, just that we have it on a task level.

That’s basically the same as returning or throwing out of a function.

1 Like

I'm confused?
I thought the parent task cancel all its other child tasks then and cancel itself or bubble up the thrown Exception from the throwing child task unless specified otherwise (e.g. over a catch), I'm unsure.

Agree.

The parent won't know if a child task cancels unless it specifically waits for it, e.g., via group.next(). Even then, the parent can choose to drop the error or handle it in some other way than to simply cancel together with the child.

1 Like

This is a good point. Are children “listening” implicitly if a parent is cancelled so they cancel too or are parents canceling all children before they cancel themselves?

I kinda miss the cancel token workflow from other languages since that allows me to cancel children and parents together. In that model all the task are “listening” for the cancel signal.

After the pushback we’re seeing in this thread from @Douglas_Gregor and @ktoso, I’m trying to switch my point of view, to evaluate — and see if I can embrace — their apparently exclusively global-actor-based conceptualization of serial-execution contexts.

In that regard, I’m confused on a couple of points, where I can’t find adequate clarification in the proposals. It may be there, but I can’t find it.

Consider an actor:

actor MyActor {
	func dataFromNetwork() async -> Data { … }
	func doStuff() async {
		let data = await dataFromNetwork()
		…
	}
}

I can’t find anything in the Actors proposal that’s very explicit about this, but I assume that dataFromNetwork — apart from its asynchronous portions, such as making a network request, that might run on an execution context outside the actor — will execute its synchronous portions and returns its result in MyActor’s execution context. Is that correct?

Now let’s change doStuff like this:

	func doStuff() async {
		async let data = await dataFromNetwork()
		…
	}

According to my reading of the Async Let proposal, this would produce a compilation error, because it would be attempt to invoke an isolated function (dataFromNetwork) from a non-isolated closure. The proposal says:

The initializer of the async let can be thought of as a closure that runs the code contained within it in a separate task [… and …] the closure is @Sendable and nonisolated.
Is that the intended behavior inside an actor? Or does `dataFromNetwork` actually begin and end running in the MyActor execution context in this scenario?

If the answer to that question is “yes”, I think I can embrace this approach, even though I’m not thrilled with the idea that actors are the only way to manage Swift concurrency in serial-execution contexts.

(In contrast, the basic structured concurrency proposals have a rich-enough set of concepts and APIs to manage Swift concurrency in concurrent-execution contexts, aside from any considerations of actors.)

Yes. But talking about synchronous and asynchronous portions seems a bit confusing. Basically the system makes sure that all actor code is run on the corresponding actor's execution context.

As far as I understand this, the system will just switch to the actor's execution context. You will only get a compilation error if you try to access non-Sendable data, as that is not safe to do from a different thread. I think the nonisolated part just tells you that this doesn't start out unconditionally "locking" the actor context.

That's my understanding, yes.

1 Like

The Actors proposal talks about what happens to synchronous isolated functions inside the actor. This is in the section " Actor isolation checking":

[The bolded words are my emphasis.] The first paragraph is talking explicitly about non-async functions. The second paragraph seems to be talking about all functions, but the last sentence says "synchronous operation".

I'm looking for confirmation of the behavior when the operation is asynchronous. In that case, some of the operation may go outside the actor, but the actor-related parts run in the actor context, I assume.

Well, I hope so, but the actual text (as quoted earlier) says the exact opposite. :slightly_smiling_face:

I'm not sure why this says "synchronous operation". But I don't see how this says the exact opposite of switching to the actor's execution context.

The gist of the matter is that async code can safely use any actor, and the system will just do the right thing. Synchronous code, on the other hand, is not able to jump / suspend so it has to already be isolated to the right actor if it needs to use any (unless it uses async() or asyncDetached(), of course).

This is a very helpful North Star for thinking through a lot of this: when thinking about concurrent execution context needs, prefer letting the callee specify instead of the caller. I find that clears a lot of fog around this proposal for me. Thanks, Doug.

(Still wondering about my assorted questions above.)

2 Likes

Or "schedule", like the Combine.Scheduler APIs?

Or "subtask"?

2 Likes

Sorry, I didn't connect my dots very well. I'm asking two questions:

  1. Can we get confirmation that an isolated async function in an actor can be awaited "inside" the same actor instance in the obvious way? The Actors proposal seems to go out of its way to avoid mentioning this case.

  2. Can we get clarification about the behavior of async let when initialized to an isolated async function inside the same actor instance? The Async Let proposal seems to say explicitly that this won't even compile.

I like ‘subtask’. Perhaps we could also use:

subtask let veggies = chopVegetables()
1 Like

I think the notion of a task bounded to the scope of its parent is important enough to be put scope in the name. If I were to use the word "subtask" in the a task classification, it'd be here:

  • subtask, one of:
    • scoped subtask
    • unscoped subtask
  • detached task

But that's just my own perception of what the words should mean.

While I appreciate the simplicity of this approach, I agree with others that there is a downside in that the similarity of names masks important differences. I also agree, and tried to state this inelegantly in the structured concurrency thread, that Child Task is a poor term of art for structured tasks. To me, the word child itself (not as a term of art) denotes a creator/created relationship which all tasks have, or an inheritance relationship which the new async{} has. I find it inherently confusing to use the word “child” for narrowly defined structured tasks only.

I’ve heard in this thread:
Scoped Tasks
Structured Tasks
Subtasks

I like them all and think this proposal and the structured concurrency proposal would both be clearer if Child Task was replaced with one of the above.

3 Likes

Indeed…’child’ is a poor metaphor for the hierarchical structure here given that in the real world we generally hope and expect that children outlive their parents.

3 Likes

unless the proposed async let somehow doesn't work with this as a future direction.

Currently I don't see an obvious way to extend async let to non-escaping futures in an additive way. The two are pretty different approaches. Do you have any ideas on how that might work?