[Pitch] Inherit isolation by default for async functions

Hello, Swift evolution!

SE-0338 specifies that nonisolated async functions never run on an actor's executor. This design decision was made to prevent unnecessary serialization and contention for the actor by switching off of the actor to run the nonisolated async function, and any new tasks it creates that inherit isolation. The actor is then free to make forward progress on other work. This behavior is especially important for preventing unexpected overhang on the main actor.

However, based on adoption feedback, the default execution behavior of nonisolated async functions is a common pain point in the Swift 6 language mode, because it's difficult to write async functions on non-Sendable types that are used within actors. Swift's general philosophy is to prioritize safety and ease-of-use over performance, while still providing tools to write more efficient code. The current behavior of nonisolated async functions prioritizes main actor responsiveness at the expense of usability.

I'm working on a proposal to change the default execution semantics of nonisolated async functions to run on the caller's actor. You can view the proposal draft on GitHub at swift-evolution/proposals/NNNN-async-function-isolation.md at async-function-isolation · hborla/swift-evolution · GitHub.

This proposal draft is a work in progress; I'm still working on incorporating more examples and explanations, and there are some open questions throughout the proposal. Note that I tried to err on the side of specifying behavior that already exists in the model today to help explain some of the behavior changes. For example, most of the details on closure isolation inference and async function conversions are not new with this proposal, but the existing rules haven't been written down anywhere.

Please leave design feedback in this pitch thread, and leave editorial feedback on the swift-evolution PR at Add a proposal to change the execution semantics of nonisolated async functions. by hborla · Pull Request #2572 · swiftlang/swift-evolution · GitHub.

I look forward to your questions, thoughts, and other constructive feedback!

-Holly

36 Likes

Yeah, this is a tough one. On the one hand I’m sympathetic to the difficulties here, but on the other hand this seems like a dangerous decision to reverse—I’m curious to see the source compatibility section fleshed out. I’ve written numerous async functions which explicitly rely on the SE-0338 behavior and contain no suspension points. I think we’d likely need to identify that behavior and warn about it, but even more concerning are async functions which do have a suspension point but also do meaningfully computationally heavy work and were relying on SE-0338 to move them to the concurrent executor. I don’t see off the bat how you could identify that… The SE-0338 behavior was actually a super convenient way to offload work to the cooperative thread pool if you were aware of the behavior!

14 Likes

I think this area is worth a revisit. I would want us to apply the principle of least surprise, which I think you'd be doing here by changing the default. I do worry about it being a breaking change.

(I also realize that isn't particularly deep or insightful feedback.)

4 Likes

I am +1 on this change. From my experience (both personal and watching other people struggle with this) the new behavior is easier to explain and understand. I understand that this is a change and surely there are people who have written code against the new behavior, but the consequences for making this change are things like "things now run in a defined context that you might not want to block" versus "your app has difficult-to-diagnose race conditions in production due to implicit suspension points you weren't aware of" and I will pick the former every time. In fact I will posit that once these get fixed (which I fully understand is unfortunate, but IMO a lot easier to diagnose during development) the end result will be performance uplift solely because people will feel more confident with using Swift Concurrency and design concurrent systems that actually behave how they think they should rather than being to scared to reach for it because they don't feel they understand how to use it safely.

P. S. I am vaguely -0.25 on the keyword @concurrent but that's just because I think it is too overloaded a term for the specific behavior it indicates here. But I don't have any alternatives to propose and my goal is not to bikeshed so I would be happy with this proposal as-is ;)

5 Likes

+1, I love this change!

I think this is so much more understandable. I think this makes it so much easier to use nonisolated types which I think should be something that’s easy to reach for, safe, and easy to use.

I think @concurrent is great and makes it super clear when some specific code is known to be heavy and should be pushed off an actor. I think it’s so much better to explicitly mark heavy code as getting off the actor instead of taking everything off the actor.

One spot I wasn’t sure about was Sendable functions automatically moving off the actor. I don’t have much experience with those but my initial thought is that they shouldn’t have an additional special behavior and still require @concurrent if you want it. But I don’t know.

Thanks!

I may not be fully appreciating this point, but I'm not really seeing how this would meaningfully change reasoning about suspension points. If you're calling an async function then presumably it intends to suspend in at least some cases, so even if you stay stuck on the calling isolation for a bit longer you still need to consider what happens with that suspension actually takes place.


