I think this is actually quite a big hole in Swift's concurrency story.
I want to focus on the actor
naming because isolation in Swift fundamentally is modelled in terms of actors. And what I'm actually asking for here is a composable island of isolation.
Actors are able to "protect" arbitrary non-sendable state and operations and make them sendable because they are tied to a single isolation domain which the compiler knows how to access and can somewhat reason about the boundaries of - enough that whichever isolation domain you happen to be in, it knows how to switch to the serialised timeline that this non-sendable state lives on so you can access it safely.
So anyway, let me reduce the shape of this hole to its essential characteristics:
-
We have some non-sendable state with identity (e.g. it contains a resource handle or something)
-
We need to respond to events (incl. async continuations), so handlers for those events need the ability to copy an owned reference to it
Those are the essentials, I think: has identity, needs to respond to events.
Of course, we want something that is composable - sure, the component may be receiving callbacks from asynchronous workers/event sources, but we still want its state to live in the same concurrency context as its container (as is typical for values in Swift) so the container can easily use the component.
Looking at requirement 1, it seems we want to encapsulate our state in something with identity, so either a class/actor, or a noncopyable struct. Requirement 2 rules out noncopyable structs, so our options are:
A Class
Classes are the traditional solution to this kind of thing, and we can summarise the options Swift concurrency gives us by considering whether our class should be Sendable
:
-
If we do not mark it as Sendable
, it is subject to the limitations on nonsendable types described in this thread: it is assumed to have no fixed isolation, always on the move switching isolation domains. That isn't what we want to model, and it's not suitable for our purpose, as it cannot respond to events; event handlers know of no isolation from which they can call back to it safely.
-
So we must mark it as Sendable
, but this means it must be directly callable from every isolation. Because the language lacks the ability to express isolation of a classes, it doesn't know which isolation to switch to in order to use instances of this class safely. So it assumes it is callable concurrently from everywhere, at any time, and it's up to us to try make that work somehow 
It is not very easy to actually implement a Sendable
class over non-sendable state from scratch. In most cases, this is going to involve wrapping all your internal state in a Mutex
or dispatching everything to some internal queue (basically a poor-man's actor
). This is technically making it maximally thread-safe, but it's only really necessary because the language doesn't know how to switch to our actual isolation.
An Actor
Actors seemed really great when they were pitched because, as I described above, they offer an island of isolation for non-sendable state. Concretely, that allows you to locally reason about events from async workers a single piece of state, with the compiler ensuring all the calls are serialised and called from the appropriate context.
However, it is difficult to actually use actors in your program because they simply do not compose in a concurrency sense.
(struct/class) Foo {
let component: MyActor
func useComponent() {
component.anything() // Error: crosses isolation boundary, must be await-ed. Means you need async/Task {...} everywhere.
}
}
I think the real benefit of having islands of isolation for developers is that they should form the foundation of building code that can compose and use concurrency without fear of introducing data races. But actors, with the limitations they have today, are unable to fill that need.
...but wait, what's this? A third way??
There is one more option. The language offers us one, singular way to express isolation for classes in a way that can be used to build composable... components with static concurrency checking: global actors.
If you mark everything as @MainActor
, then the compiler is finally able to reason about things that should be in the same isolation domain. I am fairly convinced this is why we're considering proposals such as SE-0466 Control default actor isolation inference:
A lot of code is effectively “single-threaded”. For example, most executables, such as apps, command-line tools, and scripts, start running on the main actor and stay there unless some part of the code does something concurrent (like creating a Task). If there isn’t any use of concurrency, the entire program will run sequentially, and there’s no risk of data races — every concurrency diagnostic is necessarily a false positive! It would be good to be able to take advantage of that in the language, both to avoid annoying programmers with unnecessary diagnostics and to reinforce progressive disclosure.
The easiest and best way to model single-threaded code is with a global actor. Everything on a global actor runs sequentially, and code that isn’t isolated to that actor can’t access the data that is. All programs start running on the global actor MainActor
, and if everything in the program is isolated to the main actor, there shouldn’t be any concurrency errors.
In other words, I think the problem is deeper than the proposal lets on: it's not about just avoiding annoying diagnostics for the sake of progressive disclosure; global actors are a necessary cludge to make simple things work in Swift concurrency because they are the only way to express that multiple components with identity have the same, non-floating isolation.
So yeah, I think we need composable actors.