Initiating asynchronous work from synchronous code

Hey all,

While working with Swift Concurrency, we've found that we really need a better way to initiate asynchronous work from synchronous code. Here's a proposal to do that with an additional standard API. Ill keep the proposal up-to-date in this gist.

Motivation

Swift async functions can only directly be called from other async functions. In synchronous code, the only mechanism provided by the Swift Concurrency model to create asynchronous work is detach. The detach operation creates a new, detached task that is completely independent of the code that initiated the detach: the closure executes concurrently, is independent of any actor unless it explicitly opts into an actor, and does not inherit certain information (such as priority).

Detached tasks are important and have their place, but they don't map well to cases where the natural "flow" of control is from the synchronous function into async code, e.g., when reacting to an event triggered in a UI:

@MainActor func saveResults() {
  view.startSavingSpinner()                            // executes on the main actor, immediately
  detach(priority: .userInitiated) { @MainActor in  // task on the main actor
    await self.ioActor.save()                          // hop to ioActor to save
    self.view.stopSavingSpinner()                      // back on main actor to update UI
  }
}

The "detach" has a lot of boilerplate to get the semantics we want:

  • Explicit propagation of priority
  • Explicitly requiring that this closure run on the main actor
  • Repeated, required self. even though it's not indicating anything useful (the task keeps self alive, not some other object)

