[Pitch] Inherit isolation by default for async functions

Let me say upfront that I do not feel qualified enough to be commenting on changes in the language. But I've had to explain Concurrency to other developers before, so I wanted to join the numerous other posts on the naming of @concurrent.

I feel like there already is some asymmetry when stating isolation in functions and closures. Here are some examples:

nonisolated // only for functions
@isolated(any) // only for closures
@GlobalActor // for functions and closures
isolated SomeActor // for functions and closures
// and now:
@concurrent // only for functions

I think symmetry would let developers get to know isolation control in a more structured way. Talking about (self) teaching but also a great opportunity for documentation. (I am mentioning documentation because I could not find an official document, other than maybe the Swift 6 migration guide and proposals, talking about nonisolated or isolated(any)).

Now, what do I mean by symmetry. I am pretty sure, someone else mentioned a similar way of writing this in this thread already, but I feel like @isolated(global) or something along those lines could be a great start instead of @concurrent. Why? Let's assume in a future proposal, we could formalize isolation symmetry like this:

// A new annotation, that, after this proposal gets accepted,  
// is the default annotation of every non-isolated async and sync function. 
// Therefore it does not have to written out by the programmer, but could be, 
// if you want to have more clarity and resilience against future changes in isolation control.
// This annotation of course infers `nonisolated`.
@isolated(caller) func test() { }

// Currently proposed as `@concurrent`, only available for asynchronous functions, 
// but could be expanded to be used in synchronous functions as well.
// It states that the isolation should be chosen by the cooperative global executor.
// This annotation also infers `nonisolated`.
@isolated(global) func test() async { }

// A new annotation which is the longer form of @GlobalActor. 
// Both ways to write global actor isolation should be allowed. 
// This is the default annotation for isolated async and sync functions.
// For structs and classes isolated to actors or actors themselves, 
// those annotations are inferred, but could again be written out.
@isolated(MainActor) func test() { }
@isolated(Self) func test() { }

// I could not find a symmetrical way to express functions that are isolated to actor instances.
// So I guess it has to stay like this:
func test(actor: isolated SomeActor) { }

Symmetry to closures:

func test(closure: @isolated(caller) () -> Void) { }
func test(closure: @isolated(global) () async -> Void) { }
func test(closure: @isolated(MainActor) () -> Void) { }
func test(closure: (isolated SomeActor) -> Void) { }

// There is one annotation that only makes sense for closures, 
// but conveniently, it is spelled similarly:
func test(closure: @isolated(any) () -> Void) { }
// The behavior of `@isolated(any)` would of course stay the same.
// It gets its isolation dynamically from the closure itself.

These changes should also be purely additional. So the developer could stick to their preferred isolation annotation.

Don't get me wrong I do not want to hijack this pitch, nor am I pitching the syntax I sketched out, but I am trying to make the point that the writing of @concurrent might be a missed chance to get uniform isolation annotations in the future and also it is another keyword to remember instead of a "keyword family".

If I got something wrong and this is just not possible at all, then please correct me and ignore the alternative writing I propose. This all is still somewhat new and keeping up with all proposals and understanding them is some work.

15 Likes

How related are semantics of @concurrent to those of the handler passed to withTaskCancellationHandler(handler:operation:)? That handler is currently typed as a simple synchronous closure.

To be clear I really like the idea of defaulting to an implicit actor isolation parameter on async methods. I just want a caller side syntax for deciding to inherit or disinherit isolation. I would also recommend preserving the existing behavior to reduce the complexity of this change and making the caller side syntax an opt in to inheritance when calling from explicitly isolated methods to async methods that can inherit isolation

1 Like

Just wanted to +1 this effort.

I recently ran into this problem and I really think it warrants solving, even if we need to defer it to a future source-incompatible language version, I would enable this as an "upcoming feature" on my projects across the board.

A few observations:
Right now, it almost feels like not-actor-isolated functions are a third "color" of function, which makes structured concurrency hard to sell.
In diving deep into actor-isolation, I found having the isolated parameter able to be explicit very helpful. On the other hand, I was most confused when things were implicit, specifically around behavior related to values that haven't yet been associated with a specific isolation domain (and thus could be sending-ed). From an education perspective alone, I think isolation: isolated (any Actor)? = nil to be a good phrasing of the "do not inherit actor isolation" mode. While the core team thinks about ABI compatibility a lot, the rest if us seldom do outside of a few specific niches, so instead of making @concurrent something that seems like a tool people should use, I lean towards using nil isolation and having a less "seems like its useful" fallback for ABI compatibility (like @abiCompatibility(implicitIsolationMode: foo))

5 Likes

