[Pitch] Inherit isolation by default for async functions

I noticed this proposal once again leaves poor old async let unmentioned.

IIUC it should now be relatively easy to make this work, right?

final class MyUtility { //not sendable
   func doSomethingCool() async -> Something { ... }
}

actor A {
   func doStuff() async {
     let util = MyUtility()
     async let first = util.doSomethingCool()
     async let second = util.doSomethingCool()

     await processThisSomeMore(first, second)
   }
}

I'm surprised with this pitch and by the positive reception :smiley: .

IMO the decision for non-isolated async functions to be "non-sticky" was brilliant. It gave us a model where by default code would not run on the MainActor which mean we didn't have to think about blocking the main actor most of the time. In fact this has been a huge win with the code I've worked with and people I've taught. The people I've seen having trouble are those that had a hard time "forgetting" about GCD and embracing Swift concurrency. The fact that 99% of the time you don't have to think about "threads" or "background jobs" was huge. I feel like switching this default loses this aspect of the model that imo was a critical part of it. I see there are ways to manually recover that, but it doesn't feel as elegant and natural anymore, it feels like a workaround to recover "dispatch async" in a different way. It was a big win to tell newcomers to just forget about threads and thinking to run things on background, just make a function, don't put any isolation and your will be correct.

Granted, seeing smarter people than me want to switch the default and the huge positive reception of the community means this might be better for sure, but I just wanted to raise an opinion from the other side :smiley:

18 Likes

I'm personally quite torn, because a function inheriting the actor feels more intuitive, and I agree with all of the usability concerns in the Motivation section. At the same time, I think that purely techincally having more functions that default to the global executor is the more correct default for most programs, as actor-isolated functions should be few and far between in the first place. As also evident in the examples, the functions that'd really benefit from sticking to the actor are the "generic data structure-y" ones, which are more common in libraries, but less so in application code.

I vaguely remember reading somewhere in the earliest proposals/threads on Concurrency that "a function always chooses its own isolation". The SE-0338 behaviour represents this to me: being non-isolated (even if not explicitly spelled out through nonisolated) in this case is a specific choice rather than the lack of one.