All of these are approximations of what we actually want to have happen. There might be attributes other than priority that a particular OS would want to propagate for async work that continues synchronous work (but that don't make sense in a detached task). The code specifies @MainActor explicitly here, but would rather that the actor isolation of this closure be inherited from its context.

Moreover, experience with the Swift Concurrency model has shown that the dominant use case for initiating asynchronous work from synchronous code prefers these semantics. Fully-detached tasks are necessary, but should not be the default.

Proposed Solution

We propose to introduce a new async function that addresses the above concerns and should be used when continuing the work of a synchronous function as async. It propagates both the priority and actor from where it is invoked into the closure, and suppresses the need for self.. Our example above will be rewritten as:

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

The declaration of the async function is as follows:

func async(_ body: @Sendable @escaping () async -> Void)

Priority propagation

The async operation propagates priority from the point where it is called to the detached task that it creates:

  1. If the synchronous code is running on behalf of a task (i.e., withUnsafeCurrentTask provides a non-nil task), use the priority of that task;
  2. If the synchronous code is running on behalf of the main thread, use .userInitiated; otherwise
  3. Query the system to determine the priority of the currently-executing thread and use that.

The implementation will also propagate any other important OS-specific information from the synchronous code into the asynchronous task.

Actor propagation

A closure passed to the async function will implictly inherit the actor of the context in which the closure is formed. For example:

func notOnActor(_: @Sendable () async -> Void) { }

actor A {
  func f() {
    notOnActor {
      await g() // must call g asynchronously, because it's a @Sendable closure
    }
    async {
      g() // okay to call g synchronously, even though it's @Sendable
    }
  }
  
  func g() { }
}

In a sense, async counteracts the normal influence of @Sendable on a closure within an actor. Specifically, SE-0306 states that @Sendable closure are not actor-isolated:

Actors prevent this data race by specifying that a @Sendable closure is always non-isolated.

Such semantics, where the closure is both @Sendable and actor-isolated, are only possible because the closure is also async. Effectively, when the closure is called, it will immediately "hop" over to the actor's context so that it runs within the actor.

Implicit "self"

Closures passed to async are not required to explicitly acknowledge capture of self with self..

func acceptEscaping(_: @escaping () -> Void) { }

class C {
  var counter: Int = 0
  
  func f() {
    acceptEscaping {
      counter = counter + 1   // error: must use "self." because the closure escapes
    }
    async {
      counter = counter + 1   // okay: implicit "self" is allowed here
    }
  }
}

The intent behind requiring self. when capturing self in an escaping closure is to warn the developer about potential reference cycles. The closure passed to async is executed immediately, and the only reference to self is what occurs in the body. Therefore, the explicit self. isn't communicating useful information and should not be required.

Note: A similar rationale could be applied to detach and TaskGroup.spawn. They could also benefit from this change.

Renaming detach

Experience with Swift's Concurrency model has shown that the async function proposed here is more commonly used than detach. While detach still needs to exist for truly detached tasks, it and async have very different names despite providing related behavior. We propose to rename detach to asyncDetached:

@discardableResult
func asyncDetached<T>(
  priority: Task.Priority = .unspecified,
  operation: @Sendable @escaping () async -> T
) -> Task.Handle<T, Never>

/// Create a new, detached task that produces a value of type `T` or throws an error.
@discardableResult
func asyncDetached <T>(
  priority: Task.Priority = .unspecified,
  operation: @Sendable @escaping () async throws -> T
) -> Task.Handle<T, Error>

This way, async and asyncDetached share the async prefix to initiate asynchronous code from synchronous code, and the latter (less common) operation clearly indicates how it differs from the former.

44 Likes

I’m glad to see an async function for the common case!

• • •

Regarding detach / asyncDetached, I still think it’s worth considering the idea of making it an initializer rather than a free function.

In particular, if we rename Task.Handle to simply be Task, then when someone wants a new detached task they just need to instantiate a Task:

let myDetachedTask = Task {
  ...
}

I tend to agree that an initializer on Task makes a lot of sense to me. It conveys the slightly more "heavy handed" form of creating a task. That would mean that the struct Task would need to become generic to handle the throwing and non throwing cases as well as the construction w/ the return value.

For point 1; how will any sort of blocking elevate the QoS? Can it create an override across that space? this is something that will need to be addressed eventually within async/await so we shouldn't design ourselves into a corner there.

For point 3; how can this happen w/o root level access to the QoS? Only the kernel knows the applied QoS to the current queue because of overrides etc.

To be quite clear the new func async (even though will be hard to discuss by talking about it) seems to have great behavior with regards to actors. This solves a whole set of bugs associated w/ the concurrent execution of detach and the serialization per an actor, and to iterate further - this new behavior really seems like the right choice.

Will func _runTaskForBridgedAsyncMethod be executed via func async or func detach?

Making Task generic wouldn't work so well with the various static operations we have on it. I agree that Task.Handle could work this way, but I think it would be unfortunate to lose detached from the name entirely.

The only priority-escalation mechanisms are described here.

Not the "applied QoS", it's the QoS the thread set for itself. Perhaps there is a better phrase to use here, but in Dispatch terms this is qos_class_self().

We should switch it to async.

Doug

2 Likes

I'm very happy to see this. I think the async(_:) name will be especially great for those who will be learning Swift concurrency soon. Want to start a top-level task? Just use async(_:). Want to make a function which will be running in a task and have its own suspension points? Just use async on the function.

Where I could see this naming become confusing is when wanting to "fire and forget" an async function inside of an another async function, so as to not introduce a suspension point.

func parentFunction() async {
    async {
        await fireAndForget()
    }
    // spawn let....
}

Here, the two async words have to different meanings - one indicates that the function may have a potential suspension point, while the other indicates the creation of a top-level task (while still keeping some information). That's why I enjoyed the idea of async(_:) being instead called dispatch(_:), so this would instead look like:

func parentFunction() async {
    dispatch {
        await fireAndForget()
    }
    // spawn let....
}

However, although this reads nicer in this specific use case, I think the ease of learnability and readability of async(_:) in almost all use cases, as shown in the pitch, far outweighs its disadvantages in a couple very specific situations like this one.

I've also seen @ktoso mentioning that it could instead be called send(_:). Although that's definitely much nicer than async(_:) when working with actors, I think it would feel awkward in other situations. But I do agree that actors could benefit from a more natural-feeling API when sending "fire and forget" messages. Couldn't we extend the Actor protocol like this:

extension Actor {
    nonisolated func send<T>(priority: Task.Priority = .unspecified, message: @escaping (Self) async -> T) {
        async(priority: priority) {
            _ = await message(self)
        }
    }
}

So that sending "fire and forget" messages would look like this:

myActor.send { await $0.myActorMethod() }

Although we're getting into bikeshedding, I'll put in that I don't like the initializer, because it obscures the fact that this is a hot-start task rather than a cold-start task. (That is, let task = Task { … } makes me think there'll be a task.start() later, rather than it already having been started.) The same goes for a top-level async with no verb, for that matter, though that's exacerbated for me personally because Rust uses that exact syntax for a cold-start future.

