Swift Digest
SE-0344 | Swift Evolution

Distributed Actor Runtime

Proposal
SE-0344
Authors
Konrad 'ktoso' Malawski, Pavel Yaskevich, Doug Gregor, Kavon Farvardin, Dario Rexin, Tomer Doron
Review Manager
Joe Groff
Status
Implemented (Swift 5.7)

01 何が問題だったのか

SE-0336 では、distributed actor キーワードと DistributedActor プロトコルを導入し、リモートに存在する可能性のあるアクターに対する isolation と型検査のルールを整えました。ただし、distributed func の呼び出しが実際にどうネットワークを越えて届き、結果が戻ってくるのかというランタイム側の振る舞いは、そこでは意図的にスコープ外とされていました。

実際に分散アクターを動かすためには、次のような疑問に答える仕組みが必要になります。

  • 分散アクターの ID は誰がいつ採番し、どのタイミングでリモート呼び出しに備えた登録が行われるのか。
  • 呼び出し側が try await actor.method(x) と書いたとき、コンパイラとランタイムは引数をどうやって集め、メッセージとしてネットワークに流すのか。
  • 受信側は到着したメッセージから、どの分散アクターのどのメソッドを呼ぶべきかをどう突き止め、引数をどのように型安全にデコードして実行するのか。
  • 戻り値や投げられたエラーは、どのようにして呼び出し側へ運ばれるのか。

さらに、クラスタ、IPC、クライアント/サーバなど、現実の通信モードは多岐にわたります。ライブラリ側は用途に応じてシリアライズ形式(Codable に限らない)、トランスポート(TCP、WebSocket、その他)、メッセージのエンベロープ形式を自由に選べる必要があります。一方で、利用者から見た distributed func の呼び出しの書き味は、どのライブラリを使っても変わらないことが望まれます。

つまり、ライブラリ作者が独自の分散アクターシステムを実装できるだけの拡張点を言語ランタイムが提供しつつ、利用者は location transparency を保ったまま同じ構文で書ける、という分業を成立させるための下回りが必要でした。本Proposalは、そのための DistributedActorSystem プロトコルと、コンパイラ・ランタイム・ライブラリの協調関係を定めます。

02 どのように解決されるのか

分散アクターの実行を支える中心が、ライブラリ側で実装する DistributedActorSystem プロトコルです。コンパイラが distributed actor のライフサイクル(ID の採番、ready 化、破棄)や distributed func の呼び出しをこのプロトコルのメソッドに差し込み、ライブラリ作者は内部でネットワーク通信やシリアライズを担当する、という分業の枠組みが定義されます。

ここで扱う内容は、主に 分散アクターシステムのライブラリを実装する側 に向けたものです。利用者として distributed func を書いて呼ぶだけであれば、ここまで踏み込まなくても SE-0336 の内容で十分です。

DistributedActorSystem プロトコルの骨格

DistributedActorSystem は、ID の型、呼び出しを記録・復元するエンコーダ/デコーダ、そしてシリアライズ要件を associated type として持ちます。

protocol DistributedActorSystem: Sendable {
  associatedtype ActorID: Sendable & Hashable
  associatedtype InvocationEncoder: DistributedTargetInvocationEncoder
  associatedtype InvocationDecoder: DistributedTargetInvocationDecoder
  associatedtype SerializationRequirement
    where SerializationRequirement == InvocationEncoder.SerializationRequirement,
          SerializationRequirement == InvocationDecoder.SerializationRequirement

  // ライフサイクル
  func assignID<Actor>(_ actorType: Actor.Type) -> ActorID
    where Actor: DistributedActor, Actor.ID == ActorID
  func actorReady<Actor>(_ actor: Actor)
    where Actor: DistributedActor, Actor.ID == ActorID
  func resignID(_ id: ActorID)

  // ID の解決
  func resolve<Actor>(_ id: ActorID, as actorType: Actor.Type) throws -> Actor?
    where Actor: DistributedActor,
          Actor.ID == ActorID,
          Actor.SerializationRequirement == Self.SerializationRequirement

