Swift Digest
SE-0336 | Swift Evolution

Distributed Actor Isolation

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

01 何が問題だったのか

Swiftのアクターは、ローカルプロセス内でのデータ競合を防ぐための強力な基盤です。しかし実世界のアプリケーションでは、複数プロセスや複数ノードにまたがる分散システムを扱う場面も少なくありません。そうした環境でも、アクターモデルの「状態をisolateして、メッセージで通信する」という考え方はそのまま通用するはずです。

問題は、ローカル専用のアクターの isolation ルールが、分散環境では緩すぎるという点です。普通の actor では、async なメソッドなら自由に呼び出せますし、プロトコル経由で任意のメソッドを横断的に呼ぶこともできます。

actor Player {
  let name: String
  var score: Int

  func thinkOfNextMove() -> Move { ... }
}

func test(p: Player) async {
  _ = await p.name           // stored property もクロスアクターで読める
  _ = await p.thinkOfNextMove() // 任意の async メソッドを呼べる
}

ところが、もし p が実際には別マシン上にあるリモートの参照だったとしたらどうでしょうか。p.name を読むだけでネットワーク往復が発生しますし、thinkOfNextMove() の引数や戻り値はネットワークを越えて送る必要があるため、シリアライズ可能でなければなりません。実行時に初めてクラッシュが発覚するようでは、分散システムの開発は成り立ちません。

また、分散アクターには「location transparency」という重要な性質があります。あるアクター参照がローカルにあるのかリモートにあるのかを、コード上では意識せずに同じように扱えるべきだ、というものです。静的にローカル/リモートを区別できないということは、常にリモートの可能性がある 前提でプログラムを書かせる仕組みが必要になります。さもなければ、テスト中はローカルで動くのに本番で配置を変えた瞬間に壊れる、といった事態を招きます。

つまり、分散アクターをSwiftに持ち込むには次の点を言語レベルで保証する必要があります。

  • アクターの state は、クロスアクターから直接は見えないようにする(stored property にそのままアクセスさせない)。
  • リモートに露出してよいメソッドを明示的に opt-in させ、ネットワーク越しに呼ばれる前提の型検査(シリアライズ可能性など)を強制する。
  • ネットワーク通信の失敗や遅延を型システムに反映し、呼び出し側が必ず try await で扱うようにする。
  • Actor プロトコルを通じて「普通のアクター扱いにダウングレード」されてしまう抜け穴を塞ぐ。

本Proposalは、SE-0306 で導入されたアクターを土台にしつつ、これらを保証するための isolation 上の追加ルールと、distributed キーワードおよび DistributedActor プロトコルを導入するものです。実際のネットワーク通信やメッセージングの仕組み(ランタイム側)はSE-0344以降で別途扱われます。

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

actor の前に distributed 修飾子を付けることで、分散アクター を宣言できるようにします。分散アクターは普通のアクターよりも厳しい isolation ルールが適用され、「実体がリモートにあるかもしれない」前提で型検査されます。

distributed actor Player {
  let name: String
  var score: Int

  distributed func yourTurn() -> Move { ... }
  func thinkOfNextMove() -> Move { ... }
}

分散アクターは DistributedActor プロトコルに自動的に適合します。このプロトコルは Actor ではなく、新設される AnyActor という共通の親プロトコルを通じて Actor と並ぶ位置に置かれます。DistributedActor: Actor としてしまうと、Actor を要求するジェネリクスに分散アクターを渡した瞬間に distributed isolation がすり抜けてしまうためです。AnyActor を頂点に据えることで、両者のメソッドを無差別に混ぜることができなくなります。

分散メソッド

クロスアクターから呼び出せるのは、distributed を付けて明示的に公開したメソッドに限られます。非 distributed なメソッドは、たとえ async であっても外から呼べません。

func test(p: Player) async throws {
  try await p.yourTurn()         // OK: distributed func
  try await p.thinkOfNextMove()  // error: distributed でないメソッドは呼べない
}

呼び出し側では常に try await が必要です。distributed func は実体がリモートなら必ずネットワークを経由し、通信エラーやシリアライズエラーで失敗しうるため、宣言時に throwsasync を書いていなくても、クロスアクター呼び出しでは暗黙的に async throws として扱われます。アクター内部からの呼び出しには、この暗黙の効果は付きません。

distributed はアクセス制御とは独立しています。private distributed func のような組み合わせも可能ですが、リモートから呼び出せるという意味で実質的に public であることに変わりはないため、内容には注意が必要です。また、distributednonisolated は同時には付けられません(分散メソッドは必ずアクターに isolate される必要があるため)。inout パラメータや可変長引数、subscript を distributed にすることもできません。

シリアライズ要件

分散アクターは、どの「actor system」と連携するかを ActorSystem という associated type で静的に指定します。actor system は DistributedActorSystem プロトコルに適合し、SerializationRequirement という typealias でメッセージ送受信に使うシリアライズ要件(たとえば Codable)を宣言します。この要件は分散アクター側にも波及し、distributed func のすべてのパラメータと戻り値の型がこの要件に適合しなければコンパイルエラーになります。

protocol CodableMessagingSystem: DistributedActorSystem {
  typealias SerializationRequirement = Codable
}

