章と記事の一覧
読むストレージ
Durable Objects と Actor モデル
なぜ class なのか、どこまでが actor なのか
Durable Objects(DO)は、計算とストレージを組み合わせた特別な Worker である。その仕組みは、並行コンピューティングの古典的理論である Actor モデル(アクターモデル) の見方で説明でき、Cloudflare のドキュメントも各 DO を Actor として見なせると述べている。この見方を知ると、DO がオブジェクト指向のクラス構文で定義される理由と、1 つのインスタンスの中で JavaScript が同時に走らない理由が理解しやすくなる。ただし、await の合間に別の処理が割り込める点など、厳密な Actor モデルと異なる部分もある(末尾の節で述べる)。
Actor モデルが課す 4 つの規約
Erlang のプロセスや Akka の Actor に代表される Actor モデルは、分散環境で安全に状態を保つために次の規約を定める。
- 唯一無二の同一性(Identity): すべてのアクターは一意な識別名(ID または名前)で指し示される。同一の名前を指定する限り、物理的に同一の実体に到達する。
- 私有された状態(Private State): 状態はアクターの内部にのみ閉じており、外部のプロセスから直接メモリを覗き見ることはできない。
- メッセージ通信(Message Passing): アクター間のあらゆるやり取りは、非同期メッセージの送受信によってのみ行われる。届いたメッセージは受信トレイへ順に積まれる。
- 一度に一つだけの処理(Turn-based Processing): アクターは受信トレイからメッセージを 1 件ずつ取り出し、順番に処理する。内部で同時に複数の処理が走らないため、競合状態(Race Condition)が発生し得ない。
この規約により、共有メモリの排他制御や分散ロックの複雑さを避けられ、状態の物理的な配置場所(ロケーション透過性)もプラットフォームに任せられる。
Durable Objects と Actor の対応関係
| Actor モデルの概念 | Cloudflare Durable Objects の実装 |
|---|---|
| アクターの識別名 | env.ROOM.idFromName('room-1') で生成される一意の ID |
| 私有された状態 | this.ctx.storage(内蔵 SQLite)および this のインスタンス変数 |
| メッセージ | クラスに定義された型付き RPC メソッド、または fetch() |
| 受信箱と直列化 | 単一スレッドで実行。ストレージ以外の I/O(fetch() など)を await する間は、別のリクエストが割り込む |
| アクターの消滅と復活 | 約 10 秒のアイドルでメモリから退避(Hibernation)。次のイベントで constructor() が走り直して再生成され、メモリ上の状態は失われる |
| 物理的な配置位置 | 原則として、その実体に最初にリクエストを送ったエッジ(Colo)近傍 |
なぜ関数ではなくクラスで書くのか
クラス構文が使われる理由は、クラスが「振る舞いの定義単位」であり、同時に DO の名前空間とそのライフサイクル(作成・改名・削除・移管)を管理する単位でもあるからである。デプロイの単位は Worker であり、クラスではない。
同一の振る舞いを持つアクターの実体を、部屋名やユーザー名ごとに何万個と生成する。この要件を満たすには、振る舞いを表す「型(クラス)」と、個々の状態を保持する「インスタンス(名前)」を明確に切り分けなければならない。
export class ChatRoom extends DurableObject {
async join(user: string) {
const members = (await this.ctx.storage.get<string[]>("members")) ?? [];
if (!members.includes(user)) {
members.push(user);
await this.ctx.storage.put("members", members);
}
return members;
}
async alarm() {
// `setAlarm()` で設定した時刻に起床し、この DO の保存データをすべて消す
await this.ctx.storage.deleteAll();
}
}RPC メソッド join に複数のクライアントから同時にアクセスが来ても、配列の読み出しと書き込みの間に別の処理が割り込んでデータが壊れることはない。ストレージ操作を await している間は、入力ゲート(input gate)が他のイベントの配送を止めるからである。
考慮すべきスループットの境界線
- 1 つの Durable Object は単一スレッドで動き、単純な処理で毎秒 500〜1,000 リクエスト程度が目安になる(1 オブジェクトのソフトリミットは毎秒 1,000 リクエストで、超えるとオーバーロードのエラーが返る)。何十万ものアクセスを 1 つの DO インスタンスに集中させれば当然ボトルネックになる。部屋ごと、テナントごと、ユーザー ID ごとに細かくインスタンスを分割配置する設計が基本となる。
- ストレージ以外の I/O(
fetch()や R2 など)をawaitしている間は、別のリクエストの処理が割り込める(Interleaving)。ストレージ操作をawaitする間は入力ゲートが他のイベントの配送を止めるので、割り込まれない。割り込みで状態の不変条件が崩れる処理は、blockConcurrencyWhile()か、バージョン番号を確認してから書き込む楽観ロックで守る。 - ハイバネーション可能な状態でアイドルのインスタンスには、時間(GB 秒)課金が発生しない。保留中のタイマーや標準の WebSocket(
ws.accept())があると、アイドルでも課金は続く。常時接続の WebSocket は、Hibernation API を使えば接続を保ったまま待機中の時間課金を避けられる。