  // リモート呼び出し
  func makeInvocationEncoder() -> InvocationEncoder

  // ad-hoc requirement(後述)
  func remoteCall<Actor, Failure, Success>(
    on actor: Actor,
    target: RemoteCallTarget,
    invocation: inout InvocationEncoder,
    throwing: Failure.Type,
    returning: Success.Type
  ) async throws -> Success
    where Actor: DistributedActor,
          Actor.ID == ActorID,
          Failure: Error,
          Success: Self.SerializationRequirement

  func remoteCallVoid<Actor, Failure>(
    on actor: Actor,
    target: RemoteCallTarget,
    invocation: inout InvocationEncoder,
    throwing: Failure.Type
  ) async throws
    where Actor: DistributedActor,
          Actor.ID == ActorID,
          Failure: Error
}

remoteCall / remoteCallVoid は戻り値型が SerializationRequirement に適合することを型レベルで要求する必要があり、その制約を現行のプロトコル要件として書き下すことができないため、ad-hoc requirement として扱われます。Swift ソースには書かれませんが、コンパイラが署名を認識して適合性を検査します。Void 用に別メソッドを用意しているのも同じ理由で、将来 variadic generics が入れば remoteCall ひとつに統合できる見込みです(speculative)。

分散アクターのライフサイクル

すべての distributed actor には nonisolated let id: ActorSystem.ActorIDnonisolated let actorSystem: ActorSystem の2つの stored property がコンパイラによって合成されます。これらの値は、actor system との協調のもと次の順序で管理されます。

  1. 非デリゲートイニシャライザに入ると、まず self.actorSystem をユーザが代入します。その直後にコンパイラが self.id = actorSystem.assignID(Self.self) を差し込みます。
  2. すべての stored property が初期化された瞬間(= fully initialized になったタイミング)で、actorSystem.actorReady(self) が呼ばれます。以後、この actor system は ID からインスタンスを解決できるようになります。
  3. アクターが deinit されるとき、またはイニシャライザが nil / throw で初期化に失敗したときに、actorSystem.resignID(self.id) が呼ばれます。

イニシャライザの形は次のようになります。

distributed actor DA {
  let number: Int

  init(system: ActorSystem) {
    self.actorSystem = system
    // << self.id = system.assignID(Self.self)
    self.number = 42
    // << system.actorReady(self)
  }
  // deinit {
  //   << self.actorSystem.resignID(self.id)
  // }
}

ユーザ定義のデシグネイテッドイニシャライザでは、self.actorSystem への代入が必須になります(以前の案にあった「actor system 型の引数をちょうど1つ受け取る」という制約は撤廃され、普通のプロパティ代入のルールに揃えられました)。グローバルな actor system を握って代入することも可能ではありますが、テスト時に差し替えられなくなるのでアンチパターンとされています。

ライブラリ側から見ると、assignID / actorReady / resignID はそれぞれ1つのアクターに対して高々1回ずつしか呼ばれません。失敗するイニシャライザでは actorReady が呼ばれないまま resignID が走る場合があるので、その順序に耐えるよう実装しておく必要があります。また、assignID の直後から actorReady までの間は「ID は予約済みだがまだインスタンスが紐付いていない」状態になるため、到着したメッセージを握って待つか、明確に弾くかをシステム側で決めておく必要があります。

ID の解決とリモート参照

リモートのアクターを得る唯一の方法は、各分散アクター型に自動生成される静的メソッド resolve(id:using:) です。これは内部で actor system の resolve(_:as:) を呼び出し、次のように振る舞います。

extension DistributedActor {
  static func resolve(id: Self.ID, using system: ActorSystem) throws -> Self {
    switch try system.resolve(id: id, as: Self.self) {
    case .some(let local):
      return local           // ローカルに管理されている実体を返す
    case nil:
      return /* Swift ランタイムがリモート参照(プロキシ)を生成 */
    }
  }
}