On a separate note (though I understand why it's necessary), I also find it unfortunate that the rules for async function values are left different from those for async function declarations. It's not clear at the call site whether a call is taking place on a function value or a function declaration, and so having different rules between those two seems like it would introduce more complexity with respect to reasoning about isolation.

10 Likes

I definitely think that the wrong decision was made originally, and I’m keen on the attempt to reverse it.

Since main actor responsiveness in particular is a concern, I suspect this should come with a warning where the compiler can see that a nonisolated async function will always inherit MainActor isolation?

I’m pretty negative about using @concurrent to spell the existing behavior. We see time and again that attributes disable metaprogramming, and in this case, can it not already be spelled with an isolation parameter as isolated AnyActor? = nil or similar?

What I like about the behavior of the nonisolated async functions today is that it's fully deterministic, which makes the code safer in a way. I agree that it's very counterintuitive, but I feel like education will solve this issue over time. Inheriting the context of the caller (especially in a big codebase) means that the same function could be executed on isolation domain A in one place, and isolation domain B in another place, which might lead to hard-to-debug gotchas. Furthermore, it means that the isolation domain could potentially change indirectly after touching a completely unrelated higher-level piece of code that's calling that function deep down, which is also less safe in my opinion.

Having said that, I totally agree that the difference between the behavior of sync vs. async functions is very confusing. I personally struggle to explain it to some of my teammates sometimes.

9 Likes

How about adding support for @isolated(any) on function declarations (as a shorthand for the #isolation parameter) and then applying that to async functions with no explicit isolation by default? That would allow you to jump to the default concurrent pool with an explicit nonisolated just like today, while allowing unadorned async functions to be executed from any isolation domain. @isolated(any) would be equivalent to nonisolated for synchronous functions since they wouldn’t be able to perform a hop (although that does feel a bit strange — maybe nonisolated should be deprecated for sync functions? Or maybe this is on)

8 Likes

Would it make sense to spell @concurrent as async(concurrent) to align with the syntax for typed throws?

So instead of this:

struct S: Sendable {
  @concurrent func alwaysSwitch() async { ... }
}

It could perhaps could be this:

struct S: Sendable {
  func alwaysSwitch() async(concurrent) { ... }
}

I'm not sure if this is better, exactly, but I'm probably not the only one getting @attribute fatigue - although perhaps that's a silly thing for me to be worrying about.

3 Likes

As I was extremely confused with 5.10 release that has introduced warnings on nonisolated async code in the project and learning to adopt to SE-0338 change, right now I don’t see this change of behavior back to inherit by default to be any less confusing. A lot of code might also rely on this behavior, so that this can be a breaking change not in a good way.

At this point I would rather have this behavior as opt-in for functions — and, ideally, types as whole — probably, using already existing syntax with @isolated(any) that does this isolation carry for closures. This will create much less confusion, allowing to isolate functions in a simpler way then passing isolated parameter, keeping deterministic and explicit execution semantics of such functions. I guess then there also can be a fix-it for nonisolated async functions when they produce an error to add this isolation attribute.

2 Likes

I'm far from an expert on this topic, but as I understand the proposed change right now, my biggest fear would be that I've very much internalized the idea that if I want to make sure a function runs off the main thread (and thus doesn't cause UI stutter or something), the surest way was to make it nonisolated and async. (Assuming I'm not mistaken about this - as I said, not an expert on this topic...)

If that is right, though, and this behavior changes the way I think it does, I suspect I have several standalone utility functions doing expensive operations that were marked nonisolated async on purpose to keep them off of main that would suddenly be running on main sometimes.

Would it be possible to warn about that statically or detect them somehow (without having to find out at runtime) so they could be converted to the @concurrent (or whatever spelling)?

4 Likes

Yeah, I think for async functions which have no suspension points, this proposal ought to warn that they will behave no differently from synchronous functions and recommend either adding @concurrent or dropping async. But in the general case I don't think we can reasonably detect all functions which were relying on this behavior, because some of them may indeed suspend in addition to doing computationally heavy work.

3 Likes

I actually really like this proposal for the reasons you outline. However, no matter how this is expressed, it should certainly not be called concurrent. That term is far too general and overloaded to be a useful indicator of behavior to Swift users. It makes the issue visible but doesn't tell the issue what the issue they're solving actually is, so something more specific will be much more useful. I think async(global) makes the right compromise between succinctness and correctly. Technically it should be something like defaultExecutor, but that's too verbose, and just default is too vague. async(global) gives at least some indicator that it's running on the global pool rather than the current context, even if that is technically an implementation detail of the default executor. Another option, which I think may still be a bit too general, is something like what @vns was saying, async(any). I don't think it's quite clear enough, as any is still vague, but it at least matches other parts of the language, and so has some precedent to build on. Plus, technically, the current executor would fall in the group of any, so it's more like "any other", but I don't think that would come up in practice.

In short, @currency is too vague, async(<context>) gives us more flexibility in naming for better clarity.

5 Likes

It would still be useful if the language supported a construct that allowed work to be deferred within the current context, like DispatchQueue.main.async does when you're on the main queue. Technically async functions can't do that today, but it is the obvious interpretation the syntax, especially after this proposal. In fact, I find the current "may suspend" behavior rather annoying, as we have no way of telling whether the compiler will choose to inline some or all of the function and whether that optimization will get rid of the suspension or not.

1 Like

To avoid any confusion, I wasn’t suggesting this as alternative naming to @concurrent, but it is actually opposite to this new proposed attribute. The main reason I mention the @isolated(any) is that this is what we already have in the language and it does isolation carry for closures.

Sorry, but you just added to the confusion. I was supporting async(<something>) as an alternative to @concurent, where something shouldn't be concurrent, but perhaps global or any.

1 Like

Just to clarify, does this proposal largely negate the need for explicit isolation inheritance via isolated (any Actor)? = #isolation?

Yes. I'm happy to see this revisited because I think it's one of the biggest pain point in Swift Concurrency. Hopping off the caller isolation is unexpected and is a shock to this very day to every developer I explain this to. Ironically this caused so many data races because migrating from completion handlers implicitly changed isolation for a lot of code that was running on the main thread off to a background thread (and this was before the concurrency warnings). For nonisolated types, it makes little sense that sync and async functions behave differently and this prevents from reusing those types in different isolation domains (or makes it very difficult).

Also in real-world apps, I believe this didn't really address much performance problems, reusing the caller context is certainly much faster than hoping threads, and many functions are turned async because they themselves need to await other async functions in underlying frameworks or for networking, not because they're doing any actual long running work.

As for the proposal:

Unstructured tasks created in nonisolated async functions do not capture the isolated parameter implicitly, and therefore do not inherit the isolation. This decision is deliberate to match the semantics of unstructured task creation in nonisolated synchronous functions.

This is bothering me because it seems like the same kind of problem this proposal is trying to address, yet another confusing and unexpected implicit hopping. I think Task should always reuse the current isolation. It does when used from inside an isolated function, and I would expect the same to be true in this case. We have Task.detached and (upcoming) closure isolation control to explicitly move the closure to a different isolation. I hope the default implicit behavior of Task can be made consistent.

Finally I'm also curious as to how we would make the migration from code that has now become reliant on the fact that nonisolated async functions implicitly run on a background thread. Do we expect some sort of Xcode migrator that would add @concurrent to every nonisolated async function in order to maintain the current behavior?

4 Likes

oh man, what a time to be alive!

I 100% agree with this proposal! It spells out in actual well-thought-out words what my gut feeling was about the isolation rules.

I have bumped into this very issue many times (eg: this post). In current Swift, certain patterns (really any type of async utility type or function) are just so cumbersome to spell out or plain impossible to implement with Swift Concurrency.

But seeing how complicated that all would be (for both complier and runtime I guess) I resigned and settled on "maybe one day we at least get a @inheritsIsolation on a function or something".

I applaud the courage to tackle this, and I appreciate calling out that SE-0338 was ultimately not the ideal move (I understand though that at the time, doing what is proposed here was probably too far out). If this actually lands, to me, this incremental evolution at its finest!

Also, taking a step back: Especially with this proposal in place, I find the concurrency system in swift is truly remarkable. The level of static analysis of isolation and RBI (especially with these new inheritance rules), combined with hard-to-get-right runtime features, is awe-inspiring. I hope once the dust settles, and we have all tended to our cuts and bruises from migrating to it, we can fully appreciate this impressive work of art.

4 Likes