- Published on
Actor Model 完全ガイド 2025: Erlang OTP、Akka、Orleans、Elixir — 数百万同時接続を扱う本物の方法
- Authors

- Name
- Youngju Kim
- @fjvbn20031
はじめに: 200 万接続の奇跡
WhatsApp の伝説
2012 年、WhatsApp のあるエンジニアがベンチマークを公開した。
「FreeBSD + Erlang で単一サーバーから 2,000,000 の TCP 接続」
これは伝説となった。当時は C10K (同時 1 万接続) も難しいとされていたのに、200 万? どうやって可能だったのか。
答えは Erlang の Actor モデル。Erlang という言語は 1986 年に Ericsson の電話交換機のために作られ、最初から 並行性、分散、耐障害性 のために設計された。
Actor モデルとは
1973 年に Carl Hewitt が提案した Actor モデル は伝統的プログラミングと根本的に異なる。
「すべては actor である。Actor はメッセージを受け取り、メッセージを送り、新しい actor を作る」
- 共有メモリなし: すべての通信はメッセージで。
- ロックなし: 状態は actor 内部のみに存在。
- Asynchronous: メッセージは非同期に届く。
- Fault isolation: 一つの actor の失敗は他に波及しない。
このシンプルなモデルから 驚くべきシステム が生まれる。
この記事で扱うこと
- Actor モデルの数学的定義。
- Erlang/OTP: 元祖でありリファレンス。
- Akka: JVM の actor 実装。
- Elixir: Erlang の現代的文法。
- Orleans: Microsoft の .NET Actor。
- Let it crash 哲学。
- Supervision tree パターン。
- 分散 Actor システム。
なぜこの知識が重要か
- 並行性: 数百万接続の処理。現代バックエンドの必須。
- 耐障害性: 復旧でなく「死なせる」哲学。
- 分散システム: ローカルとリモート actor が同じコード。
- Elixir: Phoenix LiveView のような現代的フレームワーク。
- WhatsApp、Discord、Riot: 大手企業の選択。
1. Actor モデルの理論
三つの基本操作
Carl Hewitt のオリジナル定義。
- Create: 新しい actor を生成する。
- Send: 他の actor にメッセージを送る。
- Become: 次のメッセージに対する動作を決定する (状態変更)。
これだけ。この三つですべての計算が表現できる。
Actor の構成要素
各 Actor は次を持つ。
- Address (PID): 一意の識別子。
- Mailbox: 受信メッセージのキュー。
- Behavior: メッセージをどう処理するかを定義する関数。
- State: Actor 内部の状態 (外部からアクセス不可)。
Actor A
┌─────────────┐
│ Mailbox │ ← msg3 ← msg2 ← msg1
├─────────────┤
│ Behavior │ (関数: msg → action)
├─────────────┤
│ State │ (内部データ)
└─────────────┘
メッセージ処理モデル
Actor は 一度に一つのメッセージ を処理する。
while True:
msg = mailbox.pop() # 次のメッセージ
action = behavior(msg) # 現在の動作で処理
apply(action) # 状態変更、メッセージ送信など
ポイント: 一つの actor 内部は シングルスレッド で動作。だから actor 内部の状態は 同時変更の心配なし。ロック不要。
共有メモリがない
Actor 間の通信は メッセージのみ。
// 間違ったアプローチ
actor_B.state.counter += 1 // 違法!
// 正しいアプローチ
actor_B.send(IncrementMessage())
この制約がすべての 並行性問題を消し去る。Race condition、deadlock、locks — ない。代わりに メッセージ伝達 の複雑さに変わる。
Asynchronous Message Passing
メッセージ送信は 非同期。
actor_A.send(actor_B, msg)
// 即座に戻る。B が実際に処理するのは後。
これは次を意味する。
- Sender をブロックしない: 送信者は他の仕事を続ける。
- Backpressure: Mailbox が溢れる可能性 (実装次第)。
- 順序保証: ほとんどの実装が「一つの A から一つの B へのメッセージは順序通り」を保証。
- 配送保証: ローカルは at-most-once、リモートは実装次第。
Location Transparency
Actor の PID は ローカル/リモートを区別しない。
% 同じコード
Pid ! Message
Pid が同じノードでも別ノードでもコードは同一。これが 透過的な分散 を可能にする。Erlang が最初にこの原則を実装した。
Actor vs Thread
| 項目 | Thread | Actor |
|---|---|---|
| 共有メモリ | あり | なし |
| 同期 | ロック、セマフォ | メッセージ |
| 重さ | 重い (数 MB) | 軽い (数 KB) |
| 数 | 数千 | 数百万 |
| エラー伝播 | プロセス全体ダウン | 個別隔離 |
| 分散 | 困難 | 内蔵 |
| デバッグ | 困難 | 比較的容易 |
2. Erlang: 元祖の力
歴史
- 1986: Ericsson の電話交換機用。
- 1998: オープンソース化。
- 1989: OTP (Open Telecom Platform) ライブラリ。
- 2010 年代: WhatsApp、Ericsson、Heroku などで大規模利用。
言語哲学
Erlang は 意図的に制限された 言語。
- Immutable: 変数は一度だけ代入。
- Pattern matching: 主要な制御フロー。
- Functional: 副作用を最小化。
- Dynamic typing: 実行時型付け。
- Single assignment: Prolog の影響。
これらの制約が 並行性を安全にする。
軽量プロセス
Erlang の actor は 「process」 と呼ばれる。しかし OS プロセスではなく VM 内部のスケジューリング単位。
- サイズ: 約 300 バイトから開始。
- Spawn コスト: 1 μs 未満。
- Context switch: OS スレッドより速い。
- 同時プロセス数: 数百万が可能。
% 100 万プロセス生成
lists:foreach(
fun(_) -> spawn(fun() -> loop() end) end,
lists:seq(1, 1000000)
).
% 数秒で完了。
OS スレッドでは不可能。Erlang の軽さが秘密。
Send と Receive
% Actor(プロセス)生成
Pid = spawn(fun() -> counter(0) end).
% メッセージ送信
Pid ! {increment, 5}.
Pid ! {get, self()}.
% メッセージ受信
counter(N) ->
receive
{increment, Delta} ->
counter(N + Delta);
{get, From} ->
From ! {count, N},
counter(N);
stop ->
ok
end.
! は send、receive はブロッキングメッセージ受信、self() は自分の PID。
Pattern Matching in Receive
receive は mailbox を スキャン してパターンに合う最初のメッセージを取り出す。
receive
{urgent, Msg} -> handle_urgent(Msg);
{normal, Msg} -> handle_normal(Msg)
after
5000 -> timeout()
end.
- 選択的マッチング (特定のメッセージのみを選んで処理)。
- タイムアウトサポート。
- メッセージキューを並べ替えずマッチングのみ。
Selective Receive の罠
Erlang は mailbox を 前から スキャンする。マッチしないメッセージは 保留キュー に残る。
receive
{critical, X} -> ...
end.
もし mailbox に {critical, _} がなければ 永遠に待つ。他のメッセージが溜まって メモリ爆発。これは Erlang の有名な罠。解決策は常に catch-all を入れるか after タイムアウト。
Erlang/OTP
OTP (Open Telecom Platform) は Erlang の標準ライブラリ + デザインパターン集。
- gen_server: 一般的な server behavior。
- gen_statem: ステートマシン。
- gen_event: イベントハンドラ。
- supervisor: 監視ツリー。
- application: アプリケーション構造。
OTP は actor モデルの 再利用可能なデザインパターン。Erlang で何かを作るとき、ほぼ常に OTP の上で作る。
gen_server の例
-module(counter).
-behaviour(gen_server).
-export([start_link/0, increment/1, get/0]).
-export([init/1, handle_call/3, handle_cast/2]).
start_link() -> gen_server:start_link({local, ?MODULE}, ?MODULE, 0, []).
increment(N) -> gen_server:cast(?MODULE, {increment, N}).
get() -> gen_server:call(?MODULE, get).
init(State) -> {ok, State}.
handle_cast({increment, N}, State) -> {noreply, State + N}.
handle_call(get, _From, State) -> {reply, State, State}.
構造的で馴染みのあるパターン。作成と保守が容易。
3. Let It Crash: 革命的哲学
伝統的エラー処理
ほとんどの言語では:
try:
result = risky_operation()
except Exception as e:
log_error(e)
# 復旧を試みる、または失敗を返す
return None
- 可能なすべてのエラーを 予測。
- Defensive programming。
- 問題: 予期せぬエラーは依然クラッシュ。
Erlang のアプローチ
「Let it crash」
- エラーを予測しようとするな。
- Actor が失敗したら そのまま死なせろ。
- Supervisor が再起動する。
コードは happy path に集中 し、失敗は外部 (supervisor) が管理する。
なぜこれが機能するのか
伝統的前提: 「エラーは例外的」。Erlang の前提: 「エラーは正常」。
ネットワークは切断され、ディスクは満杯になり、メモリは不足する。これは 予想すべき日常。しかし各エラーケースを扱おうとするとコードが複雑になる。
Erlang の解決策: エラーが出たら再起動。再起動後は クリーンな状態 で始まる。これがほとんどの問題を解決する (「have you tried turning it off and on again?」)。
Supervision Tree
Supervisor は子 actor を監視する特殊な actor。
Main Supervisor
/ | \
Worker Worker Sub-Supervisor
/ | \
Worker Worker Worker
各 supervisor は restart strategy を持つ。
- one_for_one: 失敗した子のみ再起動。
- one_for_all: 一つが失敗したらすべての子を再起動。
- rest_for_one: 失敗した子とその後のすべての子を再起動。
- simple_one_for_one: 動的に生成される子。
再起動制限
「無限再起動」は危険 (同じエラーが繰り返されると CPU の浪費)。Supervisor は 再起動制限 を持つ。
max_restarts: 時間あたり最大再起動回数。max_seconds: 測定期間。
超過すると Supervisor 自体が死に、上位 supervisor に伝播。これが エラーの階層的拡散。
例: WhatsApp 構造
Application Supervisor
├── Cluster Supervisor
│ ├── Node Manager
│ └── Distribution Manager
├── Connection Supervisor
│ ├── Connection 1
│ ├── Connection 2
│ └── ... (数百万)
├── Message Supervisor
│ ├── Message Router
│ └── Storage Manager
└── Monitoring Supervisor
├── Metrics Collector
└── Health Checker
各レベルで障害が隔離される。一つの接続のエラーが他の接続に影響しない。一つのサブシステムの問題が全体を崩さない。
教訓: 失敗を受け入れるソフトウェア
Let it crash は ソフトウェア設計のパラダイムシフト である。
- 伝統: 「自分のコードは完璧であるべき」。
- Erlang: 「自分のコードは失敗する。それでもシステムは動く」。
これは 分散システムの本質 に合う。分散システムでは 何も 100% 信頼できない。ネットワーク、ハードウェア、ソフトウェアすべてが時折失敗する。受け入れて設計しよう。
4. Akka: JVM の Actor
動機
2009 年に Jonas Bonér が開始。JVM に Erlang スタイルの actor を持ち込もうとした。Scala と統合。
2012 年 Typesafe (現 Lightbend) が商用サポート。Akka は Java/Scala エコシステムで actor の代名詞となった。
Akka vs Erlang の違い
言語の違い:
- Erlang: 動的型付け、immutable、関数型。
- Akka: JVM 言語の型システムをそのまま使用。
Actor の軽量性:
- Erlang: プロセス 200-300 バイト、完璧な隔離。
- Akka: Akka actor 数 KB、JVM thread pool ベース。
Selective receive:
- Erlang: ネイティブサポート。
- Akka: Stash で類似実装可能。
文化:
- Erlang: 「言語即哲学」。言語全体が actor 中心。
- Akka: 「ライブラリ」。JVM コードと混ぜて使える。
基本 Akka 例 (Scala)
import akka.actor.{Actor, ActorSystem, Props}
class Counter extends Actor {
var count = 0
def receive = {
case Increment(n) => count += n
case Get => sender() ! count
}
}
val system = ActorSystem("MySystem")
val counter = system.actorOf(Props[Counter], "counter")
counter ! Increment(5)
counter ! Get // 応答は sender に届く
- Props: Actor 生成設定。
- receive: メッセージ処理関数。
- sender(): 現在のメッセージの送信者 ref。
Typed Actor (Akka 2.6+)
初期 Akka はメッセージが Any 型だった (Erlang スタイル)。これは型安全性を捨てたもの。Akka 2.6 から Typed actors がデフォルト。
sealed trait CounterMessage
case class Increment(n: Int) extends CounterMessage
case class Get(replyTo: ActorRef[Int]) extends CounterMessage
val counterBehavior: Behavior[CounterMessage] = Behaviors.setup { context =>
var count = 0
Behaviors.receiveMessage {
case Increment(n) =>
count += n
Behaviors.same
case Get(replyTo) =>
replyTo ! count
Behaviors.same
}
}
コンパイル時にメッセージ型を検証。JVM の強みを活用。
Akka Cluster
Akka Cluster は複数ノードを一つの actor system にまとめる。
- Gossip プロトコル: ノードメンバーシップ。
- 分散 actor: ローカルのように使える。
- Cluster Sharding: 状態を自動分散。
- Cluster Singleton: クラスタ全体で単一 actor。
- Distributed Data: CRDT ベースの共有状態。
Cluster Sharding の例:
val shardRegion = ClusterSharding(system).start(
typeName = "User",
entityProps = Props[UserActor],
settings = ClusterShardingSettings(system),
extractEntityId = extractEntityId,
extractShardId = extractShardId
)
// userId=42 でメッセージを送る
shardRegion ! Envelope(42, SomeMessage)
// 42 という ID の actor がどのノードにあっても自動ルーティング
これが 数百万の actor を数十ノードに自動分散する。ノード追加/削除時に rebalance。
Akka Persistence
Event Sourcing 内蔵サポート。Actor の状態変更を イベントログ として保存。
val behavior: Behavior[Command] = EventSourcedBehavior[Command, Event, State](
persistenceId = PersistenceId("counter", "1"),
emptyState = State(0),
commandHandler = (state, cmd) => cmd match {
case Increment(n) => Effect.persist(Incremented(n))
},
eventHandler = (state, evt) => evt match {
case Incremented(n) => state.copy(count = state.count + n)
}
)
Actor 再起動時にイベントログを再生して 状態復旧。分散システムの failure recovery に強力。
Akka の罠
- 「Props で子生成」: Erlang より複雑。
- Untyped actor レガシー: 多くのチュートリアルが未だ古い。
- JVM チューニング: GC、heap、thread pool。
- ライセンス変更: 2022 年 BSL (Business Source License) に変更。大規模商用は有料。
ライセンス問題以降 Pekko (Apache fork) が登場。Akka と 100% 互換。オープンソース。
5. Elixir: Erlang の現代的な顔
誕生
- 2012: Ruby 開発者 José Valim が開始。
- Erlang VM (BEAM) 上で動作。
- Ruby/Python スタイル文法 + Erlang の強力さ。
なぜ Elixir か
Erlang は強力だが 文法が異質 (Prolog の影響)。多くの開発者が参入障壁を感じた。Elixir は次を提供。
- 親しみやすい文法: Ruby/Python ユーザーに親和的。
- 現代的ツール: Mix (build)、ExUnit (test)、Hex (package manager)。
- 100% Erlang 互換: すべての OTP、すべてのライブラリ利用可能。
- Metaprogramming: マクロで DSL を構築。
例: Counter
defmodule Counter do
use GenServer
# Client API
def start_link(initial \\ 0) do
GenServer.start_link(__MODULE__, initial, name: __MODULE__)
end
def increment(n) do
GenServer.cast(__MODULE__, {:increment, n})
end
def get do
GenServer.call(__MODULE__, :get)
end
# Server callbacks
def init(initial), do: {:ok, initial}
def handle_cast({:increment, n}, state) do
{:noreply, state + n}
end
def handle_call(:get, _from, state) do
{:reply, state, state}
end
end
Erlang の gen_server と構造は同じだが、はるかに読みやすい。
Phoenix と LiveView
Elixir のキラーアプリ: Phoenix フレームワーク。Web 開発の Rails 級の生産性 + Erlang の並行性。
Phoenix LiveView: サーバーレンダリング + リアルタイム更新を JavaScript なしで。内部的に WebSocket + actor で実装。
性能: 一つの Phoenix サーバーで 数百万同時接続 処理可能 (WhatsApp レベル)。
Discord の利用
Discord は Elixir + Erlang を大規模に利用している。
- メッセージルーティング: Elixir クラスタ。
- 同時音声チャット: 数百万接続。
- 500 万以上の同時ユーザー: 2019 年ブログで明かした。
「Erlang は死んだ言語じゃない?」という問いへの答えはここにある。Discord、WhatsApp、Riot (League of Legends チャット) など 数百万ユーザーのサービス が BEAM の上で動いている。
6. Orleans: Microsoft の Virtual Actors
歴史
- 2010: Microsoft Research で開始。
- Project Orleans の目標: 「クラウドスケール .NET actor」。
- Halo 4+: プレイヤーマッチング、統計、リーダーボード。
Virtual Actors
Orleans の革新: virtual actor。Actor のライフサイクル管理をランタイムが自動で。
伝統的 actor:
- 開発者が 明示的に生成/消滅。
- PID 管理が必要。
Virtual actor:
- Actor は 常に存在 すると仮定。
- メッセージ受信時に 自動活性化。
- アイドル状態なら 自動非活性化。
- 再起動時に 自動復元。
public interface ICounterGrain : IGrainWithIntegerKey
{
Task Increment(int n);
Task<int> Get();
}
public class CounterGrain : Grain, ICounterGrain
{
private int count = 0;
public Task Increment(int n)
{
count += n;
return Task.CompletedTask;
}
public Task<int> Get() => Task.FromResult(count);
}
使用例:
var counter = grainFactory.GetGrain<ICounterGrain>(42);
await counter.Increment(5);
int value = await counter.Get();
ID 42 の counter grain が初めて呼ばれると 自動生成、その後アイドル状態が長くなると 自動非活性化。開発者はライフサイクルを気にしなくてよい。
利点
- 単純化: 生成/消滅コードなし。
- 自動 failover: ノード障害時に他ノードで再生成。
- 状態の永続化: Grain の状態は storage provider で自動保存。
- .NET ネイティブ: C# 型システム活用。
欠点
- ランタイムオーバーヘッド: 自動化のコスト。
- エラーモデル: let it crash とは異なるアプローチ。
- 学習曲線: virtual actor の概念が最初は馴染みにくい。
利用先
- Halo シリーズ のゲームサービス。
- Azure の一部サービス 内部。
- 金融取引 (FIS などのパートナー)。
- IoT: デバイスごとに grain。
.NET 唯一の Actor システムか
Orleans は .NET エコシステムで最も有名な actor フレームワークだが唯一ではない。Akka.NET (Akka の .NET 移植)、Proto.Actor などもある。しかし Microsoft 公式 + Azure 統合で Orleans が主流。
7. 他の Actor システム
Dapr Actors
Microsoft の Dapr (Distributed Application Runtime) は sidecar ベースの分散ランタイム。Dapr Actors は言語中立の virtual actor。
# Python
from dapr.actor import Actor
class CounterActor(Actor):
async def increment(self, n):
state = await self._state_manager.get_state("count")
await self._state_manager.set_state("count", state + n)
HTTP/gRPC ベースなので どの言語でも 使える。Kubernetes 親和。
Akka.NET
Akka の .NET 移植。F#/C# から利用。Pekko と共に Akka 哲学の延長線。
Pykka
Python の簡単な actor ライブラリ。Thread ベース。
Ray
Python の分散コンピューティングフレームワーク。Actor 抽象を含む。
import ray
@ray.remote
class Counter:
def __init__(self):
self.count = 0
def increment(self, n):
self.count += n
def get(self):
return self.count
counter = Counter.remote()
counter.increment.remote(5)
count = ray.get(counter.get.remote())
ML ワークロード で人気。Ray Tune、Ray Serve などと統合。
CAF (C++ Actor Framework)
C++ 用高性能 actor フレームワーク。ゲームエンジン、HFT で利用。
8. 数百万同時接続の秘密
Erlang の秘訣
なぜ Erlang は数百万の同時接続が可能なのか。
1. 軽量プロセス:
- 開始サイズ 約 300 バイト。
- Spawn コスト 約 1 μs。
- JVM thread より 1000 倍軽い。
2. 先取型スケジューリング:
- BEAM VM の preemptive scheduling。
- 関数呼び出し後に「reduction」をカウント。
- プロセスが CPU を「長く」占有すると強制切り替え。
3. 独立 heap:
- 各プロセスに別々の heap。
- Per-process GC: 一つのプロセスを GC しても他に影響なし。
- 「Stop the world」なし。
4. Share-nothing:
- 共有メモリがないので ロック競合なし。
- メッセージ送信時に コピー (ローカルポインタではない)。
5. 単純なスケジューラ:
- CPU あたりスケジューラスレッド 1 個。
- 各スレッドが自分の run queue。
- Work stealing でバランス。
ベンチマーク
Erlang の The Road to 2 Million Websocket Connections in Phoenix (2015 年ブログ):
- テスト: Phoenix Channels with WebSocket。
- 目標: 200 万接続。
- 結果: サーバー 1 台で達成。
- メモリ: 約 40 GB (接続あたり 20 KB)。
- CPU: ほぼ idle。
これは 単一サーバー での話。Node.js や Java では非常に難しい。
比較: Go goroutine
Go の goroutine も軽量並行性で有名。
- サイズ: 2 KB 開始 (動的拡張)。
- 数: 数百万可能。
- 性能: Erlang と同等かやや優位。
ただし Go は actor モデルではない。Share-memory + channel。エラー処理も伝統的 (recover/defer)。
Erlang の強みは 耐障害パターン と 分散透過性。純粋な性能だけ見れば Go と同程度。
実戦設計: State Management
数百万の actor があると 状態管理 が重要。
- In-memory: 各 actor が自分の状態。最速。
- Persistent: Event sourcing でイベントログ保存。
- Distributed: CRDT、または別途 DB。
WhatsApp は主に in-memory を使った。メッセージルーティングは stateless に近いので可能。より複雑な状態が必要なら Cassandra/DB と組み合わせ。
9. 実戦パターン
パターン 1: Request-Reply
最も基本的なパターン。
# 同期 call
reply = GenServer.call(server, :request)
# 非同期 cast (応答なし)
GenServer.cast(server, :fire_and_forget)
call は内部的にタイムアウトを持つ (デフォルト 5 秒)。応答がなければ exception。
パターン 2: Pub-Sub
# Subscriber
Phoenix.PubSub.subscribe(MyApp.PubSub, "chat:lobby")
# Publisher
Phoenix.PubSub.broadcast(MyApp.PubSub, "chat:lobby", {:new_message, msg})
# Subscriber receives
def handle_info({:new_message, msg}, state), do: ...
Actor システムで自然に実装される。
パターン 3: Worker Pool
複数の worker actor に作業を分配。
# poolboy 使用
{:ok, pool_pid} = :poolboy.start_link(
[worker_module: MyWorker, size: 10],
init_args
)
worker = :poolboy.checkout(pool_pid)
result = MyWorker.process(worker, task)
:poolboy.checkin(pool_pid, worker)
パターン 4: State Machine
gen_statem でステートマシン。
defmodule TrafficLight do
use GenStateMachine
def init(_), do: {:ok, :red, %{}}
def handle_event(:state_timeout, :tick, :red, data) do
{:next_state, :green, data, [{:state_timeout, 3000, :tick}]}
end
def handle_event(:state_timeout, :tick, :green, data) do
{:next_state, :yellow, data, [{:state_timeout, 1000, :tick}]}
end
def handle_event(:state_timeout, :tick, :yellow, data) do
{:next_state, :red, data, [{:state_timeout, 3000, :tick}]}
end
end
状態ベースのロジックが明確。
パターン 5: Circuit Breaker
Actor で実装しやすい。
defmodule CircuitBreaker do
use GenServer
def call(name, func) do
case GenServer.call(name, :state) do
:closed -> try_call(name, func)
:open -> {:error, :circuit_open}
:half_open -> try_call(name, func)
end
end
# ... handle failures and state transitions
end
10. 制限と罠
罠 1: Actor は万能ではない
計算集約的 な作業には不利。
- メッセージ伝達のオーバーヘッド。
- 単一 actor はシングルスレッド。
- GPU のような並列ハードウェアと合わない。
解決策: 計算は他の言語/システムで、actor は調整に。
罠 2: Mailbox 爆発
速い producer + 遅い consumer:
Producer: 秒間 100 万メッセージ送信
Consumer: 秒間 1 万メッセージ処理
→ 毎秒 99 万個が mailbox に溜まる
→ メモリ爆発、OOM
解決策:
- Backpressure: Consumer が「slow down」シグナル。
- Sampling: 溢れたら drop。
- Bounded mailbox: Akka で設定可能。Erlang はデフォルト無限。
- Flow control: GenStage、Broadway (Elixir)。
罠 3: Selective Receive の性能
Erlang の selective receive は mailbox スキャン。
receive
{specific, Msg} -> handle(Msg)
end
Mailbox に 10 万個のメッセージがあり、欲しいパターンが 後ろの方 にあれば 10 万個をスキャン。遅い。
解決策:
receiveなしで すべてのメッセージを順次処理。spawnで 新しい actor に特定のメッセージを分岐。
罠 4: 分散環境のメッセージ順序
ローカルでは「A → B のメッセージは順序維持」が保証されるが、分散では:
- メッセージがネットワークを流れる。
- 再試行、再順序化が発生。
- Eventual consistency。
解決策:
- Causal ordering が必要なら明示的に実装。
- Idempotent 設計。
- Event sourcing。
罠 5: Global state の誘惑
Actor モデルの哲学に反するが便利のため:
# :ets テーブルはプロセス外部に共有可能
:ets.new(:shared, [:public, :named_table])
こうした共有状態は便利だが ロック競合、デバッグ困難 を生む。必要なときだけ慎重に。
罠 6: リモート呼び出しのタイムアウト
GenServer.call(remote_pid, :get, 5000)
5 秒タイムアウト。ネットワーク問題でタイムアウトした場合:
- 実際に完了したかどうか わからない。
- At-least-once を望むなら再試行が必要。
- ただし再試行すると重複の可能性。
解決策: Idempotent 設計。一意な request ID。
11. Actor モデル vs 他の並行性モデル
vs Thread + Lock
- Thread: 共有メモリ、ロック、複雑。
- Actor: メッセージ、ロックなし、単純。
Actor が ほとんどの場合 より安全で単純。ただし計算集約的作業には thread がまだ速い。
vs CSP (Go channel)
CSP (Communicating Sequential Processes): Go の goroutine + channel。Actor と類似だが:
- Actor: メッセージは actor の mailbox へ。
- CSP: メッセージは channel へ (actor に紐づかない)。
Go のチャネルはより柔軟だが、actor の「オーナーシップ」モデルがないので考察が複雑になることがある。
vs Reactive Streams
Reactive Streams (RxJS、Reactor): 非同期データストリーム + backpressure。
Actor は「個体」中心、Reactive Streams は「データフロー」中心。互いに補完的で、実際によく組み合わされる。
vs Software Transactional Memory (STM)
STM (Clojure、Haskell): メモリアクセスをトランザクションのように。
Actor は STM が不要 (共有メモリなし)。両方ともロックベース並行性の代替だがアプローチが異なる。
vs Event-driven (Node.js)
Node.js は シングルスレッドのイベントループ。一度に一つの作業のみ。
Actor は 複数の actor が並列 実行可能。各 actor 内部は順次。
Node は単純で理解しやすいが CPU 活用に制限。Actor は複雑さはあるがマルチコアを自由に使える。
12. 学習と実戦
どこから始めるか
Elixir + Phoenix 推奨:
- Ruby/Python に似た親しみやすい文法。
- 即座に Erlang OTP のすべての力。
- Phoenix で実戦プロジェクト。
- 「Programming Elixir」(Dave Thomas) 書籍。
- Phoenix LiveView でリアルタイム Web。
Erlang 推奨:
- 「Learn You Some Erlang」(無料オンライン)。
- 「Programming Erlang」(Joe Armstrong)。
- 古いチュートリアルを心配せず OTP 全般。
Akka 推奨:
- JVM 環境なら自然な選択。
- ただしライセンス問題 (Pekko を検討)。
- 「Reactive Messaging Patterns with Akka」(資料豊富)。
Orleans 推奨:
- .NET 環境。
- Microsoft 公式サポート。
- Azure と統合。
実戦プロジェクトアイデア
- Chat application: WebSocket + actor。Phoenix LiveView が良い。
- Game server: プレイヤーごとに actor。
- Stock ticker: シンボルごとに actor。
- IoT device manager: デバイスごとに actor。
- Task scheduler: ワーカープール。
- Metrics collector: 複数ソースから収集、集計。
小さく始めて actor の力を体感しよう。共有メモリ方式よりどれほど単純で安全かが見えるはず。
実戦事例
- WhatsApp: サーバーあたり 200 万以上の接続。
- Discord: 500 万以上の同時ユーザー。
- Riot Games: League of Legends チャット。
- Heroku: ルーティング層。
- Klarna: 金融取引。
- Pinterest: 通知システム。
- Bleacher Report: リアルタイムフィード。
すべて Erlang または Elixir を利用。「niche 言語」ではない。
クイズで復習
Q1. Actor モデルの三つの基本操作は何で、なぜこれで「すべての計算」が表現可能なのか?
A. Carl Hewitt のオリジナル定義:
- Create: 新しい actor を生成。
- Send: 他の actor にメッセージ送信。
- Become: 次のメッセージに対する動作を決定 (状態変更)。
なぜこれで十分か:
「Become」が鍵。Actor は次のメッセージを受け取る前に 自分の動作を変更できる。これは ステートマシン と同等。
例: Counter
behavior_0 = increment を受けたら behavior_1 へ
behavior_1 = increment を受けたら behavior_2 へ
...
behavior_N = get を受けたら送信者に N を返す
「状態が N の counter」は「次のメッセージに対し特定の動作をする actor」と等価。Stateful computation が actor で表現される。
Turing-completeness:
- Create で 再帰 可能 (自己複製 actor)。
- Send で 入出力 可能。
- Become で 状態 可能。
- 組み合わせで すべての λ-calculus 演算 可能。
よって actor モデルは Turing-complete。どんな計算も表現可能。
実戦的意味: Actor モデルの単純さが 並行性を管理可能にする。伝統的プログラミングのロック、セマフォ、モニタはすべて共有メモリ問題を解こうとするもの。Actor は共有メモリ自体を 禁止 して問題をなくす。
「複雑な問題をどう解くか」ではなく「問題自体をなくそう」のアプローチ。これが Hewitt の哲学的貢献。
Q2. Erlang の「Let it crash」哲学が伝統的エラー処理より優れている理由は?
A. 伝統的エラー処理は 「エラーを予測して処理」 しようとする:
try:
result = db.query(...)
except ConnectionError:
retry(3)
except Timeout:
return cached_value()
except DataCorruption:
log_and_skip()
except UnknownError: # 予期せぬ場合は?
raise # プロセス死亡
問題:
- すべてのエラーを予測できない: 実際に起きるエラーの 30% は「予期せぬ」もの。
- コード複雑度の爆発: 幸福パス 20 行、エラー処理 200 行。
- 一貫性の喪失: エラー後の状態が中途半端。部分的に完了したトランザクション。
- 連鎖失敗: 一つのエラーが他所に伝播。
- 再現困難: 特定のエラーシーケンスはテストできない。
Let it crash アプローチ:
handle_request(Req) ->
Result = do_work(Req), % エラーチェックなし
reply(Result).
エラーが発生したら プロセスが死ぬ。Supervisor が再起動。新しいプロセスは クリーンな状態。
このアプローチの利点:
- コードが単純: 幸福パスに集中。
- 一貫性: 死んだプロセスは いかなる状態も残さない (隔離メモリ)。
- 自然な復旧: ほとんどのエラーが「一時的」。再起動で解決。
- 可視性: 再起動ログから 問題パターンを把握。
- 耐障害がデフォルト: すべての actor が自動的に「resilient」。
「なぜ再起動が解決策か」:
実データ:
- ネットワーク障害の 95% は 一時的 (数秒で復旧)。
- ディスク書き込み失敗の 90% は再試行で解決。
- 外部サービス障害の 80% は数秒後に復旧。
「とりあえず再起動」は実戦で驚くほどよく機能する。Google SRE 本の有名な助言: 「Have you tried turning it off and on again?」
前提条件:
Let it crash が機能するためには:
- 隔離された状態: 一つの actor の状態が他に影響しない。
- Supervisor 階層: 自動再起動メカニズム。
- 再起動制限: 無限再起動を防止。
- Idempotent 設計: 再起動後に同じ結果。
- Event sourcing (選択): 再起動後の状態復旧。
Erlang は言語レベルでこれらすべてをサポート。だから let it crash が 哲学ではなくエンジニアリング原則 として機能する。
他の言語の試み:
- Kubernetes: コンテナレベルの let it crash。Pod 失敗 → 再起動。
- Erlang の理念がクラウドネイティブに進化。Kubernetes の「self-healing」概念がまさにこれ。
教訓: 完璧を諦めると堅牢性が生まれる。すべてのエラーを予防しようとする試みそのものが複雑度を生み、複雑度が新しいバグを生む。失敗を受け入れて 回復メカニズムを設計 する方が実用的。
Q3. Erlang プロセスが OS スレッドより 1000 倍軽い理由は?
A. 複数の要因が結合して極端な軽量性を達成する。
1. スタックサイズ:
- OS スレッド: デフォルトスタック 2-8 MB (変更可能だが実際ほぼしない)。
- Erlang プロセス: 開始時に 約 233 words (約 1.8 KB)。必要なら動的拡張。
1000 個の OS スレッド = 8 GB メモリ。1000 個の Erlang プロセス = 2 MB。4000 倍の差。
2. Context switch:
- OS スレッド: カーネル呼び出しが必要。TLB flush、レジスタ save/restore。数 μs。
- Erlang プロセス: BEAM VM 内部で。カーネル呼び出しなし。数十 ns。
3. スケジューリング単位:
- OS: カーネルがスケジューリング。数千スレッドでオーバーヘッド急増。
- BEAM: User-space スケジューラ。数百万プロセスをサポート。
4. Independent Heap: 各 Erlang プロセスが 自分の heap を持つ:
- Per-process GC: 一つのプロセスの GC は他に影響なし。
- Small heap: ほとんどのプロセスは小さなデータのみ。GC が速い。
- No stop-the-world: JVM の悪夢なし。
5. Share-nothing:
- ロックなし (アクセスする共有状態なし)。
- キャッシュ競合なし。
6. Reduction-based scheduling:
- BEAM は function call を「reduction」としてカウント。
- 2000 reduction ごとに preempt。
- 公平かつ非常に速いスケジューリング。
7. NIF を除けば純粋 Erlang:
- FFI がないので外部の複雑さなし。
- すべてが VM 内部で管理される。
実測:
- WhatsApp: 単一サーバーで 2,000,000 connection。
- Phoenix: 200 万 WebSocket 接続ベンチマーク。
- 100 万 actor spawn: 数秒。
JVM との比較:
- JVM スレッド: OS スレッドベース。同様に重い。
- JVM + Akka actor: 単一の JVM スレッドを複数の actor が共有。中間レベル の軽量性。
- Goroutine (Go): 2 KB 開始。同レベルの軽量性。
いつこの軽量性が重要か:
- 多数の接続: WebSocket、チャット、ゲームサーバー。
- 多数の独立タスク: IoT デバイス管理。
- Actor ベースモデリング: 各 entity を actor に。
- 並列作業: 数万の独立計算。
なぜ他の VM はこれができないのか:
JVM、V8 などは OS スレッドベース のモデルを選んだ。次と互換である必要があったため:
- 既存ライブラリ (OS API 呼び出し)。
- 他言語との FFI。
- 複雑なメモリモデル。
Erlang は 白紙から始めて 軽量プロセスを言語レベルに内蔵できた。これが Erlang の独特な歴史的遺産。
教訓: 制約は時に解放である。Erlang の「多くのことができない」(共有メモリ、直接のシステムコールなど) が「他のことをできる」ようにした。軽量プロセスは自由の代価ではなく 制約の結果 である。
Q4. Supervision Tree が分散システムの耐障害性をどう提供するか?
A. Supervision tree は 障害を階層的に隔離し復旧する メカニズム。
基本構造:
Application
│
Main Supervisor
/ | \
Child Child Sub-Supervisor
/ | \
Child Child Child
各 supervisor は:
- 子 actor のリストを保持。
- 子の失敗を検出。
- 設定された strategy に従って再起動。
Restart Strategies:
one_for_one: 失敗した子のみ再起動。
[A, B, C] のうち B が失敗
→ B のみ再起動
→ [A, B', C]
用途: 子が 独立 のとき。
one_for_all: 一つ失敗したら全員再起動。
[A, B, C] のうち B が失敗
→ 全員再起動
→ [A', B', C']
用途: 子が 共有状態 に依存するとき。
rest_for_one: 失敗したものから後のすべての子を再起動。
[A, B, C, D] のうち B が失敗
→ B, C, D 再起動
→ [A, B', C', D']
用途: 順次依存関係があるとき。
simple_one_for_one: 動的に生成される子。
- 事前定義された template で子生成。
- 各子は独立。
- 用途: 数千の似た actor (例: 接続ごとに一つ)。
再起動制限:
{intensity, 10}, % 時間あたり最大 10 回の再起動
{period, 60} % 60 秒の間に
超過すると:
- Supervisor 自体が死亡。
- 上位 supervisor に障害伝播。
- 連鎖的に上がってより大きな再起動。
階層的障害復旧の力:
小さな障害 → 小さな再起動 (下位 actor)。 中程度の障害 → 中程度の再起動 (sub-supervisor 全体)。 大きな障害 → 上位再起動 (全サブシステム)。
致命的障害 → アプリケーション全体再起動 (最悪の場合)。
各レベルで 隔離 が行われるので、小さなエラーは小さな範囲で処理される。
例: Chat アプリケーション
ChatApp Supervisor
├── AuthService Supervisor
│ ├── User1 Session
│ ├── User2 Session
│ └── UserN Session ← 一つのセッション失敗 = 他のセッションに影響なし
├── MessageRouter Supervisor
│ ├── RoomA Router ← RoomA 失敗 = 全体に影響
│ ├── RoomB Router
│ └── RoomC Router
└── Storage Supervisor
├── Primary DB
├── Replica DB
└── Cache
- User1 のセッションがバグでクラッシュ → User1 再接続。他ユーザーに影響なし。
- RoomA がクラッシュ → RoomA メッセージ一時的失敗。他の部屋は正常。
- Storage 全体障害 → アプリケーション再起動が必要な可能性。
教訓: Let it crash + Supervisor = Fault Tolerance:
個々の actor: 死ぬことを恐れない。 Supervisor: 常に準備された復旧メカニズム。 階層構造: 障害の影響範囲を制限。 再起動制限: 無限ループを防止。
この組み合わせが 実際に機能する 耐障害システムを作る。Erlang システムが 9 ナイン (99.9999999%) の可用性を達成したという Ericsson の主張 (AXD301 交換機) はこの哲学の証明。
現代の応用:
- Kubernetes Pod restart: 単純な supervisor 概念。
- Docker restart policy: コンテナレベル。
- Systemd: Linux サービス supervisor。
- PM2 (Node.js): プロセス supervisor。
Erlang が 1980 年代に解決した問題をクラウドネイティブ世界が再発見している。アイデアは変わっていない。
実戦設計原則:
- 浅く階層化: 3-4 レベルが適度。深すぎると複雑。
- 一時的 vs 永久的失敗の区別: 永久的失敗は再起動で解決しない。
- 初期化ロジックを単純に: Supervisor が再起動するとき速く。
- State 復旧: 重要な状態は event sourcing または DB で。
- Metric 収集: 再起動頻度のモニタリング → 異常検知。
Supervision tree は 完璧なシステムを作る方法 ではなく 不完璧なシステムが堅牢に動作する方法。これが Erlang が私たちに与えた最大の贈り物の一つ。
Q5. Actor モデルと Go の goroutine+channel の根本的な違いは?
A. どちらも「軽量並行性 + メッセージベース通信」だが 哲学が異なる。
Actor モデル:
- Entity 中心: Actor が「何か」を表す (user、order、device)。
- Actor 所有: メッセージは 特定の actor の mailbox へ。
- アドレス指定:
actorRef.send(msg)— 誰に送るかを明示。 - 共有なし: Actor 外部から actor 内部状態にアクセス不可。
CSP (Go channel):
- Process 中心: Goroutine が「作業」を表す。
- Channel 所有: メッセージは チャネル へ (誰が受けるか不明)。
- 間接通信:
ch <- msg— チャネルに送り、誰が読んでも構わない。 - 共有可能: Go は共有メモリも許可 (select と共に)。
哲学比較:
Erlang: 「Do not share.」
Actor ! Message % 特定の actor へ
Go: 「Do not communicate by sharing memory; share memory by communicating.」
ch <- message // チャネルで
具体的な違い:
1. アドレスモデル
- Actor: 各 actor が PID/ref を持つ。「User #42」にメッセージ。
- Go: Channel が「パイプ」役割。誰が書き誰が読むかは実行時に決まる。
2. ライフサイクル
- Actor: 明示的な生成/終了。Supervisor が管理。
- Goroutine:
go f()で開始、関数終了時に自動消滅。Supervisor なし。
3. エラー処理
- Actor: Let it crash + supervision。
- Go:
recover()+ defer。または goroutine が死んだらプロセス全体が死ぬ。
4. 分散
- Actor: ローカル/リモート透過 (Erlang、Akka)。
- Go: ローカルのみ。分散は別途 (gRPC など)。
5. 状態管理
- Actor: 内部状態を隠蔽。
- Go: 共有状態が使える (sync.Mutex など)。混合モデル。
6. 型安全性
- Erlang: 動的型付け。
- Akka: Typed actor (Akka 2.6+)。
- Go: 静的型付け。チャネルも型がある (
chan int)。
7. 通信パターン
Actor - Request-Reply:
response = GenServer.call(user_server, :get_profile)
- Call は特定の actor へ。
- 応答は caller に戻る。
Go - Fan-out:
results := make(chan Result, 10)
for _, job := range jobs {
go func(j Job) {
results <- process(j)
}(job)
}
- Channel で複数の goroutine の結果を収集。
- 「誰が処理するか」は自動。
どちらが優れているか:
Actor 選好:
- Entity ベースドメイン: 各 user、order、session が actor。
- 耐障害性が重要: Supervision のおかげ。
- 分散: Location transparency。
- 長期状態: 長く生きる actor の状態。
Go channel 選好:
- Pipeline 処理: データフロー。
- 単純な並行性: 短い作業が多数。
- Fan-in/Fan-out: 複数の producer/consumer。
- 素早い実装: 単純な場合はコードが短い。
実戦では混合:
多くのシステムが両パターンを 組み合わせる:
- Phoenix (Elixir): Actor が基本、ただし内部にはパイプライン。
- Go システム: チャネルが基本、必要なら actor スタイルの構造体。
学問的関係:
- Actor モデル: 1973 Hewitt。
- CSP: 1978 Hoare。
- 同時代、独立開発。
- どちらも 共有メモリの代わりにメッセージ を強調。
- 違い: Actor は identity 中心、CSP は process 中心。
一行まとめ:
- Actor: 「この entity (actor) にこのリクエスト (message) を送る」。
- CSP: 「このデータ (msg) をこのパイプ (channel) に入れる。誰が受け取っても」。
両方の観点とも有効でそれぞれの強みがある。問題が entity 管理 なら actor、データフロー ならチャネルが自然。現代のシステムは両概念を理解して適切に使うのが理想。
おわりに: メッセージがすべて
コア整理
- Actor model: Create、Send、Become — 三つの操作。
- 共有なし: メッセージのみで通信。ロックなし。
- Erlang/OTP: 元祖。数百万の軽量プロセス。
- Let it crash: 失敗を受け入れて再起動。
- Supervision tree: 階層的耐障害性。
- Akka: JVM の actor。Lightbend 主導。
- Elixir: Erlang の現代的な顔。Phoenix + LiveView。
- Orleans: .NET の virtual actor。
- Location transparency: ローカルとリモートが同じコード。
いつ Actor モデルを選ぶか
選ぶとき:
- 数千-数百万の同時接続。
- 分散システム。
- 耐障害性が重要。
- Entity ベースドメイン (user、game player、IoT device)。
- Stateful サービス。
避けるとき:
- 単純な CRUD API。
- 計算集約的作業。
- 小規模システム。
- チーム経験なし。
最後の教訓
Erlang は 1986 年に始まり 40 年近く 進化してきた。その間に無数の並行性モデルが現れては消えた。Erlang は依然として現役。
理由? Joe Armstrong (Erlang 共同創始者、2019 年逝去) の言葉:
「あなたが作るシステムが 10 万の接続を処理しなければならないなら、間違った言語を使っているかもしれない」
WhatsApp は 2014 年に 190 億ドルで Facebook に買収された。わずか 55 人のエンジニアで 4 億 5000 万ユーザーにサービスを提供した。Erlang の力がその比率を可能にした。
Actor モデルは パラダイムシフト。「共有メモリをどう同期するか」から「メッセージでどう協業するか」へ。この転換は難しいが、一度受け入れれば多くの問題が単純になる。
次に「多数の同時ユーザーをどう処理するか?」「分散システムをどう信頼性高く作るか?」という問いが浮かんだら、Actor モデルを思い出そう。1973 年のこのアイデアが依然として最高の答えの一つである。
参考資料
- Erlang/OTP Official Documentation
- Learn You Some Erlang for Great Good! - 無料オンライン
- Programming Erlang (Joe Armstrong)
- Programming Elixir (Dave Thomas)
- Designing for Scalability with Erlang/OTP - Francesco Cesarini
- Akka Documentation
- Reactive Messaging Patterns with Akka
- Orleans Documentation
- Phoenix Framework
- Phoenix LiveView
- The Road to 2 Million Websocket Connections in Phoenix
- How Discord Scaled Elixir to 5M Concurrent Users
- Hewitt: Actor Model of Computation (1973 original)
- Joe Armstrong: Why Erlang Is Safe