That handler is already @Sendable, which should be sufficient to force it to not rely on being called with any specific actor isolation. It would not be @concurrent because it does not have to be called without isolation if the current context (really, the context of the thread that triggered cancellation) is isolated.
Thanks Kyle that’s an interesting post but no, it was this one:
If the purpose of isolation is to enable the language to statically guarantee exclusive access to an actor, how can a function that might run concurrently with another task on that actor also safely be isolated to that actor?
You cannot convert an isolated function to @Sendable () -> Void.
This compiles:
let x: @Sendable () -> Void = { @MainActor in print("hello") }
I’m pretty sure I’ve even implemented this pattern for delegates in the Game Controller framework.
Compiling in Swift 6 mode gives me:
error: converting function value of type '@MainActor @Sendable () -> Void' to '@Sendable () -> Void' loses global actor 'MainActor
Interesting. It compiles in the latest Swift Playgrounds for iPad, which only supports Swift 5.9.
I thought this was the officially supported way to allow clients to get their delegate methods to run on a specific actor.
For this conversion to be valid the type of x needs to be async so that the compiler can generate the thunk to hop to the main actor when needed. It's otherwise not valid to synchronously call an unknown function value which may need to run in an isolation which is different from the calling context.
Edit: Decided to go ahead and move my comment to a new post instead. The crux of it was: "I like the term 'concurrent' for this use case. Could it also be a new keyword that behaves similarly?"
If either the type of the closure is
@Sendableor the closure is passed to asendingparameter, the closure is inferred to benonisolated.
How would this work with @isolated(any) and @_inheritActorContext?
It's kind of weird to me that this is a thing that needed to be pitched, and @concurrent (i.e. The current default) was not.
async, as a programming language concept, was originally invented as a way to avoid blocking the entire program on every little IO (especially potentially time-unbounded actions, like waiting for a network peer), without having to dive to the mess that is threading and mutexes. It was designed around single-threaded paradigms. Having every async function default to (for all intents and purposes) spawning a thread just seems to contradict this completely.
Spawning a new thread is something that should be done with great intention. Even if the language itself protects against data races, it doesn't mean spawning threads should be as easy as adding an attribute (i.e. @concurrent). Doing it implicitly without so much as an attribute goes against any reasonable expectation.
Unless I'm completely misunderstanding how this would work. Someone correct me if I'm wrong: This would make it so any CPU-bound tasks within a non-isolated (i.e. default) async function would block the calling actor, but calls to something like await Task.sleep from within said function would still yield and allow other methods within the actor to run, correct?
As a historical note, the current default was very much pitched (and accepted) as SE-0338.
Hmm, if I'm reading it correctly, the original issue SE-0338 set out to solve is that await could return on a different executor than it was called. And instead of just disallowing that, it posited that a specific executor should be enforced instead.
For comparison, C# simply made it so await captures the current executor, unless explicitly told not to do so via .configureAwait(false).
The thing is, a "return on the same executor called" could have very easily been implemented on the callee:
- "System" async functions like
Task.sleeporwithUnsafeContinuationcould be implemented to capture the executor they are called on, and automatically switch back to that executor when they complete. - Simple/non-isolated
asyncfunctions would need no modification, since the only way they could switch executors is by awaiting another function that does so. - Actor/isolated
asyncfunctions should capture the current executor on call, and switch back if it is not the same executor in which they run. This is no more difficult/costly than checking and switching to the actor's executor on call.
The end result is roughly what this pitch suggests, just simpler.
One other alternative I can see is if instead of being inherited, the executor would be inferred. i.e. If you await a non-isolated async method on a member of an actor, it should be isolated to that actor. Of course, since a member of an actor can only be accessed from within the same actor, the end result would be the same.
Sure, there could have been a different solution proposed at the time of SE-0338, I just wanted to emphasize that the current default behavior did receive specific attention and consideration.
I ran into this problem today. Essentially just trying to call await asyncIterator.next() inside an actor. Conceptually, all data should have been isolated to the actor. And yet:
Error: Non-sendable type 'Element?' returned by implicitly asynchronous call to nonisolated function cannot cross actor boundary
The next function escapes the actor (even though that's not intended by the AsyncIteratorProtocol and certainly not intended by me) and my non-sendable Element type can't get back.
So yeah, +1 from me. This situation needs to change.