Swift Digest
SE-0428 | Swift Evolution

Resolve DistributedActor protocols for server-client apps

Proposal
SE-0428
Authors
Konrad 'ktoso' Malawski, Pavel Yaskevich
Review Manager
Freddy Kellison-Linn
Status
Implemented (Swift 6.0)

01 何が問題だったのか

SE-0336 / SE-0344 で導入された distributed actor は、「ローカルかリモートか」を意識せずに actor へメッセージを送れる location transparency を提供します。しかし、リモート参照を得る唯一の方法は具体的な distributed actor 型に対して resolve(id:using:) を呼ぶことでした。

protocol Greeter: DistributedActor {
  distributed func greet(name: String) -> String
}

distributed actor EnglishGreeter: Greeter {
  typealias ActorSystem = ClusterSystem
  func greet(name: String) -> String { "Hello, \(name)!" }
}

let remote: EnglishGreeter =
  try EnglishGreeter.resolve(id: knownID, using: system)
let greeting = try await remote.greet(name: "Caplin")

この前提は、クラスタの全ノードが同じバイナリを共有するpeer-to-peer構成ではうまく働きます。しかし、次のようなユースケースでは破綻します。

  • クライアント/サーバ構成: クライアントはサーバ実装の具体的な distributed actor 型(EnglishGreeter 等)を知らないし、知らせたくない。
  • IPC: デーモンプロセスをサーバ、アプリをクライアントとして通信したいが、やはり実装型はクライアントから隠蔽したい。

理想的にはクライアント/サーバ/APIの3モジュール構成で、

  • APIモジュール: DistributedActor 制約付きの protocol Greeter だけを公開
  • サーバモジュール: Greeter を実装した具体的な distributed actor を持つ
  • クライアントモジュール: Greeter の具体実装を知らずに、プロトコルだけからリモート参照を解決して呼び出す

としたいのですが、現状では resolve(...) に具体型が必須なため、この分離ができません。

さらに前提として、distributed actor の ActorSystem具体的なnominal型でなければならず、プロトコルで抽象化したり、distributed actor 自身を ActorSystem についてジェネリックにすることもできませんでした。そのため、「どんな actor system でも動く汎用的な distributed protocol / distributed actor」を書く術もありませんでした。

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

distributed actor のプロトコルに @Resolvable マクロを付けることで、具体型を知らないクライアントからもプロトコルだけでリモート参照を解決 できるようにします。加えて、distributed actor を ActorSystem についてジェネリックにできるようにし、distributed 向けメタデータの仕組みをプロトコル要件にも対応させます。

@Resolvable マクロ

@Resolvable は、DistributedActor を継承したプロトコルに付与するattached declaration macroです。付与するプロトコルは、ActorSystem が扱う SerializationRequirementwhere 節で明示する必要があります(SerializationRequirement は必ずプロトコル型である必要があります)。

import Distributed

@Resolvable
protocol Greeter: DistributedActor
    where ActorSystem: DistributedActorSystem<any Codable> {
  distributed func greet(name: String) -> String
}

マクロは、プロトコル名に $ を前置したスタブとなる具体的な distributed actor 型(上の例なら $Greeter)を合成します。利用者が意識するAPIは基本的にこの $付き型だけで、クライアント側ではこれを使ってリモート参照を解決します。

// クライアント側
let greeter = try $Greeter.resolve(id: id, using: clusterSystem)
let name = try await greeter.greet(name: "Caplin")

サーバ側は従来通り、プロトコルに適合する具体的な distributed actor を普通に定義します。

// サーバ側
distributed actor EnglishGreeter: Greeter {
  typealias ActorSystem = ClusterSystem
  distributed func greet(name: String) -> String {
    "Hello, \(name)!"
  }
}

この構成により、APIモジュール(プロトコルのみ)/サーバモジュール(実装)/クライアントモジュール($Greeter 経由で呼び出すだけ)という役割分担ができるようになります。クライアントは EnglishGreeter のような具体型を一切知る必要がありません。

実際に受け取った参照を取り回す際は、$Greeter そのものより some Greeter / any Greeter を使うことが推奨されます。これにより、「プロキシ経由かローカル実装か」に依存しないコードになり、後からモジュール構成を変更してローカル参照に差し替えるような場合にも対応しやすくなります。

distributed でない要件をプロトコルに含めることも可能ですが、リモート参照に対しては実質的に呼び出せません(whenLocal(operation:) でローカル参照として扱えた場合のみ呼び出せます)。

@Resolvable が合成するもの

@Resolvable は、次のような $付きスタブ型と、スタブ用のデフォルト実装を合成します。併せて、空のマーカプロトコル DistributedActorStub も導入されます。

public protocol DistributedActorStub where Self: DistributedActor {}

// マクロが合成するスタブ型(概念イメージ)
distributed actor $Greeter<ActorSystem>: Greeter, DistributedActorStub
    where ActorSystem: DistributedActorSystem<any Codable> {
  private init() {} // 直接初期化は不可。resolve(id:using:) のみ
}