If "a function always chooses its own isolation" is a good paradigm, I think the problem is not which of the two modes is the better default (so far in my experience, I'd required both of them with pretty much equal frequency), but the fact that there should be three modes that we talk about:

  • isolated
  • non-isolated (aka "always runs on global executor)
  • "do not care"/"happy to inherit"

I think that the lack of an option to explicitly express the latter distinction is the crux of the problem; moreso to do it uniformly (as proposed, we'll end up with a mix of both a parameter keyword and an attribute).

Not blocking an actor is a very legitimate reason for a function to strongly insist on running on the global executor; but so is the intent to not hop off an actor, and I think they should have ideally received the same treatment and syntax.

Perhaps there is still an opportunity to explore this direction?


Edit: as a bikesheddable proposition, I'd consider spellings like nonisolated(strict)/nonisolated(always) on the one hand and nonisolated(inherit) on the other — or something similar. Whichever becomes the default, it'd at most be a redundant annotation (like the internal visibility qualifier), but still leaves the option to be explicit about the choice regardless.

The nonisolated proper could become deprecated, and the compiler could provide a fixit to annotate all current functions that behave in accordance to SE-0338 with nonisolated(always).

11 Likes

I am really excited for this change, especially introducing an explicit concept for parallel execution. Relying on a side effect of "defining no isolation" in order to achieve parallel execution always felt wrong to me. I want to explicitly tell the system that work is supposed to leave the actor. nonisolated should mean just that, this is not isolated yet but it'll get the isolation of where it's used.

One question: the proposal says: "Async functions can be declared to always switch off of an actor to run using the @concurrent"

Does "switch off an actor" imply it could "switch off to the main actor"? I would like it not to because when we run code concurrently, we usually want it to not run neither in the current actor nor the main actor.

It'll be nontrivial to migrate my codebase to this change, but I think that the improvements in intuitiveness of how nonisolated works and how to run concurrently without actors are completely worth it.

2 Likes

While I definitely see how this can be a topic potentially, and maybe even trigger a few "but we must never block the main thread" feelings, my best guess is that in most cases this change will actually help performance.

I don't even want to know how many million times current Swift code hops off the main thread (which is somewhat expensive), just to execute a few instructions before doing the actual awaiting (like network requests, or reading from storage or something). Very wasteful.

If you really have compute-heavy code that should "hop off" it can easily be marked as such, but my (totally unresearched, experience and gut-feel based) prediction is that this will be the exception rather than the norm. And if it is such a heavy thing, chances are you don't even want it on the global executor, but rather on something specific for long-running computations to avoid random delays for other small chunks of code that should fly through.

Also, maybe a bit spicy, but from a "general purpose" language perspective (ie: not just apps) that notion of "there is the main actor, and then you hop off to the background not block the render loop" being (more or less) baked into the concurrency system so hard always rubbed me the wrong way a bit.

10 Likes

Unfortunately that's not how devs are taught. The idea of not blocking the main thread is pervasive in app developers. For the past months since moving to Swift Concurrency I've been able to directly compare iOS code with Android Kotlin coroutines and it was night and day. For many reasons of course, but one of them being this non-sticky behaviour. Kotlin coroutine code tends to be littered with the equivalent of our new withTaskExecutor to make sure any work happens on the IO scheduler. And we don't have to go to another language. Pretty much all Combine and Rx code you can find does that.

And my argument is less about the performance impact and more about the cognitive load. I argue that the current behaviour unloads a lot of burden once you embrace swift concurrency.

But I do see all other points raised and I'm sure people have thought about it way more than me. I'm just raising a data point in a sea of developers ^^

1 Like

With this proposal, I think there are many situations where an isolated parameter would no longer be necessary, yes.

2 Likes

This is, to put it mildly, a consequential proposal. Maybe this is a weird thing to say, but I think it should be a tough call. There are serious source compatibilty concerns. This may also make maintaining backwards-compatibility with older compilers even more of a pain. But perhaps worst of all, this proposal would invalidate (or at least complicate) lots of material out there that explains how things work. This will introduce some short-term confusion, but I'm worried it could be end up being a medium-to-long-term problem too. This is particularly bad timing for more confusion.

Lots of people get started with concurrency by translating completion handler-based functions into an async version. In fact, this process is encoded into the compiler itself with Obj-C->async stuff. However, SE-0338 can make this seemingly straightforward change a big problem. It makes these kinds of direct translations incorrect in the general case. For Obj-C, I'm not even sure how to address the problem, because that can require Swift-only constructs. I have also encountered code that was fundemantally incorrect, but relied on @preconcurrency import to paper over the problems caused by introducing async functions on non-Sendable types.

Now, to be fair, some of these problems are only possible because warnings are off. That is the default though, so it really matters. But, putting that aside, I'm pretty sure this propsoal would make things work correctly in many of these cases. That really underscores the motivation here. Developers just expect things to work this way. Relying on education is fine, but even better is not needing it the first place.

To me, the most important concern is the affect on existing non-isolated async functions. I know this is a forthcoming section of the proposal, but as written I'm unsure how to rigorously audit a code base to be sure you aren't introducing unintended behavior. Also, this doesn't just have peformance implications! I could totally believe that freeing up an actor as a side-effect of calling an non-isolated function could be a critical runtime behavior. Runloop interactions like that, for example, can be very subtle and non-obvious.

I think that @nkbelov's suggestion of variants on the nonisolated keyword is facinating. I also think @Jon_Shier's async(global) is really interesting too. Moving the execution into the effect draws a more clear line between isolation and execution, which is kinda what's happening here. Both ideas also might also help with the source compatibility story. This doesn't mean I'm saying I'm opposed to the @concurrent annotation. I just think these ideas are very interesting and definitely worth more exploration.

I strongly sympathize with, but ultimately do not share, the concern that @Alejandro_Martinez has about cognitive load here. It's true, just marking something nonisolated is convenient. Speaking for myself, it has become second-nature. But is it easy to understand? I have found it consistently challenging to explain. People seem to remember it fairly easily, but I have seen very little evidence it is intuitive. I think it is a certainty this will result in more main-thread blocking than before. But, I think significant amounts of work shifted off the main thread today, in real projects, is both accidental and incorrect.

Also, personally, I just want to second @sliemeobn's praise of the team to consider something like this. Even if it does not ultimately succeed, I think it's wonderful and important to see.

Whew, sorry about writing so much. I'm strongly in favor of this change, as long as the source compatibility can actually be pulled off in a non-disruptive way.

26 Likes

I mostly agree with what Matt's written here, and I want to echo the sentiment that a lot of people are currently learning concurrency using the system that's in place right now and changing the semantics of how we can reason about where code runs will be hugely impactful and will almost certainly introduce unintended behavior in codebases.

Personally I like the model that's in place right now. I can look at a function and its declaration and reason about where it will run. With this new change, it looks like that won't be possible anymore.

I see value in adjusting the behavior to match existing mental models, but for those that have invested a lot into learning and understanding the model that we've had for a while now will most likely be a frustrating experience and we'll need to have some good migration tools in place so that we can make sure that we don't end up with code that runs in an entirely different way than we'd have expected based on the current model.

I'll be paying close attention how this evolves.

12 Likes

IMO the thing that made it click for me, and the reason I have a soft spot for the SE-0338 behavior, is that it allows us to tell a relatively simple story: in GCD, the caller was responsible for deciding where code will run; in Swift Concurrency, the callee decides where code is run. Even in the case of nonisolated async functions, this held true. The callee is making a decision to run the async function on the global concurrent executor. In my view this proposal would muddy the waters and trade convenience for a model that is ultimately less straightforward. Now, it may be that that's the right tradeoff to make—I don't think it's an obvious call.

One potential path forward which would address the source compatibility issues would be to require every async function to choose its behavior explicitly. I.e., bare async would become a warning and you'd have to choose async(global) or async(inherit). This would be... a drastic change as well, but IMO it would be preferable to deliberately changing the meaning of existing code. If there's a way to thread the needle on source compatibility that I'm not seeing, I would love to be wrong, but I don't think that silent, drastic changes to the existing semantics are acceptable.

19 Likes

I strongly agree with this change. async functions should run in the caller's context just like synchronous functions do.

I've long thought that this behavior should be the default for performance reasons alone. Why should simple utility functions force a context switch when called from actor isolation?

It's unfortunate that this change wasn't included in the Swift 6 language mode.

3 Likes

I agree completely with everything here. Most definitely including that it is not an obvious call!

But the existance of isolated parameters, and the trend towards using the #isolation default greatly complicates this story. Don't you think?

2 Likes

I wonder if we could put it in the executor's control whether async code is allowed to stay on the executor by default or not. Unintentionally holding up the MainActor was one of the main motivations for the current behavior, but I think the MainActor might be in the minority in benefitting from that policy (though possibly not alone).

7 Likes

+1 – problem is worth solving, but I'm not sure about the solution.

The behavior introduced in SE-338 is one of the more unexpected bits of Swift Concurrency. If you are coming from the closure-based APIs, your mental model for async/await is "syntax sugar for completion closures". You expect async functions and tasks to start synchronously on the current thread (queue, isolation domain). It's also often important for the code on the main actor to not leave the thread to make sure the state updates go immediately and in order (noting that it's not what's covered by the proposal). The current behavior is the opposite of what you typically want.

It sounds like the right move to revert this decision, but it needs a migration strategy or it will break a lot of code that relies on the current behavior to offload work to the background.

isolated (any Actor)? = #isolation ?

This API always felt like a non-starter, but I can't provide arguments why.

2 Likes
  1. I agree with @Alejandro_Martinez that the non-sticky model was brilliant. I also agree that it is taking a while to internalize, but once I wrapped my head around it, I came to see its benefits. (Although I am not at all an expert, I wrote a little more about this here.)

  2. There is a lot of talk of performance concerns on both sides of this issue. Are there hard numbers anywhere that measure any aspect of this? For example, how expensive are context switches / hops in the grand scheme of things? I don’t have examples at hand right now, but I feel like the subject of context switches / hops always comes up in topics like this, but often in opposing ways! In other words, last time a change came through that potentially increased hops, there was plenty of input opining that hops are relatively negligible / can be elided by the runtime / etc. Now that we are considering this change, hops are the enemy again. Which is it? :stuck_out_tongue:

  3. If I understand the motivating example at the top of the pitch correctly, this is a very simple usage (non-sendable reference type inside an actor) that breaks data-race safety assumptions in a big way. Changing the convention of nonisolated functions seems to be one way to fix this particular construction, for sure. But to me this example, assuming I understand it, suggests more that the model itself is broken. Specifically, it shows that isolation does not compose in a meaningful way. This is a bummer but personally I am already somewhat used to some Swift features not composing as well in cases where they are used with reference types (property observers, @ObservableObject, etc.) as when they are used with value types. To me this usage is no different and if I wanted to fix it I could just pass an isolation to the nested object, right?

  4. I feel like this new construction may place too much emphasis on actors. actor instances are — and to be clear I am open to being wrong — the trickiest part of designing systems in the Swift Concurrency world, for me. They seem like they should be great, but I am not yet comfortable with how isolation and “responsibility” (in the OO sense) are conflated for actor instances. One benefit of the current nonisolated is that it’s easy to get “dispatch-like” concurrency in a system by using nonisolated alone, without having to redesign one’s object graph. Personally as an actors skeptic, I like this. I’m not sure how designing such systems would work after this change — would using actual actor instances be the right way to get back to a world where concurrency / “backgrounding” was always “at hand”?

1 Like

Always a pleasure to read your thoughts on concurrency topics, I admire the contribution you have done to the community so if there is someone out there that has seen all pros and cons of Swift concurrency on the wild I'm sure is you ^^

I just wanted to point out that "just marking something nonisolated is convenient." it's not even what I'm talking about. You don't even need to mark things nonisolated. In fact, I think i've just done that in a few very specific places. Maybe is because folks use MainActor a lot, or because everybody is embracing actors to their fullest, but in my experience where a big part of the interaction with concurrency is fetching data, and this is most likely what newcomers will do first, you don't need this. Picture some simple class that is just wrapping some underlaying api without state, it's just a class with async functions, all those functions are nonisolated without having to write it, or even without having to understand it. So then, from your SwiftUI view.task {} calling that async method makes the default of jumping instantly outside the main actor the correct approach. I've seen plenty of devs go trough this, and maybe is because how our codebase is structured, but this is natural, without having to understand complex topics of isolatoin yet and without using any source markers at all. I'm sure I can look around for more patterns like this, but my point is just that. For somebody writing iOS apps, interacting with UI frameworks, and writing simple async code, the current defaults are, imo, the best option and a huge win over any other infrastructure.

In any case, my feedback is probably just one data point but I thought was worth clarifying. For context, I've been using and teaching concurrency since the first proposal, and we don't work with greenfield code. ( 400k LOC codebase, 12y old with some objc but mostly Swift. With some modules using full concurrency checks while others not. the entire shabang! :smiley: )

In any case I'm glad to see all the different opinions here ^^ and I do understand the concerns raised on both side. Is a tricky one for sure! there is always something new to learn! <3

3 Likes

What's the point of annotating them async then? Don't we expect some parallelism from async functions? I'm thinking of caller's "context" as caller's "thread" here.

Not really, no. async/await it really just for splitting up code so it can run "concurrently" with other things (think interleaved with other bits of code that run in the meantime).

One task (unless you branch out with task groups or async let or similar) still executes one thing after the other.

the fun part is, most often you have an async call stack (ie: an async function, calling another async function, calling another...) and so on, and at the bottom of this there is something that actually warrants the async-ness by doing something to wait for (ie: a network request, a storage loage, a long computation maybe off-thread, ...)

hopping around during this call stack is not ideal, because usually the few lines of code you run through before you hit the "actual thing to be awaited" are often quite cheap compared to "hopping".

9 Likes

I agree that “the callee decides where the code is run” is a nice clean way of understanding Swift Concurrency as it currently exists, but I think if this change is made then you can still describe the behavior with a simple rule: when calling an async function, the execution context never changes unless that function has its own execution context.

2 Likes

Well pointed out. IMHO this is the major source of confusion about Swift concurrency. In other words, "in GCD a programmer decides where code will run; in Swift Concurrency Swift decides, and programmer is only making an (often false) assumption".

1 Like