LabHub

ブログ

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

한국어English日本語

はじめに: 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 を作る」

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

この記事で扱うこと

  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 システム

なぜこの知識が重要か


1. Actor モデルの理論

三つの基本操作

Carl Hewitt のオリジナル定義。

  1. Create: 新しい actor を生成する。
  2. Send: 他の actor にメッセージを送る。
  3. Become: 次のメッセージに対する動作を決定する (状態変更)。

これだけ。この三つですべての計算が表現できる。

Actor の構成要素

各 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 が実際に処理するのは後。

これは次を意味する。

Location Transparency

Actor の PID は ローカル/リモートを区別しない

% 同じコード
Pid ! Message

Pid が同じノードでも別ノードでもコードは同一。これが 透過的な分散 を可能にする。Erlang が最初にこの原則を実装した。

Actor vs Thread

項目ThreadActor
共有メモリありなし
同期ロック、セマフォメッセージ
重さ重い (数 MB)軽い (数 KB)
数千数百万
エラー伝播プロセス全体ダウン個別隔離
分散困難内蔵
デバッグ困難比較的容易

2. Erlang: 元祖の力

歴史

言語哲学

Erlang は 意図的に制限された 言語。

これらの制約が 並行性を安全にする

軽量プロセス

Erlang の actor は 「process」 と呼ばれる。しかし OS プロセスではなく VM 内部のスケジューリング単位

% 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 の標準ライブラリ + デザインパターン集。

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

Erlang のアプローチ

「Let it crash」

コードは 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 を持つ。

再起動制限

「無限再起動」は危険 (同じエラーが繰り返されると CPU の浪費)。Supervisor は 再起動制限 を持つ。

超過すると 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 は ソフトウェア設計のパラダイムシフト である。

これは 分散システムの本質 に合う。分散システムでは 何も 100% 信頼できない。ネットワーク、ハードウェア、ソフトウェアすべてが時折失敗する。受け入れて設計しよう。


4. Akka: JVM の Actor

動機

2009 年に Jonas Bonér が開始。JVM に Erlang スタイルの actor を持ち込もうとした。Scala と統合。

2012 年 Typesafe (現 Lightbend) が商用サポート。Akka は Java/Scala エコシステムで actor の代名詞となった。

Akka vs Erlang の違い

言語の違い:

Actor の軽量性:

Selective receive:

文化:

基本 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 に届く

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 にまとめる。

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 の現代的な顔

誕生

なぜ Elixir か

Erlang は強力だが 文法が異質 (Prolog の影響)。多くの開発者が参入障壁を感じた。Elixir は次を提供。

例: 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 を大規模に利用している。

「Erlang は死んだ言語じゃない?」という問いへの答えはここにある。Discord、WhatsApp、Riot (League of Legends チャット) など 数百万ユーザーのサービス が BEAM の上で動いている。


6. Orleans: Microsoft の Virtual Actors

歴史

Virtual Actors

Orleans の革新: virtual actor。Actor のライフサイクル管理をランタイムが自動で。

伝統的 actor:

Virtual 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 が初めて呼ばれると 自動生成、その後アイドル状態が長くなると 自動非活性化。開発者はライフサイクルを気にしなくてよい。

利点

欠点

利用先

.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. 軽量プロセス:

2. 先取型スケジューリング:

3. 独立 heap:

4. Share-nothing:

5. 単純なスケジューラ:

ベンチマーク

Erlang の The Road to 2 Million Websocket Connections in Phoenix (2015 年ブログ):

これは 単一サーバー での話。Node.js や Java では非常に難しい。

比較: Go goroutine

Go の goroutine も軽量並行性で有名。

ただし 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 は調整に。

罠 2: Mailbox 爆発

速い producer + 遅い consumer:

Producer: 秒間 100 万メッセージ送信
Consumer: 秒間 1 万メッセージ処理
→ 毎秒 99 万個が mailbox に溜まる
→ メモリ爆発、OOM

解決策:

罠 3: Selective Receive の性能

Erlang の selective receive は mailbox スキャン。

receive
    {specific, Msg} -> handle(Msg)
end

Mailbox に 10 万個のメッセージがあり、欲しいパターンが 後ろの方 にあれば 10 万個をスキャン。遅い。

解決策:

罠 4: 分散環境のメッセージ順序

ローカルでは「A → B のメッセージは順序維持」が保証されるが、分散では:

解決策:

罠 5: Global state の誘惑

Actor モデルの哲学に反するが便利のため:

# :ets テーブルはプロセス外部に共有可能
:ets.new(:shared, [:public, :named_table])

こうした共有状態は便利だが ロック競合デバッグ困難 を生む。必要なときだけ慎重に。

罠 6: リモート呼び出しのタイムアウト

GenServer.call(remote_pid, :get, 5000)

5 秒タイムアウト。ネットワーク問題でタイムアウトした場合:

解決策: Idempotent 設計。一意な request ID。


11. Actor モデル vs 他の並行性モデル

vs Thread + Lock

Actor が ほとんどの場合 より安全で単純。ただし計算集約的作業には thread がまだ速い。

vs CSP (Go channel)

CSP (Communicating Sequential Processes): Go の goroutine + 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 と統合。

実戦プロジェクトアイデア

小さく始めて actor の力を体感しよう。共有メモリ方式よりどれほど単純で安全かが見えるはず。

実戦事例

すべて 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 モデルを選ぶか

選ぶとき:

避けるとき:

最後の教訓

Erlang は 1986 年に始まり 40 年近く 進化してきた。その間に無数の並行性モデルが現れては消えた。Erlang は依然として現役。

理由? Joe Armstrong (Erlang 共同創始者、2019 年逝去) の言葉:

「あなたが作るシステムが 10 万の接続を処理しなければならないなら、間違った言語を使っているかもしれない」

WhatsApp は 2014 年に 190 億ドルで Facebook に買収された。わずか 55 人のエンジニアで 4 億 5000 万ユーザーにサービスを提供した。Erlang の力がその比率を可能にした。

Actor モデルは パラダイムシフト。「共有メモリをどう同期するか」から「メッセージでどう協業するか」へ。この転換は難しいが、一度受け入れれば多くの問題が単純になる。

次に「多数の同時ユーザーをどう処理するか?」「分散システムをどう信頼性高く作るか?」という問いが浮かんだら、Actor モデルを思い出そう。1973 年のこのアイデアが依然として最高の答えの一つである。


参考資料

コメント

まだコメントはありません。

ログインするとコメントできます