アクターは Lambda Prelude における常駐サービスの単位。OCaml 5 のエフェクトハンドラ上に直接実装されている — Eio・Miou・POSIX 専用 API には依存しない。同じアクターコードが Windows と Linux、インタプリタと AOT で動く。
3 つの通信プリミティブ
アクター表層は spawn / send / receive の 3 つの通信プリミティブと、自分自身への参照を返す Actor self から始まる。
Actor self "現在の fiber のアクター参照"
Actor spawn: [:me | body] "新規 fiber を生成、body は自身の参照を受ける"
actor ! message "fire-and-forget 送信"
actor receive "メッセージが来るまでブロック"
最初の ping-pong は次のとおり。
parent := Actor self.
child := Actor spawn: [:me |
msg := me receive.
msg printNl.
parent ! #pong
].
child ! #ping.
parent receive printNl.状態はループ引数として生きる
共有可変状態はない。アクターの状態は再帰ループの引数として表現する。「状態を更新する」とは、新しい値で自分自身を呼び直すこと。
Object subclass: #Counter.
Counter class >> run: me count: n =
me receive: {
Inc -> [:msg | Counter run: me count: n + 1].
GetCount -> [:msg | msg reply ! n. Counter run: me count: n]
}.
counter := Actor spawn: [:me | Counter run: me count: 0].
n は呼び出しフレームの中に閉じている — 他の fiber から読み書きされない。
型付き選択受信
me receive: { Class -> [:msg | ...] } はクラス・タグディスパッチ表。スケジューラがメールボックスを先頭から末尾へ走査し、最初に一致したハンドラを実行する。一致しないメッセージは元の位置に残る — これが選択受信。
Object subclass: #Inc.
Object subclass: #Get fields: (reply).
Counter class >> run: me state: n =
me receive: {
Inc -> [:msg | Counter run: me state: n + 1].
Get -> [:msg | msg reply ! n. Counter run: me state: n]
}.
ディスパッチ表は各メッセージクラスを Hindley-Milner 推論に通すので、ハンドラ本体は msg を具体型として扱える — Obj.magic も型を回避する抜け穴もない。
受信タイムアウト
receive:after:do: は一致したメッセージかタイムアウト分岐のどちらかを返す。
me receive: { Tick -> [:t | handle: t] }
after: 1000
do: [Log warn: 'no tick for 1 s'].Future と ask:
ask: は send + receive の上に乗った request / response の糖衣。応答が到着したときに解決する Future を返す。
fut := worker ask: (Compute fields: { x: 7 }).
fut await printNl.
Future の手動生成も用意してあるが、通常経路は ask:。
リンク / モニタ / 終了シグナル捕捉
失敗の意味論は Erlang の語彙をローカルモデルに当てはめたもの。
- リンク — 対称。どちらか一方が異常終了すると他方も終了する。2 つのアクターのライフタイムを縛る。
- モニタ — 非対称。観察対象が死ぬと観察側のメールボックスに
Downメッセージが届くが、観察側は走り続ける。 - 終了シグナル捕捉 — リンク経由の死の連鎖を
Exitメッセージに変換し、アクター本体がハンドラで受け取れるようにする。
Object subclass: #Boom.
Object subclass: #Worker.
Worker class >> loop: me =
me receive: {
Boom -> [:msg | Error raise: 'kaboom!']
}.
main := Actor self.
worker := Actor spawn: [:me | Worker loop: me].
main monitor: worker.
worker ! (Boom fields: {}).
main receive: {
Down -> [:d |
'Down received' printNl.
d reason printNl
]
}.
'main still alive' printNl.
Down は死んだアクターの参照と失敗理由を運ぶ。観察側は走り続ける — リンクを貼っていない限り連鎖死は起きない。
スーパーバイザ
スーパーバイザツリーは専用構文ではなく、ふつうのクラスとして書く。スーパーバイザは子を監視し、Down メッセージを契機に再起動する。再起動予算を使い切った場合は停止し、上位スーパーバイザに監視されていればそこで扱われる。
stdlib の Supervision モジュールは再利用可能な戦略 (one-for-one / one-for-all / rest-for-one) と再起動ポリシーを提供するが、内部機構は monitor: + spawn: の組合せに過ぎない。
マルチドメインスケジューリング
ランタイムはマルチドメイン構成 — アクターは OS スレッドをまたいで spawn でき、クロスドメインの inbox 受け渡しとワークスティーリングが効く。AOT バイナリには同じランタイムが一緒にコンパイルされているので、まったく同一のマルチドメインスケジューラを備える。CPU バウンドな仕事はどちらのモードでもコア間に拡がり、別個の単一ドメインランタイムは存在しない。
境界つきソケット I/O
TCP connect:port:timeout:、TCPSocket >> readLine:timeout: / readBytes:timeout:、TLS wrapClient:host:timeout:、TLS の read / write はすべてアクタースケジューラと統合されている。遅い上流は呼び出し側 fiber を wait_readable / wait_writable で待機させるだけで、OS スレッドはブロックしない — 他のアクターは走り続ける。
TLS ブリッジは Tls.Engine を直接駆動する — ハンドシェイク / アプリケーション read / write がそれぞれランタイムの I/O wait プリミティブの上で待機する。
ロードマップから外しているもの
Erlang / Akka Cluster 形式の分散アクター — ノード間 spawn / link / monitor / スーパーバイザのワイヤ越し版 — はロードマップ外。ローカルアクターモデルはそのまま維持する。
複数ノード対応 — 仮想アクター (grain)
複数ノード方式は仮想アクター (grain) として実装済み。grain は fiber 参照ではなく安定した (クラス, id) の組で宛先指定されるアクターで、ライブ状態は固定したプロセスメモリではなく外部ストアに置く。ネットワーク上のオブジェクト参照ではなく (クラス, id) で到達するので、アイドル時は活性化を落とし(メモリを解放し)、次の呼び出しで外部ストアから状態を読み直して再活性化でき、GC すべきノード間参照グラフが存在しない。アプリケーションは grain クラスに通常のメソッドを定義し、その (クラス, id) 宛てにメッセージを送る — 分散ライフサイクルは stdlib/grain.lp の Grain モジュールが担う。
ランタイムは「(クラス, id) ごとに生きたオブジェクトは 1 つ」を 3 つの役割で保つ:
- ディレクトリ (サーバごとに 1 つ) —
id -> ownerマップを持つ単一スレッドアクター。プロセス内で get-or-create が競合しない。 - オーナ (アクティブな id ごとに 1 つ) — 状態をメモリに保持し、その id への全呼び出しをメールボックス経由で順序実行 (id 単位の直列化)。アイドルタイムアウトで非活性化し、メモリはアクティブな作業集合だけを追う。
- リース (クロスノード) — id の活性化時に Redis リースを取り、TTL の間そのノードに固定する。状態書き込みは単調増加のフェンストークンを伴い、リースの切れたノードが新しいオーナを上書きできない。ノードが死ねばリースが失効し、別ノードが保存状態から活性化する。
単一ノードモード (for:initial:redis:) はディレクトリとオーナのみ、クロスノードモード (for:initial:redis:url:) がリースを足す。id を所有しないノードはオーナを名指すエラーを返し、クライアントが再試行をルーティングする (サーバ側の透過転送は未実装)。Redis は揮発的な活性化ストアであって永続的な記録ではない — system-of-record DB への永続化はアプリケーションの責務。
ストアは差し替え可能。GrainServer は GrainStore プロトコル (stateFor: / persist:json:token: / acquireFor: / renew:token: / release:token:) 越しに話す。RedisGrainStore が組込バックエンドで、アプリケーションは独自ストア (例: アクター保持のインメモリマップ) を for:initial:store: で注入できる。
選択受信 / リンク / モニタはあくまでローカル限定 — ノードをまたいだリンク連鎖は導入しない。遠隔参照 Remote at: url for: #Class id: はリテラルのクラスを渡すと型に乗り、未知セレクタはコンパイル時エラーになる (非リテラルの for: は従来通り動的型付け)。