無題の備忘録

IT技術について調べたことや学んだこと、試したこと記録するブログです。Erlang、ネットワーク、 セキュリティ、Linux関係のものが多いです。

Erlang の gen_server ビヘイビアを学ぶ

Erlang/OTP の gen_server ビヘイビアについて学びたいと思います。

クライアント・サーバの原則

クライアントサーバーモデルは、複数の異なるクライアントで共有するリソース管理に使用されます。サーバーは、このリソースの管理を担当します。

f:id:storkibis:20191014151228p:plain

図の実線はQuery, 破線はReplyを表す。

サンプルコード

gen_server のサンプルコードとして sample_queue.erl というファイル名で下記のコードを作成します。 このコードは、gen_server のビヘイビアを使って、キューの機能を提供します。sample_queue.erl モジュールは、gen_server のコールバックモジュールです。

in/1関数で gen_server にアイテムを登録して、out/0関数で gen_server からアイテムを取得します。

ここで、start_link/0, in/1, out/0 関数はインターフェース関数と呼ばれます。init/1, handle_call/3, handle_cast/2関数はコールバック関数と呼ばれます。インターフェース関数とコールバック関数は同じモジュールに配置されます。一般的に、1つのプロセスに対応するコードは1つのモジュールにまとめられます。

ただし、インターフェース関数の実行は gen_server を起動したり、gen_server にリクエストを送信する Erlang プロセス側で実行されます。ここでのコールバック関数は、gen_server として起動する Erlang プロセス側で実行されるものであることに注意してください。

-module(sample_queue).
-behaviour(gen_server).

-export([start_link/0, in/1, out/0]).
-export([init/1, handle_call/3, handle_cast/2]).

start_link() ->
    gen_server:start_link({local, ?MODULE}, ?MODULE, [], []).

in(Item) ->
    gen_server:cast(?MODULE, {in, Item}).

out() ->
    gen_server:call(?MODULE, out).


init(_Args) ->
    {ok, queue:new()}.

handle_call(out, _From, Queue) ->
    case queue:out(Queue) of
        {{value, Item}, NewQueue} -> {reply, Item, NewQueue};
        {empty, NewQueue} -> {reply, empty, NewQueue}
    end.

handle_cast({in, Item}, Queue) ->
    NewQueue = queue:in(Item, Queue),
    {noreply, NewQueue}.

下記は使用例です。下記では、sample_queue モジュールの gen_server を起動し、その後、sample_queue モジュールのAPIを使って、a, b, c というアイテムを登録し、その後は逆にキューのアイテムを空になるまで取り出しています。

> c(sample_queue).
{ok,sample_queue}
> sample_queue:start_link().
{ok,<0.68.0>}
> sample_queue:in(a).
ok
> sample_queue:in(b).
ok
> sample_queue:in(c).
ok
> sample_queue:out(). 
a
> sample_queue:out().
b
> sample_queue:out().
c
> sample_queue:out().
empty

gen_server の起動

サンプルコードの start_link/0 を呼び出すことで gen_server が起動します。

start_link() ->
    gen_server:start_link({local, ?MODULE}, ?MODULE, [], []).

start_link/0gen_server:start_link/4 関数を呼び出します。この関数は gen_server として新しいプロセスを spawn し、そのプロセスに link します。

最初の引数 {local, ?MODULE} は gen_server プロセスの名前を指定します。この gen_server はローカルのErlangノードに ?MODULE として登録されます。?MODULEはモジュール名を表します。つまり、この例では sample_queue です。

名前を省略すると、gen_server の名前は登録されません。代わりに、その pid を使用する必要があります。名前は{global, Name}としても指定できます。この場合、gen_server は global:register_name/2 を使用して登録されます。

第2の引数も ?MOUDLE です。これは gen_server のコールバックモジュールの名前を指定します。たいてい start_link関数をコールバックモジュールに記述するので、そのモジュール自身の名前が使われます。

第3の引数 []は、コールバック関数 init/1 関数にそのまま渡される1つの erlang term です。今回のサンプルコードでは、データを必要としないので単に無視しています。

第4の引数は[]は、オプションのリストです。ここでは、gen_server の概要を掴みたいので、オプションには触れません。

gen_server プロセスの名前の登録が成功すると、新しい gen_server プロセスはコールバック関数 sample_queue:init([]) を呼び出します。 init は {ok, State} を返すことが期待されます。State は gen_server の内部状態です。 今回の場合は空の queue です。

init(_Args) ->
    {ok, queue:new()}.

gen_server:start_link は同期的に実行されます。 gen_server が init 関数によって初期化され、リクエストを受信する準備ができるまで戻りません。

gen_server が監視ツリーの一部である場合、つまりスーパーバイザーによって開始された場合は、gen_server:start_linkを使用しなければなりません。 これとは別に、スタンドアロンの gen_server を起動する gen_server:start があります。この関数で起動した gen_server は、監視ツリーには含まれません。

