The actors pitch lays out a simple design for how code is executed on an actor. I will summarize this briefly, and along the way I'll point out some perceived problems.
Conceptually, an actor has an exclusive execution service ("executor"). When a task wants to start running on an actor, it "parks" itself, becoming an opaque chunk of work that can be run by an executor (a "partial task"). It then submits itself to the actor's executor to run.
By default, actors use a standard executor implementation provided by the Swift runtime using a certain amount of inline storage in the actor object. The standard implementation is less like a serial queue and more like an "asynchronous lock". If there's no contention on an actor, a task can switch to it by simply continuing execution on the current thread without any interruption. The standard-implementation actors are able to freely shift between threads: running for awhile on one thread, then giving that thread up when the task moves on. The thread follows the task rather than following the actor.
Actors may also provide their own exeuctor implementation. In the current pitch, they do this by defining an enqueue(partialTask:)
method, which is expected to quickly enqueue a task to run asynchronously. This interface is adequate for non-standard actor implementations that run tasks on something like a dedicated thread. However, it is conspicuously not powerful enough to allow actor implementations to participate in the flexible task-threading model above; the scheduler cannot ask a non-standard actor to give up its thread or to try to begin executing in the current thread. The runtime thus recognizes two tiers of actor: standard actors and everything else. This would be an unfortunate limitation given that some kinds of custom executor might otherwise be able to participate in flexible task-threading.
It is important to be able to efficiently ask whether a task is already running on a particular actor. In the current design, this is done by comparing the actor reference against the current active actor. We increasingly see this as problematic: it locks in the idea that an actor is one-to-one with its executor, but it may be desireable to allow actors to share an executor instead.
The most important requirement is that any added abstraction must not significantly penalize the common case. In particular, a naive interface like this is likely to force the executor to be redundantly retained and released during every task switch:
protocol ExclusiveExecutor: AnyObject {
...
}
protocol Actor: AnyObject {
var exclusiveExecutor: ExclusiveExecutor { get }
}
protocol ExclusiveExecutor: AnyObject {
/// Enqueue a partial task on this executor to run asynchronously.
func enqueue(partialTask: AsyncPartialTask)
/// Is it possible for this executor to give up the current thread
/// and allow it to start running a different actor?
var canGiveUpThread: Bool { get }
/// Given that canGiveUpThread() previously returned true, give up
/// the current thread.
func giveUpThread()
/// Attempt to start running a task on the current actor. Returns
/// true if this succeeds.
func tryClaimThread() -> Bool
}
/// Equatability is determined purely by equality of reference.
struct UnownedExecutorRef: Equatable {
/// Bind this reference to the given executor. If the executor will
/// never return true from canGiveUpThread() or tryClaimThread(),
/// passing false for `supportsSwitching` may allow the runtime to
/// avoid calling those functions in more cases; however, the runtime
/// is permitted to still call those functions at its discretion.
init<E: ExclusiveExecutor>(_ executor: E, supportsSwitching: Bool = true)
}
protocol Actor: AnyObject {
/// Return the exclusive executoor for this actor. This must return the
/// same executor on every call, and the reference must remain valid as
/// long as the actor remains alive.
var exclusiveExecutor: UnownedExecutorRef { get }
}
An actor may derive its executor implementation in one of the following ways. We may add more ways in the future.
-
If the actor declares a property named
exclusiveExecutor
, no synthesis is performed. -
Otherwise, if the actor declares a property named
delegateActor
,exclusiveExecutor
is synthesized as follows:var exclusiveExecutor: UnownedExecutorRef { return delegateActor.exclusiveExecutor }
-
Otherwise, the actor uses the standard executor implementation, and its type implicitly conforms to
ExclusiveExecutor
.
Synthesized functions in public or open classes are public but final; they may not be overridden in subclasses, even within the module.
Which implementation an public actor uses is an ABI-resilient aspect of
the class unless the class is @frozen
. If the class is @frozen
,
any synthesized functions are considered @inlinable
.
Many existing classes use DispatchQueue
s for synchronization. This
proposed design allows them to incrementally become actors without
having to completely eliminate all uses of that queue, assuming that
DispatchQueue
conforms to ExclusiveExecutor
. That can be both bad
and good. Many of the existing uses within the actor probably should
be eliminated; such explicit dispatches will generally be redundant
with Swift's normal actor dispatching and are only likely to introduce
problems. Furthermore, in the long run it is probably best for performance
if the explicit dispatch queue is completely removed in faavor of the
standard implementation, which will enable the more efficient switching
behavior described above. On the other hand, this explicit use of an
existing queue does allow subtle cross-class uses of dispatch queues to
be safely and easily migrated without having to immediately figure out
exactly what's actually going on.
Questions:
Actor
protocol: (1) the addition of a newexclusiveExecutor
field, and (2) moving theenqueue(partialTask:)
fromActor
toExclusiveExecutor
, correct? If so, I think that's a great move, because it makes the distinction between executors and actors more clear.Actor.exclusiveExecutor
must be a fixed executor reference. I can imagine this is a restriction for correctness that is specific to the entire concept of an exclusive executor, i.e., an executor that runs exactly one task at any given time, but what about non-exclusive / parallel executors?API bike-shedding:
Perhaps we could eliminate the boolean from the initializer of
UnownedExecutorRef
by making different flavors ofExclusiveExecutor
available via other protocols, whether through protocol inheritance or composition. Here's one with composition:I'm not sure how compatible this would be with the runtime system, but I thought I'd mention that I'm overall not a big fan of the boolean indicating something about the implementation of the executor. Plus, if we have parallel executors in the future, we'll probably want
Executor
to only require anenqueue
method to keep things uniform.Typos:
faavor => favor
an public => a public
some mix-ups between "actor" and "thread". specifically, on
tryClaimThread
comment: "task on the current actor" => "task on the current thread"