[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.

17 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))

7 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?
13 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.

1 Like

I think this issue is also present for async properties, i.e. ones with get async { ... }. Unless I'm missing something, it seems impossible to use an async property in an isolated context. Is there a plan to change/fix this? The same issue applies for sending (i.e. you can't have a sending or async isolated get)

I cannot stop thinking about this, and the more I think about it the more I'm convinced that this is an education problem (which is expected and normal), and our solution to it is a downgrade on the long-term. If we forget about "the confusion" this has been causing, are there logical reasons why this change would be better? Doesn't this decrease predictability and increase potential gotchas / bugs that are hard to debug?

2 Likes

Personally my biggest pain on this topic is when working with libraries which are not optimized for Swift Concurrency yet, so if main actor performance is the concern, maybe we can change the default behavior depending on @preconcurrency delcaration?

@preconcurrency
class PreConcurrencyNonSendable {
    func someProcess() async { ... }
}

class NormalNonSendable {
    func someProcess() async { ... }
}


@MainActor
struct Holder {
    let pcns: PreConcurrencyNonSendable
    let nns: NormalNonSendable

    func handleProcess() async {
        await pcns.someProcess() // ← Inherit current actor, which won't cause data race and the build passes
        await nns.someProcess() // ← Use global actor, which may cause data race and the build fails
    }
}
1 Like

