この記事の要点
- Swift サーバーエコシステム向けの新しいオープンソースパッケージ Swift Distributed Actors(swift-distributed-actors)が発表されました。当時開発が進められていた
distributed actor言語機能のための、サーバー向けクラスタライブラリです。 distributed actorは、ローカルのアクターや構造化並行性に続く、Swift の並行処理モデルの次のステップとして位置づけられた言語機能です。ローカルのアクターとよく似た書き味のまま、ネットワークをまたいだ分散システムを構築できるようにすることを目指しています。- この言語機能はライブラリの発表当時まだ実験的なもので、Swift Evolution のレビューを経ていない段階でした。ライブラリは正式リリース前の「early preview」として公開され、API は予告なく変更・削除される可能性があるとされていました。本ダイジェストでは、発表当時に示されていた設計とコード例を当時の文脈として紹介します。
何が発表されたのか
Swift Distributed Actors は、distributed actor 言語機能を使って分散システムを構築するための、サーバー向けクラスタライブラリです。言語機能の設計と並行してライブラリを早期にオープンソース化することで、言語機能やトランスポート実装の形について、より有用なフィードバックを集めることが狙いとされていました。
発表当時、distributed actor は実験的な言語機能であり、設計の検討が続いている段階でした。ローカルのアクターと構造化並行性で並行プログラミングを変えたのと同じように、分散システムプログラミングを簡素化し進化させることが目標として掲げられていました。この言語機能は、ほかのすべての言語機能と同様に、実験的なステータスを外す前に Swift Evolution プロセスを経るとされていました。
なお、このライブラリは未リリースで作業中の、Swift Evolution のレビュー待ちの言語機能に依存しているため、発表時点では本番利用は推奨されておらず、特定の nightly ビルドのツールチェインに依存する場合があるとされていました。ライブラリを早期にオープンソース化した主な目的は、distributed actor 言語機能を使って機能の揃った魅力的なクラスタソリューションを実装できることを示し、言語機能とライブラリを並行して進化させることでした。
何に使えるのか
distributed actor の概要
ローカルのアクターは、状態をカプセル化し、非同期な呼び出しだけを通じて通信します。コンパイラはアクター isolation の検査によって、低レベルのデータ競合のないプログラムを書けるよう支援します。distributed actor は、この考え方を分散システムにも広げるものです。
distributed actor の鍵となる概念が location transparent(ロケーション透過)です。アクターという馴染みのある考え方のまま分散システムを記述しておき、それをクラスタのような分散環境へそのまま移せます。ただし、これは分散呼び出しがネットワークを越える事実を完全に隠蔽するものではありません。むしろ逆に、呼び出しがリモートになり うる ことを前提にプログラミングします。これにより、最初から分散を意図したシステムを、ローカルのテストクラスタ上でテストできるようになります。
distributed actor は、新しい distributed 修飾子を付けて宣言します。
// **** API と構文は作業中 / Swift Evolution のレビュー待ち ****
// 1) アクターは新しい 'distributed' 修飾子を付けて宣言できる
distributed actor Worker {
// 2) アクターの isolated な状態は、そのアクターが存在するノード上にのみ保持される。
// アクター isolation のルールにより、状態へのアクセスは常にスレッドセーフな形で、
// かつ状態が存在することが分かっている場合に限られる。
var data: SomeData
// 3) クロスアクターでアクセスできるのは 'distributed' として宣言された関数(と computed property)だけ。
// distributed な関数の引数と戻り値は、リモート呼び出しでネットワーク境界を越えるため
// Codable でなければならない。
distributed func work(item: String) -> WorkItem.Result {
// ...
}
}
distributed actor は、分散 RPC システムを作るたびに書き直していたボイラープレートの多くを肩代わりします。上の例では、シリアライズやネットワークの詳細を意識することなく、必要な処理を宣言して作業リクエストをネットワーク越しに送れます。
distributed actor を分散システムに参加させるには、リモート関数呼び出しに必要なネットワーク処理を担う ActorTransport を与えます。ActorTransport はライブラリ側で実装できるコンポーネントで、アクターのインスタンス化時に渡します。
// **** API と構文は作業中 / Swift Evolution のレビュー待ち ****
// 4) distributed actor は初期化時にトランスポートを関連付ける必要がある
let someTransport: ActorTransport = ...
let worker = Worker(transport: someTransport)
// 5) クロスアクターで実行される distributed な関数呼び出しは、
// ネットワーク越しの処理が起こりうるため非同期で throwing になる。
// これらの効果は必要な文脈でのみ暗黙的に適用される。
// たとえば対象アクターがローカルだと分かっている場合、暗黙の throwing 効果は適用されない。
_ = try await worker.work(item: "work-item-32")
// 6) リモートのシステムは 'resolve' 関数でアクターへの参照を得られる。
// 返るのは、distributed な関数呼び出しをすべてメッセージに変換する特別な "proxy" オブジェクト。
let result = try await Worker.resolve(worker.id, using: otherTransport)
標準ライブラリ自体は特定のトランスポートを提供せず、言語モデルと、トランスポート実装が利用できる拡張ポイント(ActorTransport プロトコル)を定義することに集中します。これにより、クラスタシステム、WebSocket ベースのメッセージング、プロセス間通信など、さまざまなトランスポート実装が可能になります。
クラスタトランスポートの提供
今回オープンソース化された Swift Distributed Actors ライブラリは、この ActorTransport プロトコルの実装であり、ほかのトランスポート作者にとってのリファレンス実装としての役割も担います。サーバーサイドのピアツーピアシステムに焦点を当てており、プレゼンスシステム、ゲームのロビー、監視・IoT システム、オーケストレータやスケジューラといった「コントロールプレーン」系のシステムなど、複数の当事者とのリアルタイムなやり取りが必要な用途を想定しています。
ライブラリは Swift の高性能なネットワークライブラリ SwiftNIO でネットワーク層を実装し、メンバーシップサービスには Swift Cluster Membership を利用します。これにより、別途サービスディスカバリやデータベースを立てなくても、スタンドアロンでクラスタを動かせます。
クラスタの形成
同じプロセス内で複数のノードを起動してクラスタを組むこともできます。これは、分散システムのテストをプロセス内で書き、メモリ上で通信させたり実際のネットワークを使ったりを、アクターに渡すトランスポートの違いだけで切り替えられることを意味します。同じ distributed actor のコードを、ローカル開発用の単一ノード、テスト用の同一プロセス内マルチノード、本番用の物理的に分かれた複数マシンのいずれでも動かせます。
// **** API と構文は作業中 / Swift Evolution のレビュー待ち ****
let first = ActorSystem("FirstNode") { settings in
settings.cluster.enable(host: "127.0.0.1", port: 7337)
}
let second = ActorSystem("SecondNode") { settings in
settings.cluster.enable(host: "127.0.0.1", port: 8228)
}
first.cluster.join(host: "127.0.0.1", port: 8228)
クラスタの状態や操作には .cluster プロパティからアクセスします。すでに複数ノードがあるクラスタなら、1 つのノードが新ノードを join させるだけで、メンバーシップ情報が gossip によって自動的にクラスタ全体へ伝わります。本番ではこうした join をハードコードする代わりに、Swift Service Discovery を使って DNS や Kubernetes などのバックエンドからノードを発見し、自動で join できます。
distributed actor の発見
リモートのアクターへの参照を得るには、その ActorIdentity を runtime に渡す必要がありますが、リモートアクターの識別子を「当て推量」することはできません。この問題のために、ライブラリは Receptionist パターンを提供します。ホテルの受付のように、アクターは受付に「チェックイン」することで、ほかのアクターから見つけてもらえるようになります。チェックインは意図的に任意かつ手動で、すべてのアクターが自分の存在を全アクターに知らせたいわけではないという設計です。
アクターは、既知の reception キーのもとで自分自身を登録できます。
// **** API と構文は作業中 / Swift Evolution のレビュー待ち ****
distributed actor FamousActor {
init(transport: ActorSystem) async {
await transport.receptionist.register(self, withKey: .famousActors)
}
}
extension DistributedReception.Key {
static var famousActors: Self<FamousActor> { "famous-actors" }
}
登録された情報は gossip によってクラスタ全体に広まります。ほかのノードでは、その reception キーの更新を AsyncSequence として購読し、新しいアクターが現れるたびに通知を受け取れます。
for try await famousActor in transport.receptionist.subscribe(.famousActors) {
print("Oh, a new famous actor appeared: \(famousActor.id)")
// すぐに famousActor を使ってメッセージを送れる
}
クラスタとアクターのライフサイクルイベントへの反応
クラスタは、アクターが同一ノードにあるかリモートにあるかにかかわらず、そのライフサイクルを扱えます。アクターどうしが互いの終了を「watch」できる形で提供されます。watch されたアクターがデイニシャライズされたり、そのアクターが動いていたノードが「down」と判定されたりすると、watch していたアクターに対して terminated シグナルが送られます。ノードの障害検知には Swift Cluster Membership が使われます。
クラスタイベントは AsyncSequence<Cluster.Event> として送出され、最初に現在の状態の「スナップショット」が来て、以降は変化が続きます。たとえばクラスタが一定サイズに達するまで待つ処理を書けます。
// **** API と構文は作業中 / Swift Evolution のレビュー待ち ****
var membership: Membership = .empty
// クラスタイベントの "無限" ストリーム
for try await event in system.cluster.events {
print("Cluster event: \(event)")
// イベントを membership に適用できる
try membership.apply(event)
if membership.count(atLeast: .up) > 3 {
break
}
}
ただし多くの開発者はこの粒度の API を直接触る必要はありません。クラスタは関連するイベントをアクターのライフサイクルシグナルへ自動的に変換するため、LifecycleWatch を使って特定のアクターだけを監視できます。アクターが乗っているノードごと終了した場合も通知されます。
// **** API と構文は作業中 / Swift Evolution のレビュー待ち ****
let other: Person
let system: ActorSystem
watchTermination(of: other) { terminatedIdentity in
system.log.info("Actor terminated: \(terminatedIdentity)")
}
watch API はアクターを retain しない点が重要です。retain してしまうとアクターが生き続け、終了を観測できなくなるためです。アクターを生かし続けたい場合は、管理用アクターやレジストリ、あるいは receptionist に強参照で保持させます。
例: 分散ワーカープール
ここまでの機能を組み合わせると、分散ワーカープールを構築できます。WorkerPool は receptionist の worker キーを購読し、クラスタに現れた worker をプールに追加し、終了したら取り除きます。
// **** API と構文は作業中 / Swift Evolution のレビュー待ち ****
extension Reception.Key {
static var workers: Self<Worker> { "workers" }
}
distributed actor WorkerPool {
var workers: Set<Worker> = []
init(transport: ActorSystem) async {
Task {
for try await worker in transport.receptionist.subscribe(.workers) {
workers.insert(worker)
watchTermination(of: worker) {
workers.remove($0) // スレッドセーフ!
}
}
}
}
distributed func submit(work item: WorkItem) async throws -> Result {
guard let worker = workers.shuffled.first else {
throw NoWorkersAvailable()
}
try await worker.work(on: item)
}
}
WorkerPool は distributed actor であると同時に actor でもあるため、アクター isolation のおかげでスレッドを意識せずに workers を安全に変更できます。ワーカー側は、初期化時に receptionist へ自分自身を登録するだけです。worker がデイニシャライズされたりノードがクラッシュしたりすると、ほかのシステム上の receptionist がそれを terminated シグナルへ自動的に変換します。
// **** API と構文は作業中 / Swift Evolution のレビュー待ち ****
distributed actor Worker {
init(transport: ActorSystem) async {
await transport.receptionist.register(self, withKey: .workers)
}
distributed func work(on item: WorkItem) async -> Result {
// 作業を行う
}
}
ネットワーク処理やリクエスト・レスポンスの対応付け、エンコード・デコードを自分で書く必要はなく、これらはすべて言語機能と ActorSystem トランスポート実装が連携して処理します。
導入・今後の位置づけ
発表当時、distributed actor は実験的な言語機能であり、ライブラリも「early preview」として公開された段階でした。すべての API は予告なく変更・削除される可能性があり、本番利用は推奨されていませんでした。当時の nightly ツールチェインで言語機能とライブラリを試せるとされ、フィードバックは Swift フォーラムの Distributed Actors カテゴリや Swift Evolution の pitch で募られていました。