system.resolve(_:as:) は、ローカルで管理している実体がある場合はそれを返し、ない場合は nil を返す という契約です。nil が返ってきたときは、Swift ランタイムが固定サイズのプロキシオブジェクトを割り当て、以後そのインスタンスに対するすべての distributed func 呼び出しは system.remoteCall にルーティングされます。プロキシ判定は _isDistributedRemoteActor() で行えます。

resolve は軽量・ノンブロッキングでなければなりません。リモートの実在確認のためにネットワークを叩いてはいけません。ID 自体が不正で proxy さえ作れない場合は throw することで「作れない」ことを伝えます(エラー型は DistributedActorSystemError 準拠が推奨)。

送信側: distributed func の呼び出しが remoteCall になるまで

distributed func は、コンパイラが distributed thunk と呼ばれる nonisolated async throws なラッパーを合成します。呼び出しは常にこのサンクを経由し、ローカルかリモートかで分岐します。

// distributed func greet(name: String) -> String { ... }

// コンパイラが合成(擬似コード)
extension Greeter {
  nonisolated func greet_$distributedThunk(name: String) async throws -> String {
    guard _isDistributedRemoteActor(self) else {
      return await self.greet(name: name) // ローカルなら通常のアクター呼び出し
    }

    // 1. エンコーダを作り、引数・ジェネリクス・戻り値型などを記録する
    var invocation = self.actorSystem.makeInvocationEncoder()
    try invocation.recordArgument(
      RemoteCallArgument(label: nil, name: "name", value: name))
    try invocation.recordReturnType(String.self)
    try invocation.doneRecording()

    // 2. actor system の remoteCall に委譲する
    return try await self.actorSystem.remoteCall(
      on: self,
      target: RemoteCallTarget(/* mangled な関数識別子 */),
      invocation: &invocation,
      throwing: Never.self,
      returning: String.self
    )
  }
}

サンクが nonisolated なのは、リモートプロキシ上で走る可能性があるためです。ローカルだった場合の executor hop は本来の self.greet(name:) 側に任され、オプティマイザが余計なホップを削れるようになっています。

DistributedTargetInvocationEncoder は、ランタイムから次の順で呼ばれます。

  • recordGenericSubstitution(_:) ― 呼び出しに必要なジェネリクス置換があれば、順序を保って記録します。
  • recordArgument(_:) ― 引数ごとに1回ずつ呼ばれます。引数は RemoteCallArgument<Value> にラップされ、ValueSerializationRequirement に適合することが型で保証されています。
  • recordErrorType(_:) / recordReturnType(_:) ― 対象が throws の場合・戻り値が Void でない場合にのみ呼ばれます。
  • doneRecording() ― 最後に1回呼ばれ、エンコード処理を完了します。

エンコーダは associated type SerializationRequirement を持ち、recordArgumentrecordReturnTypeSerializationRequirement への適合を型レベルで要求します(これも ad-hoc requirement)。Codable を要件にすれば、recordArgument の中で普通の Encoder.encode(_:) を直接呼べます。

remoteCall の実装は、エンコーダの内容と宛先アクター、ターゲット識別子を合わせて自前の「エンベロープ」に仕立て、トランスポートに流し、応答を待ってデコードして返す、という形になります。

func remoteCall<Actor, Failure, Success>(
  on actor: Actor,
  target: RemoteCallTarget,
  invocation: inout InvocationEncoder,
  throwing: Failure.Type,
  returning: Success.Type
) async throws -> Success
where Actor: DistributedActor, Actor.ID == ActorID,
      Failure: Error, Success: Self.SerializationRequirement
{
  var envelope = invocation.envelope
  envelope.recipient = actor.id
  envelope.target = target.identifier
  let responseData = try await transport.send(envelope, to: actor.id)
  return try decoder.decode(Success.self, from: responseData)
}

RemoteCallTargetmangledName / fullName を持つ軽量な値で、ライブラリ側ではこれを不透明な識別子として扱えば十分です。既定では Swift の mangled name が使われます。この方式は型ごとのオーバーロードを許す反面、シグネチャを変えるとターゲットを解決できなくなるため、将来より進化耐性の高い識別スキームが検討されています(speculative)。

