他言語とのインターフェースによるBEAMとERTSの拡張

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

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

イントロダクション

C、C++、Rust、アセンブラとのインターフェースは、BEAMの能力を拡張する機会になります。 本章ではほとんどの例でCを使いますが、ここで説明する方法はほぼどのプログラミング言語とのインターフェースにも応用できます。 RustとJavaの例もいくつか示します。 本章の残りの部分では、ほとんどの場合Cを他の言語に置き換えても成り立ちますが、簡潔さのためにCだけを使います。

Cのコードを組み込むことで、開発者はErlangアプリケーションの性能を向上させられます。とくに、システムレベルのリソースに直接アクセスする必要がある計算負荷の高い処理では効果的です。 また、Cとのインターフェースによって、Erlangアプリケーションはハードウェアやシステムレベルのリソースと直接やり取りできます。 メモリ操作、特殊なハードウェアへのアクセス、リアルタイムのデータ処理など、低レベルの操作を必要とするアプリケーションにとって、この能力は欠かせません。 CをErlangに組み込むもう一つの利点は、既存のCライブラリやコードベースを利用できることです。 強力なライブラリやツールの多くはCで提供されており、それらとインターフェースすることで、Erlangの開発者はErlangで再実装することなくその機能を取り込めます。

さらに、実行を精密に制御する必要がある場面でも、Cとのインターフェースが役立ちます。 Erlangの仮想マシンは優れた並行性管理を提供しますが、一部のリアルタイムアプリケーションには、Cのほうがうまく実現できるより決定論的な振る舞いが求められることがあります。 Cのコードを組み込むことで、開発者は特定の要件を満たすようアプリケーションの性能や振る舞いを細かく調整できます。

ERTSとBEAM自体もCで書かれているため、Cのコードによってそれらを拡張することもできます。

これまでの章では、ソケットやポートを介して他のアプリケーションやサービスと安全にインターフェースする方法を見てきました。 本章では、より低レベルなコードと直接インターフェースする方法を扱います。これは、より安全性の低いやり方でもあります。

公式ドキュメントには相互運用性に関するチュートリアルがあります。相互運用性チュートリアルを参照してください。

Cコードを安全にインターフェースする方法

CのコードをErlangとインターフェースする際、BEAM仮想マシンを不安定にするリスクを抑えたいくつかの機構を使えば、安全に行えます。 主な方法を以下に示します。

os:cmd

os:cmd関数を使うと、Erlangプロセスからシェルコマンドを実行し、その出力を取得できます。 コマンドはBEAM VMとは別のOSプロセスで実行されるため、この方法は安全です。 os:cmdを使うことで、開発者はErlangのランタイム環境に直接影響を与えることなく、外部のCプログラムとやり取りできます。 ただしオーバーヘッドが伴い、Cプログラムはコマンドラインから実行でき、標準出力に結果を返すスタンドアロンのプログラムであることが前提になります。

例を示します。

// time.c
#include <stdio.h>
#include <time.h>

void get_system_time()
{
    time_t rawtime;
    struct tm *timeinfo;

    time(&rawtime);
    timeinfo = localtime(&rawtime);

    printf("Current Date and Time: %s", asctime(timeinfo));
}

int main()
{
    get_system_time();
    return 0;
}
> os:cmd("./time").
"Current Date and Time: Mon May 20 04:46:37 2024\n"

open_portspawn_executable

プログラムとやり取りするうえで、とくに引数がユーザー入力に基づく場合にさらに安全な方法は、open_portspawn_executable引数付きで使うことです。 この方法では、オペレーティングシステムのシェルを介さずに引数を実行ファイルへ直接渡すため、引数インジェクションのリスクを軽減できます。 引数を直接渡すことでシェルによる引数の解釈を防げるため、ユーザー入力に含まれる特殊文字やコマンドに起因する潜在的なインジェクション攻撃を回避できます。

1> Port = open_port({spawn_executable, "./time"}, [{args, []}, exit_status]).
#Port<0.7>
2> receive {Port, {data, R}} -> R after 1000 -> timeout end.
"Current Date and Time: Mon May 20 13:59:32 2024\n"

標準入力から読み取り標準出力へ書き込むプログラムでポートをspawnし、そのポートとデータを送受信することもできます。

例を示します。

Port = open_port({spawn, "./system_time"}, [binary]),
port_command(Port, <<"get_time\n">>).

外部プログラムに接続するためのポートドライバの使い方については、IOの章を参照してください。

Sockets

ソケットは、Erlangと外部のCプログラムとの通信を実現するわかりやすい方法です。 TCPソケットやUDPソケットを使うことで、Cアプリケーションはネットワーク越しにErlangプロセスとデータをやり取りでき、両者は互いに独立した状態を保てます。 この方法は分散システムでとくに有用で、非同期通信にも対応できます。

