Hi. One problem we've been seeing with Swift concurrency is that there isn't an easy way for async functions to polymorphically share their callers' actor isolation. This makes it hard to use certain kinds of async abstractions from isolated contexts because technically the async functions in the abstraction have a different isolation, which means e.g. you can't share non-Sendable values with them. It also can have unwanted performance consequences.
I've written a proposal which adds a handful of related features that should go a long way towards solving this problem. This hasn't been implemented yet, so it might change substantially, but I'd be interested in hearing what folks think about it.
Please make editorial comments on the pull request; everything else can go in this thread.
It brings to mind a reisolated attribute in the same vein as rethrows, to indicate that a function (and by extension its return value(s)) are isolated in the same way as one or more of its arguments (in which case all arguments must agree on their isolation domain).
Re. the "isolation" parameter, it seems a bit weird to have a function parameter that's not actually referenced in the function body. It seems suboptimal in the same way as it would be if you had to always write "self: Self" as the first parameter for instance methods.
My intuition in that regard is to expand the existing isolated function prefix, complimentary to the existing nonisolated. Currently you can't actually write isolated func(β¦) explicitly (I believe), it's only accessible by implication when writing actor instance methods. Making the keyword usable explicitly would allow it to expand to all functions, perhaps with suitable arguments (e.g. isolated(caller)).
Hi there! This seems like it would solve some pain points that I've run across, so I'm interested to see this implemented.
Just to check my understanding: is there any reason for someone to use isolated (any Actor)? = #isolation instead of @inheritsIsolation besides allowing callers to (optionally) explicitly provide an isolation value that's different from the current isolation?
This appears to cover all of the remaining problems I ran into while implementing sendability and actor isolation in Realm.
We have a few functions which take a mutable, NSCopyable obj-c object as an argument, which we want to deep copy and stuff in an @unchecked Sendable wrapper before unblocking the caller. Currently we use @_unsafeInheritExecutor for this. This has been error-prone due to that it doesn't actually inherit the isolation and so doesn't resume on the same executor after an await. We've been able to dodge the problem so far, but it's also easy to imagine wanting to unwrap a non-sendable type in the caller's isolation, which isn't possible with @_unsafeInheritExecutor. @inheritsIsolation appears to fix these problems, and of course gets us off an underscored attribute.
#isolation is also something we've wanted in a few spots. The simplest example is that we'd like try await Realm() to give the caller an object isolated to the caller's isolation context. Currently we require that users do try await Realm(actor: actor) and explicitly pass in the current actor. In addition to being clunky and confusing, this has the significant problem that passing in the wrong actor is a runtime error, and Swift 5.9 has started producing sendability warnings even if the correct actor is passed in.
I'm not entirely confident about this, but I suspect that all of the places where we'd use #isolated could also be achieved via a combination of @inheritsIsolation and @_inheritActorContext. You can't really do anything with an any Actor other than use it as an isolated parameter later, and we could replace storing the actor with storing an appropriately isolated closure. There isn't any reason we'd want to do this if exactly this proposal was implemented, but if for some reason #isolated turns out to be a problem, then expanding inheritsIsolation to also subsume @_inheritActorContext may also be a viable approach.
Overall, this is awesome, it feels like a truly necessary (and missing!) part of the concurrency story. As a library author myself, I've already bumped into limitations and difficulties related to isolation domains & sensibility. Can't wait to use this capability.
On this:
I think Wade was suggesting this as an alternative spelling for @inheritsIsolation specifically β so that "inheriting isolation" is more closely aligned with the other isolation modifiers.
The #isolation part of the proposal would stay exactly the same.
I do like his suggestion. It makes the feature feel more cohesive and intuitive. And I can't think of any edge cases that would a keyword prohibitive. isolated(caller) var et. al. should be just fine, right?
I don't know if there are technical restrictions that would make an attribute better than a keyword here.
It might be tricky to parse, but I do like the idea of isolated(caller); it carves out a very consistent spelling for function isolation, where you either put some flavor of isolated on the declaration itself or (in a relatively uncommon situation) on a specific parameter. And we're already talking about other kinds of isolation specification.[1]
The parsing problem is that isolated is only a contextual keyword, but it would need to be specially recognized in a lot of situations where it would be very helpful for it to be a keyword. That isn't a problem with current uses of isolated because they're only allowed on member declarations anyway. For example, is this a function modified by isolated, or is it a call followed by a function?
func test() {
isolated(caller)
func foo() {}
}
I'd be really tempted to just outright steal isolated as a keyword, but of course that's compatibility-breaking. Maybe it'd be okay in practice.
So isolated MyActor is changing the isolation of actorFunction declaration itself, but isolated(caller) is changing the isolation of that parameter. Very similar code meaning very different things. I see what you mean.
And furthermore swapping the keywords in either case β like isolated(caller) MyActor or vice versa β I'm assuming that would be completely invalid. So the visual similarity would be misleading.
Yeah, I see your point.
I was attracted to the isolated(caller) idea because it avoids adding another attribute for closures β there are already so many! But I can see how it would be misleading, and attributes for functions are already prolific, so it makes sense to follow that pattern.
Can we create a dynamic actor attribute the means the same thing? That way it could be used everywhere users would expect to specify an actor. @CallerActor seems to fit.
I am currently leaning towards using isolated(caller) and isolated(any) for this and then gradually deprecating the existing isolated parameter modifier in favor of some new spelling (maybe isolated(parameter: actor) on the function level, although that has some trouble with being written on function types). I brought this up with the rest of the LSG, and we're tentatively in favor of exploring that direction.
I have not only run into a lot of situations where I thought I needed this, I actually feel like inheriting the caller's actor isolation should be the default behavior and not doing so is unusual. I'd love to see the proposal expand on why this is not the case and/or a bad idea, because it certainly went against my intuitive interpretation of how this should work.
I think the sequentialMap function is a particularly strong example in that this kind of unannotated, general-purpose higher-order function is exactly the kind of thing I'd expect to inherit isolation rather than becoming its "own thing" and effectively unusable from within isolated code.
In fact, I had even convinced myself that this was already the way it worked with a tiny example that I thought would fail otherwise:
// EDIT: accidentally left this in, but my intention was to not isolate this function:
//@MainActor
func general(block: () async -> Void) async {
await Task.detached(priority: .background) { print("hi") }.value
// still on the same actor afterwards as where we started
await block()
}
await general {
MainActor.assertIsolated()
}
I figured that if general wasn't inheriting isolation, after awaiting the detached task's value it would either continue executing on that task's executor or on some other place, but it seems it hops back to the main actor for some reason. Is this behavior intentionally undefined or is it in fact guaranteed, as if it inherited isolation?
The behavior of non-isolated async functions used to be to run on the caller context; then in SE-0338: Clarify the Execution of non-actor-isolated async functions it was defined that we force a hop off the caller's executor -- the proposal explains the rationale. It may have "over-corrected" but the rationale is explained there.
The currently under review SE-0417: Task Executors brings back control over this to users -- a task may specify a preference and be sticky to some task executor.
The snippet you share also doesn't really work under these rules -- because it IS an actor isolated function, specifically isolated to the @MainActor -- so there's nothing to inherit since there's an explicit requirement that general() must run on the main actor.
About the snippet: Sorry, I just realized I had left in that @MainActor annotation without meaning to! The thing is that it still succeeds without it (when called from the main actor), but I suppose this falls under code that does little work of its own and thus has been chosen to remain on the caller's executor by the runtime?
Thank you for the insight! I should have been paying more attention when SE-0338 dropped, because now that I've been using concurrency for a bit, it certainly feels like the wrong move in hindsight. The thing is that I never expected async functions to implicitly hop off their caller's executor simply by virtue of being async, so I never even thought to use this to offload long-running code from a contended actor. I imagine many other developers would feel the same way, but I have no way to support that claim. Instead, I was just as cognizant as I normally would be about calling into heavy code from the main thread and such, using tasks or other actors to offload heavy code.
I do think it is a strong argument that code should receive less guarantees by default and state any additional things it needs Γ la mutating, but the big difference here is that the need for mutating becomes obvious while implementing the function, while needing to inherit the context only does so once you try to call it from an isolated context with non-sendable values involved. This looks to me to be a major pitfall for general code that doesn't anticipate needing to do anything special for this use case, especially if you're designing a Swift package, e.g. with a few generic utilities for managing concurrency.
The performance argument about always needing to store the current executor to resume on it makes sense, though it's hard to judge without some statistics on just how much it would cost in practice.
Applying these thoughts to this proposal, maybe we could somehow allow the caller to decide to pass on its isolation to a called function, e.g. await(isolated) foo() (I think this syntax would be source breaking, but only for unusual code). This would then be suggested in a fix-it when sendable rules would otherwise warn about the function call.
(The function could still explicitly request inheriting isolation or the generic executor, but this would allow us to work with unannotated functions that didn't anticipate this use case.)