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 keepsself
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.
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)
The async
operation propagates priority from the point where it is called to the detached task that it creates:
- 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; - If the synchronous code is running on behalf of the main thread, use
.userInitiated
; otherwise - 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.
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.
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
andTaskGroup.spawn
. They could also benefit from this change.
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.