Skip to content

Instantly share code, notes, and snippets.

@DougGregor
Created April 26, 2021 20:26
Show Gist options
  • Save DougGregor/2dd62cb9130db678f3fc8cd44b5535bc to your computer and use it in GitHub Desktop.
Save DougGregor/2dd62cb9130db678f3fc8cd44b5535bc to your computer and use it in GitHub Desktop.
Initiating async work from synchronous code

Initiating async work from synchronous code

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment