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

Hi folks,

The review of SE-0461: Run nonisolated async functions on the caller's actor by default begins now and runs through March 2, 2025.

This proposal is one that's been contemplated in the recently approved vision document to improve the approachability of Swift's data-race safety facilities.

Reviews are an important part of the Swift evolution process. All review feedback should be either on this forum thread or, if you would like to keep your feedback private, directly to the review manager via the forum messaging feature. When contacting the review manager directly, please keep the proposal link at the top of the message.

Trying it out

If you'd like to try this proposal out, you can download a toolchain supporting it. Any February 2025 trunk development snapshot should do. You will need to add -enable-experimental-feature NonIsolatedAsyncInheritsIsolationFromContext to your build flags.

What goes into a review?

The goal of the review process is to improve the proposal under review through constructive criticism and, eventually, determine the direction of Swift. When writing your review, here are some questions you might want to answer in your review:

  • What is your evaluation of the proposal?
  • Is the problem being addressed significant enough to warrant a change to Swift?
  • Does this proposal fit well with the feel and direction of Swift?
  • If you have used other languages or libraries with a similar feature, how do you feel that this proposal compares to those?
  • How much effort did you put into your review? A glance, a quick reading, or an in-depth study?

More information about the Swift evolution process is available at https://github.com/apple/swift-evolution/blob/main/process.md.

Thank you,

Xiaodi Wu
Review Manager

17 Likes

One nit, under ā€œDetailed designā€:

However, note that the end state under the AsyncCallerExecution upcoming feature will mean that@execution(caller) is not necessary to explicitly write, and @execution(caller) will likely be used sparingly because it has far stricter data-race safety requirements.

The second instance of @execution(caller) was probably intended to be @execution(concurrent).

More general feedback: having not followed the pitch super closely, does this basically mean that nonisolated as a keyword by itself is useless? If Iā€™m understanding the proposal correctly, it seems like with both normal and async methods, that keyword becomes a no-op:

actor Example {
    func synchronous() { ā€¦ } // isolated to `Example`
    nonisolated func synchronousNonisolated() { ā€¦ } // isolated to `Example`

    func asynchronous() async { ā€¦ } // isolated to `Example`
    nonisolated func asynchronousNonisolated() async { ā€¦ } // isolated to `Example`

    @execution(concurrent) nonisolated func asynchronousActuallyNonisolated() { ā€¦ } // finally not isolated to `Example`
}

At this point, why not just deprecate the nonisolated keyword entirely in favor of just the @execution attribute?

2 Likes

Indeed it was, thank you for pointing this out! I'll fix this in the proposal.

No, nonisolated is still the way to specify that a method or property is safe to access from anywhere. In your example:

In the above example, synchronous and asynchronous are isolated to Example, but synchronousNonisolated, asynchronousNonisolated, and asynchronousActuallyNonisolated are not. None of the nonisolated methods can touch actor-isolated state, because they can run outside the actor. asynchronousActuallyNonisolated always runs off the actor, while synchronousNonisolated and asynchronousNonisolated will run on whatever actor they are called from.

4 Likes

Yep, thatā€™s my bad for reading a proposal before the coffee kicked in, thanks for the clarification - you may want to add something about this to the proposal though, since it doesnā€™t have any instances of nonisolated actor methods in it.

2 Likes

It seems like these changes will greatly improve the consistency and intuition many developer's have for Swift Concurrency (even if there will be a transition period where some developers may have to re-internalize some of these fundamentals).

My only thought was that @execution(caller) seems a bit strange/vague upon first reading it, especially compared to @execution(concurrent) (which seems rather clear to me). Would something like @execution(inherit) be more clear and inline with concurrent's meaning?

1 Like

But doesnā€˜t @MPLewis point still make sense? Any @execution annotation infers nonisolated and just writing nonisolated always infers @execution(caller).

I donā€˜t quite understand when a standalone nonisolated would be used.

My @-llergy is acting up again. nonisolated is wonderfully @-free. Itā€™s also likely a developerā€™s first point of contact with the concept of ā€œisolationā€. Isolation and execution are related but different conceptsā€”a nonisolated function can execute anywhere.

8 Likes

A simple example of a standalone nonisolated would be when constructing a synchronous actor method which does not require isolation:

actor SomeActor {
  nonisolated func f() {
    // ...
  }
}

Because this is not an async method it cannot be annotated with @execution(...).

3 Likes

Moreover, if we chose to straight up deprecate nonisolated in favor of @execution(caller) everywhere, even on synchronous functions and stored properties, that would lead to much much more code churn than what is currently proposed. I am strongly against that direction, because it means more already-internalized concepts that programmers need to re-learn. Part of the goal here is to minimize the change to what is absolutely necessary to solve the major usability problem with async functions on non-Sendable types.

I'm going to add a section to the alternatives considered section about deprecating nonisolated - this also came up in the pitch so it's worth elaborating on in the proposal text directly.

4 Likes

+1, I love it!

Still in the process of reading, but I must say I appreciate the authors' decision to use a staging mechanism, this really shows cares for people who have internalized the current nonisolated semantics, and it eases teaching stress.

However, I'm not sure whether the following attempt described in the proposal will ease the transition.

To mitigate these consequences, the compiler will emit warnings in all language modes that do not enable this upcoming feature to prompt programmers to explicitly specify the execution semantics of a nonisolated async function.

Compared to other warnings, this may seem overwhelming, especially for libraries compiled with "-warnings-as-errors". The author of such a library will have to manually update all its nonisolated async functions, otherwise it will suddenly stop compiling.

Another thing that worries me is the spelling of @execution(concurrent), here the phrase "concurrent" may be misleading. The ambiguity between a concurrent executor and a concurrent task could create a source of confusion, some Swift learners may think @execution(concurrent) can make an existing function run concurrently (as if it will be wrapped in an implicit Task.init).

How about @executor instead of @execution?

2 Likes

+1. Currently non-Sendable types are effectively generic over isolation, and it makes sense that async methods inside non-Sendable types would be generic over isolation. And not only methods, but pretty much any async functions accepting or returning non-Sendable types.

+1 for preserving attribute in the type system.

However, if the conversion happens on the actor, and the new function type is not @Sendable , then the function must only be called from the actor. In this case, the function conversion is allowed, and the resulting function value is merged into the actor's region

That's an interesting point. This aligns well with [Pre-pitch] Isolated conformances. +1.

For most calls, the switch upon entering the function will have no effect, because it's already running on the executor of the actor parameter

Are there any cases where switching upon entering will have the effect? If no, should switching upon entering be removed as an optimization?

Note that this introduces a semantic difference compared to synchronous nonisolated functions, where there is no implicit isolated parameter and #isolation always expands to nil .

This could solved if runtime would store reference-counted actor references, which was discussed before and rejected. So, I guess, we will have to accept this discrepancy.

If the attribute were spelled @isolated(caller) and @isolated(concurrent) , presumably that attribute would not work together with nonisolated ; it would instead be an alternative kind of actor isolation.

Sounds good to me. The fact that @execution(caller)/@isolated(concurrent) are mutually exclusive with isolation attributes, and that there is always one or another present (possibly implicitly), leads me to the conclusion that @execution(caller)/@isolated(concurrent) are indeed isolation attributes.

From the other hand, name @execution() sounds as if it has something to do with the task executors, which it is not.

To avoid having two syntaxes, we could deprecate nonisolated altogether, and use @isolated(caller) on sync functions.

Another plus is that it makes it easier to understand if feature is adopted or not from reading the code.

This increases code churn, but it should be solvable with tooling. I'm not concerned about it.
Also would be nice if tooling could automatically recognise isolation: isolated (any Actor)? = #isolation pattern and replace it with @isolated(caller).

I'm a bit concerned that replacing nonisolated with @isolated(caller) would increase syntax length from 11 characters to 17, but this is well compensated by replacement of isolation: isolated (any Actor)? = #isolation as well (45 characters).

2 Likes

I was opposite to the change initially, and to some degree still find it quite a drastic change in the language, creating huge behavior distinctions between not even major versions. I still prefer control in an opposite direction, where caller could specialize execution context instead.

However, the complexity of the current nonisolated behavior is really hard to reason even collectively as Iā€™ve observed in several topics, which has convinced me that the change in the form of altering behavior is probably the best in the long term. And I find this new behavior more convenient for async code.

It is also a great solution to how this change will land into the language, but I wonder if it makes sense to upgrade warnings to errors? Developers are quite often donā€™t fix them for a long time, because thatā€™s something that can be postponed, while the change is quite significant. Could it be an error with some auto-migration?

I do think that the language should always have been like this, so I'm in favor of the proposal.

I'm a little scared of how subtle bugs might be during a transition from the current state to the new state, though.

Does this mean that AsyncIteratorProtocol could go back to mutating func next() async throws(Failure) -> Element? though? The two nexts are already a major pain point, if the stdlib wanted to evolve this requirement a 2nd time, that sounds extra-unpleasant...

5 Likes

I added these clarifications in [SE-0461] Proposal clarifications based on early review discussion. by hborla Ā· Pull Request #2703 Ā· swiftlang/swift-evolution Ā· GitHub

Yeah, this is a fair concern. I think there are a few directions we could go in to make this transition less painful:

  1. Add a warning group for this diagnostic so that anybody building with -warnings-as-errors can opt out of this specific warning becoming an error.
  2. Implement automatic migration tooling for enabling AsyncCallerExecution via [Pitch] Adoption tooling for upcoming features. This would effectively give people a way to opt into such a warning, instead of enabling a warning by default for everybody.
  3. Surface implicit @execution(caller) and @execution(concurrent) attributes in SourceKit's cursor info request, so the attributes are made explicit in editor inspection tools like QuickHelp.