同期リクエスト - call

同期リクエストの out/0 は、 gen_server:call/2 を使用して実装されています。同期リクエストではリクエストに対して gen_server からレスポンスが返ってきます。

out() ->
    gen_server:call(?MODULE, out).

?MODULE はモジュール名であり、これが gen_server の名前になっています。サンプルコードでは sample_queue という atom です。これは gen_server の起動に使用される名前と一致する必要があります。 out が gen_server への実際のリクエストです。

リクエストはメッセージになり、?MODULE (sample_queue) という名前の gen_server に送信されます。 リクエストを受信すると、gen_server は handle_call(Request, From, State) を呼び出します。これは、タプル{reply, Reply, NewState} を返すことが期待されています。 Reply はクライアントに送り返されるレスポンスであり、NewState は gen_server の内部状態の新しい値です。

handle_call(out, _From, Queue) ->
    case queue:out(Queue) of
        {{value, Item}, NewQueue} -> {reply, Item, NewQueue};
        {empty, NewQueue} -> {reply, empty, NewQueue}
    end.

この場合、out リクエストを受け取る handle_call/3 関数の応答は、QueueItemがあればそのItemを返し、なければ emptyを返します。新しい状態はQueueからItemを削除したキュー NewQueue です。

したがって、queue_sample:out/1 の呼び出しは、Item または empty を返し、gen_server は新しいリクエストを待機します。待機中の gen_server の内部状態はリクエストに含まれていたアイテムが削除されたキューで更新されています。

非同期リクエスト - cast

非同期リクエストの in/1 は、 gen_server:cast/2 を使用して実装されています。非同期リクエストではリクエストに対して gen_server からレスポンスは返って来ません。単に ok が返ってきます。

in(Item) ->
    gen_server:cast(?MODULE, {in, Item}).

?MODULE はモジュール名であり、これが gen_server の名前になっています。サンプルコードでは sample_queue という atom です。{in, Item} が gen_server への実際のリクエストです。

リクエストはメッセージになり、gen_server のプロセス送信すると、okが返ってきます。この ok は gen_server のプロセスのレスポンスではなく、単に gen_server:cast 関数が ok を返しているだけで、リクエストの結果ではないことに注意してください。また、メッセージを送信しただけで、gen_server がメッセージを受け取ったかどうかも保証されません。

gen_server のプロエスはリクエストを受信すると、handle_cast(Request, State) を呼び出します。これは、タプル{noreply, NewState} を返すことが期待されています。 NewState は、gen_serverの内部状態の新しい値です。

handle_cast({in, Item}, Queue) ->
    NewQueue = queue:in(Item, Queue),
    {noreply, NewQueue}.

この場合、新しい状態は Queue にリクエストに含まれる Item を登録した新しいキュー NewQueue です。これで、 gen_server プロセスは内部状態のキューに Item を登録しました。

停止

監視ツリーでの停止

gen_server が監視ツリーの一部である場合、停止機能は必要ありません。gen_serverは、スーパーバイザーによって自動的に終了します。これがどのように行われるかは、スーパーバイザーに設定されたシャットダウン戦略によって定義されます。

終了前にクリーンアップする必要がある場合は、シャットダウン戦略にタイムアウト値にし、関数initで終了シグナルを補足できるように gen_server を設定する必要があります。 シャットダウンするように命令されると、gen_server はコールバック関数 terminate(shutdown, State) を呼び出します。この teminate/2 関数に後始末をする処理を記述します。

init(Args) ->
    ...,
    process_flag(trap_exit, true),
    ...,
    {ok, State}.

...

terminate(shutdown, State) ->
    ..code for cleaning up here..
    ok.

スタンドアローンでの停止

もし、gen_server が監視ツリーの一部でなかった場合には、 stop 関数が役に立ちます。

...
export([stop/0]).
...

stop() ->
    gen_server:cast(?MODULE, stop).
...

handle_cast(stop, State) ->
    ... create new state
    {stop, normal, NewState};
handle_cast({in, Item}, Queue) ->
    ....

...

terminate(normal, NewState) ->
    ..code for cleaning up here..
    ok.

停止リクエストを処理するコールバック関数は、タプル {stop, normal, NewState} を返します。ここで、normal は正常終了であり、NewState はgen_server の状態の新しい値です。 これにより、gen_serverは terminate(normal, NewState) を呼び出してから、正常に終了します。

他のメッセージの扱い

gen_server がリクエスト以外のメッセージを受信できるようにする場合、コールバック関数 handle_info(Info, State) を実装してそれらを処理することができます。 他のメッセージの例は、gen_server が他のプロセス(スーパーバイザーではない)にリンクされていて、終了シグナルをトラップしている場合、そのプロセスの終了メッセージです。

handle_info({'EXIT', Pid, Reason}, State) ->
    ..code to handle other process exits here..
    {noreply, NewState}.

以上で、おおよその gen_server の実装イメージを把握しました。