レガシーガジェット研究所

気になったことのあれこれ。

Erlang 入門

概要

1986年にスウェーデンの通信機器メーカーであるEricsson社が電話通信アプリケーションを開発する目的でErlangを開発し1998年にOSSとして公開した。

Erlangは巨大なサーバソフトウェア(メッセージキューイングやWebサーバ、分散データベースなど)などの高レベルプロトコルの実装に向いており、現在ではWhatsApp、ドワンゴ、LINEなどがErlangを採用していることを表明している。

qsort([]) -> [];
qsort([Pivot|Rest]) ->
  qsort([ X || X <- Rest, X < Pivot ])
  ++ [Pivot] ++ 
  qsort([ Y || Y <- Rest, Y >=Pivot ]).

e.g. クイックソート

Ericssonは移動体通信事業を中心とし携帯電話の地上固定設備を世界的に展開しており、現在世界180カ国において50億以上ある全モバイル端末の約40%がエリクソンのシステムを通じて利用されている。当該分野では世界最大手である。Ercssonでは世界17カ国・約19,800名の技術者たちが研究開発に取り組んでおり、年間研究開発費は売上高の約15%にのぼる。

思想・哲学

通信業界にはErlangが登場するよりも昔から平行性を目指す文化が存在し、その結果プロセスベースの平行性と非同期処理によるメッセージパッシングにたどり着いた。加えてErlangには以下のような満たすべき要求があった。

  • スケールアップ
  • フォールトトレランス

スケールアップ

スケーラビリティの一つの側面としてハードウェアの限界を回避できるかどうか、という問題が存在しその結果平行・分散処理が必要となった。

加えて電話通信というシステムの性質上分高い信頼性が求められるが故にプロセスが共有メモリを使用することを禁止した(意図しないタイミングでプロセスがクラッシュした場合データの一貫性の担保やロックの開放ができないため)。よってプロセス間でのメッセージのやり取りでは全てのデータはコピーされる(速度低下に繋がるがより安全)。

スケーラビリティのもう一つの側面は宣言的なリソース(プロセス)定義ではなく動的な生成が必要になる点である。Erlangの軽量プロセスは分散処理を達成するための一つの機構であり、生成及び削除を高速に行うことが可能で且つサイズも小さく300ワードほど。

フォールトトレランス

障害は頻繁に発生することを念頭においており、バグは必ず残りエラーが発生し、仮にバグが存在せずともハードウェアの故障は回避することができない。そのためエラーや障害が発生した際にどう対処するのかというアプローチを取った。

クラッシュを許容するためにはクラッシュ自体をグリーンシャットダウンを同様に行うことになる。これを実現するためメモリ上のシェアードナッシング、ロックレスなどの安全策をErlangの設計に組み込むことで、プロセスを必要に応じてクラッシュさせエラーや障害の伝搬を最小限に留めた。

特徴

Erlangは逐次処理及び平行・分散処理の2つの側面を持つ。

主な特徴として以下のようなモノが挙げられる。

  • 関数型
  • 動的型付け
  • 単一代入(Immutable)
  • パターンマッチング(バイナリがかなり強力)
  • 軽量プロセス
  • アクターモデル(メッセージパッシング)
  • プロセス・リンク
  • ホットリロード(DI/リコンパイル)
  • 共有KVS(ETS/DETS)
  • 分散・リアルタイムDB(Mnesia)
  • OTP

(他にも様々な特徴があるがキリがないので割愛...)

関数型

Erlangの基本パラダイムは関数型で以下のように記述する。例えば配列の要素を合計するsum関数は以下のように定義する。

sum([]) -> 0;
sum([Item|Rest]) -> Item + sum(Rest).

*上記にはパターンマッチの特徴も含んでいる。

上記の例では関数が0を返した後結果を結合するためスタックを大量に消費する。

以下の例ではアキュムレータを用いてスタックの消費を最適化する。

sumWithA([], A) -> A;
sumWithA([Item|Rest], A) -> sumWithA(Rest, Item + A).

上記の例では末尾再帰を行っており直前の呼び出しの値を用いる必要がないためスタックを大量に消費することがない。コンパイラは関数呼び出しをjump命令などに置き換え最適化を図る。

単一代入(Immutable)

1> Num = 100.
100
2> Num = 200.
** exception error: no match of right hand side value 200
3>

変数に値を代入した段階でその変数は束縛されるため、値が意図しないものであっても束縛した箇所をトレースすれば問題は容易に見えてくる。

パターンマッチ

if文などで条件分岐することはなく、引数のパターンで以下のように記述する。

greet(male, Name) ->
  io:format("Hello, Mr. ~s!", [Name]);
greet(female, Name) ->
  io:format("Hello, Mrs. ~s!", [Name]);
greet(_, Name) ->
  io:format("Hello, ~s!", [Name]).

バイナリパターンマッチは強力で以下の例ではIPv4ヘッダをマッチさせている。

-define(IP_VERSION, 4).
-define(IP_MIN_HDR_LEN, 5).