I'm completely open to being convinced that a warning on every nonisolated async function is a bad idea, and that the other ideas above are sufficient for easing the transition to the new semantics. I'm sympathetic to the confusion the intermediate state could cause on what an async function means in a project, but there's a real risk of causing too much code churn and annoyance by introducing a warning.

Big +1 on this, when developing SwiftClaude this was a major pain point, both from understanding what the underlying model was, and from putting isolation: isolated Actor = #isolation everywhere (which required dealing with some compiler edge cases where the "right" isolation variable needed to be passed to a function for the behavior to be correct.

A couple questions:

  1. How will this work with method overloading? I'd imagine if you wanted to support existing code and code which uses this functionality, you may want both a function which takes an isolation parameter and one which is annotate with execution(caller), but without the parameter.
  2. Will get async properties also be annotatable with execution? This is a significant limitation in the current model which requires an isolation parameter. Maybe something like
var foo: Foo {
  @execution(caller) get async {
    ...
  }
}
3 Likes

Overall I'm a big +1 on the goals this proposal aims to solve. I think the initial semantics of Swift Concurrency that async methods inherit the isolation of the caller were correct and the system was really sound and easy to understand. The change in SE-0338 was mostly driven by performance concerns and IMO we learned over the past years was the wrong trade-off.

I like how this proposal allows to stage in the change back to the old behaviour and hopefully at some point we can make that the default again. I also love that the proposal finally spells out the function conversion rules.

Having said that I personally really dislike calling the attribute @execution. Execution and isolation are two orthogonal but related concepts in Swift concurrency and we are mixing them here. Isolation is either in terms of the current actor or the current task and completely decoupled from where the code actually executes. Where code executes is actually a decision tree that takes isolation into account. Currently the decision tree for execution is the following

  • Check the isolation
  • If the code is isolated we check if the actor has a custom executor
  • If the code is nonisolated or on an actor without a custom executor we check if there is a task executor preference
  • If nothing applies we run on the global default executor

Now I understand that the proposal is trying to change the semantics of nonisolated from meaning "definitely not isolated to an actor" to "maybe isolated to an actor". Looking at the alternatives considered section:

One other possibility is to use isolation terminology instead of @execution for the syntax. This direction does not accomplish goal 1. in the previous section to have a consistent meaning for nonisolated across synchronous and async functions. If the attribute were spelled @isolated(caller) and @isolated(concurrent) , presumably that attribute would not work together with nonisolated ; it would instead be an alternative kind of actor isolation.

Why would this have inconsistent meaning for synchronous and asynchronous functions?

Having used Concurrency extensively over the past years it would make complete sense to me to call these attributes @isolated(caller) and @isolated(non) and allow them to apply to nonsisolated methods.

While I can see the argument for saying code is running concurrently with the callers actors I think this reasoning falls short on two fronts:

  • When the caller itself is nonisolated nothing runs concurrently when calling a proposed @execution(concurrent) method
  • When using task and actor executors nothing might run concurrent at all

Take a simple example like this

@execution(concurrent)
nonisolated func foo() async {
  await bar()
}

@execution(concurrent)
nonisolated func bar() async {}

From looking at the code one might assume that bar now runs concurrently with foo but that's not the case at all.

I really feel like we shouldn't mix isolation and execution terms here. Right now it is somewhat easy to explain where code is isolated to and where code actually executes. Both have their own decision tree that are related but have separate vocabularies. With the proposed @execution attributes this becomes way harder to explain.

Similarly, the async function can be described as running on the concurrent executor.

FWIW, I think that we should stop calling it the concurrent executor but rather call it the default executor similar to what the pitch for custom global executor used.

task executor preference can still be used to configure where a nonisolated async function runs. However, if the nonisolated async function was called from an actor with a custom executor, the task executor preference will not apply.

This reads like task executors preference is dropped when in fact the preference is still there it just isn't evaluated since in our execution decision tree we stop at the first check if we are isolated. If at any point further down the call chain we drop the callers isolation the task executor preference will apply again.
I also think that task executors show again how isolation and execution are orthogonal. We might still run on the task executor even if we are isolated to the caller since an actor (without a custom executor) can continue to run on the callers executor.

11 Likes

(Taking off review manager hat)

How would you and others feel about describing the distinction, perhaps, as "eagerly" nonisolated (as in, hops to global executor asap) and "lazily" nonisolated (as in, doesn't change executors if it doesn't have to)?

If that's an inoffensive characterization, could we spell these nonisolated(eager) and nonisolated(lazy)?

4 Likes

I'm thinking how I would explain that to somebody. This method is nonisolated(eager) which means if the caller is isolated it eagerly sheds its isolation. On the other hand this method is nonisolated(lazy) which means it will lazily inherit the isolation of the caller.

I can see eager working but I feel lazy isn't really getting across the semantics here. As I see it, the distinction really is definitely not isolated and inherits the caller's isolation. At the same time we want to make nonisolated mean the latter to migrate existing code. So something along the lines of noisolated and nonisolated(none).

1 Like