17 Likes

This is awesome :+1:

I also love the name asyncDetached(). I assume it's not detachedAsync() due to auto-completion?

The implicit self feels like a bit of a hack, but it's super neat and useful :1st_place_medal:

1 Like

Do I understand correctly that async maps to Grand Central Dispatch’s DispatchQueue.myQueue.async?

If so, is there an equivalent to DispatchQueue.myQueue.sync that I’ve missed? Although blocking is often undesired there are cases that require it, such as application state. Namely, SwiftUI’s @State, which "is safe to mutate ... from any thread", probably blocks internally.

I very much like the proposal and it is filling a large hole in the model that we had until now, thanks Doug!

Some notes:

Task-local propagation
Task-local values will also be propagated to such asynched task, and I'll implement that shortly :+1:

The only place we'd drop task-local values is when detaching


task creation

Some quick notes of things that came up and I'm not quite in love with:

  • Task {} for detaching is terrible. It really should have it spelled out that it's detaching from any context it is declared in.

  • While I don't love async {} it is fine enough and easy to talk about.

  • I'm not sure about multiple-word things for detaching... like asyncDetached, it really looks like a case of "let's make it so super terrible looking" that is becoming a bit overzealous...

    • Really, what we mean to say we just did: detach from this context, it is a scary enough word "detach" rings the right alarm bells since we're in structured concurrency. And I don't love conflating it with other things...
    • it muddies waters by reusing words. If I talk to someone and they say "yeah I async-ed" I'd have to always double check "but did you async detached?". It is much simpler to have different words for those very different concepts -- one of them completely breaks traces and priority propagation and deserves it's own word.

Future work:

I would really like to keep the door open for potential future work to allow us to go from:

async { await hello() } 

to

async hello()
// detach hello()

Perhaps it'd be issuing a warning if the function is non-Void or something and require the longer version etc... Or we'd leave it to a linters to guide the style here.

I really want to, eventually, have a not super painful way for uni-directional messages where the runtime would be able to know it is being called uni-directionally. For distributed actors the ability to know "okey, I have no need to send back a reply since noone is waiting on it" is a huge network bandwith and (network) protocol design feature.

But I'm more than happy to delay these discussions to the near / medium term future :+1:


As for names... I'll at least pitch what feels the most consistent to me:

  • spawn (spawn let, group.spawn) create a child task, must be awaited on, carries all metadata
  • send, uni-directional, usable from non-async code, carries all metadata, fire and forget will not be awaited on send hello()
    • some folks said it would be annoying if writing networking but as someone who wrote/writes networking for a living... I don't buy that. We'd normally just self.send if we had such API, or otherwise easily disambiguate. It's not a concern IMHO.
    • if send is a no go, this is fine as async :+1:
  • detach, does return a handle, can be awaited on, is detached and carries no metadata
    • it is a prior art word, looks scary enough; it is useful enough not to make it a multiple word thing

I could live with the async and asyncDetach but really think this is very overzealous uglifying for no real benefit.

5 Likes

This is great. +1

I love the idea of unifying the root for async and asyncDetached.

I think I much rather see these as some kind of modifier on do.

@async do {...}

@async @detached do {...}

//Or 

@async go {}

I have some discomfort about the dual meaning of “async” as both an adjective (“is asynchronous”) and an imperative verb (“do this asynchronously”).

A newcomer to Swift’s concurrency model ought to be able to get a simple answer to the question “What does async mean when I see it in code?” I’m not sure this proposal provides that.


A question which will reveal my ignorance of the concurrency work thus far: would async await its own contents? In other words, in the following code:

func foo() async {
  one()
  async { two() }
  async { three() }
  four()
}

