ProtocolDistributed

    DistributedActorSystem

    A distributed actor system underpins and implements all functionality of distributed actors.

    • iOS 16.0+
    • macOS 13.0+
    • tvOS 16.0+
    • watchOS 9.0+
    protocol DistributedActorSystem : Sendable

    Overview

    A DistributedActor is always initialized in association with some concrete actor system. That actor system instance is then used to manage the identity of the actor, as well as handle all remote interactions of the distributed actor.

    Using a DistributedActorSystem library

    From a library user’s perspective (e.g. someone using a ClusterSystem or SampleWebSocketActorSystem), the basic use of a distributed actor system is fairly opaque.

    Any distributed actor must declare what actor system it is able to operate with. This is done either by a typealias ActorSystem in the body of such distributed actor declaration, or a module-wide global typealias DefaultDistributedActorSystem. Refer to the DistributedActor documentation to learn more about the tradeoffs of these approaches.

    Once an actor has declared the system it is able to work with, an instance of the system must be provided at initialization time, in order for the system to be able to take over the actor’s identity management.

    For example, a simple distributed actor may look like this:

    distributed actor Greeter {
      init(name: String, actorSystem: ActorSystem) {
        self.name = name
        self.actorSystem = actorSystem // required (!) initialization of implicit actorSystem property
      }
    }

    Notice that every distributed actor initializer must initialize the synthesized DistributedActor.ActorSystem. This property is later used for identity management and other remote interactions of the actor. For more details refer to DistributedActor which explains more about declaring distributed actors.

    For more details about how the specific actor system implementation deals with remote message transports and serialization, please refer to the specific system’s documentation.

    Implementing a DistributedActorSystem

    This section is dedicated to distributed actor system library authors, and generally can be skipped over by library users, as it explains the interactions of synthesized code and specific distributed actor system methods and how they must be implemented.

    Methods discussed in this section are generally not intended to be called directly, but instead will have calls generated to them from distributed actor declarations in appropriate places (such as initializers, distributed func calls, or distributed computed properties).

    Assigning and Resigning Actor Identifiers

    During a local distributed actor’s initialization (i.e. any init of a distributed actor), the actor system will be invoked in order to assign an ActorID for this actor.

    A call to assignID(_:) is made during the initialization of the distributed actor. The snippet below showcases this, though no guarantees are made at this point about the exact placement of this call.

    distributed actor ShowcaseIDInit {
      // let actorSystem: ActorSystem // synthesized;
    
      // typealias ID = ActorSystem.ActorID
      // let id: ID // synthesized; implements `Identifiable.id` requirement
    
      init(actorSystem: ActorSystem) {
        self.actorSystem = actorSystem
        // ...
        // self.id = actorSystem.assignID(Self.self) // synthesized;
        // ...
      }
    }

    The result of assignID(_:) is then directly stored in the synthesized id property of the actor.

    The actor system should assign globally unique identifiers to types, such that they may be properly resolved from any process in the distributed actor system. The exact shape of the ActorID is left up to the library to decide. It can be as small as an integer based identifier, or as large as a series of key-value pairs identifying the actor.

    The actor system must retain a mapping from the ActorID to the specific actor instance which it is given in actorReady(_:) in order to implement the resolve(id:using:) method, which is how incoming and outgoing remote calls are made possible.

    Users have no control over this assignment, nor are they allowed to set the id property explicitly. The DistributedActor.id is used to implement the distributed actor’s Hashable, Equatable, and even Codable conformance (which is synthesized if and only if the ActorID is Codable itself).

    Manually invoking assignID and resignID is generally not recommended but isn’t strictly a programmer error, and it is up to the actor system to decide how to deal with such calls.

    Once the distributed actor deinitializes, a call to resignID(_:) will be made. Generally this is made from the distributed actor’s deinit, however in the case of throwing initializers it may also happen during such failed init, in order to release the ID that is no longer used.

    // Synthesized inside a distributed actor's deinit:
    deinit {
      // actorSystem.resignID(self.id)
    }

    After an ID is resigned, it technically could be used to identify another instance. For example, an advanced actor system implementation could use such approach to implement actors which are created “ad-hoc” and always contain the appropriate ID, and if one isn’t allocated yet for such ID, it could then create one on demand and make sure it is assigned the required ID.

    Readying Distributed Actors

    Once a distributed actor has been fully initialized during its initializer, a call to actorReady(_:) is synthesized. This call is made after the actor’s properties (including all user-declared properties) have been initialized, but before other user-defined code in the initializer gets a chance to run.

    The actorReady(_) call on the actor system is a signal to the actor system that this actor instance is now ready and may be resolved and interacted with via the actor system. Generally, a distributed actor system implementation will weakly retain the actors it has readied, because retaining them strongly would mean that they will never be deallocated (and thus never resign their ID’s).

    Next, we will discuss the just mentioned resolve method, which is closely tied to readying actors.

    Resolving (potentially remote) Distributed Actors

    An important aspect of any distributed actor system is being able to turn a DistributedActor type and ActorID into a reference to an actor (instance), regardless where the actor is located. The ID should have enough information stored to be able to make the decision of where the actor is located, without having to contact remote nodes. Specifically, the implementation of resolve(id:as:) is not async and should not perform long running or blocking operations in order to return.

    The actor system’s resolve(id:as:) method is called by the compiler whenever end-users call the DistributedActor‘s resolve(id:using:) method. The return types of those methods differ, as the actor system’s return type is Act? (and it may throw if unable to resolve the ActorID).

    The actor system’s resolve returning nil means that the ActorID passed to it refers to a remote distributed actor. The Swift runtime reacts to this by creating a remote actor reference (sometimes called a “proxy”).

    Handling remote calls

    Finally, calls on a remote distributed actor reference’s distributed methods are turned into invocations of remoteCall(on:target:invocation:returning:throwing:) (or remoteCallVoid(on:target:invocation:throwing:) for Void returning methods).

    Implementing the remote calls correctly and efficiently is the important task for a distributed actor system library. Since those methods are not currently expressible as protocol requirements due to advanced use of generics combined with type aliases, they will not appear in the protocol’s documentation as explicit requirements. Instead, we present their signatures that a conforming type has to implement here:

     /// Invoked by the Swift runtime when making a remote call.
     ///
     /// The `arguments` are the arguments container that was previously created
     /// by `makeInvocationEncoder` and has been populated with all arguments.
     ///
     /// This method should perform the actual remote function call, and await for its response.
     ///
     /// ## Errors
     /// This method is allowed to throw because of underlying transport or serialization errors,
     /// as well as by re-throwing the error received from the remote callee (if able to).
    func remoteCall<Act, Err, Res>(
        on actor: Act,
        target: RemoteCallTarget,
        invocation: inout InvocationEncoder,
        throwing: Err.Type,
        returning: Res.Type
    ) async throws -> Res
        where Act: DistributedActor,
              Act.ID == ActorID,
              Err: Error,
              Res: SerializationRequirement
     /// Invoked by the Swift runtime when making a `Void`-returning remote call.
     ///
     /// The `arguments` are the arguments container that was previously created
     /// by `makeInvocationEncoder` and has been populated with all arguments.
     ///
     /// This method should perform the actual remote function call, and await for its response.
     ///
     /// ## Errors
     /// This method is allowed to throw because of underlying transport or serialization errors,
     /// as well as by re-throwing the error received from the remote callee (if able to).
    func remoteCallVoid<Act, Err>(
        on actor: Act,
        target: RemoteCallTarget,
        invocation: inout InvocationEncoder,
        throwing: Err.Type
    ) async throws -> Res
        where Act: DistributedActor,
              Act.ID == ActorID,
              Err: Error

    Implementations of remote calls generally will serialize actor.id, target and invocation into some form of wire envelope, and send it over the network (or process boundary) using some transport mechanism of their choice. As they do so, they need to suspend the remoteCall function, and resume it once a reply to the call arrives. Unless the transport layer is also async/await aware, this will often require making use of a CheckedContinuation.

    While implementing remote calls please keep in mind any potential failure scenarios that may occur, such as message loss, connection failures and similar issues. Such situations should all be surfaced by resuming the remoteCall by throwing an error conforming to DistributedActorSystemError.

    While it is not required to conform error thrown out of these methods to DistributedActorSystemError, the general guideline about conforming errors to this protocol is that errors which are outside of the user’s control, but are thrown because transport or actor system issues, should conform to it. This is to simplify separating “business logic errors” from transport errors.

    Further reading

    For an even more in-depth explanation about the inner workings of a distributed actor system, you can refer to the following Swift Evolution proposals:

    Requirements

    Associated Types

    Instance Methods

    Members

    Instance Methods