As somebody who recently flipped their opinion on this by 180 degrees, I believe it may be beneficial to try to sum up the problem in this long thread.
To me it seems that this discussion, but also general use & feedback for concurrency (with SE-0338) has brought some aspects to light that are not immediately obvious:

  • async functions that are isolated to an actor need to run on that actor's executor. That means if they are called from an isolation context other than their actor's, there is a hop. (Okay, that one's obviously obvious... :sweat_smile:)
  • async functions that are not isolated to an actor are apparently not as clear-cut as the current implementation assumes. The assumption was that it is most often best to ensure they free any actor by leaving its isolation context (i.e. hop so that it can make forward progress on other work), practice seems to indicate that is often not desirable. At least a significant amount of people were very surprised that something nonisolated still "hopped off" an actor.
  • Instead of this behavior it seems to often be beneficial to pass the isolation context they're supposed to run on to them. This means we basically want to say "Just continue to run on the isolation context we currently are on, no need to hop as long as we're in the async world and can await you!"
  • We do have a way to express this (giving them a parameter isolation: isolated (any Actor)? = #isolation), but this appears to be cumbersome and makes writing higher-order APIs very hard.

As said I myself have at first not seen this as an issue, because I tended to mentally equate "async function that is not isolated to an actor" to "rare async function". That may have been a shortcoming on my part, but perhaps I am not the only one. I think it is very reasonable to want an easy syntax to specify "Hey, this might be async, but it's best not to switch isolation context if possible".

Now I think that indeed, if you explicitly mark a function with nonisolated, that is kind of what is indirectly implied: They're not isolated to any actor, so they "may run anywhere". So why not on the actor they were called on?[1]
The case where a given actor they're called from "wants them gone" to ensure it can make forward progress asap seems like a special case to me now and it would be good to have a means to express that (with the default perhaps indeed to "leave it up to the function itself").


  1. I know this is bad phrasing as async functions don't run on actors but executors, but we already see that this expression is used all the time ā†©ļøŽ

8 Likes

It might be useful to provide examples with those paragraphs.

Hm... fair enough, though I fear that without the context of a wider context in related, realistic code, the issues appear smaller than they are (except for the first, of course). Here goes:


  • async functions that are isolated to an actor:
actor MyActor {
    var protectedState: Int = 0
    func updateState() {
        protectedState += 1
    }
}

@MainActor func doSomething() async {
    let myActor = MyActor()
    await myActor.updateState() // there is a hop here and nobody is surprised by that
}

  • async functions that are not isolated to an actor
extension MyActor {
    // let's assume this is part of MyActor because it's semantically relevant 
    // to its functionality, but we intentionally don't isolate it as it doesn't rely on its state
    nonisolated func asyncButNonIsolated(someState: Int) async {
        // imagine something that requires an async operation instead...
        print("logging \(someState)")
    }
}

@MainActor func moreHopsThanNeeded() async {
    let myActor = MyActor()
    let theState = await myActor.protectedState
    // the next line hops to an executor that does not belong to any actor
    await myActor.asyncButNonIsolated(someState: theState)
}

To reiterate: It seems we are divided in how expected or unexpected the last hop is. One way to read it is "this is not isolated, so we hop to something else entirely" while the other is "this is not isolated, so we don't care to hop anywhere at all".
Whether it makes sense to hop in part depends on the implementation of asyncButNonIsolated (is it perhaps such a large operation we better hop off the main actor to allow it to make forward progress in the meantime?) and in part to the caller (the main actor doesn't have anything else to do anyway, so it is fine allowing the function to run on its executor). Of course I have just chosen @MainActor as an example here, these considerations may vary depending on which actor the call happens on.


  • Instead of this behavior ...
  • We do have a way to express this
extension MyActor {
    func callerDecides(someState: Int, isolation: isolated (any Actor)? = #isolation) async {
        // imagine something that requires an async operation instead...
        print("logging \(someState)")
    }
}

@MainActor func fromMainActor() async {
    let myActor = MyActor()
    let theState = await myActor.protectedState
    // note that the next line does not hop off the main actor!
    await myActor.callerDecides(someState: theState)
}

For why it's hard to write a higher order function for the last example I (for now) want to point to the proposal text so far. As said, I am unsure if this illustrates the issues, but I hope it at least shows the different intentions of how isolated and non-isolated functions can be seen and used.

1 Like

Yeah, I've never understood this point. It literally says nonisolated, why would anyone expect it to still be isolated? I'd rather understand that instead of changing fundamental runtime behavior.

Because the motivation for the nonisolated keyword was to enable certain functions to be called from any isolation context because they internally implement the necessary synchronization. Executing things in no isolation context is not an intrinsically useful operation. It’s just one possible model for implementing the feature.

1 Like

I think this is just an oddity of how we talk about the behavior, not a problem with the behavior itself. We shouldn't think of the async function as being isolated or "inheriting isolation" (despite the name that I chose for this pitch :slightly_smiling_face:). We should talk about it as being nonisolated, which means it can run anywhere because it does not touch any actor-isolated state. It can be given parameters whose values come from actor-isolated state, but the nonisolated function cannot store those values into any other actor. So, it should be able to operate on non-Sendable state in the current isolation domain when it's called, just as any synchronous nonisolated function can do today.

8 Likes

If nonisolated only suppresses the isolation constraint, maybe we should refer to it as ~isolated.

1 Like

Some scattered thoughts here. Nothing groundbreaking, but I hope they're informative and helpful!

@briancroom, @smontgomery, and I chatted with @hborla about this pitch earlier this week. We're generally in favour of changes to the language that make it easier to use, and making concurrency easier to reason about certainly falls into that category.

Progressive disclosure is also something we try to lean into for tests because we want everyone who uses Swift to be able to write tests as soon as they can read the language. Despite noble intentions, Swift concurrency has made progressive disclosure harder because even trivial non-async tests need to worry about things like global mutable state.

So we're generally in favour of making these adjustments. I don't think we have strong opinions on the spelling nonisolated vs. @concurrent vs. something else. Today, all Swift Testing tests run on the concurrent thread pool even if they are not async, and I suspect that we'll want to adjust that behaviour to align with what Holly's proposing here, but otherwise Swift Testing and XCTest ought to "just work."

4 Likes

I want to quote a bunch of earlier posts but it’s hard from mobile.

I’ve seen several really good points about why staying in the current isolation makes sense and I’m a big +1 on that.

  • progressive disclosure: it shouldn’t be hard to use async functions and making general async functions (global, on a struct, or on a class) jump to a different actor context makes them hard to use because you now have to worry about all the (good!) swift concurrency checks. Async functions are easy to create and should be easy to use!
  • @saagarjha had a great post with a simple example that should totally work as you would expect and NOT run out of order. That is super confusing. Why wouldn’t we want this to ā€œjust workā€ in the simple case?
  • (For this point, a caveat: I work on apps and this point is biased. We should definitely be trying to make sure different types of engineering problems and styles are addressed.) In my experience building apps (and I suppose maybe this may just be my coding style), it’s really really rare that I want to run code on an unknown actor (or thread or queue). It should be running on the main thread for UI stuff or it should be running in a context specifically set aside for whatever the thing is. My point is: it should be explicit. And the concurrency errors are showing me why: jumping actors has real costs (in having to think about things and make sure things are safe). Those errors are why I avoided jumping back in GCD, you have to make all that safe. Or you can stay on the current context as much as possible and only have to think hard about the boundaries.

Sorry, I’m kind of rambling :). Hope this makes sense to someone.

1 Like

Re: @concurrent: with the above caveat, I don’t really see when I would need this. Perhaps for building a concurrent tool like a concurrent map but I would imagine there would be better apis for that like task group.

@mattie made a good point that without @concurrent there ISNT an exact other way to do it so maybe there aren’t better apis?

Someone else (sorry I forget who) brought up all the different types of APIs that do similar things but for different cases and are all spelled differently. I wholeheartedly agree, it’s very confusing and there should be less keywords!

You might be referring to my question above:

I was hoping for a definitive answer from Holly, but I’ll posit that if @concurrent existed, the handler passed to withTaskCancellationHandler hhandler:operation:) would be marked @concurrent to prevent code from relying on actor semantics that cannot be guaranteed.