DgramSize = byte_size(Dgram),
case Dgram of 
    <<?IP_VERSION:4, HLen:4, SrvcType:8, TotLen:16, 
      ID:16, Flgs:3, FragOff:13,
      TTL:8, Proto:8, HdrChkSum:16,
      SrcIP:32,
      DestIP:32, RestDgram/binary>> when HLen>=5, 4*HLen=<DgramSize ->
        OptsLen = 4*(HLen - ?IP_MIN_HDR_LEN),
        <<Opts:OptsLen/binary,Data/binary>> = RestDgram,
    ...
end.

アクターモデル

引用: https://tech-lab.sios.jp/archives/8738

Erlangにはメッセージパッシングのための!プリミティブが存在し、以下のようにPidに対してメッセージを送信するためのシンタックスが存在する。

> Pid = spawn(fun area:loop/0).
<0.148.0>

> Pid ! {rectangle, 100, 20}.  
Area of rectangle is: 2000
{rectangle,100,20}

> Pid ! {circle, 20}.        
Area of circle is: 1256.6370614359173
{circle,20}

spawnはプロセスを生成するための関数でPIDを戻り値として返す。

メッセージを受け取るにはreceiveプリミティブが存在し、以下のようにメッセージを受け取る。

loop() ->
    receive
        {rectangle, Width, Height} ->
            io:format("Area of rectangle is: ~w~n", [Width * Height]),
            loop();
        {circle, Radius} ->
            io:format("Area of circle is: ~w~n", [math:pi() * Radius * Radius]),
            loop();
        _ ->
            io:format("I don't understand."),
            loop()
    end.

リンク/モニター

リンク

引用: https://learnyousomeerlang.com/errors-and-processes

リンクはプロセスを結びつける機構で片方のプロセスが死んだ場合にもう一方のリンクされたプロセスも死ぬことになる。依存するプロセスを失ったことに対処する必要がなくなるため期待する動作のみを記述すればよい。

指定した時間(msec)後に終了する関数が以下。

die_in(Time) ->
    receive
    after Time ->
        exit("So Long, and Thanks for All the Fish")
    end.

これをプロセスとして生成しシェルにリンクする。

> self(). % 現在のシェルのPID
<0.117.0>

% 上記の関数をプロセスとして起動(10秒後に終了)
> Pid = spawn(fun() -> linker:die_in(10000) end).
<0.126.0>

% 上記で生成したプロセスを現在のシェルとリンクさせる
> link(Pid).
true

% 10秒後終了したプロセスに引きづられる形でシェルがクラッシュ
** exception error: "So Long, and Thanks for All the Fish"

% シェルが再起動しており最初に確認したPIDと異なることがわかる
> self().
<0.129.0>

リンクでは以下を実行することによってプロセスをシステムプロセスに昇格させることができ、システムプロセスはリンク先のプロセスの死を補足できる。

> Pid = spawn(fun() -> linker:die_in(10000) end).
<0.100.0>

> link(Pid).
true

> process_flag(trap_exit, true). % EXITシグナルをトラップ
true

> receive X -> X end. % プロセスの終了を補足
{'EXIT',<0.100.0>,"So Long, and Thanks for All the Fish"}

モニター

リンクでは相互に影響を及ぼしていたが、一方からもう一方を監視するだけであれば監視プロセスの消滅を被監視プロセスは補足する必要がない。そういったユースケースの場合にモニターを使用する。

> erlang:monitor(process, spawn(fun() -> linker:die_in(500) end)).
#Ref<0.1925330508.2157182978.78235>

> flush().
Shell got {'DOWN',#Ref<0.1925330508.2157182978.78235>,process,<0.110.0>,
                  "~s < So Long, and Thanks for All the Fish"}

ホットコードスワッピング(DI/リコンパイル)

ここでホットコードスワッピングとはシステムを停止させずコードのアップデートを行うことを定義する。ErlangではDI(Dependency Injection: 依存注入)及びリコンパイルの2種類の方法が用意されている。

DI

まずスワップするモジュールを使用するサーバを定義する。

-module(hot_reload_server).
-export([start/1, loop/1, swap_module/1, rpc/1]).

%% "hot_reloader"と名付けサーバプロセスを生成
start(Module) ->
    register(hot_reloader, spawn(fun() -> loop(Module) end)).

%% swap_module命令の場合にはモジュールを入れ替え、それ以外は対応するハンドラを呼び出す
loop(Module) ->
    receive
        {From, {swap_module, NewModule}} ->
            From ! swap_module,
            loop(NewModule);
        {From, Request} ->
            Response = Module:handle(Request),
            From ! Response,
            loop(Module)
    end.

%% コードのホットリロードをリクエスト
swap_module(NewModule) ->
    rpc({swap_module, NewModule}).

%% サーバへ任意の命令を送る
rpc(Request) ->
    hot_reloader ! {self(), Request},
    receive
        swap_module ->
            io:format("swapping module is done~n");
        Message ->
            io:format("Received message is ~s~n", [Message])
    end.

任意のモジュール内に存在するハンドラをクライアントのメッセージに応じて使用する。swap_module命令の場合には保持しているモジュールを指定のモノに入れ替える。

上記のサーバで使用するモジュールは以下の2種類。