distributed actor Worker {
  typealias ActorSystem = CodableMessagingSystem

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

  struct NotCodable {}
  distributed func reject(not: NotCodable) { ... }
  // error: パラメータ 'not' の型 'NotCodable' は 'Codable' に適合していない
}

クロージャは Codable に適合しないため、distributed func のパラメータには渡せません。existential type は自分自身に適合しないため、P: Codable なプロトコル P をそのまま受け取ることもできず、代わりにジェネリック引数として受け取る形に書き換える必要があります。ジェネリックな distributed func は書けますが、型パラメータにも同じシリアライズ要件を満たすよう制約を付ける必要があります。

モジュール全体の既定の actor system は typealias DefaultDistributedActorSystem = ... で指定できます。明示的な初期化子を書かない場合、コンパイラが init(system:) を合成します。分散アクターのデシグネイテッドイニシャライザは、必ず DistributedActorSystem 型のパラメータをちょうど1つ受け取らなければなりません。これはアクターの ID の採番と管理を actor system が担うためです。

状態への完全な isolation

stored property はクロスアクターから直接読み書きできません。リモート参照にはそもそも state が存在しないため、アクセスさせること自体が不整合につながるからです。

let player: Player = // ... リモート参照かもしれない
player.name  // error: distributed actor の state はアクター内部からのみ参照可能

読み取り専用の computed property なら distributed を付けられ、実質的に引数なしの distributed func と同じ扱いになります(読み書き両対応の computed property や stored property を distributed にはできません)。普通のアクターと違い、stored property を nonisolated 宣言することもできません。リモート参照には stored property の実体が存在しないためです。static プロパティは宣言できますが、値はあくまで「そのプロセス内」でのものであり、リモートノードとは別物になる点に注意が必要です。

なお、stored property 自体の型にシリアライズ要件は課されません。データベース接続のような送れないものでも、distributed func の中で要約して返すようにカプセル化すれば自由に持てます。

Codable の自動合成

分散アクターの IDCodable に適合している場合、分散アクター自身も自動的に Codable に適合します。エンコード時には ID だけがエンコードされ、デコード時は Decoder.userInfo から取り出した actor system を使って resolve(id:using:) で参照を解決する、という実装がコンパイラによって合成されます。これにより、「アクター参照そのもの」を別の分散アクターへメッセージとして送ることができ、「後でコールバックしてほしい相手」をネットワーク越しに渡すような用途が自然に書けます。

isolation の状態と known-to-be-local

分散アクターの参照は、次のいずれかの状態にあります。

  • isolated ― ローカル専用アクターと同じ isolated で、その場合は分散 isolation のチェックがそのまま通常のアクター isolation に戻ります。stored property へも同期的にアクセスできます。
  • 「known-to-be-local」 ― 型システム上は専用の状態になっていませんが、self をキャプチャした Task.detached の中など「必ずローカル」と分かる文脈では、暗黙の throws が付かなくなります(ネットワークを越えないため)。ただし isolation ドメインが違うので await は必要です。
  • 「potentially remote」 ― 既定の状態。暗黙的に async throws になります。

実行時にローカルかどうかを確かめたい場合は、すべての分散アクターで使える whenLocal メソッドを使います。渡したクロージャは、アクターがローカルのときだけ isolated Self として呼び出されるため、その中では非 distributed なメソッドや stored property にも通常のアクターのようにアクセスできます。リモートだったときは nil(あるいは else: クロージャ)が返ります。本来、分散アクターを使うプログラムは location transparency を尊重して書くべきですが、テストなど location transparency を意図的に破りたい場面での出口として用意されています。

extension DistributedActor {
  nonisolated func whenLocal<T>(
    _ body: (isolated Self) async throws -> T
  ) async rethrows -> T?
}

プロトコル適合

プロトコル要件を distributed func で満たす場合、要件側は async throws でなければなりません。クロスアクター呼び出しは常に暗黙的に async throws になるためです。DistributedActor を継承するプロトコルを作れば、「分散アクターでしか実装できないプロトコル」を表現できます。

protocol Worker: DistributedActor {
  distributed func work(on: Item) -> Int
  nonisolated func same(as other: Worker) -> Bool
  static func isHardWorking(_ worker: Worker) -> Bool
}

このようなプロトコルのメソッド要件は distributed / nonisolated / static のいずれかでなければなりません。普通のメソッド要件を含めてしまうと、分散アクターが必要な isolation を保てなくなるためです。actor system を特定の型に固定したい場合は protocol ClusterActor: DistributedActor where ActorSystem == ClusterSystem {} のように where 句で縛れます。

Future Directions

本Proposalで入ったのは isolation ルールまでで、メッセージのシリアライズやリモート呼び出しの実体、resolve(id:using:) のランタイム挙動などは後続のProposalに委ねられています。そのほか、現時点ではspeculativeなアイデアとして次のような方向性が検討されています(いずれも本Proposalで採択されたわけではありません)。

  • 分散メソッドの進化(引数追加)を扱うため、@available をパラメータ単位に拡張し、クラスタのバージョンを条件に取れるようにする。
  • 「known-to-be-local」を型システム上も表現する local キーワードを導入し、ローカル側だけで使いたいプロトコル適合やプロパティアクセスを素直に書けるようにする。