最も一般的で手軽な方法は、HTTPやHTTPS越しにREST風のインターフェースを使うことです。 HTTPクライアント向けのhttpcやHTTPサーバ向けのcowboyなど、クライアント・サーバ双方の実装に使えるErlangライブラリがあります。 この方法を使うと、Cアプリケーションは、Erlangプロセスが呼び出せるAPIを公開でき、明確に定義されたプロトコル越しのやり取りが容易になります。

その次の段階は、純粋なソケット通信を使うことです。HTTP/HTTPSより効率的になり得ますが、独自のプロトコルを自分で考えるか、別の低レベルプロトコルを使う必要があります。 この方法では独自のデータ交換フォーマットを使え、より上位のプロトコルに伴うオーバーヘッドを減らすことで性能を最適化できます。

ソケットの仕組みについて詳しくはIOの章を参照してください。

リンクインドライバ

Erlangのリンクインドライバを使うと、Cのコードを直接Erlangランタイムシステムに組み込め、高性能な処理と外部リソースとのシームレスなやり取りが可能になります。

リンクインドライバは同じプロセス空間内で動作するため、外部ポートと比べてErlangとCのコード間の通信が高速で、プロセス間通信のオーバーヘッドもかかりません。

ハードウェアとのインターフェースやリアルタイムのデータ処理など、タイミング制約が厳しいアプリケーションでは、リンクインドライバが必要な応答性を提供します。

ただし、直接メモリアクセスやプログラミングエラーの可能性により、リンクインドライバはErlang VMを不安定にするリスクがより高くなります。

リンクインドライバの実装方法

Erlangでリンクインドライバを実装する方法を、3段階で追っていきます。 まず抽象的に何をすべきかを、次に一般的な擬似コードで、最後に完全な例で示します。

Erlangでリンクインドライバを実装する手順は、次のとおりです。

手順1:ドライバのCコードを書きます。次の内容を扱います。

  1. ドライバの初期化
    • erl_drv_entry構造体を、コールバック関数へのポインタとともに定義する。
    • DRIVER_INITマクロを使ってドライバを登録する。
  2. 非同期処理の実装
    • start、stop、outputの各操作に対応するドライバコールバックを処理する。
    • driver_selectなどの関数を使ってI/Oイベントを管理する。
  3. リソースの確保と解放の管理
    • ドライバ固有のデータを保持する構造体を定義する。
    • ドライバのstart時にリソースを確保し、stop時に解放する。

手順2:Erlangと統合します。

  • erl_ddll:load_driver/2を使ってドライバをロードする。
  • ドライバと通信するためのポートをErlang側に作成する。

上記の手順を、リンクインドライバの擬似コードでもう少し詳しく追っていきます。

手順1:ドライバのCコードを書きます。

A. ドライバの初期化

  • erl_drv_entry構造体の定義:この構造体には、Erlangランタイムシステムが呼び出すコールバック関数へのポインタが格納されます。
  • ドライバの登録DRIVER_INITマクロを実装し、ドライバをErlangランタイムに登録します。
static ErlDrvEntry example_driver_entry = {
    NULL,                   // init
    example_drv_start,      // start
    example_drv_stop,       // stop
    example_drv_output,     // output
    NULL,                   // ready_input
    NULL,                   // ready_output
    "example_drv",          // driver_name
    NULL,                   // finish
    NULL,                   // handle
    NULL,                   // control
    NULL,                   // timeout
    NULL,                   // outputv
    NULL,                   // ready_async
    NULL,                   // flush
    NULL,                   // call
    NULL,                   // event
    ERL_DRV_EXTENDED_MARKER, // extended_marker
    ERL_DRV_EXTENDED_MAJOR_VERSION, // major_version
    ERL_DRV_EXTENDED_MINOR_VERSION, // minor_version
    0,                      // driver_flags
    NULL,                   // handle2
    NULL,                   // process_exit
    NULL                    // stop_select
};

DRIVER_INIT(example_drv) {
    return &example_driver_entry;
}

B. 非同期処理

  • ドライバコールバックの処理example_drv_startexample_drv_stopexample_drv_outputなど、ドライバの操作を管理するために必要なコールバック関数を実装します。
  • I/Oイベントの管理driver_selectのような関数を使って、非同期のI/O操作を効率的に処理します。
static ErlDrvData example_drv_start(ErlDrvPort port, char *command) {
    example_data* d = (example_data*)driver_alloc(sizeof(example_data));
    d->port = port;
    return (ErlDrvData)d;
}

static void example_drv_stop(ErlDrvData handle) {
    driver_free((char*)handle);
}

static void example_drv_output(ErlDrvData handle, char *buff, ErlDrvSizeT bufflen) {
    example_data* d = (example_data*)handle;
    // Process the input and produce output
    driver_output(d->port, output_data, output_len);
}

