I wanted to discuss concurrency ergonomics to see if anyone else thinks this is a gap worth putting effort into.
First, this isn't a functionality gap, I can express what I want to in swift today, which looks a bit like this:
final class Model { var count = 0 }
func doWork(with model: Model, on isolation: isolated any Actor = #isolation) {
Task {
_ = isolation
model.count += 1
}
}
This is an actor-isolated function that:
- Takes a 'Non-Sendable'
Modeltype - Launches a Task (strongly capturing the isolation)
- Mutates the 'Non-Sendable' from the Task on the same isolation
Note: Calling the above on Swift 6.4 from a nonisolated context eliding the isolation parameter via the macro results in: "Error: Call to actor-isolated global function 'doWork(with:on:)' in a synchronous nonisolated context" which is behaviour I want
The example is in essence the actor generalised version of the following:
@MainActor func doWork(with model: Model) {
Task { model.count += 1 }
}
The above code is a lot nicer to read. Because the isolation is statically known, the function does not need to be parameterised and the Task can also inherit that same isolation automatically.
You may be wondering why I'm even launching a Task like this, which is a fair question. Staying in structured concurrency longer can certainly make things easier. The above examples are simplified stand-ins for more complex code I'm working on. Which is more akin to an entry point that takes events and launches/cancels Tasks, where the lifetimes of those Tasks can also be bound to app events (like sign out). This is shared app facing code targeting iOS and Android so .task {} isn't available on both platforms.
Generalising the code to be runnable on the caller's actor, whatever it is, is helpful to allow code to have the ability to mutate non sendable objects and to unlock parallel testing (e.g. not forcing tests to serialise through MainActor but also to be runnable in parallel via a TestActor).
For my purposes it'd be helpful to see an addition or sugar added to the language that allows specifying something as: isolated(inherited) (following the @_inheritActorContext name) or isolated(caller) (I understand this was the rejected name for nonisolated(nonsending)). Where in this case we're saying "We're on the isolation of the caller and child Tasks should also inherit it by default, also disallow calling this function from a nonisolated context".
I know there is work being done to allow explicit isolation capture (which looks useful) but what I'm looking at here hopefully allows automatic inferred / inherited isolation capture. In a way that is static and determined by the call site in contrast to dynamic (and optional) runtime isolation.
As an aside: I'd also argue most app-facing code usually runs "on an actor". I imagine this is why @MainActor was chosen as the sensible default isolation for 'Approachable Concurrency'. But as many find that can be quite restrictive and I feel like "isolated to a non-nil Actor" is a decent middle ground between @MainActor and nonisolated. This is probably well out of scope for this discussion but on class types it could allow inferring [isolated self] for Task creation too.
For this discussion in the simple case the final example would look something like this:
isolated(inherited) func doWork(with model: Model) {
Task { model.count += 1 }
}
There's a semantic difference here too, not just nicer syntax. Today the parameter lets an async caller override the isolation:
@MainActor let model = Model()
func nonisolatedFunc() async {
await doWork(with: model, on: MainActor.shared)
}
That works because the await hops onto the actor you passed in. Whereas I'm looking for "run on the caller's isolation, without substitution." You can still do a similar thing but it now requires the same syntax as @MainActor func doWork variant:
func nonisolatedFunc() async {
await MainActor.run { doWork(with: model) } // Hop to match the isolation of `model`
}
I assume this is non-trivial to implement like this, but I wanted to raise it for discussion because while passing isolation as a parameter does work it seems like something that could be more semantic for the language. Being able to express "having an isolation" generally, at least the function level seem useful. I'm not attached to any particular naming here, just the idea of expressing "we have a non nil isolation".