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
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:
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
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.
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.
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.
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.
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.
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.)
Or "schedule", like the Combine.Scheduler
APIs?
Or "subtask"?
Sorry, I didn't connect my dots very well. I'm asking two questions:
Can we get confirmation that an isolated async
function in an actor can be await
ed "inside" the same actor instance in the obvious way? The Actors proposal seems to go out of its way to avoid mentioning this case.
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()
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:
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.
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.
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?