C. リソース管理

  • 確保と解放:ドライバのstart時にリソースを確保し、stop時に解放することで、メモリリークを防ぎ、適切なメモリ管理を保証します。
typedef struct {
    ErlDrvPort port;
    // Additional driver-specific data
} example_data;

手順2:Erlangとの統合

  • ドライバのロードerl_ddll:load_driver/2を使って、ドライバを含む共有ライブラリをロードします。
  • ポートを開くopen_port/2{spawn, DriverName}タプルとともに使い、ドライバと通信するためのポートをErlang側に作成します。
start(SharedLib) ->
    case erl_ddll:load_driver(".", SharedLib) of
        ok -> ok;
        {error, already_loaded} -> ok;
        _ -> exit({error, could_not_load_driver})
    end,
    Port = open_port({spawn, SharedLib}, []),
    loop(Port).

実装例

整数を2倍にするリンクインドライバの、完全な実装例を示します。

手順1:Cのドライバを書きます。

#include "erl_driver.h"
#include <stdlib.h>
#include <stdio.h>
#include <string.h>

typedef struct {
    ErlDrvPort port;
} double_data;

// start関数:ドライバの状態を初期化する
static ErlDrvData double_drv_start(ErlDrvPort port, char *command) {
    double_data* d = (double_data*)driver_alloc(sizeof(double_data));
    d->port = port;
    return (ErlDrvData)d;
}

// stop関数:リソースを解放する
static void double_drv_stop(ErlDrvData handle) {
    driver_free((char*)handle);
}

// output関数:Erlangから送られたデータを処理する
static void double_drv_output(ErlDrvData handle, char *buff, ErlDrvSizeT bufflen) {
    double_data* d = (double_data*)handle;

    // 入力バッファを整数に変換する
    int input = atoi(buff);

    // 処理を実行する(入力値を2倍にする)
    int result = input * 2;

    // 結果を文字列に戻す
    char result_str[32];
    snprintf(result_str, sizeof(result_str), "%d", result);

    // 結果をErlangへ送り返す
    driver_output(d->port, result_str, strlen(result_str));
}

// ドライバのエントリ構造体を定義する
static ErlDrvEntry double_driver_entry = {
    NULL,                      // init
    double_drv_start,          // start
    double_drv_stop,           // stop
    double_drv_output,         // output
    NULL,                      // ready_input
    NULL,                      // ready_output
    "double_drv",              // driver_name
    NULL,                      // finish
    NULL,                      // handle
    NULL,                      // control
    NULL,                      // timeout
    NULL,                      // outputv
    NULL,                      // ready_async
    NULL,                      // flush
    NULL,                      // call
    NULL,                      // event
    ERL_DRV_EXTENDED_MARKER,   // extended marker
    ERL_DRV_EXTENDED_MAJOR_VERSION, // major version
    ERL_DRV_EXTENDED_MINOR_VERSION, // minor version
    0,                         // driver flags
    NULL,                      // handle2
    NULL,                      // process_exit
    NULL                       // stop_select
};

// ドライバ初期化マクロ
DRIVER_INIT(double_drv) {
    return &double_driver_entry;
}

手順2:Erlangのモジュールを書きます。

-module(double).
-export([start/0, stop/0, double/1]).

start() ->
      SharedLib = "double_drv",
      case erl_ddll:load_driver(".", SharedLib) of
         ok -> ok;
         {error, already_loaded} -> ok;
         _ -> exit({error, could_not_load_driver})
      end,
      register(double_server, spawn(fun() -> init(SharedLib) end)).

init(SharedLib) ->
      Port = open_port({spawn, SharedLib}, []),
      loop(Port).

loop(Port) ->
      receive
         {double, Caller, N} ->
            Port ! {self(), {command, integer_to_list(N)}},
            receive
                  {Port, {data, Result}} ->
                     Caller ! {double_result, list_to_integer(Result)}
            end,
            loop(Port);
         stop ->
            Port ! {self(), close},
            receive
                  {Port, closed} -> exit(normal)
            end
      end.

double(N) ->
      double_server ! {double, self(), N},
      receive
         {double_result, Result} -> Result
      end.

stop() ->
      double_server ! stop.

double_drv_output関数は、Erlangランタイムからドライバへ送られたデータの処理を担います。 まずatoiを使って入力文字列を整数に変換し、値を2倍にするという求められた処理を行ったうえで、snprintfを使って結果を文字列に戻します。 最後にdriver_output関数を使って結果をErlangランタイムへ送り返し、Erlangとネイティブコードとのシームレスな通信を実現します。

double_driver_entry構造体は、ドライバの中心的な定義として、Erlangランタイムとドライバのコア機能を結びつけます。 start、stop、outputといったコールバック関数へのポインタを保持し、ドライバのライフサイクルややり取りの各局面を扱います。 またdriver_nameフィールドは、Erlang側からドライバをロードする際に使う識別子を指定します。 最後に、DRIVER_INITマクロがdouble_driver_entry構造体へのポインタを返すことでドライバをErlangランタイムに登録し、ランタイムがドライバを認識して適切に管理できるようにします。

