はじめに
GenServerの動作ってどうなっているのか、と元のErlangのコードを調べてみて、その内容と「GenServerって何だ」との自分になり理解した事を記述します。
GenServer(Generic Server)って何
読み込んだり、書き込んだり、また、外部に通知する機能を持ちデータを管理するサーバー(データを提供する人)との事。
そのサーバーの機能を一般化(generic)し、使い易いようにしたもので、上記3つの機能をapp側で作る必要がないようになっている。
ただ、app側から見るとcallback関数ばかりとなり、動きがよくわからず、戸惑う事が多い気がする。
実際には
defmodule Stack do
use GenServer
- - -
end
上記Stack
モジュールには隠れているタスク(init_it())とAPI関数の処理があり、そこからcallbackされる事により動作している。use GenServer
の定義により、そのモジュール自体(Stack)がGenServerの機能を持つ事となる。
データの型(state)はなんでも良く、各callback関数内で更新される事となる。逆にcallback関数内で型を随時変更可能となる。データはどこかに保存されるものでなく、関数の引数として、保持されている。
GenServerの関数
主な関数として以下のものがある。
- 起動:GenServer.start_link(module, init_arg, options \ [])
GenServerのタスクを起動し、init()をcallbackする - データ取得(返信あり):GenServer.call(server, request, timeout \ 5000)
上記タスクにメッセージを送信し、handle_call
が呼ばれ、応答待ち解放により戻る - データ更新(返信なし):GenServer.cast(server, request)
上記タスクにメッセージを送信し、handle_cast
が呼ばれる - 情報通知(GenServerへの情報通知) handle_info()
上記タスクがメッセージを受けた時にcallbackを呼ぶ
GenServerの使用例
defmodule Stack do
use GenServer
# Client ラッパー
def start_link(state) when is_list(state) do
GenServer.start_link(__MODULE__, state, name: :stack) # pidを:stackに名付ける
end
def push(element) do # pid=:stack
GenServer.cast(:stack, {:push, element})
end
def pop() do # pid=:stack
GenServer.call(:stack, :pop)
end
# Server (callbacks)
@impl true
def init(stack) do # start_link()のcallback
{:ok, stack}
end
@impl true
def handle_call(:pop, _from, [head | tail]) do # GenServer.callのcallback
{:reply, head, tail}
end
@impl true
def handle_cast({:push, element}, state) do # GenServer.castのcallback
{:noreply, [element | state]}
end
end
ラッパー関数を準備するのが一般的
次のように直接GenServerを呼べば動作する。
iex> GenServer.start_link(Stack, [], name: :stack)
{:ok, #PID<0.153.0>}
iex> GenServer.cast(:stack, {:push, "first"})
:ok
iex> GenServer.call(:stack. :pop)
"first"
このように直接でも問題はないが、一般的にラッパー関数(上記例、start_link(),push(),pop())を準備する事が多い。
ちなみに、cast()、call()はpidを渡すが、ここでは起動時にname: :stack
の設定より、pidと:stack
が関連付けられ、:stack
が使用できるようにしている。
個人的には違うNodeからremoteで使用する事が多いので、あまりこのラッパーを準備する事はしていない。
start_link()の動き
- start_link(module, init_arg, option)によりmodule内にGenServerの機能が準備される。
- 内部のタスク
init_it()
を起動する。その時のinit_argが初期のデータ(state)となる。 - 起動された内部のタスクからcallback関数、init()が呼ばれる。引数はstateとなり、データを渡す。
- init()callback関数から戻る時に
{:ok, state}
とデータが戻り、データ更新される。このinit()関数は無くても、defaultとして準備されている。 - init()が呼ばれ、内部のタスクの処理が終了した時点で受信応答待ちとなっているapiにackとしてmsg送信する。
- start_link()のreturnとして、
{:ok, pid}
を戻す事により、内部のタスクのpidがわかる。
call()の動き
-
call(pid<server>, request, timeout)
によりapi関数に通知する。 - 内部タスクに
request
のmsgを送信する。この時、$gen_call
の情報付加し、内部タスクがcall関数である事を認識する。そして、api関数内で応答待ち(receive)となる。 - 内部タスクからcallback関数、handle_call()を呼ぶ。引数
(request, from, state)
、このrequest情報により、どのcallbackなのか、判定されている。 - 戻り、
{:reply, reply, new_state}
、により応答送信、戻り値、データ更新する。 - api関数内で応答待ちに対して、応答データ(reply)とともにmsg送信する
- api関数はデータ(reply)を戻す。
cast()の動き
-
cast(pid<server>, request)
により情報をapi関数に渡す - 内部タスク(pid)に対して、
request
に$gen_cast
を付加したmsgを送信する。 - castは応答待ちをせず、そのまま戻る。
- 内部タスクはmsgを受けるとcallback関数、
handle_cast()
を{requst, state}
で呼ぶ、request
の情報によりどのhandle_cast()かが判定される。 - callback関数から戻り
{:noreply,new_state}
により新しいデータを更新する。
情報通知(GenServerへの情報通知)の動き
- callback関数から内部タスク(pid)にmsg送信する。
- 他の処理から内部タスク(pid)にmsg送信する。
- 内部タスクはcallback関数、handle_info()をmsg受信により呼ぶ。
{msg, state}
- handle_info()からの戻り、
{:noreply, new_state}
によりデータ更新する。
callback
関数内でのreceiveのようなwait処理は禁止
内部のタスク内からcallback関数が呼ばれているので、関数内でwait処理があるとGenServerの動きが壊される。そのために、callback関数は素直に戻る必要がある。
GenServer.start_link()、call()などの関数内でwaitしているものがあるので、その点注意が必要となる。
まとめ
これまで、正常動作の処理を記述したが、エラー処理やtimeout処理、そして、start()、start_link()の違いなどのoptionよる動作があり、複雑に絡んでいる。
erlangのコードを見ていた結果、理解するのに困難な代物で、二度と見たくないと思わされるものだった。(感想)
この資料、GenServer
を使う上での参考になれば幸いです。