-module(module_a).

-import(hot_reloader, [rpc/2]).
-export([greet/0, handle/1]).

greet() -> rpc(hot_reloader, greet).

handle(greet) -> "good morning".
-module(module_b).

-import(hot_reloader, [rpc/2]).
-export([greet/0, handle/1]).

greet() -> rpc(hot_reloader, greet).

handle(greet) -> "good night".

実際に実行した結果が以下。

> c(hot_reload_server).
{ok,hot_reload_server}

> hot_reload_server:start(module_a).
true

> hot_reload_server:rpc(greet).
Received message is good morning
ok

> hot_reload_server:swap_module(module_b).
swapping module is done
ok

> hot_reload_server:rpc(greet).
Received message is good night
ok

上記ではhot_reload_server:rpc(greet).がモジュール毎に挙動が変化しているのがわかる。

コンパイル

プロセスの動作中に使用しているモジュールをリコンパイルすることでコードをホットスワッピングする。

コンパイルするモジュールを使用するコードは以下。

-module(recompiler).

-export([start/1, loop/1]).

start(Module) ->
    spawn(fun() -> loop(Module) end).

loop(Module) ->
    Module:version(),
    timer:sleep(1000),
    loop(Module).

start関数に指定されたモジュールのversion関数を使用する。

> c(module_c).
{ok,module_c}

> recompiler:start(module_c).
ver 1.0.0
<0.85.0>
ver 1.0.0
ver 1.0.0
ver 1.0.0
ver 1.0.0
:

> c(module_c).
{ok,module_c}
ver 2.0.0
ver 2.0.0
ver 2.0.0
ver 2.0.0
:

共有KVS(ETS/DETS)

ETSはErlang Term Storageの略で複数ノードからアクセスが可能な共有KVSである。DETSはDisk ETSの略でデータの格納をディスクに対して行う。

ETS及びDETSには以下のような専用の関数が用意されている。

関数名 説明
new() テーブルの生成
open_file(FileName) テーブルのオープン
insert(TableName, Term) データの登録(Termはタプル e.g. {key, val})
lookup(TableName Key) データの検索
delete(TableName) テーブルの削除

テーブルにはセット型及びバッグ型の基本的な型あり、セット、順序付きセット、バッグ、重複バッグの計4種類が存在する。

説明
セット タプルのキーが重複しないようなテーブル
順序付きセット ソートされたタプルのキーが重複しないようなテーブル
バッグ キーが重複するようなタプルを保持するテーブル(同じキーに対して値は重複できない)
重複バッグ キーが重複するようなタプルを保持するテーブル(同じキーに対して値も重複できる)

セットは内部でハッシュテーブルとして実装されるためメモリを消費する(データの挿入は定数時間)。順序付きセットは平衡二分木で実装され、セットへの挿入はエントリ数の対数に比例した時間がかかる。

バッグはデータ挿入時に同じキーを持つタプルに対して値の比較をかけるため重複バッグよりもコストが高い。

データを使用するプロセスやテーブルの数を追跡する参照カウンタを保持しておりカウンタが0になった場合にガーベッジコレクタにより記憶領域が開放される。ETS自体はErlangで実装されておらず下位のErlangランタイム(VM)で実装されているため性能特性が異なる。

Mnesia(分散・リアルタイムDB)

MnesiaはETSをラップした分散・リアルタイムデータベースでRAMとディスクを組み合わせてアクセス速度や永続化の優先度を決定したり、複数ノードで複製を作成し耐障害性を高めたりと非常に柔軟性が高い。RAM上のデータに対して(勿論ディスク上のデータにも)トランザクションを用いたりSQLを発行したり、HAやスケーラビリティなども考慮している(NewSQL?)。

Mnesiaは大量のデータをDCをまたがって扱うようなものではなく少量のデータを限られたノードで処理するためものでだいたい10ノード前後が実用上の限界だと考えられている。

OTP

OTPとはOpen Telecom Platformの略で一種のフレームワークErlangの様々なプリミティブやエコシステムを最大限活用し安全性の高いアプリケーションを構築するための標準化であり、OTPを用いていることはその標準に従っていることになる。OTPのビヘイビア(ふるまい)に従うかたちで実装する。

http://erlang.org/doc/man_index.html

代表的なものに以下が挙げられる。

名前 説明
application 特定の機能を実装したコンポーネントとして起動や停止が可能になる。ランタイムで開始されるアプリケーションコントローラと対話しプロセスの起動や停止、情報へのアクセスなどを行う。
supervisor 汎用スーパーバイザー。子プロセスを監視するためのプロセスを生成する。インターフェース関数の標準セットがあり、トレースやエラーレポートの機能も含まれる。監視ツリーにも適合可能。
gen_server 汎用サーバー。インターフェース関数の標準セットがあり、トレースやエラーレポートの機能も含まれる。監視ツリーにも適合可能。
gen_event 汎用イベントハンドラ。ダイナミックに追加及び削除されるイベントハンドラをイベントマネージャが管理する。インターフェース関数の標準セットがあり、トレースやエラーレポートの機能も含まれる。監視ツリーにも適合可能。

参考