リンクインドライバをテストするには、まずCのコードを共有ライブラリへコンパイルします。

gcc -o double_drv.so -fPIC -shared double_drv.c -I /path/to/erlang/erts/include

本書のdevcontainerでコードを実行している場合は、次のようにします。

cd /code/c_chapter
gcc -o double_drv.so -fPIC -shared -I /usr/local/lib/erlang/usr/include/ double_drv.c

Erlang側でドライバをロードします。

Erlangシェルを起動します。

erl

ドライバをロードします。

1> c(double).
{ok,double}
2> double:start().
true

機能を呼び出します。

3> double:double(21).
42

ドライバを停止します。

4> double:stop().
stop
5>

組み込み関数(BIF)

組み込み関数(BIF、built-in functions)は、BEAM仮想マシンに組み込まれた、あらかじめ定義されたネイティブの関数群です。 多倍長整数の演算、プロセス管理、リストやタプルといったデータ型の処理など、Erlangにおける基本的な操作の一部を実装しています。 BIFはCで実装されています。

BEAM命令、BIF、演算子、ライブラリ関数の違いを明確に理解しておくとよいでしょう。

BEAM命令は、BEAM仮想マシンが実行する命令です。 BEAM命令のコードは通常小さく保たれており、新しめのErlangバージョンではJITローダーによってインライン化されることもあります(詳しくはJITの章で扱います)。

BIFはCで実装されており、BEAM仮想マシンの一部です。 多倍長整数の+のように、BEAM命令と比べてかなり大きな演算子であることが多く、あるいはlists:reverse/1のように性能上の理由からCで実装されているライブラリ関数であることが多いです。 一部のBIFは、Erlangだけで実装するのが難しい、低レベルの重要な機能を提供します。 たとえば、プロセス間でメッセージを送るために使われるerlang:send/2がそうです。

演算子は、ライブラリ関数に対応付けられる構文上の言語構成要素です。 たとえば演算子+は、コンパイラによってerlang:+/2に対応付けられます。 これらの関数は、複雑さに応じてBEAM命令として実装されることもあれば、BIFとして実装されることもあります。

ライブラリ関数は、lists:length/1のような、Erlang言語の一部をなす基本的な関数です。 BEAM命令、BIF、あるいはErlang自体のいずれでも実装されえます。

BIFはCで書かれ、BEAM内での性能のために最適化されているため効率的です。 一方でBIFには欠点もあり、実行時間の長いBIFはBEAMのスケジューラをブロックし、システムの応答性に影響を与えることがあります。 またランタイムシステム内に固定されているため、拡張性にも限りがあります。

ほとんどのErlangユーザーはBIFを書く必要に迫られることはありませんが、BIFが何であり、どう動くのかを知っておくとよいでしょう。 またEEP(Erlang Enhancement Proposal)を書く場合には、新機能を実装するためにBIFを書く必要が生じることもあります。

以降の節では、BIFの実装方法の詳細を扱います。

実装の手順

  1. BIFの作成
    • BEAM内部のメモリ・プロセス安全性APIに従って、関数をCで書く。
    • 関数の目的に合った、スレッドセーフで効率のよいアルゴリズムを実装する。
    • BEAMのソースコードに関数を追加し、ランタイムシステムとともにコンパイルする。
    • 関数の時間計算量に見合ったリダクション数を計算する。
    • 関数の実行に時間がかかりうるなら、yieldできるようにすることを検討する。
  2. 性能面での考慮事項
    • BIF内で実行時間の長い処理を避ける。
    • メモリの安全性とErlangの項の適切な扱いを確保し、システムの安定性を保つ。

実装例

数値の階乗を計算する単純なBIFを実装するとします。このBIFは、次のことを行います。

  1. 入力引数を解析し、妥当性を確認する。
  2. Cで効率よく計算を行う。
  3. 結果をErlang側に返す。

BIFが定義されているERTS内のコードは、bif.cにあります。 今回の場合はerl_math.cのほうが適しているかもしれません。

BIFには特別なシグネチャがあり、次のように定義されます。

BIF_RETTYPE [NAME]_[ARITY](BIF_ALIST_[ARITY])

名前にはモジュール名と関数名がアンダースコアで区切られて含まれる点に注意してください。 アリティは関数が取る引数の数です。 BIF_ALIST_Xマクロは引数を定義するために使われ、BIF_RETTYPEマクロは関数の戻り値の型を定義するために使われます。

コード例を示します。


