Skip to content

✍️ 필사 모드: Actor Model 完全ガイド 2025: Erlang OTP、Akka、Orleans、Elixir — 数百万同時接続を扱う本物の方法

日本語
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.

はじめに: 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 の失敗は他に波及しない。

このシンプルなモデルから 驚くべきシステム が生まれる。

この記事で扱うこと

  1. Actor モデルの数学的定義
  2. Erlang/OTP: 元祖でありリファレンス。
  3. Akka: JVM の actor 実装。
  4. Elixir: Erlang の現代的文法。
  5. Orleans: Microsoft の .NET Actor。
  6. Let it crash 哲学。
  7. Supervision tree パターン。
  8. 分散 Actor システム

なぜこの知識が重要か

  • 並行性: 数百万接続の処理。現代バックエンドの必須。
  • 耐障害性: 復旧でなく「死なせる」哲学。
  • 分散システム: ローカルとリモート actor が同じコード。
  • Elixir: Phoenix LiveView のような現代的フレームワーク。
  • WhatsApp、Discord、Riot: 大手企業の選択。

1. Actor モデルの理論

三つの基本操作

Carl Hewitt のオリジナル定義。

  1. Create: 新しい actor を生成する。
  2. Send: 他の actor にメッセージを送る。
  3. 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

項目ThreadActor
共有メモリありなし
同期ロック、セマフォメッセージ
重さ重い (数 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 の罠

  1. 「Props で子生成」: Erlang より複雑。
  2. Untyped actor レガシー: 多くのチュートリアルが未だ古い。
  3. JVM チューニング: GC、heap、thread pool。
  4. ライセンス変更: 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 があると 状態管理 が重要。

  1. In-memory: 各 actor が自分の状態。最速。
  2. Persistent: Event sourcing でイベントログ保存。
  3. 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 推奨:

  1. Ruby/Python に似た親しみやすい文法。
  2. 即座に Erlang OTP のすべての力。
  3. Phoenix で実戦プロジェクト。
  4. 「Programming Elixir」(Dave Thomas) 書籍。
  5. Phoenix LiveView でリアルタイム Web。

Erlang 推奨:

  1. 「Learn You Some Erlang」(無料オンライン)。
  2. 「Programming Erlang」(Joe Armstrong)。
  3. 古いチュートリアルを心配せず OTP 全般。

Akka 推奨:

  1. JVM 環境なら自然な選択。
  2. ただしライセンス問題 (Pekko を検討)。
  3. 「Reactive Messaging Patterns with Akka」(資料豊富)。

Orleans 推奨:

  1. .NET 環境。
  2. Microsoft 公式サポート。
  3. 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 のオリジナル定義:

  1. Create: 新しい actor を生成。
  2. Send: 他の actor にメッセージ送信。
  3. 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  # プロセス死亡

問題:

  1. すべてのエラーを予測できない: 実際に起きるエラーの 30% は「予期せぬ」もの。
  2. コード複雑度の爆発: 幸福パス 20 行、エラー処理 200 行。
  3. 一貫性の喪失: エラー後の状態が中途半端。部分的に完了したトランザクション。
  4. 連鎖失敗: 一つのエラーが他所に伝播。
  5. 再現困難: 特定のエラーシーケンスはテストできない。

Let it crash アプローチ:

handle_request(Req) ->
    Result = do_work(Req),  % エラーチェックなし
    reply(Result).

エラーが発生したら プロセスが死ぬ。Supervisor が再起動。新しいプロセスは クリーンな状態

このアプローチの利点:

  1. コードが単純: 幸福パスに集中。
  2. 一貫性: 死んだプロセスは いかなる状態も残さない (隔離メモリ)。
  3. 自然な復旧: ほとんどのエラーが「一時的」。再起動で解決。
  4. 可視性: 再起動ログから 問題パターンを把握
  5. 耐障害がデフォルト: すべての actor が自動的に「resilient」。

「なぜ再起動が解決策か」:

実データ:

  • ネットワーク障害の 95% は 一時的 (数秒で復旧)。
  • ディスク書き込み失敗の 90% は再試行で解決。
  • 外部サービス障害の 80% は数秒後に復旧。

「とりあえず再起動」は実戦で驚くほどよく機能する。Google SRE 本の有名な助言: 「Have you tried turning it off and on again?」

前提条件:

Let it crash が機能するためには:

  1. 隔離された状態: 一つの actor の状態が他に影響しない。
  2. Supervisor 階層: 自動再起動メカニズム。
  3. 再起動制限: 無限再起動を防止。
  4. Idempotent 設計: 再起動後に同じ結果。
  5. 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 開始。同レベルの軽量性。

いつこの軽量性が重要か:

  1. 多数の接続: WebSocket、チャット、ゲームサーバー。
  2. 多数の独立タスク: IoT デバイス管理。
  3. Actor ベースモデリング: 各 entity を actor に。
  4. 並列作業: 数万の独立計算。

なぜ他の 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 は:

  1. 子 actor のリストを保持。
  2. 子の失敗を検出。
  3. 設定された 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 年代に解決した問題をクラウドネイティブ世界が再発見している。アイデアは変わっていない。

実戦設計原則:

  1. 浅く階層化: 3-4 レベルが適度。深すぎると複雑。
  2. 一時的 vs 永久的失敗の区別: 永久的失敗は再起動で解決しない。
  3. 初期化ロジックを単純に: Supervisor が再起動するとき速く。
  4. State 復旧: 重要な状態は event sourcing または DB で。
  5. 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、データフロー ならチャネルが自然。現代のシステムは両概念を理解して適切に使うのが理想。


おわりに: メッセージがすべて

コア整理

  1. Actor model: Create、Send、Become — 三つの操作。
  2. 共有なし: メッセージのみで通信。ロックなし。
  3. Erlang/OTP: 元祖。数百万の軽量プロセス。
  4. Let it crash: 失敗を受け入れて再起動。
  5. Supervision tree: 階層的耐障害性。
  6. Akka: JVM の actor。Lightbend 主導。
  7. Elixir: Erlang の現代的な顔。Phoenix + LiveView。
  8. Orleans: .NET の virtual actor。
  9. 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 年のこのアイデアが依然として最高の答えの一つである。


参考資料

현재 단락 (1/870)

2012 年、WhatsApp のあるエンジニアがベンチマークを公開した。

작성 글자: 0원문 글자: 27,331작성 단락: 0/870