受信側: executeDistributedTarget による実行

受信側は、トランスポート層でバイト列を読み取り、少なくともエンベロープのヘッダ(targetrecipient)を先に復元します。引数部分は遅延的に扱い、DistributedTargetInvocationDecoder がバイト列を保持したまま後続の処理に渡されます。

struct ClusterTargetInvocationDecoder: DistributedTargetInvocationDecoder {
  typealias SerializationRequirement = Codable
  let system: ClusterSystem
  var bytes: ByteBuffer

  mutating func decodeGenericSubstitutions() throws -> [Any.Type] { ... }
  mutating func decodeNextArgument<Argument: SerializationRequirement>() throws -> Argument {
    let length = try bytes.readInt()
    let data = try bytes.readData(bytes: length)
    return try system.decoder.decode(Argument.self, from: data)
  }
  mutating func decodeErrorType() throws -> Any.Type? { ... }
  mutating func decodeReturnType() throws -> Any.Type? { ... }
}

受信したら、まず recipient の ID から対象の分散アクターを内部の管理テーブル越しに解決し、その後 Swift ランタイムが提供する DistributedActorSystem.executeDistributedTarget を呼びます。

try await executeDistributedTarget(
  on: recipient,                          // 宛先のアクターインスタンス
  mangledName: envelope.targetName,       // 呼び出すメソッドの識別子
  invocation: &decoder,                   // 引数を遅延デコードする InvocationDecoder
  handler: MyResultHandler(system, envelope) // 結果を呼び出し側へ返す
)

executeDistributedTarget は、識別子からメソッドを突き止め、decoder から順に引数をデコードし、必要なジェネリクス置換とウィットネステーブルを組み立ててメソッドを呼び出す、という処理をまとめて担当します。内部では distributed func ごとにコンパイラが生成する distributed method accessor thunk(IR 層で合成される)を使い、引数のアンボクシングと型検査を存在型に落とさずに行います。これにより、引数の数や型、ジェネリクスの組み合わせに関わらず、不要なヒープ割り当てなしに呼び出しを実現できます。

結果の返送: ResultHandler

executeDistributedTarget は結果を戻り値として返す代わりに、呼び出し側から渡された DistributedTargetInvocationResultHandler に通知します。戻り値の具体型を SerializationRequirement 制約付きで受け取れるため、存在型ボックスを介さずにそのままエンコードできます。

protocol DistributedTargetInvocationResultHandler {
  associatedtype SerializationRequirement

  func onReturn<Success: SerializationRequirement>(value: Success) async throws
  func onThrow<Failure: Error>(error: Failure) async throws
}

ハンドラ側は、onReturn では結果をエンコードして送信側へ応答として返し、onThrow では失敗を伝えるレスポンスを作ります。エラーの中身をそのまま送るかどうかはシステム側の判断で、情報漏洩の観点から型名だけを返す、Codable かつ allow-list に入っているときだけ中身を送る、といった運用が想定されています。呼び出し側でタイムアウトにならないよう、成功・失敗のいずれであっても必ず何らかの応答を返す のが原則です。

Future Directions

本Proposalで扱うのはランタイムの基本骨格までです。今後 speculative に検討されている方向性として次のようなものがあります(いずれも本Proposalで採択されたものではありません)。

  • variadic generics の導入により、remoteCallVoid を廃止して remoteCall 1本に統合する。
  • mangled name に代わる、進化耐性のある呼び出し識別スキームを整備し、引数追加(既定値付き)などの互換性ある変更を可能にする。大きな識別子を繰り返し送らないよう、ピア間で短い ID へ圧縮する仕組みも併せて検討されています。
  • any Greeter のように、具体型を知らずに分散アクターのプロトコルだけを公開して resolve できるようにする(コンパイラがスタブ型を合成する案)。
  • assignID にインスタンスごとの設定情報(ActorConfiguration)を渡せるようにする。