BIF_RETTYPE math_factorial_1(BIF_ALIST_1)
{
    /* factorial = n! を計算する(n >= 0) */
    int i, n, reds;
    long factorial = 1;
    Eterm result;

    /* 引数を検査し、正の整数でなければエラーを返す。
       BIF_ALIST_X でBIFを定義すると、引数は BIF_ARG_1、BIF_ARG_2 のように名付けられる。
       エラーを通知するには BIF_ERROR(BIF_P, BADARG); を使う。
       BIF_P マクロは、そのBIFを呼び出したプロセスのプロセス構造体(PCB)へのポインタで、
       ヒープポインタ、スタックポインタ、レジスタなど、そのプロセスに関するすべての情報を保持する。
    */
    if (is_not_small(BIF_ARG_1) || (n = signed_val(BIF_ARG_1)) < 0)
    {
        BIF_ERROR(BIF_P, BADARG);
    }

    /* 階乗を計算する */
    for (i = 1; i <= n; ++i) factorial *= i;

    /* 結果をErlangの項に変換する */
    result = erts_make_integer(factorial, BIF_P);

    /* 計算ループの反復回数に応じてリダクション数を計算する。*/
    reds = n / 1000 + 1;

    /* 結果とリダクション数を返す。
      BIF_RET2 マクロは結果とリダクション数を返すために使う。
      第1引数が結果、第2引数がリダクション数である。
    */
    BIF_RET2(result, reds);
}

このおもちゃの例は大きな数をうまく扱えない点に注意してください。factorial(66)は64ビット整数(long)をオーバーフローします。 実際の実装では多倍長整数ライブラリを使うことになります。次の節でその方法を示します。

このBIFを、bif.tabファイル内のBIF一覧に追加します。

bif math:factorial/1

このBIFをランタイムの他の部分からも使えるようにしたい場合は、たとえばbif.hにヘッダーを追加するのを忘れないでください。

続いて、Erlangモジュールmath.erlにスタブを追加します。

-doc "The factorial of `X`.".
-doc(#{since => <<"OTP 29.0">>}).
-spec factorial(X) -> integer() when
      X :: pos_integer().
factorial(_) ->
    erlang:nif_error(undef).

関数をexportするのを忘れないでください。

これで、BEAMを再コンパイルし、Erlang上で新しいBIFを実行できるようになるはずです。

1> math:factorial(7).
5040

カーネルモジュールへ機能を追加したい場合は、erl_prim_loader.cファイル内でプリロードされたモジュールを扱う必要があります。 詳しくはBEAMローダーの章を参照してください。

マシンのsmall integerが扱える範囲を超える大きな数で試すと、誤った結果が返ってきます。 多倍長整数を使う、より複雑な例に進みましょう。

BIF_RETTYPE math_factorial_1(BIF_ALIST_1)
{
    /* Calculate factorial = n! for n >= 0 */
    Sint64 i, n, reds;
    Uint64 factorial = 1;
    Eterm result;
    Eterm *hp;
    Eterm big_factorial;
    ErtsDigit *temp_digits_a;
    ErtsDigit *temp_digits_b;
    ErtsDigit *src;
    ErtsDigit *dest;
    ErtsDigit *temp;
    dsize_t temp_size;
    dsize_t curr_size;
    dsize_t new_size;

    if (is_not_integer(BIF_ARG_1) || (!term_to_Sint64(BIF_ARG_1, &n)) || n < 0)
    {
        BIF_ERROR(BIF_P, BADARG);
    }

    /* We do not want to use heap space for intermediate results since we are
       going to do a lot of them. So we use a temporary buffer.
       We create two buffers, one to keep (n-1)! and one to keep n!.
    */

    /* Initial allocation of buffers, this will be large enough for most n,
       that is up to ~ 50.000. Then we will start increasing the buffer.  */
    temp_size = 2 * (n + 1);
    temp_digits_a = (ErtsDigit *)erts_alloc(ERTS_ALC_T_TMP, temp_size * sizeof(ErtsDigit));
    temp_digits_b = (ErtsDigit *)erts_alloc(ERTS_ALC_T_TMP, temp_size * sizeof(ErtsDigit));
    src = temp_digits_a;
    dest = temp_digits_b;

    for (i = 1; i <= n; ++i)
    {
        /* We want to avoid heap allocation for small numbers.
           These we do on the C stack and just return a small_int. */
        if (!IS_USMALL(0, factorial * i))
        {
            // Initial conversion to bignum in our temp buffer
            hp = (Eterm *)src;
            big_factorial = uint_to_big(factorial, hp);
            curr_size = BIG_SIZE(big_val(big_factorial));

            for (; i <= n; ++i)
            {
                new_size = curr_size + 1;

                if (new_size > temp_size)
                {
                    dsize_t alloc_size = new_size * 2;
                    src = (ErtsDigit *)erts_realloc(ERTS_ALC_T_TMP, src, alloc_size * sizeof(ErtsDigit));
                    dest = (ErtsDigit *)erts_realloc(ERTS_ALC_T_TMP, dest, alloc_size * sizeof(ErtsDigit));
                    temp_size = alloc_size;
                }

                big_factorial = big_times_small(big_factorial, i, (Eterm *)dest);

                temp = src;
                src = dest;
                dest = temp;
            }

            // We are done.
            // Only now allocate on process heap and copy the final result
            hp = HAlloc(BIF_P, BIG_SIZE(big_val(big_factorial)) + 1);
            sys_memcpy(hp, big_val(big_factorial), (BIG_SIZE(big_val big_factorial)) + 1) * sizeof(Eterm));
            result = make_big(hp);

            erts_free(ERTS_ALC_T_TMP, temp_digits_a);
            erts_free(ERTS_ALC_T_TMP, temp_digits_b);

            goto done;
        }
        factorial *= i;
    }

    result = make_small(factorial);

done:
    reds = n / 1000 + 1;
    BIF_RET2(result, reds);
}