…is the order of execution:

  • 1 2 3 4?
  • 1 4 2 3?
  • 1 4 (2,3 in either order)?
  • something else?
5 Likes

Given my understanding of the runtime and what this operation does, I believe it'd be:

Assuming you're inside an actor when foo is called:

actor A { 
  func foo() async {
    one()
    async { two() } // run closure isolated to `self` actor
    async { three() } // run closure isolated to `self` actor
    four() 
  }
}

Since the closures inherit the actor context, and thus serial executor it runs on... the only legal order is:

  • 1 4 2 3

Adding an await complicates the ordering again, since now we have interleaving and reentrancy to worry about:

actor A { 
  func foo() async {
    one()
    async { two() } // run closure isolated to `self` actor
    async { three() } // run closure isolated to `self` actor
    await four()  // imagine it actually does suspend before printing
  }
}

it could be that:

  • 1 2 3 4
  • 1 4 2 3

Because our imagined four() does actually suspend (say Task.yield() before print()), then indeed the 2 and 3 could run before the 4 gets to complete.

// With the future idea of @reentrant(never) this would not be the case and we'd force the actor even here to guarantee 1 4 2 3

I don't think I'd expect 2 and 3 to be inversed here... tasks are submitted at same priority after all, so even if the actor runtime gained reordering magic I don't think it should do this to 2 and 3 here.


And if not in an actor:

class/struct X { 
  func foo() async {
    one()
    async { two() }
    async { three() }
    four()
  }
}

since there is no serial actor executor to inherit the context from, and thus the tasks are submitted to a concurrent executor and could run as any of the following:

  • 1 2 3 4
  • 1 4 2 3
  • 1 3 2 4
  • 1 4 3 2

This is semantically equal to a detached task, run on the global concurrent executor, ordering wise.

The 2 and 3 running before 4 is highly unlikely but possible; there's nothing that would force the four to run before or after the asynchronous work after all.

2 Likes

If that is the case, I might prefer a name like doLater that would give at least a little bit of intuitive help reasoning about the ordering:

func foo() async {
  one()
  doLater { two() }    // fairly clear, even on a naive reading,
  doLater { three() }  // that these lines happen after four()
  four()
}

(Also, I realize maybe foo() doesn’t have to be async at all, does it?)

2 Likes

Right, that's the purpose of this API kind of, to allow creating a task (in order to call some async stuff) from synchronous code.

I'm really thrilled to see this. I love standardizing around async and asyncDetached as terms of art here, they blend in very nicely with the general concurrency model. I also love that this is a library feature!! :gift_heart:

The thing I'm not clear about is - is this just a library feature or are their compiler components to this (e.g. to effect implicit self)?

FWIW, I am not in favor of this. We should be very deliberate about applying syntactic sugar and increasing language complexity. I don't see why this is something that we need to sugar to eliminate a couple braces. The eliminated braces increase magic, thereby increasing conceptual complexity of the language.

-Chris

10 Likes

Isn't it confusing that something named async executes synchronously?

2 Likes

That's a bit unfortunate wording in the pitch text perhaps wrt. that "immediately executed".

It is immediately enqueued/scheduled for execution.

It absolutely is asynchronous, after all, the caller proceeds with their life (and processing of the function that created the new async-ed task); there's no waiting for it, so it is not synchronous.

5 Likes

This seems like a lot of magic for a “normal function”. I always worry about things like this because they look like they compose with other language features, but they really don’t.

It might be good to think of these as, at least in the long run, separate features that we want to support more generally.

What if there isn’t an actor in this context? Is async(_:) illegal?


Do we have use cases where the async(_:) call isn’t the last statement in the function? If not, I’d be tempted to support this use case with something like:

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

Basically, in a non-async function, @detached await is legal and plain await is legal if a @detached await always executes first. This avoids having to create several ad-hoc closure rules for a specific function, since no closure is involved at all.

7 Likes

I like the idea of this. This seems like the sort of thing the originally-pitched @asyncHandler was going to handle. However, this has the benefit that you can do the first part synchronously.

This would be particularly useful for IBActions/delegate method implementations