extension Greeter where Self: DistributedActorStub {
  // 各要件に対するスタブ実装(中身は fatalError)
}

スタブ実装は呼び出されると fatalError になりますが、resolve で得られるのは常にリモート参照なので、実際の呼び出しは DistributedActorSystemremoteCall に回送され、このスタブ本体が呼ばれることはありません。

なお合成コードの詳細は将来変わりうるもので、利用者が依存してよいのは「プロトコル名に $ を前置した型が、元プロトコルと同じアクセスレベルで公開される」という事実だけです。

親プロトコルを継承する場合、親側の要件にはデフォルト実装があるか、親プロトコル自身にも @Resolvable が付いている必要があります。

ActorSystem についてジェネリックな distributed actor

スタブ型 $Greeter<ActorSystem> が成立するための基盤として、distributed actor を ActorSystem についてジェネリックにできるようになります。従来は typealias ActorSystem = ClusterSystem のような具体型指定が必須で、同じ distributed actor を別の actor system で使い回すことはできませんでした。

この制限を取り払うため、DistributedActorSystem プロトコルは SerializationRequirementprimary associated typeとして露出するようになります。

protocol DistributedActorSystem<SerializationRequirement>: Sendable {
  associatedtype SerializationRequirement
    where SerializationRequirement == InvocationEncoder.SerializationRequirement,
          SerializationRequirement == InvocationDecoder.SerializationRequirement,
          SerializationRequirement == ResultHandler.SerializationRequirement
  // ...
}

これによって、次のようにシリアライゼーション要件を指定したうえで、任意の actor system で使える汎用 distributed actor を書けます。

distributed actor DistributedAsyncSequence<Element, ActorSystem>
    where Element: Sendable & Codable,
          ActorSystem: DistributedActorSystem<any Codable> {
  distributed func exampleNextElement() async throws -> Element? { /* ... */ }
}

ActorSystem のシリアライゼーション要件がコンパイル時に分かるため、引数や戻り値が要件(上の例なら Codable)を満たすかどうかはこれまで通り静的にチェックされます。

同じことはプロトコル側でも可能です。

protocol DistributedAsyncSequence: DistributedActor
    where ActorSystem: DistributedActorSystem<any Codable> {
  associatedtype Element: Sendable & Codable
  distributed func exampleNextElement() async throws -> Element?
}

ActorSystem.SerializationRequirement を明示せずに where ActorSystem: DistributedActorSystem とだけ書くとコンパイルエラーです。DistributedActorSystem<any Codable> のような具体的な要件が必要です。

DefaultDistributedActorSystem との関係

モジュール全体に typealias DefaultDistributedActorSystem = ClusterSystem を置くと、各 distributed actor の ActorSystem が暗黙に埋められます。ただし ActorSystem をジェネリックにした distributed actor ではこの暗黙 typealias よりもジェネリックパラメータが優先され、汎用の actor として扱われます。

typealias DefaultDistributedActorSystem = ClusterSystem

distributed actor Worker<ActorSystem>
    where ActorSystem: DistributedActorSystem<any Codable> {
  distributed func work()
}
// ActorSystem は ClusterSystem に固定されず、呼び出し側が決める

プロトコル要件に対応した呼び出しメタデータ

distributed 呼び出しは、受信側で RemoteCallTarget.identifier(従来はmangleされた具体メソッド名)から実メソッドを引き当てて実行されます。今回の変更では、プロトコル要件の呼び出しに対して$付きスタブ型を基準としたidentifier(例: "Shared.$Greeter.greet(name:)" のようなイメージ)を生成するようにmanglingを拡張し、受信側ではそのidentifierから、実際に解決された具体 distributed actor のwitnessを経由してメソッドを呼べるようにします。

この結果、クライアントは具体実装型を知らずにプロトコル経由でのリモート呼び出しが可能になり、サーバ側の executeDistributedTarget(on:target:invocationDecoder:handler:) の使い方は従来と変わりません。

この変更はwire protocol上もadditiveで、以前サポートしていなかった「distributed protocolメソッドのリモート呼び出し」が新たに可能になるだけです。

Future Directions

今回のスコープ外として、次のような発展が示されています(speculativeなもので、実現を約束するものではありません)。

  • distributed protocolのノン・ブレイキングな進化を支援するツール。たとえば @Resolvable(deprecatedName: ...)@Distributed.Deprecated(newVersion:defaults:) のようなマクロで、非推奨メソッドから新APIへの自動委譲を生成する方向性。
  • remote call target identifierのmangling方式を actor system 側からカスタマイズできるようにする仕組み。identifierを短くしたり、送信回数を減らすような圧縮表現を組み込む余地があります。
  • distributed メタデータを使った監査支援。swift-inspect のようなツールでバイナリから全ての distributed エントリポイントを列挙できるようにし、セキュリティ監査を容易にする方向性。