I think this problem is important to address since currently starting to write asynchronous code doesn't align with Swift's progressive disclosure principles. Almost immediately developers are asked to understand what a data race is, what Sendable means and as of Swift 6 also what region isolation means. This is not only a problem for newcomers to concurrent programming but also one for seasoned developers. The current models around isolation and nonisolated methods is very hard to explain. Specifically, when it comes to closures.
During the recent Server Side Swift conference I gave a talk which showed how to write a Swift 6 with-style method to provide scoped access to a resource. While it's possible to create a complete data-race free pattern doing so comes with significant complexities.

While I am overall +1 on tackling this problem I have some feedback on the details of this proposal.

Inheriting the isolation by default

I think it's the right thing to make async methods inherit the isolation of the caller by default. While the current model is "easy" to reason about when it comes to the question "where does my code execute" it severely restricts any actor isolated code. The original motivation of wanting to leave the main actor as fast as possible is still there but main actor isolated code is also the place that suffers the most in UI applications from the current behaviour and requires the most annotations. I personally think the trade-off that is proposed here is the correct one. I would rather hang onto an actor for a bit longer than having to deal with potential data races as early as we have to do right now.
Also from past experience when developing iOS applications I would expect most my code to run and stick to the main actor unless I specifically ask to execute it somewhere else. I would only do this for code where I know it is beneficial to offload it from the main thread.

@concurrent methods

I am not yet convinced that a method level annotation is the right thing. When we change nonisolated async methods to inherit the isolation by default it seems more coherent to let the caller decide where to run such a method. In my opinion, something like await on(globalExecutor) works better. Together with task executors it puts the control to the calling side. Authors of methods can still decide where their code runs by using this await on(TaskExecutor) pattern themselves.
I can understand if we need that new attribute for ABI compatibility reasons but it will introduce yet another complexity to the language that I don't see as needed right now.

Isolation inference for closures

Thanks for spelling out the behaviour here. We don't have this documented right now and it was one of the biggest sources of confusion for me. Regardless of where we go with this proposal we should document this behaviour.

If either the type of the closure is @Sendable or the closure is passed to a sending parameter, the closure is inferred to be nonisolated

While I understand what this means saying that a closure is inferred to be nonisolated is confusing since closures cannot be marked nonisolated. I am also not yet convinced that this is the right thing for async @Sendable closures.

@MainActor
func closureOnMain(ns: NotSendable) {
  let asyncClosure: @Sendable (NotSendable) async -> Void = {
    // inferred to be nonisolated and runs off of the actor

    print($0)
  }
}

Looking at the above code should it even be allowed to declare a @Sendable closure that takes non-Sendable parameters or returns a non-Sendable type? Can we error at the declaration side already?

Let's assume for a second the closure doesn't take any parameters

@MainActor
func closureOnMain() {
  let asyncClosure: @Sendable () async -> Void = {
    // inferred to be nonisolated and runs off of the actor

    print($0)
  }
}

Why is this not inferred to run on the main actor? It seems completely safe to do this since we can just dynamically hop to the actor's executor in the closure.

Another thing that I find missing in this section is @isolated(any). What is the isolation inference for a @Sendable @isolated(any) method? Right now it looks like it is "nonisolated" but why can't this be inferred to the surrounding context?

Open question. The current compiler implementation does not implicitly capture the isolation of the enclosing context for async closures formed in a method with an isolated parameter

I am wondering if @escaping should play into this here. For non-escaping async closures it seems beneficial to align the behaviour of nonisolated async methods that is proposed here.

Executor switching

A 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. Otherwise, the code will risk a data-race, because the task executor preference does not apply to actor-isolated methods with custom executors, and the nonisolated async method can be passed mutable state from the actor.

While I agree that this proposal changes nothing with the semantics of task executors e.g. the priority of where something executes stays the same: actor isolated executor > task preferred executor > global concurrent executor. I think we need to potentially revisit the withTaskExecutorPreference API. Currently all nonisolated async methods are switching to the task preferred executor if on has been set. However, with this proposal we are inheriting the isolation by default so any usage of withTaskExecutorPreference inside an actor will become almost useless. It might make sense to consider changing the closure of withTaskExecutorPreference to be @Sendable when we change the default of nonisolated async methods here.

Other feedback

  • What is the plan for methods in the stdlib that have adopted the #isolation parameter already such as next from AsyncSequence?
9 Likes

I am strongly +1 on this proposal.

I'm particularly interested in isolation inheritance being the default.

My personal experience is of rarely benefiting from implicit offloading, or from offloading to a global executor. My main app has only a few computationally heavy tasks, but has a lot of I/O (network and database accesses), which are explicitly offloaded to a background global actor, or other non-ui actors.

I find it to be a pain to write higher order async functions under the current model. And converting callback-based api to async are not always as straightforward as it could be.