汎用BEAM命令

この記事は英語の原文を日本語に翻訳したものです。原文: https://blog.stenmans.org/theBeamBook/#CH-Instructions

翻訳元: happi/theBeamBook 7998e22e78417dbe20e5136b9aee862a1ecaa404(コミット 7998e22

Beamには2種類の命令セットがあります。 内部的な命令セットであるspecific(固有)と、外部向けの命令セットであるgeneric(汎用)です。

汎用命令セットは、いわば公式の命令セットに当たります。 コンパイラとBeamインタプリタの両方が使う命令の集合であり、もしErlang仮想マシンの公式な仕様書が存在するなら、そこで規定されるのはこの命令セットでしょう。 Beam向けに自作のコンパイラを書きたいなら、狙うべきはこの命令セットです。 自作のEVMを書きたいなら、扱うべきもこの命令セットです。

外部命令セットはかなり安定していますが、Erlangのバージョン間、特にメジャーバージョンをまたぐと変わることがあります。

本章で扱うのはこの命令セットです。

もう一方の命令セットである固有命令セットは、Beamが外部命令セットを実装するために使う最適化された命令セットです。 Beamの動作を理解してもらうために、この命令セットはオンライン版の付録で扱います。 内部命令セットはマイナーバージョン間、パッチリリースの間でさえ、予告なく変わることがあります。 内部命令セットに依存するツールを作るのはリスクが大きいものです。

本章では、命令の一般的な構文といくつかの命令グループを詳しく見ていきます。 命令の全一覧と簡単な説明は、BEAM命令一覧にまとめてあります。

命令の定義

汎用命令の名前とオペコードは lib/compiler/src/genop.tab で定義されています。

このファイルにはBeam命令フォーマットのバージョン番号が書かれており、この番号は .beam ファイルにも書き込まれます。 この番号はこれまで一度も変わったことがなく、いまもバージョン0のままです。 外部フォーマットに後方互換性のない変更を加える場合は、この番号が変更されることになります。

genop.tabファイルは、opsタブからコードを生成するPerlスクリプト beam_makeops の入力として使われます。 このジェネレータは、コンパイラ向けのErlangコード(beam_opcodes.hrlとbeam_opcodes.erl)とエミュレータ向けのCコード(beam_opcodes.cとbeam_opcodes.h。従来型のBEAMインタプリタ向けにはbeam_hot.h、beam_warm.h、beam_cold.hも生成されます。BeamAsm向けにはbeamasm_emit.hとbeamasm_protos.hが生成されます)の両方を生成するために使われます。

ファイル中で"#“から始まる行はコメントであり、beam_makeops からは無視されます。 ファイルには次の形式の定義を書くことができ、これはPerlスクリプト内の変数束縛になります。

NAME=EXPR

たとえば次のようになります。

BEAM_FORMAT_NUMBER=0

このBeamフォーマット番号は、外部Beamフォーマットの instructionset フィールドと同じものです。 命令セットに後方互換性のない変更を加えたときにだけ、この番号が上がります。

このファイルの主な内容は、次の形式のオペコード定義です。

OPNUM: [-]NAME/ARITY

ここでOPNUMとARITYは整数、NAMEは小文字(a-z)で始まる識別子であり、:-/ はリテラルです。

例を挙げます。

1: label/1

マイナス記号(-)は非推奨の関数であることを示します。 非推奨の関数もオペコードは維持されます。 これはローダーが後方互換性をある程度保つためであり、非推奨命令を認識した上でそのコードのロードを拒否できるようにするためです。

この章の残りでは、いくつかのBEAM命令を詳しく見ていきます。 簡単な説明を添えた全命令の一覧はBEAM命令一覧を参照してください。

BEAMコードリスティング

コンパイラの章で見たように、Erlangコンパイラに S オプションを渡すと、モジュールのBEAMコードを人間にも機械にも読める形式(実際にはErlangの項)で出力する .S ファイルが得られます。

たとえば次のファイルbeamexample1.erlを考えます。

-module(beamexample1).

-export([id/1]).

id(I) when is_integer(I) -> I.

erlc -S beamexample1.erl でコンパイルすると、次のようなbeamexmaple1.Sファイルが得られます。

{module, beamexample1}.  %% version = 0

{exports, [{id,1},{module_info,0},{module_info,1}]}.

{attributes, []}.

{labels, 7}.


{function, id, 1, 2}.
  {label,1}.
    {line,[{location,"beamexample1.erl",5}]}.
    {func_info,{atom,beamexample1},{atom,id},1}.
  {label,2}.
    {test,is_integer,{f,1},[{x,0}]}.
    return.


{function, module_info, 0, 4}.
  {label,3}.
    {line,[]}.
    {func_info,{atom,beamexample1},{atom,module_info},0}.
  {label,4}.
    {move,{atom,beamexample1},{x,0}}.
    {line,[]}.
    {call_ext_only,1,{extfunc,erlang,get_module_info,1}}.


{function, module_info, 1, 6}.
  {label,5}.
    {line,[]}.
    {func_info,{atom,beamexample1},{atom,module_info},1}.
  {label,6}.
    {move,{x,0},{x,1}}.
    {move,{atom,beamexample1},{x,0}}.
    {line,[]}.
    {call_ext_only,2,{extfunc,erlang,get_module_info,2}}.

整数の恒等関数そのもののBeamコードに加えて、いくつかのメタ命令も出力されています。

最初の行 {module, beamexample1}. %% version = 0 は、モジュール名 beamexample1 と命令セットのバージョン番号 0 を表しています。

続いてエクスポートされた関数 id/1module_info/0module_info/1 の一覧が出てきます。 見てのとおり、コンパイラは自動生成した2つの関数をコードに追加しています。 この2つの関数は、モジュール名を第1引数に加えた上で、汎用のモジュール情報BIF(erlang:get_module_info/1erlang:get_module_info/2)へ単に処理を委譲するだけのディスパッチャです。

{attributes, []} の行は、定義済みのコンパイラ属性を一覧にしたものであり、この例では属性は何も定義されていません。

続いて {labels, 7} から、モジュール中のラベル数が7未満であることがわかります。 これによりコードロードを1パスで行えるようになります。

最後のメタ命令の種類は {function, Name, Arity, StartLabel} という形式の function 命令です。 id 関数を見るとわかるように、開始ラベルは実際には関数のコード中で2番目のラベルになっています。

{label, N} という命令は実際には命令ではなく、メモリにロードされたときに場所を取りません。 これはコード中のある位置にローカルな名前(あるいは番号)を与えるだけのものです。 各ラベルはジャンプの飛び先候補になるので、それぞれが基本ブロックの先頭になりえます。

最初のラベル({label,1})の直後にある2つの命令は、実際にはエラーを生成するコードであり、行番号やモジュール・関数・アリティの情報を付加した上で例外を投げます。 これが line 命令と func_info 命令です。

関数の本体は {label,2} の後にある {test,is_integer,{f,1},[{x,0}]} という命令です。 test命令は、末尾のリストにある引数(ここでは変数 {x,0})がテスト条件(ここでは整数であること、is_integer)を満たすかどうかを調べます。 テストが成功すれば次の命令(return)が実行されます。 そうでなければ関数はラベル1({f,1})に失敗し、そこで関数節の例外が投げられます。

ファイル中の残り2つの関数は自動生成されたものです。 2つ目の関数を見ると、{move,{x,0},{x,1}} という命令はレジスタ x0 にある引数を第2引数レジスタ x1 へ移します。 続く {move,{atom,beamexample1},{x,0}} という命令はモジュール名のアトムを第1引数レジスタ x0 へ移します。 最後に erlang:get_module_info/2 への末尾呼び出し({call_ext_only,2,{extfunc,erlang,get_module_info,2}})が行われます。 次の節で見るように、呼び出し命令にはいくつかの種類があります。

呼び出し

呼び出しの章で見るように、Erlangには何種類かの呼び出しがあります。 命令セットではローカル呼び出しとリモート呼び出しを区別するために、リモート呼び出しの命令名には _ext が付きます。 ローカル呼び出しはモジュールのコード中のラベルを指すだけですが、リモート呼び出しは {extfunc, Module, Function, Arity} という形式の宛先を取ります。

通常の(スタックを積む)呼び出しと末尾再帰呼び出しを区別するために、後者は名前に _only_last のいずれかを持ちます。 _last を持つ命令は、最後の引数で与えられた数だけスタックスロットも解放します。

call_fun Arity という命令もあり、これはレジスタ {x, Arity} に格納されたクロージャを呼び出します。 引数は x0 から {x, Arity-1} に格納されます。

呼び出し命令の全種類の一覧はBEAM命令一覧を参照してください。

スタック(とヒープ)の管理

Beam上のErlangプロセスでは、スタックとヒープが同じメモリ領域を共有しています(詳しくはプロセスの章メモリの章を参照)。 スタックはアドレスの低い方向へ、ヒープはアドレスの高い方向へ伸びます。 スタックとヒープのどちらであれ、必要な空き容量が足りなくなるとBeamはガベージコレクションを行います。

  • 葉関数:他のどの関数も呼び出さない関数のこと。
  • 非葉関数:他の関数を呼び出す可能性がある関数のこと。

非葉関数に入るとき、継続ポインタ(CP)がスタックに保存され、抜けるときにスタックから読み戻されます。 この処理を担うのが allocate 命令と deallocate 命令であり、現在の命令のスタックフレームを組み立てたり畳んだりするのに使われます。

葉関数のスケルトンは次のようになります。

{function, Name, Arity, StartLabel}.
  {label,L1}.
    {func_info,{atom,Module},{atom,Name},Arity}.
  {label,L2}.
    ...
    return.

非葉関数のスケルトンは次のようになります。

{function, Name, Arity, StartLabel}.
  {label,L1}.
    {func_info,{atom,Module},{atom,Name},Arity}.
  {label,L2}.
    {allocate,Need,Live}.

    ...
    call ...
    ...

    {deallocate,Need}.
    return.

allocate StackNeed Live 命令は継続ポインタ(CP)を保存し、スタックに StackNeed 語分の余分な領域を確保します。 もし確保の途中でGCが必要になった場合は、Live 個分のXレジスタを退避させます。 たとえば Live が2なら、レジスタ x0x1 が退避されます。

スタックに確保するとき、スタックポインタ(E)は減少します。

「allocate 1 0」実行前後のスタックの様子。Eはスタックポインタ、HTOPはヒープの先頭を指す。実行後は退避スロットの下に継続ポインタCPが積まれ、Eがそこまで下がる。矢印はない。 図: allocate 1 0 の実行前後のスタック

allocateとdeallocateの全種類の一覧はBEAM命令一覧を参照してください。

メッセージパッシング

メッセージの送信はBeamコード上では単純です。 send 命令を使うだけでかまいません。 ただしsend命令は引数を取らない点に注意してください。 むしろ関数呼び出しに近いものです。 宛先とメッセージが引数レジスタ x0x1 に入っていることを前提にしており、メッセージも x1 から x0 へコピーされます。

メッセージの受信はもう少し複雑です。 パターンマッチを伴う選択的受信が関わるうえ、関数本体の中に降参・再開のポイントを持ち込むことになります(メッセージキューの走査を最小限にするためにrefを使う特別な仕組みもありますが、これは後で扱います)。

最小限の受信ループ

どんなメッセージも受け付け、タイムアウトを持たない最小限の受信ループ(たとえば receive _ -> ok end)は、BEAMコードでは次のようになります。

  {label,1}.
    {loop_rec,{f,2},{x,0}}.
    remove_message.
    {jump,{f,3}}.
  {label,2}.
    {wait,{f,1}}.
  {label,3}.
     ...

loop_rec L2 x0 命令は、まずメッセージキューにメッセージがあるかどうかを調べます。 メッセージが無ければ実行はL2へジャンプし、そこでプロセスはメッセージの到着を待って一時停止されます。

メッセージキューにメッセージがある場合、loop_rec 命令はそのメッセージをmバッファからプロセスのヒープへ移動する処理も行います。 mバッファの扱いの詳細はメモリの章プロセスの章を参照してください。

receive _ -> ok end のようにどんなメッセージも受け入れるコードでは、パターンマッチが不要なので、単に remove_message を行ってメッセージキューの先頭のメッセージを切り離すだけですみます(この命令はタイムアウトが設定されていればそれも取り除きます。詳細はすぐ後で扱います)。

選択的受信ループ

receive [] -> ok end のような選択的受信では、キュー中のいずれかのメッセージがマッチするかどうかを確かめるためにメッセージキューをループで走査することになります。

  {label,1}.
    {loop_rec,{f,3},{x,0}}.
    {test,is_nil,{f,2},[{x,0}]}.
    remove_message.
    {jump,{f,4}}.
  {label,2}.
    {loop_rec_end,{f,1}}.
  {label,3}.
    {wait,{f,1}}.
  {label,4}.
    ...

この場合、メールボックスにメッセージがあれば、loop_rec命令の後でNilに対するパターンマッチが行われます。 メッセージがマッチしなければL2へ進み、そこで loop_rec_end 命令が保存ポインタを次のメッセージへ進めて(p->msg.save = &(*p->msg.save)->next)L1へジャンプし直します。

メッセージキューにこれ以上メッセージが無ければ、プロセスはL3の wait 命令によって、保存ポインタをメッセージキューの末尾に向けたまま一時停止されます。 プロセスが再スケジュールされたときは、保存ポイントより後にある新しいメッセージだけを見ることになります。

タイムアウト付きの受信ループ

選択的受信にタイムアウトを加えると、wait命令の代わりにwait_timeout命令が置かれ、その後にtimeout命令とタイムアウト後に実行するコードが続きます。

  {label,1}.
    {loop_rec,{f,3},{x,0}}.
    {test,is_nil,{f,2},[{x,0}]}.
    remove_message.
    {jump,{f,4}}.
  {label,2}.
    {loop_rec_end,{f,1}}.
  {label,3}.
    {wait_timeout,{f,1},{integer,1000}}.
    timeout.
  {label,4}.
    ...

wait_timeout 命令は、指定された時間(この例では1000ミリ秒)でタイムアウトタイマーを設定するとともに、次の命令(timeout)のアドレスを p->def_arg_reg[0] に保存し、タイマーがセットされると p->i がこの def_arg_reg を指すように設定します。

つまり、プロセスが一時停止している間にマッチするメッセージが1つも届かなければ、1秒後にタイムアウトが発生し、プロセスの実行はtimeout命令から再開されるということです。

マッチしないメッセージがメールボックスに届いた場合、プロセスはスケジュールされて受信ループ中のパターンマッチのコードを実行しますが、タイムアウトはキャンセルされない点に注意してください。 タイムアウトタイマーを取り除くのは remove_message のコードです。

timeout 命令はメールボックスの保存ポイントを先頭要素にリセットし、PCBのタイムアウトフラグ(F_TIMO)をクリアします。

同期呼び出しのトリック(refトリック)

ここまでで受信ループの最後のバージョンにたどり着きました。 ここでは、先ほど触れたrefトリックを使って、メッセージボックスの長い走査を避けます。

Erlangコードでよく見られるパターンの1つに、送信と受信によって2つのプロセス間で一種の「リモート呼び出し」を実装するというものがあります。 これはたとえばgen_serverで使われています。 このコードは、多くの場合、普通の関数呼び出しのライブラリの背後に隠されています。 たとえば counter:increment(Counter) という関数を呼ぶと、裏側では Counter ! {self(), inc}, receive {Counter, Count} -> Count end のようなコードに変換されます。

これは通常、プロセスの中に状態をカプセル化するための便利な抽象化です。 ただし、呼び出し元プロセスのメールボックスに多くのメッセージが溜まっている場合には、少し問題が生じます。 この場合、受信は返信メッセージ以外のすべてのメッセージがマッチしないことを確かめるために、メールボックス中の各メッセージを調べなければなりません。

これは、多くのメッセージを受け取るサーバーが、各メッセージに対してこの種のリモート呼び出しを何度も行うような場合によく起こりえます。 バックプレッシャーの仕組みが無ければ、サーバーのメッセージキューは膨れ上がってしまいます。

これを解決するために、ERTSにはこのパターンを認識し、返信メッセージを探すためにメッセージキュー全体を走査するのを避けるハックが存在します。

コンパイラは、受信の中で新しく作られた参照(ref)が使われているコードを認識し、その新しいrefがまだ受信箱に存在しえないことを利用して、長い受信箱の走査を避けるコードを出力します。

  Ref = make_ref(),
  Counter ! {self(), inc, Ref},
  receive
    {Ref, Count} -> Count
  end.

これにより、完全な受信は次のようなスケルトンになります。

    {recv_mark,{f,3}}.
    {call_ext,0,{extfunc,erlang,make_ref,0}}.
    ...
    send.
    {recv_set,{f,3}}.
  {label,3}.
    {loop_rec,{f,5},{x,0}}.
    {test,is_tuple,{f,4},[{x,0}]}.
    ...
    {test,is_eq_exact,{f,4},[{x,1},{y,0}]}.
    ...
    remove_message.
    ...
    {jump,{f,6}}.
  {label,4}.
    {loop_rec_end,{f,3}}.
  {label,5}.
    {wait,{f,3}}.
  {label,6}.

recv_mark 命令は、現在の位置(末尾 msg.last)を msg.saved_last に、そのラベルのアドレスを msg.mark に、それぞれ保存します。

recv_set 命令は、msg.mark が次の命令を指しているかどうかを確認し、そうであれば保存ポイント(msg.save)をref生成前に最後に受信したメッセージ(msg.saved_last)へ移動します。 マークが無効な場合(すなわち msg.save と一致しない場合)、この命令は何もしません。