Initiating asynchronous work from synchronous code

This is an important bit of functionality needed to complete the concurrency model. One thing I'm a bit nervous about is the implicit-self semantics. We already live in a world where self is implicit if it is explicitly captured by the closure or if self is a struct or enum, and now we would be adding "if the closure is the argument to the async function". It feels uncharacteristic of Swift to have such special-cased behavior. It feels like we should come up with a more formal semantic for when self is implicit, and until we do so explicitly capturing self { [self] in … } seems like a small price to pay.
If this truly is a special case, it feels to me to have more in common with a do block than a closure (this was mentioned upthread). Something like do async { … } might avoid questions like "how do I get my API to capture self implicitly?" (and has some nice symmetry with func foo() async). If we stay in closure-land we should think about the core reason why implicitly capturing self is OK here, and replace the special-case semantics with a verbose-but-explicit attribute on the closure. Is it because the closure has a guaranteed bounded lifetime, even though this could cause self to outlive the call to async? Maybe @guaranteedBoundedLifetime, though likely we can come up with something better.
Finally, I don't feel like naming is the best way to distinguish the detached and non-detached versions. Something like async(priority: AsyncPriority = .implicit, detachFromCurrentTask: Bool = false, operation: …) feels a but more Swifty to me.

3 Likes

This seems to fix @Douglas_Gregor ’s earlier objection to having something like async (previously proposed as asynchronous) because it avoids the problem of there being in-async and below-async code paths SE-0296: async/await - #89 by Douglas_Gregor

Omitting the handle precludes cancellation, though. I'd rather keep the handle, if only to retain possibility of cancellation.

2 Likes

async and asyncDetached looks good, which represent context-aware-local-attached and context-independent-global-detached async code launch from sync context.

It'd be better they could be merged into async overloads, async {} /*attached*/ and async(...) {} /*detached*/, maybe need some compiler magic to help.

I don't think I get the suggestions to merge async() and detach() into one method. If we did that, and you e.g. specified a priority, then you'd suddenly jump into the semantics of detach() and also lose the actor context etc.? That would be pretty bad, as I see it.

1 Like

They have much the same purpose and semantics, yes.

No, there is no equivalent. If you need to update application state, do so by having your task hop over to the main actor (covered by the global actors pitch) to perform the updates in a coordinated way.

That's actually the way they are implemented, as two hidden parameter attributes, and I thought it would be more confusing to present them this way and I care more about the overall semantics of async. The two hidden attributes are:

  • @_inheritsActorContext: when a closure argument is provided to a parameter that has this attribute, the closure inherits the actor context in which the closure is created.
  • @_implicitSelfCapture: self can be implicitly captured without requiring self..

We could do these attributes as separate things, now or some time in the future.

No, the closure just ends up being non-isolated.

We went pretty far down this design path. The problem with it is that there may be multiple awaits, and it isn't clear which one is the one that performs the detach:

@MainActor func saveResults() {
  view.startSavingSpinner()                     // executes on the main actor, immediately
  if somethingChanged {
    await ioActor.save()                // hop to ioActor to save
  }
  if let backupServer = backupServer {
    await backupServer.save()                // hop to backupServer to save
  }
  view.stopSavingSpinner()                      // back on main actor to update UI
}

Which of those two awaits should be marked with @detached? The first one? The second? Both? We might not even detach ever, depending on those if conditions.

(Side note: it's also not actually a "detach", because we're inheriting priority, task locals, etc.)

asyncDetached cannot necessarily express all of the things that async does. For example, it's not easy to capture all of the task-local values in the currently-executing task to copy them over, and there are things the OS might want to propagate that through async that won't be expressed in the signature of asyncDetached.

I don't think we can collapse async and asyncDetached into a single function. They are different functions that share a root, async, because they are both initiating asynchronous code.

The requirement to write self. when self has been captured in a closure has a specific purpose: it is to indicate that the developer might need to think about reference cycles. Where it is not likely to be indicative of a reference cycle, the self. requirement creates visual noise, and that noise de-values the usefulness of self. to indicate potential reference cycles. We've been chipping away at the self. requirement over the years in places where reference cycles are unlikely, and this is following that precedent. It's unlikely that we'll get a reference cycle from a closure that is executed and is only held on to by that task.

