Skip to content

Instantly share code, notes, and snippets.

@rjmccall
Created January 7, 2021 09:32
Show Gist options
  • Save rjmccall/3d599dc71ba23b589859456ae56fb485 to your computer and use it in GitHub Desktop.
Save rjmccall/3d599dc71ba23b589859456ae56fb485 to your computer and use it in GitHub Desktop.
Draft pitch for Swift custom actor executors

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.

Overview

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.

Requirements

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 }
}

Proposal

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.

Consequences

Many existing classes use DispatchQueues 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.

@kavon
Copy link

kavon commented Jan 7, 2021

Questions:

  1. It seems to me that this pitch proposes two changes to the Actor protocol: (1) the addition of a new exclusiveExecutor field, and (2) moving the enqueue(partialTask:) from Actor to ExclusiveExecutor, correct? If so, I think that's a great move, because it makes the distinction between executors and actors more clear.
  2. I'm curious about the reasoning behind the restriction that 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 of ExclusiveExecutor available via other protocols, whether through protocol inheritance or composition. Here's one with composition:

protocol Executor : AnyObject {
  func enqueue(partialTask: AsyncPartialTask)
}

protocol SupportsSwitching : AnyObject {
  var canGiveUpThread: Bool { get }
  func giveUpThread()
  func tryClaimThread() -> Bool
}

struct UnownedExecutorRef: Equatable {
  init<E: Executor & SupportsSwitching >(_ executor: E)
  init<E: Executor>(_ executor: E)
}

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 an enqueue 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"

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment