呼び出しの種類とホットコードロード
この記事は英語の原文を日本語に翻訳したものです。原文: https://blog.stenmans.org/theBeamBook/#CH-Calls
翻訳元: happi/theBeamBook 7998e22e78417dbe20e5136b9aee862a1ecaa404(コミット 7998e22)
BEAMにおける関数呼び出しは、いくつかの種類に分類できます。
ローカル呼び出し
ローカル呼び出しとは、モジュール名を明示せずに同じモジュール内の関数を呼び出すことです。 この呼び出しはコンパイル時に解決され、呼び出し元と同じバージョンのモジュール内で実行されることが保証されます。
次の例を見てみましょう。
foo() -> bar(). % 同じモジュール内でのローカル呼び出し
リモート呼び出し
リモート呼び出しはモジュール名を明示的に指定し、そのモジュールの最新のロード済みバージョンが使われることを保証します。
次の例を見てみましょう。
foo() -> ?MODULE:bar(). % 最新のモジュールバージョンを保証するリモート呼び出し
リモート呼び出しはホットコードロードを可能にし、システムを再起動せずにモジュールをその場で更新できるようにします。
コードのロード
Erlangランタイムシステムでは、コードのロードはコードサーバーが担当します。
コードサーバーは実際のロード処理のために erlang モジュールの低レベルなBIFを呼び出します。
コードサーバーはパージのポリシーも決定します。
ランタイムシステムは各モジュールについてcurrent版とold版の2つのバージョンを保持できます。 完全修飾された(リモート)呼び出しはすべてcurrent版に向かいます。 old版内のローカル呼び出しやスタック上のリターンアドレスは、依然としてold版に向かうことがあります。
あるモジュールの3番目のバージョンがロードされ、まだ実行中のプロセス(スタック上にold版へのポインタを持つプロセス)が残っている場合、コードサーバーはそれらのプロセスを強制終了してold版のコードをパージします。 そのうえでcurrent版がold版になり、3番目のバージョンがcurrent版としてロードされます。
ホットコードロード
見てきたように、ローカル関数呼び出しとリモート関数呼び出しには構文上の違いだけでなく意味上の違いもあります。 リモート呼び出し、つまり「完全修飾呼び出し」、すなわち名前付きモジュール内の関数への呼び出しは、そのモジュールの最新のロード済みバージョンに向かうことが保証されています。 ローカル呼び出し、つまり同じモジュール内の関数への非修飾の呼び出しは、呼び出し元と同じバージョンのコードに向かうことが保証されています。
ローカル関数への呼び出しは、呼び出し箇所でモジュール名を指定することでリモート呼び出しに変えられます。
これは通常 ?MODULE:foo() のように ?MODULE マクロを使って行います。
ローカルでないモジュールへのリモート呼び出しをローカル呼び出しに変えることはできません。
つまり、呼び出し元から呼び出し先のバージョンを保証する方法はありません。
これはErlangの重要な機能であり、これによってホットコードロード(ホットアップグレード)が可能になります。 サーバーループのどこかにリモート呼び出しを用意しておきさえすれば、システムが稼働中でも新しいコードをロードでき、実行がそのリモート呼び出しに到達した時点で新しいコードの実行に切り替わります。
サーバーループを書く一般的な方法は、メインループにローカル呼び出しを使い、コードアップグレードのハンドラにはリモート呼び出しと場合によっては状態のアップグレードを行わせるというものです。
loop(State) ->
receive
upgrade ->
%% 最新版のコードを強制的に呼び出す
NewState = ?MODULE:code_upgrade(State),
?MODULE:loop(NewState);
Msg ->
%% それ以外の場合は古いバージョンのコードを呼び出す
%% これは古いデータも扱うバージョンのコードである
NewState = handle_msg(Msg, State),
loop(NewState)
end.
この構成は基本的に gen_server が使っているものであり、プログラマはコードアップグレードをいつどのように行うかを制御できます。
ホットコードアップグレードはErlangの最も重要な機能の一つであり、これによって年中無休で稼働し続けるサーバーを書くことができます。
Erlangが動的型付け言語である主な理由の一つでもあります。
静的型付け言語で code_upgrade 関数の型を与えるのは非常に困難です(ループ関数の型を与えるのも同様に困難です)。
Stateの型は新機能に対応するために将来変化していくものだからです。
パフォーマンスを重視する言語実装者にとって、ホットコードロード機能は負担でもあります。 リモートモジュールとの間の呼び出しはいずれも将来新しいコードに変わりうるため、モジュール境界をまたいだプログラム全体の最適化は非常に難しくなります(難しいものの不可能ではありません。解決策はありますが、これまで完全に実装されたものを見たことはありません)。
クロージャ呼び出し
Erlangのクロージャは関数を値として渡すことを可能にし、定義時のスコープから変数を捕捉します。
次の例を見てみましょう。
make_adder(N) -> fun(X) -> X + N end.
クロージャの呼び出し
クロージャが作られたら、他の関数と同じように呼び出せます。
Adder = make_adder(5),
Result = Adder(10). % 15を返す
クロージャを引数として渡す
クロージャは高階関数に渡すことができ、より柔軟な振る舞いを実現できます。
apply_fun(F, X) -> F(X).
Result = apply_fun(make_adder(3), 7). % 10を返す
関数からクロージャを返す
関数はクロージャを返すことができ、動的な関数生成を可能にします。
make_multiplier(N) -> fun(X) -> X * N end.
Multiplier = make_multiplier(2),
Result = Multiplier(4). % 8を返す
クロージャは動的な実行を可能にし、高階関数でよく使われます。
動的呼び出し
変数を使って実行時に関数を動的に呼び出すことができます。
M = lists,
F = map,
Result = M:F(fun(X) -> X * 2 end, [1,2,3]).
この動的呼び出しの手法は直接呼び出しより効率が落ちますが、実行時に実行パスを選択できる柔軟性を提供します。 コールバックの仕組みを実装するのに使うこともでき、特定の関数群をエクスポートするモジュールを用意して、そのモジュール名だけを持ち回ればよくなります。 呼び出しグラフがコンパイル時にわからないため、この手法はDialyzerのような解析ツールがコード中のエラーを見つけるのを大幅に難しくします。
高階関数とホットコードロード
高階関数は振る舞いを動的に渡すことを可能にし、ホットコードロードと独特な形で相互作用します。
init() ->
F = ?MODULE:foo/1,
L = fun(X) -> foo(X) end,
loop(F, L).
foo(X) -> X + 1.
loop(F, L) ->
F(1),
L(2),
loop(F, L).
Lはローカル関数なので、常に X + 1 を返します。
モジュールがリロードされ foo/1 が X + 2 を返すように変更されると、Fは X + 2 を返すようになります。
モジュールが2回リロードされると、Lはパージ済みのモジュールバージョンを参照しているためプロセスがクラッシュします。
結論
Erlangにおける関数呼び出しの効率には明確な序列があり、Efficiency Guideにもその内容が示されています。
明示的な呼び出し、つまりローカル呼び出しとリモート呼び出し(foo()、m:foo())がもっとも効率的です。
付け加えると、もっとも効率的な呼び出しは同じモジュール内の関数へのローカル呼び出しです。
この呼び出しはコンパイル時に解決でき、関数をインライン化できるからです。
モジュールは実行時に変わりうるため、リモート呼び出しではこれができません。
JITコンパイラはリモート呼び出しに対してもうまく処理しますが、OTP 27の時点ではインライン化はしません。
クロージャの呼び出し(Fun()、apply(Fun, []))はやや遅くなりますが、それでも効率的です。
コンパイル時に引数の数がわかっている関数の適用(Mod:Name()、apply(Mod, Name, []))はその次に効率的です(モジュールからエクスポートされていない関数をapplyすることはできません)。
引数の数が不明なエクスポート済み関数の適用(apply(Mod, Name, Args))がもっとも効率が悪くなります。
Erlangランタイムシステムはローカル呼び出しと、モジュールのcurrent版へのリモート呼び出しに最適化されています。 ホットコードロードは稼働中のシステムをその場で更新できる強力な機能ですが、パフォーマンス上のトレードオフを伴います。 また、モジュールを2回リロードするとold版がパージされ、そのバージョンへの参照はすべてプロセスのクラッシュを引き起こすことも重要な点です。