はじめてなElixir(15) で GenServer での並行プログラミングに初チャレンジしました。プログラミングElixirにはもう少し GenServer ネタがあるので、やってみます。
コールバックとは
GenServer まで行くと「コールバック」と言う単語が頻出します。これ Node.js とかやってると「コールバック地獄」という言葉があるぐらいです。ピンとこないので改めて調べてみました。
https://ja.wikipedia.org/wiki/コールバック
https://ja.wikipedia.org/wiki/コールバック_(情報工学)
私、前者の気分で読んでたのでピンと来なかったんですね。sender 側が自分の pid を receiver 側に渡したりするので、その往復のやりとりにイメージをマップしてましたが、ありていに言って後者の方がしっくりきます。実際プログラミング上のコールバックの説明に前者を使ってる例が web ではいくつも散見されます。
にしても、関数(かそれのポインタ)を渡して、どこにでも飛んでいくならバックしてくるとは限りません。「コールどっか」か「コールあっち」がふさわしい言葉じゃないかと。
あれ? OTP を使っていたの?
さて、うろ覚えの浅い理解で GenServer 使ったので spawn シリーズに「またもうひとつの並行プロセスのやりかた」が増えたぐらいな理解をしてたんですが、違いました。これプログラミングElixirの16章をちゃんと読めば書いてありまして、ツマミ食いしながら読んでるのでこんなことになります。以下は、なんでそれに気づいたかを書いてみます。
GenServer で子プロセスの状態を親に通知したい
GenServer によるプロセスがただの子プロセスの生成でないことは、こんなことで気が付きました。
# 一部を抜き出し
def init(initial_temp) do
{:ok, [initial_temp]}
end
前回はこんな風に初期化して保持する状態が空リストにならないようにしてました。でも、実際のプログラムではプロセスができたときに与えるべき初期値がない場合もありえます。センサ類でいうならプロセスができた瞬間にはまだ最初の値をゲットできてないような状況が考えられます。
- そんなタコな呼び出し方をしないように十分に気をつける
- 害がないような適当な初期値を与えておく
- 初期値もないようなタイミングで call されたかどうかの状態も合わせて返す
- 初期値がないときに call されると exit する
- 例外を発生する
ざっとこんな対策が思いつきます。一番最初のはそんなことできっこない(これの積み重ねがバグになる)のでなんとしても避けます。2番目が前回やったことであり、後の2つは、その程度のことで大騒ぎするなと言うところなので、結局「どうゆう状況で呼ばれてるのかも合わせて返す」というところに落ち着きます。ではどうやって返しましょうか。
- ちゃんと返すときは数値を、そうでないときは nil を返す
- ok: ng: をタプルにして返す
- 親プロセスに reply: と返しているところを別のアトム error-reply: とかを返す
元 lisp/scheme 屋の私としては最初のでもあまり違和感ないのですが、現代的な作法としてはあまり推奨されてる気がしません。Elixir 的には 2番目でしょう。でもアトム返すなら最後のでも良いのではないかと思えます。
パターンマッチは先に来た行から行われる
「ちゃんと返すときは数値を、そうでないときは nil を返す」ようなサンプルプログラムで考えてみましょう。ちょっと脱線します。
defmodule Temp7 do
use GenServer
def init(_void) do
{:ok, []}
end
def handle_cast(:next, temp_list) do
{:noreply, [hd(temp_list) + 1 | temp_list]}
end
def handle_cast(:next, []) do
{:noreply, [0]}
end
def handle_call(:last, _from, [head | tail]) do
{:reply, head, [head | tail]}
end
def handle_call(:last, _from, []) do
{:reply, [], []}
end
end
この簡単なプログラム、コンパイラから警告を受けます。
iex(1)> c "temp7.ex"
warning: this clause cannot match because a previous clause at line 8 always matches
temp7.ex:13
[Temp7]
def handle_cast(:next, temp_list) do
で必ずマッチしてしまうので、空リストでマッチさせたい場合の def handle_cast(:next, []) do
には絶対に制御が渡ることがないという警告です。これを防ぐには二通りのやりかたがあります。
まず、受け取るデータ構造を明示する手があります。こう書いておけば空リストで先の選択肢にマッチすることはありません。
def handle_cast(:next, [head | tail]) do
{:noreply, [head + 1 | [head | tail]]}
end
def handle_cast(:next, []) do
{:noreply, [0]}
end
もうひとつのやり方は順番を変える方法です。
def handle_cast(:next, []) do
{:noreply, [0]}
end
def handle_cast(:next, temp_list) do
{:noreply, [hd(temp_list) + 1 | temp_list]}
end
先に空リストかどうかの判定がはいります。後者は宣言的な喜寿ではないですし、それに前者のほうがElixirっぽいですね。両方をいっぺんに採用して以下としました。
defmodule Temp7 do
use GenServer
def init(_void) do
{:ok, []}
end
def handle_cast(:next, []) do
{:noreply, [0]}
end
def handle_cast(:next, [head | tail]) do
{:noreply, [head + 1 | [head | tail]]}
end
def handle_call(:last, _from, []) do
{:reply, [], []}
end
def handle_call(:last, _from, [head | tail]) do
{:reply, head, [head | tail]}
end
end
これを動かすとこんな風になります。
iex(8)> {:ok, pid} = GenServer.start_link(Temp7, nil)
{:ok, #PID<0.114.0>}
iex(9)> GenServer.call(pid, :last)
[]
iex(10)> GenServer.cast(pid, :next)
:ok
iex(11)> GenServer.call(pid, :last)
0
iex(12)> GenServer.cast(pid, :next)
:ok
iex(13)> GenServer.call(pid, :last)
1
:reply の代わりに他のアトムを使ってみる
受け取る方は nil かどうかで判定することになりますが、それはいかがなものかという向きもあろうかと思いますので、明示的に良いかどうかを判定するアトムを返すようにしましょう。:reply
を返すところに :error_reply
を入れてみます。
def handle_call(:last, _from, []) do
{:error_reply, [], []}
end
全体としてはこうなります。
defmodule Temp8 do
use GenServer
def init(_void) do
{:ok, []}
end
def handle_cast(:next, []) do
{:noreply, [0]}
end
def handle_cast(:next, [head | tail]) do
{:noreply, [head + 1 | [head | tail]]}
end
def handle_call(:last, _from, []) do
{:error_reply, [], []}
end
def handle_call(:last, _from, [head | tail]) do
{:reply, head, [head | tail]}
end
end
これを実行すると ** (stop) bad return value: {:error_reply, [], []}
と叫んでプロセスを終了してしまいます。
iex(1)> {:ok, pid} = GenServer.start_link(Temp8, nil)
{:ok, #PID<0.106.0>}
iex(2)> GenServer.call(pid, :last)
18:56:21.155 [error] GenServer #PID<0.106.0> terminating
** (stop) bad return value: {:error_reply, [], []}
Last message (from #PID<0.104.0>): :last
State: []
Client #PID<0.104.0> is alive
(stdlib) gen.erl:169: :gen.do_call/4
(elixir) lib/gen_server.ex:921: GenServer.call/3
(stdlib) erl_eval.erl:680: :erl_eval.do_apply/6
(elixir) src/elixir.erl:265: :elixir.eval_forms/4
(iex) lib/iex/evaluator.ex:249: IEx.Evaluator.handle_eval/5
(iex) lib/iex/evaluator.ex:229: IEx.Evaluator.do_eval/3
(iex) lib/iex/evaluator.ex:207: IEx.Evaluator.eval/3
(iex) lib/iex/evaluator.ex:94: IEx.Evaluator.loop/1
** (EXIT from #PID<0.104.0>) shell process exited with reason: bad return value: {:error_reply, [], []}
Interactive Elixir (1.7.3) - press Ctrl+C to exit (type h() ENTER for help)
iex(1)>
誰かが :error_reply
と返されることを予期していません。怒ってるのは誰でしょう? そもそもこれをやる前から分かってたことですが、:reply
や :noreply
は明示的に受け取ってません。誰が受け取ってたのでしょうか。そう言えば子プロセスの反応がないときに 5000ms でタイムアウトが通知されていました。一体これは誰が…
はい OTP がいたんですね。OTP を使うための準備をしていたつもりですが、使ってしまっていたんですね。
つまり「:reply :noreply は OTP に対する反応であって、プロセス間で使う値ではありません」ということでした。
明示的にエラーを伝える
さて、では :reply を触ってはならないということで、返り値をタプルにしてプロセスの状態を返すようにします。大変普通なプログラムになりました。
defmodule Temp9 do
use GenServer
def init(_void) do
{:ok, []}
end
def handle_cast(:next, []) do
{:noreply, [0]}
end
def handle_cast(:next, [head | tail]) do
{:noreply, [head + 1 | [head | tail]]}
end
def handle_call(:last, _from, []) do
{:reply, {:not_ready, []}, []}
end
def handle_call(:last, _from, [head | tail]) do
{:reply, {:ok, head}, [head | tail]}
end
end
ちょっと大げさな感じですが健全に動きます。
iex(1)> {:ok, pid} = GenServer.start_link(Temp9, nil)
{:ok, #PID<0.106.0>}
iex(2)> GenServer.call(pid, :last)
{:not_ready, []}
iex(3)> GenServer.cast(pid, :next)
:ok
iex(4)> GenServer.call(pid, :last)
{:ok, 0}
iex(5)> GenServer.cast(pid, :next)
:ok
iex(6)> GenServer.call(pid, :last)
{:ok, 1}
iex(7)>
荒業:放置してタイムアウトさせる
前回のお試しで「call して値を返さないとどうなるか」をやりました。これを積極的に使ってみます。
def handle_call(:last, _from, []) do
{:noreply, []}
end
このように call なのに値を返さない動きをプログラムします。全体ではこうなります。
defmodule Temp10 do
use GenServer
def init(_void) do
{:ok, []}
end
def handle_cast(:next, []) do
{:noreply, [0]}
end
def handle_cast(:next, [head | tail]) do
{:noreply, [head + 1 | [head | tail]]}
end
def handle_call(:last, _from, []) do
{:noreply, []}
end
def handle_call(:last, _from, [head | tail]) do
{:reply, head, [head | tail]}
end
end
これを実行してみます。
iex(1)> {:ok, pid} = GenServer.start_link(Temp10, nil)
{:ok, #PID<0.106.0>}
iex(2)> GenServer.call(pid, :last)
** (exit) exited in: GenServer.call(#PID<0.106.0>, :last, 5000)
** (EXIT) time out
(elixir) lib/gen_server.ex:924: GenServer.call/3
iex(2)> GenServer.cast(pid, :next)
:ok
iex(3)> GenServer.call(pid, :last)
0
iex(4)> GenServer.cast(pid, :next)
:ok
iex(5)> GenServer.call(pid, :last)
1
iex(6)>
想定通り 5000ms 秒でタイムアウトが通知されます。ここで、元の子プロセスはというと継続して動いてます。続けて cast や call するとちゃんと動きます。
親プロセスとしてみれば、何らかの原因で子プロセスからの返事がないことは予期できますから、それに対処するプログラミングをしそうに思います。ですので、気に食わない問い合わせを受けた子プロセスが「ダンマリを決め込む」のもプログラミングとしてはありかなと思いました。
こういう作った側が想定してない使い方ができるのに気づくとなかなか楽しいですね。
参考文献
プログラミングElixir(特に16章 OTP : サーバ)