これで、1000!のような非常に大きな階乗を計算できるようになりました。

1> math:factorial(1000).
402387260077093773543702433923003985719374864210714632543799910429938512398629020592044208486969404800479988610197196058631666872994808558901323829669944590997424504087073759918823627727188732519779505950995276120874975462497043601418278094646496291056393887437886487337119181045825783647849977012476632889835955735432513185323958463075557409114262417474349347553428646576611667797396668820291207379143853719588249808126867838374559731746136085379534524221586593201928090878297308431392844403281231558611036976801357304216168747609675871348312025478589320767169132448426236131412508780208000261683151027341827977704784635868170164365024153691398281264810213092761244896359928705114964975419909342221566832572080821333186116811553615836546984046708975602900950537616475847728421889679646244945160765353408198901385442487984959953319101723355556602139450399736280750137837615307127761926849034352625200015888535147331611702103968175921510907788019393178114194545257223865541461062892187960223838971476088506276862967146674697562911234082439208160153780889893964518263243671616762179168909779911903754031274622289988005195444414282012187361745992642956581746628302955570299024324153181617210465832036786906117260158783520751516284225540265170483304226143974286933061690897968482590125458327168226458066526769958652682272807075781391858178889652208164348344825993266043367660176999612831860788386150279465955131156552036093988180612138558600301435694527224206344631797460594682573103790084024432438465657245014402821885252470935190620929023136493273497565513958720559654228749774011413346962715422845862377387538230483865688976461927383814900140767310446640259899490222221765904339901886018566526485061799702356193897017860040811889729918311021171229845901641921068884387121855646124960798722908519296819372388642614839657382291123125024186649353143970137428531926649875337218940694281434118520158014123344828015051399694290153483077644569099073152433278288269864602789864321139083506217095002597389863554277196742822248757586765752344220207573630569498825087968928162753848863396909959826280956121450994871701244516461260379029309120889086942028510640182154399457156805941872748998094254742173582401063677404595741785160829230135358081840096996372524230560855903700624271243416909004153690105933983835777939410970027753472000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000

100000!さえも計算できますが、時間がかかります。 BEAMのスケジューラをブロックしないようにするには、ときどきyieldする必要があります。

これは、保存したい状態と戻り先の関数を指定してBIF_TRAP1を呼ぶことで実現できます。 今回の場合、戻り先はmath_factorial_trap_1関数です。

保存したい状態と戻り先の関数を保持する構造体を、math_factorial_trap_exportとして定義する必要があります。 このexport構造体は初期化も必要で、math_factorial_trap_init関数内でerts_init_trap_exportを呼び出すことで行います。 続いて、erl_init.c内のerl_init関数からmath_factorial_trap_initを呼び出す処理を追加する必要があります。 また、新しいアトムam_math_factorial_trapatom.namesに追加する必要もあります。

yieldする際は、現在のi、現在のfactorial、元のnをタプルに格納して状態を保存します。 つまりtrap関数への引数は、これらの値を持つタプルです。 元のBIFであるmath_factorial_1の中で、このタプルを作成してmath_factorial_trap_1を呼び出します。

yieldから戻ってきたときにはすでに多倍長整数になっている場合があるため、そのケースを扱えるようコードを少し組み替えます。

イテレータを確認し、1000回ごとに状態を保存してyieldします。 また、すでに多倍長整数になっている場合にも状態を保存してyieldします。

static Export math_factorial_trap_export;

