Actors
Actors are (next to async
/await
) the big thing for Swift 5.5 this year. If, like me, reading the official spec makes you want to kill yourself slowly, and even the WWDC2021 videos make your eyes roll to the back of your head, I'm taking a deep dive here and am distilling the essentials for you.
Source material:
- S-0306: Actors, the spec/proposal
- S-0302: Sendable, the spec/proposal
- Protect Mutable State with Actors, WWDC2021
- Swift Concurrency: Behind the Scenes, WWDC2021
(Slide screenshots are taken from the above videos)
Actors are plain classes, with some special behavior injected/enforced by the compiler to avoid data races. Essentially, actors ensure that their data will always be accessed in a thread-safe manner.
They do this by ensuring that that all access to their state goes thru the actor's own methods (or properties), and that each access, even from multiple concurrent threads, is run one at a time. This is very roughly comparable to a class where each member has an Oxygene-style."locked on self
" directive. But of course. Swift being Swift, it has to become messier, and the usage semantics are slightly different.
In Swift, you declare a class to be an actor simply by replacing the class
keyword with the new actor
keyword (Elements will expand this to more languages, as detailed at the end of this post).
Actors do not allow non-private fields (a concept Swifty doesn't have anyways, but all other Elements languages do). All non-private members in an actor are considered isolated by default.
- All external calls to actor members must use the
await
keyword/syntax, turning the code asynchronous. - External calls to properties (and subscripts) are only allowed for read operations, and must still be async. Write access to properties is not allowed at all (🤷🏼♀️ why? because Apple Swift doesn't have fields, that's why!🤦🏼♀️)
- From inside an actor's method, other members of the (same) actor instance can/will be accessed normally without
await
and will run synchronously 🤯. - Actors can only directly access members on
self
and not, like regular classes, on members of other instances (public or private) of the same type. Access to another instance will fail with an error stating that the value "is actor-isolated". - As an exception, Immutable properties of an actor (more on that below) can be accessed (read) directly. (but, oddly, only inside the same a module; that limitation makes no logical sense, but the spec says that this is to "preserve the ability for the module that defines
BankAccount
to evolve thelet
into avar
without breaking clients, which is a property Swift has always maintained". Fucking dumb.
Methods (and, one presumes, other members) can be explicitly marked with the nonisolated
keyword to make them escape the special actor sauce. This is e.g. helpful/needed when implementing protocol members. Non-isolated members will then run unprotected, as a normal class instance method would. They can be called from the outside without await
, but in turn, they now have to use await
to access isolated actor members or mutable members of the actor, just like outside code would.
In essence, a nonisolated
method in an actor is the same as any other old method on a regular class.
It is also worth noting that static methods do not (need to) get isolated.
Finally (hah!), actors are final
and cannot be inherited from.
Closures
Closures declared/implemented within an isolated member will themselves be actor-isolated, and can thus access actor context directly. The compiler (somehow, more on that below) knows whether the method the closure is being passed to runs it synchronously (i.e. safely within the isolated context), or not.
Magically, the compiler knows asyncDetached
will run the closure asynchronously later, and an await
is required here:
Sendable
Both value types and actors are always* safe for concurrent access. Classes might be, but might (and usually are) not. Types that are safe for concurrent access are (for reasons beyond any mere mortal's understanding) called "Sendable" 🤷🏼♀️.
(*structs are actually only sendable if they don't contain classes; granted, probably a rare case).
Apple Swift will "in the future" prevent non-sendable types from passing in and out of actors; it's unclear what enforcements it currently does or does not do.
Classes can be manually marked as "sendable", if they are immutable, or manually implement synchronization. Swift being Swift, you do this by implementing the Sendable
protocol, which has no members, on your class. Saner language design might have chosen an attribute, especially, as you'll see later, since Swift does add a new @Sendable
attribute, too 🤦🏼♀️.
The compiler will enforce that a sendable class only has (public) sendable members. Beyond that, it is up to the class's implementor to make sure that a class marked as such actually is safe:
Generic types can be sendable if their generic arguments are sendable too. It's unclear whether this has to be a constraint, or is determined at compile from the actual concrete types (an [T]
array of, say, String
, is sendable because String
is, so one would assume it's the latter).
I guess that generic constraints are why they need this to be a protocol, not just an attribute.
All actors automatically conform to the (also empty) Actor protocol, which implies Sendable. Because, why not.
public protocol Actor : AnyObject, Sendable { }
@sendable Closures
Functions can also be marked as sendable. Because of fuck consistency, this uses the @sendable
attribute, as a protocol won't work here. We're witnessing peak language design here 🚀.
Sendable functions must not capture mutable or non-sendable values from their context, and they must be either async
or nonisolated
.
Apparently, this is the magic mentioned above that lets the compiler know whether a closure inside an isolated function runs synchronously, but if i am honest, I don't quite get this part yet — unless every single async callback API anywhere has its closures annotated with @sendable
. And if so, how does this affect their use outside of actors (in cases where you don't (need to) give a fuck about safety?
A closure parameter marked as @sendable
will, inside an actor, be treated as not running isolated. (Conversely, one assumes, this means any closure not marked as such will be treated/assumed by the compiler to be synchronous? 🤷🏼♀️)
The Main Actor
The main actor encapsulates the main thread. Because when you have built a hammer, everything starts looking like a nail 🔨.
Members can be marked with the @MainActor
(also, yay for consistent capitalization! 🙌🏼) attribute, to force them to run on Main. From other MainActor functions, you can call them directly; from anywhere else (including code that maybe runs on Main, but just isn't marked as such), you have to await
them (even, apparently, if you don't really care about when they are done, as is very commonly the case in ky experience). Fun.
Side idea: what this needs is adontawait
keyword, for when you want throw off a call to something that needsawait
, but don't want tyour current scope to become async because of it? ;)
Whole types can be marked as @MainActor
, which is the equivalent of marking each member separately. Individual members can then opt-out via the nonisolated
keywords mentioned above.
Essentially, a class
with the @MainActor
attribute acts like an actor. Isn't language consistency awesome?
Behind the Scenes
At runtime, actors supposedly combine the best of sync and async GCD queue calls. If the actor is "free", the call (even though await
ed, is made on the local thread; if it's not, it will be queued for later execution and the caller thread gets freed up for other tasks as it awaits.
Caveats
Caveat: If an actor method itself await
s out to something else, the code before and after the await will not run as part of the same safe context. Other calls on the same actor might happen in-between (🤯), e.g. the example below still breaks.
What's more, actor members are re-entrant. That means while one call is suspended in an await
, another call to the same member might happen.
Other Languages
As you see, Actors make concurrency really easy /s.
For now, we'll add be adding Actor support for Swift only. In the longer term, we'll look at bringing the features to all Elements languages/platforms (except Go, it not having classes, to begin with).