This is a reasonable point. We could return a Task.Handle<Void, Never>, which would allow cancellation but would not communicate any information back on get() that "it's done."

Doug

7 Likes

That sounds good to me, yeah :+1:

2 Likes

Or Task.Handle<Never, Never>, if we don't want to allow get() at all but still want to allow for manual cancellation.

6 Likes

Cool. It might be a good idea to add a Future Directions about making these supported features.

I’m comfortable saying that you need to mark both, the second @detached await is effectively a normal await if they both execute, and the fact that we might not ever detach is more of a feature than a bug. But I don’t know enough about the implementation to know if that just casually makes life horrible for the compiler.

(Weird third alternative: Something like async return x can appear in the middle of a function body, meaning “return x to the caller here, but run the rest of this function body semi-detached.”)

I had to go back and find the definition of detached tasks in the Structured Concurrency proposal to see what you mean, but now I do.

It seems to me that what you’re proposing is a third kind of task, one which inherits many parts of the parent task (like a child task) but has unscoped execution (like a detached task). Rather than introducing a very limited third task type, it makes me wonder if detached tasks should inherit more by default but have affordances to turn it off. For instance, maybe they should inherit priority unless overridden, and actors should have a nonisolated detach method that runs the closure on the actor. (For clarity, the current global detach function might become a static method on Task.)

2 Likes

Oh, I forgot to mention the other thing with marking the await rather than having something like the proposed async. Each await that is potentially @detached also acts as an implicit return from the synchronous function, returning the flow of control to the caller. The implicit return goes against Swift's precedent of trying to mark all of the control flow, which was recently re-affirmed with (e.g.) preventing defer from having asynchronous calls in it because that would introduce implicit control flow.

This also means that @detached await would only be permitted in functions that (1) have a Void return type and (2) are non-throwing.

Doug

1 Like

I'm not sure I fully understand why detached needs a prefix. It's a pretty absolute word. I'd be happy if it was left alone.

But if it needs to be specialized I think async should also be specialized to asyncLocal or something like that. Just a nit pick.

+1 for the addition of async as a feature.

Good morning everyone,
the core team has asked we pull this feature into the structured concurrency proposal and run another review there. In a way, this feature is closer to structured concurrency than detach itself which was part of it to begin with :slight_smile:

I'll work on merging the two texts and we'll be able to give this a proper review as part of that proposal shortly.

13 Likes

Yet another muddled question as the work of folding this into the structured concurrency proposal moves along:

Muddled question part 1:

The concurrency roadmap’s glossary says, “All asynchronous functions run as part of some task.” What task does the body of an async { } run as part of?

Does async { } create a child task? I think it does not: the proposal introduces it as sugar for the most common use cases of the previously proposed detach { }, and nothing I can see in the proposal says that async { } bounds the execution of its containing task.

Then does async { } create a new detached task? It must, but it seems from the name that async { } would be non-detached in contrast to asyncDetached { }….

Muddled question part 2:

Assuming that async { } does indeed create a detached task, and creating a child task is thus not the most common case that deserved the simplest sugar, then…why not? For instance, the pitch’s motivating example of saveResults seems like it ought to spawn a child task; surely we want any containing task not to indicate completion until we’ve actually saved those results?

(Apologies if this is painfully obvious or already addressed; I haven’t kept up with all the concurrency proposals to date.)

3 Likes

You can check the new wording proposed in the update to the structured concurrency proposal here: [Struct Concurrency] add async{}, remove Task.current by ktoso · Pull Request #1345 · apple/swift-evolution · GitHub

Indeed it would be useful to have some name for the task created by async{} in the proposal I just called it an “async task” vs a “detached task”. This is pretty true, it creates a task (internally known as AsyncTask), which is neither a child task, nor a detached task, so calling it by the plain old async task sounds good to me.

It cannot be a child task–by construction–because we would not be able to allocate it using the task allocator nor can we force it to end before the task from which it was created ends — because we’re potentially in a synchronous function and thus cannot await on it.

2 Likes

Ah! That does clarify. Seeing your nice updated text in the context of the larger proposal is quite helpful.

The name is not perfectly satisfying. (Aren’t all tasks asynchronous?)

Can you elaborate on that? Is it simply because there is no globally available notion of “current task?” Or is it something more subtle?

It seems in my naiveté that this isn’t strictly true: could a synchronous function not receive a taskGroup parameter, use taskGroup.spawn to create the child-that-is-truly-a-child task, but then exit immediately without awaiting?

I think this is a feature for the awkward situation where you would normally create a child task, but for some technical reason your caller needs you to be non-async, and your caller doesn’t actually need to wait for the task to finish. For example, an @IBAction can’t be async (AFAIK), but AppKit/UIKit doesn’t actually want to wait for the whole operation to finish—it just wants you to start it and return so it can process the next event.

Maybe something like withoutActuallyAwaiting(_:) would be a good name for this function.

2 Likes

There's two reasons:

  • no parent: since we're in a synchronous context, there may or may not be a task around to become a child of; so we can't just say this is a child task
  • lifetime: even if there was a parent to attach to, we'd by design by violting structured concurrency guarantees here. the purpose of this function is to not wait which violates parent/child task guarantees. The async created task can out-live the task from which it was created.

The lifetime issue also manifests in making some optimizations impossible, but that's secondary reasons.

We can't do that, as it would violate structured concurrency guarantees. The group must wait on all spawned children before it exits. This is why the group forms a scope: withTaskGroup { group ... } and it is not legal to pass around or escape the group.

Hah yeah that's a pretty lengthy but somewhat precise name for it... We found though that the use of this function is frequent so we would argue for a short nice name for it.

Though it also can return a Task.Handle which one could await on (in the revised structured concurrency proposal linked above).

I find this quite surprising! Not escaping, yes, sure, but not passing around?

Suppose I have this somewhat redundant task creation code:

func loadGameAssets() async throws -> GameAssets {
  try await withTaskGroup(of: GameAssets.self) { group in 

    for spriteURL in contentsOfDirectory("sprite") {
      group.spawn { 
        try await Sprite(url: spriteURL)
      }
    }
    for soundURL in contentsOfDirectory("sound") {
      group.spawn { 
        try await Sound(url: soundURL)
      }
    }
    for tileMapURL in contentsOfDirectory("tileMaps") {
      group.spawn { 
        try await TileMap(url: tileMap)
      }
    }

    // ...etc...
  }
}

Wouldn’t it be both desirable and well-formed to refactor it along these lines?

func loadGameAssets() async throws -> GameAssets {
  try await withTaskGroup(of: GameAssets.self) { group in
    try loadAssets(with: group, from: "sprites", using: Sprite.init)
    try loadAssets(with: group, from: "sounds", using: Sound.init)
    try loadAssets(with: group, from: "tileMaps", using: TileMap.init)

    // ...etc...
  }
}

func loadAssets(
  with group: TaskGroup,
  from assetDir: String,
  using loader: (URL) async throws -> Asset
) {
  for spriteURL in contentsOfDirectory(assetDir) {
    group.spawn {
      try await loader(spriteURL)
    }
  }
}

IIRC, Python’s very similar Trio library not only supports but encourages this pattern.


Re the terminology:

Too long, yes, but Becca’s name suggestion was genuinely helpful for me!

I’ll just mention doLater again. I like the word “later” in this context.

Certainly some term of art that gives one a heuristic toehold here would be nice. Ideally, I’d want some term that:

  1. prompts good guesses and good questions about the meaning the first time one sees it in code,
  2. is web-searchable (async alone means something else already), and
  3. makes it convenient to have conversations about the construct.

What you wrote is fine, I wasn’t very precise in my wording there.

By passing around I should have been more specific and specifically mean escaping / storing it somewhere beyond the lifetime of the withTaskGroup.

1 Like

So we came up with async / await to get away from the pyramid of blocks and now want to return to it?

Terms of Service

Privacy Policy

Cookie Policy