static BIF_RETTYPE math_factorial_trap_1(BIF_ALIST_1)
{
    Sint64 n, i;
    Uint64 factorial;
    Eterm big_factorial = THE_NON_VALUE;
    ErtsDigit *temp_digits_a = NULL;
    ErtsDigit *temp_digits_b = NULL;
    ErtsDigit *src;
    ErtsDigit *dest;
    ErtsDigit *temp;
    dsize_t temp_size;
    dsize_t curr_size;
    dsize_t new_size;
    dsize_t big_size;
    Eterm result;
    Eterm *hp;
    Eterm *big_hp;
    Eterm *tp = tuple_val(BIF_ARG_1);
    Eterm state;

    // 現在の状態を取り出す
    i = signed_val(tp[1]); // 現在のi
    n = signed_val(tp[3]); // 元のn

    temp_size = (n * 4);
    temp_digits_a = (ErtsDigit *)erts_alloc(ERTS_ALC_T_TMP, temp_size * sizeof(ErtsDigit));
    temp_digits_b = (ErtsDigit *)erts_alloc(ERTS_ALC_T_TMP, temp_size * sizeof(ErtsDigit));
    src = temp_digits_a;
    dest = temp_digits_b;

    // small型とbignum型、両方の階乗を扱う
    if (is_small(tp[2]))
    {
        factorial = unsigned_val(tp[2]);
    }
    else
    {
        // すでに多倍長整数の領域なので、smallの計算ループは飛ばす
        big_factorial = tp[2];
        goto bignum_loop;
    }


    for (; i <= n; ++i)
    {
        if (!IS_USMALL(0, factorial * i))
        {
            // 一時バッファ内でまず多倍長整数に変換する
            big_factorial = uint_to_big(factorial, (Eterm *)src);
            goto bignum_loop;
        }
        factorial *= i;
    }

    if (temp_digits_a)
        erts_free(ERTS_ALC_T_TMP, temp_digits_a);
    if (temp_digits_b)
        erts_free(ERTS_ALC_T_TMP, temp_digits_b);

    BIF_RET(make_small(factorial));

bignum_loop:
    // 多倍長整数のまま乗算を続ける

    for (; i <= n; ++i)
    {
        curr_size = BIG_SIZE(big_val(big_factorial));
        new_size = curr_size + 1;
        if (new_size > temp_size)
        {
            dsize_t alloc_size = new_size * 2;
            src = (ErtsDigit *)erts_realloc(ERTS_ALC_T_TMP, src,
                                            alloc_size * sizeof(ErtsDigit));
            dest = (ErtsDigit *)erts_realloc(ERTS_ALC_T_TMP, dest,
                                             alloc_size * sizeof(ErtsDigit));
            temp_size = alloc_size;
        }

        big_factorial = big_times_small(big_factorial, i, (Eterm *)dest);

        if ((i % 1000) == 0)
        {
            // i % 1000 == 0のままループに留まらないよう、iをインクリメントしておく
            // 状態 {(i+1), i!, n} を保存してyieldする

            // 状態を保持するタプルをヒープ上に格納する
            big_size = BIG_SIZE(big_val(big_factorial));
            hp = HAlloc(BIF_P, 4 + big_size + 1);
            big_hp = hp + 4;
            sys_memcpy(big_hp, big_val(big_factorial), (big_size + 1) * sizeof(Eterm));
            state = TUPLE3(hp, make_small(i + 1), make_big(big_hp), tp[3]);

            // 一時バッファを解放する
            erts_free(ERTS_ALC_T_TMP, temp_digits_a);
            erts_free(ERTS_ALC_T_TMP, temp_digits_b);

            // 状態をtrap関数へ渡してyieldする
            BIF_TRAP1(&math_factorial_trap_export, BIF_P, state);
        }

        temp = src;
        src = dest;
        dest = temp;
    }

    hp = HAlloc(BIF_P, BIG_SIZE(big_val(big_factorial)) + 1);
    sys_memcpy(hp, big_val(big_factorial),
               (BIG_SIZE(big_val(big_factorial)) + 1) * sizeof(Eterm));
    result = make_big(hp);

    erts_free(ERTS_ALC_T_TMP, temp_digits_a);
    erts_free(ERTS_ALC_T_TMP, temp_digits_b);

    BIF_RET(result);
}

BIF_RETTYPE math_factorial_1(BIF_ALIST_1)
{
    Sint64 n;
    Eterm *hp;
    Eterm state;
    Eterm args[1];

    if (is_not_integer(BIF_ARG_1) || (!term_to_Sint64(BIF_ARG_1, &n)) || n < 0)
    {
        BIF_ERROR(BIF_P, BADARG);
    }

    // Create initial state
    hp = HAlloc(BIF_P, 4);
    state = TUPLE3(hp, make_small(1), make_small(1), BIF_ARG_1); // {i, factorial, n}

    // Call the continuation function with initial state
    args[0] = state;
    return math_factorial_trap_1(BIF_P, args, A__I);
}

void erts_init_math_factorial(void)
{
    erts_init_trap_export(&math_factorial_trap_export,
                          am_erts_internal, am_math_factorial_trap, 1,
                          &math_factorial_trap_1);
    return;
}

これで、BEAMのスケジューラをブロックすることなく、非常に大きな階乗を扱えるようになりました。

ある穏やかな夜の出来事:term_to_binaryとyieldしないBIF

