SE-0461: Run nonisolated async functions on the caller's actor by default

I agree. I see how we ended up going in this direction, but I think it's problematic from both the perspective John mentions (it's misleading) and also from the perspective of progressive disclosure. Executors are and should remain a very advanced topic, used for customizing behavior in narrow circumstances, not something that should surface when you first encounter concurrency. One should be able to introduce concurrency and reason about the program effectively without ever encountering an executor. We should keep executors out of the nomenclature for concurrency until the need for them naturally arises (e.g., for interacting with other concurrency models and libraries).

Right. Again, thinking of progressive disclosure: with this proposal, it will no longer be the case that having a nonisolated function introduces concurrency. Instead, developers are likely to observe that their asynchronous code is tying up an actor (likely the main actor) for longer than expected and will need to introduce concurrency. @concurrent does what it says, making the annotated function concurrent with respect to the code using it.

I think it serves mainly as a transitional tool, allowing folks to replace code using the #isolation-defaulted parameter trick piecemeal without having to go "all in" on using this upcoming feature introduced in this proposal. That has some benefits for code that needs to support compilers that predate whatever Swift release would contain SE-0461.

I agree that @isolated(caller) is a good spelling here. We've effectively taken @isolated already for @isolated(any), so doesn't really cost us any more syntax.

Doug

8 Likes

I ran a few tests with swiftc built from source and -enable-experimental-feature AsyncCallerExecution to make sure my understanding is correct.

// Simplified example

class NetworkService {
    // will be @execution(caller) with the upcoming default
    static func fooEndpoint() async -> String {
        // it runs on the main thread
        return "New data"
    }
}

@MainActor
class ExampleView {
    var content = "Hello"

    func onButtonPress() {
        Task {
            content = await NetworkService.fooEndpoint()
            print("did update content")
        }
    }
}

let view = ExampleView()
view.onButtonPress()

That strikes me as suboptimal for most apps. I fear developers will upgrade to a new version of the Swift compiler and unexpectedly get a less responsive UI because more work is now done on the main thread.

The behavior laid out by SE-0338 is very useful for UI responsiveness, but developers / the compiler / the runtime are now being held back by it. I totally agree that we need to revisit it, and provide more flexibility over the execution of nonisolated async functions.

// Motivating example from SE-0461
class NotSendable {
  func performSync() { ... }
  func performAsync() async { ... }
}

actor MyActor {
  let x: NotSendable

  func call() async {
    x.performSync() // okay

    await x.performAsync() // error
  }
}

Ideally, the example above would just work.

The rules of SE-0338 need to be repealed to allow the compiler / the runtime to execute nonisolated async functions wherever it deems appropriate. That could be in the global concurrent executor, or in the caller's executor, e.g. depending on Sendable checking.

When writing (nonisolated) async functions, developers should be able to assume the runtime will pick something appropriate, i.e. it will not run on the main thread if it's able to.

With progressive disclosure in mind, a developer could add @execution(concurrent) or @execution(caller) to functions where more control over execution is useful or necessary. For example, I want my func fooEndpoint() async function to run on the concurrent executor. When I add @execution(concurrent), I expect that the compiler will become more restrictive with Sendable checking (among other things) and may produce compile errors.

Of course, "it just works" may be a lofty goal for something as complex as the swift compiler. Ideally the language spec would allow the compiler to become more clever in the future, allowing more instances where work can be done away from the caller's executor. To enable this, we shouldn't repeat the mistake of SE-0338 where the execution is guaranteed to happen on X or Y by default.

Thank you all for your feedback. The language steering group has decided to accept the proposal with modifications and focused re-review centered on the spelling of what was proposed here as @execution(caller).

Xiaodi Wu
Review Manager

1 Like