Initiating asynchronous work from synchronous code

Okay, if the only differences are the return type and that async makes some things implicit which asyncDetached requires to be explicit, then it seems clear that these are “really” (conceptually) the same function.

One of them provides knobs and hooks to customize the behavior, and the other is streamlined for ease of use in the common case.

It is well-precedented in Swift that multiple functions with the same purpose but different options should share the same name, and I think that applies here too.

In the common case where the details are implicit, we want people to write simply:

async {
  ...
}

And in the advanced case where more things need to be explicit, the name of the call should not change, just the details should be added:

let myTask = async(priority: .userInitiated) { @MainActor in
  ...
}

This is very similar to the way closures already allow extraneous details to be omitted, while also permitting those details to be spelled out explicitly when desired.

Extending that same idea to async tasks will make the programming model easier to learn and use, by matching expectations from existing Swift precedent and simplifying the surface area of the new feature.

2 Likes

I'm sorry but I'lll have to disagree on that.

You would not want "the same" function to suddenly type check differently because it had some some extra parameter passed in saying "yeah, do inherit actor context after all".

These type check property:

actor A { 
  func a() { }
  func b() { 
    async { // actor context is inherited, i.e. this closure is isolated to the same actor
      await something()
      // implicitly hops back to the self actor
      a() // on the specific self instance of the A actor
    }
  }
}

While the same "shape" should not type check correctly with a detached task, because it is concurrent and not isolated to the creating actor:

actor A { 
  func a() { }
  func b() { 
    detach { // this executes in an independent task, on global concurrent executor
      await something()
      a() // error: cannot call actor-isolated function; function is async, insert await here
      await self.a() // this is ok, and is an explicit hop back
    }
  }
}

Also with regards to task-locals; there initially was a request from instrumentation teams and the server community to allow tracing through a detached task. We since came to the realization that's the wrong question to ask, as one wants not only the locals, but also priority, and the executor, and potentially more things yet to be defined.

Would we have to add a configuration option for every single of those? Or should we bake in the right pattern for people to trust and follow. Leaving detaching to be just that, a detached independent task.

Therefore, I do not agree these two should be spelled the same way.

1 Like

You're half right, some of the distinction is due to contextual inference magic at compile time, but some of the proposed semantics are additionally in fact down to a "different function"'s runtime behavior. async and asyncDetached differ at runtime in that the former will propagate most of the same task context that a child task would, including task locals, priority, and such, whereas asyncDetached creates a whole new independent task that does not inherit anything at all. At compile time, the call site context influences the passed-in closure to async to additionally inherit the actor context of the outer scope as well, so that the closure's implementation hops back to that actor between cross-actor calls.

4 Likes

This is a really good question. I’m not sure I’m sold on the @detached await syntax proposal that follows, but it’s a really good question.

1 Like

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] Update for 3rd review 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