分散アクターisolation
Distributed Actor Isolation
このダイジェストはClaude Opus 4.7 / 4.8によって生成されたものです(License)。原文はこちら↗。
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 は実体がリモートなら必ずネットワークを経由し、通信エラーやシリアライズエラーで失敗しうるため、宣言時に throws や async を書いていなくても、クロスアクター呼び出しでは暗黙的に async throws として扱われます。アクター内部からの呼び出しには、この暗黙の効果は付きません。
distributed はアクセス制御とは独立しています。private distributed func のような組み合わせも可能ですが、リモートから呼び出せるという意味で実質的に public であることに変わりはないため、内容には注意が必要です。また、distributed と nonisolated は同時には付けられません(分散メソッドは必ずアクターに 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 の自動合成
分散アクターの ID が Codable に適合している場合、分散アクター自身も自動的に 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 句で縛れます。
なお、本Proposalで入ったのは isolation ルールまでで、メッセージのシリアライズやリモート呼び出しの実体、resolve(id:using:) のランタイム挙動などは後続のProposalに委ねられています。
03 今後の見通し
本Proposalは isolation ルールに絞った最初の一歩であり、分散アクターを実用的に運用するうえで欠かせないいくつかのトピックは将来の課題として残されています。以下はあくまで構想段階の方向性であり、実現を約束するものではありません。
分散メソッドのバージョニングと進化
distributed func の引数の値そのものについては、Codable などのシリアライズ機構の範囲で前方/後方互換を取ることができます。一方で、メソッドのシグネチャ自体に新しい引数を増やすような進化は、いまのところ古いシグネチャを @available(*, deprecated, renamed: ...) で残し、新シグネチャに転送するメソッドを手書きする以外に手段がありません。
これを楽にするため、@available 属性をパラメータ単位にも書けるよう拡張する案が示されています。
distributed func greet(
name: String,
@available(macOS 12.1, *) in language: Language = .defaultLanguage
) {
print("\(language.greeting), name!")
}
このように書けると、コンパイラ側で「古い API」に相当する greet(name:) を合成し、デフォルト値を補って新しい実体に転送するコードを自動生成できます。これは ABI stable なライブラリの進化と分散メソッドの進化に共通する課題への解になり得ます。
さらに分散システムの文脈では「OSのバージョン」ではなく「クラスタのバージョン」で出し分けたい場面もあるため、@available(distributed(cluster) 1.2.3, *) のような新しい platform 指定を追加する余地も検討されています。クラスタ参加時のハンドシェイクで交換したバージョン情報をもとにメソッド解決を行う、という想定です。引数の削除はサポートしない方針ですが、オプショナル引数を欠落時に nil 扱いにする扱いは検討候補に挙がっています。
local キーワードによる known-to-be-local の型システム表現
「実体がローカルにあると分かっている分散アクター参照」は、現状でも whenLocal のクロージャ内など限られた文脈で暗黙的に扱われていますが、型システム上では表現されていません。これを local キーワードで明示できるようにする案が検討されています。
distributed actor GameHost {
let myself: local Player
let others: [Player]
func start() {
// myself は local と分かっているので、whenLocal を経由せずに
// クロージャや非シリアライズ可能な値を渡せる
myself.onReceiveMessage { ... }
}
}
local Player 型のプロパティや変数は、非 distributed なメソッドや stored property に直接アクセスできるようになります。isolated Player も自動的に local として扱えるようにし、whenLocal の引数も local Self を受け取る形に置き換えれば、ローカルの場合に actor へのホップを省けるなど、より効率の良い API に進化させる余地が生まれます。
これにより、「リモートからも呼べるが、登録系のメソッドだけはローカル限定にしたい」といった「receptionist」型のアクターでも、nonisolated なラッパーや whenLocal の入れ子を書かずに、local 修飾されたプロパティ越しに直接ローカル専用メソッドを呼べるようになります。