それは穏やかな夜でした。システムのすべてが滞りなく動いているように見える、数少ない瞬間の一つでした。 ところが前触れもなく、あり得ないことが起こります。システムがクラッシュしたのです。

その後の調査で、奇妙な一連の出来事が明らかになりました。 仕事と休止のバランスを穏やかに取るよう設計されているはずのBEAMのスケジューラたちが、負荷が下がったときに休止に入るのを急ぎすぎていたのです。 唯一稼働し続けていた1つのスケジューラだけが、システムの処理すべてを引き受けることになっていました。

巡り合わせと言うべきか、その孤立したスケジューラは、ロギング用に4MBの項をバイナリへ変換するというタスクに行き当たります。 一見何でもないこの処理は、組み込み関数(BIF)であるterm_to_binaryを呼び出すものでした。 term_to_binaryは処理を扱いやすい単位に分割することなく、終わるまで一気に処理を行い、その上でリダクションをわずか20しか使っていないと報告してきました。

このyieldしない性質が、致命的な欠陥だったのです。 唯一残ったスケジューラは完全に占有され、他のタスクを処理することも、システムの要求に応えることもできなくなりました。 常に監視を続けているErlangのHEART機構はこの無応答を検知し、そのノードはもう救えないと判断します。 そして容赦なく、ノードを強制終了しました。

問題の修正

修正1:スケジューラを起こしたままにする

最初の一手は、負荷が低い時間帯でもスケジューラが目を覚ました状態を保つようにすることでした。 2013年6月に導入されたこの修正は、Erlangの起動フラグを調整するものです。

  • +sfwi 50:スケジューラの強制ウェイクアップ間隔を調整し、応答性を保つ。
  • +scl false:スケジューラの集約を無効にし、負荷の分散を均等に保つ。
  • +sub true:システム全体に利用率が行き渡るようにする。

これらの調整により、スケジューラは活動の停滞にも強くなりました。

スケジューラの設定調整について詳しくは、スケジューリングの章を参照してください。

修正2:term_to_binaryの書き直し

2つ目の、より込み入った修正は、term_to_binary関数そのものを対象にしたものでした。 開発者たちはこのBIFを、定期的にyieldして処理を複数のリダクションに分散させるよう書き直しました。 この方法により、関数はスケジューラとうまく折り合いをつけられるようになり、システムリソースを長時間独占することもなくなりました。

同じ時期に実装されたこの修正では、処理をチャンクに分けて行う仕組みが関数に導入されました。 これにはBIF内部でのガベージコレクションの進化も関わっています。 詳しくは該当のコミットを参照してください。

組み込み関数(BIF)のまとめ

ほとんどの開発者は、BIFのコードを書くことも読むこともないでしょう。 それでも、BIFが何であり、どう動くのかを知っておく価値はあります。 標準ライブラリのErlangコードを覗いてみたとき、実質的な処理が書かれておらずスタブしかない理由も、これでわかるはずです。

NIF

Erlangの開発者にとってもう少し身近で、より安全な作業が、NIF(Native Implemented Functions)を書くことです。 NIFはCやC++(あるいは他の言語)で実装され、Erlangのコードから呼び出される関数です。 性能が重要な処理や外部ライブラリとのインターフェースのために、ネイティブコードでErlangを動的に拡張する手段を提供します。 それでも誤った使い方をすればVMを不安定にしうるため、注意して使う必要があります。

NIFの使い方は、今日では相互運用性チュートリアルNIFの節によく整理されていますが、ここでは基本を扱います。

単純なNIFの実装

整数の高速な加算を行う、Cベースの単純なNIFを示します。

Cのコード(nif_add.c):

#include "erl_nif.h"

static ERL_NIF_TERM nif_add(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[])
{
    int a, b;
    if (!enif_get_int(env, argv[0], &a) || !enif_get_int(env, argv[1], &b)) {
        return enif_make_badarg(env);
    }
    return enif_make_int(env, a + b);
}

static ErlNifFunc nif_funcs[] = {
    {"add", 2, nif_add}
};

ERL_NIF_INIT(my_nif, nif_funcs, NULL, NULL, NULL, NULL);

Erlangのラッパーモジュール(my_nif.erl):

-module(my_nif).
-export([add/2, load/0]).

-on_load(load/0).

load() ->
    erlang:load_nif("./my_nif", 0).

add(_A, _B) ->
    erlang:nif_error("NIF not loaded").

NIFのコンパイルと実行:

gcc -shared -fPIC -o my_nif.so nif_add.c -I /usr/lib/erlang/usr/include

Erlang側での実行:

> my_nif:add(10, 20).
30

BEAMのスケジューラをブロックしないよう、NIFの実行は短く保ってください。 NIFは、性能が重要な処理や外部ライブラリとのインターフェースに使います。 実行時間の長い処理にはダーティスケジューラを使ってください。