6
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

はじめてなElixir(15) もっとラクに並行プログラミングする(あるいは はじめてな GenServer)

Last updated at Posted at 2018-10-05

寝る前にやると頭が冴えて眠りの質が悪化しそうだし、翌日の仕事が堪えるので平日の夜中は避けてますよ elixir プログラミング。でも今日は ♪明日〜は土曜日、明日〜は土曜日、天気は大荒れ、何しよお〜。夜更かししていい金曜日 (by 2355@NHK)♪ でございます。

はじめてなElixir(11)はじめてなElixir(12)はじめてなElixir(14) と平行プロセスを作って遊びました。一度突き詰めてしまいたいです。というところで「プログラミングElixir」を読むと次の章に OTP が来てるじゃありませんか。行きましょう、行きましょう。なんで Elixir かって、これがあるのが大きいんだから。

OTP の前に GenServer をやる

プログラミングElixir の16.2節を読むとこんなことが書いてあります。

前の章でフィボナッチサーバを書いたとき、メッセージハンドリングをすべて自前で実装しなければならなかった。難しくはないが、退屈だ。

spawn_link も spawn_monitor もやさしくもなければ退屈でもなかったです。しかし、アクター間でメッセージパッシングするという能書きの割には確かに面倒な印象を持ちました。記述もすっきりしないし「毎度プロセス使うたびにこれか」という印象がありました。
もう少し読み進むと今日のテーマが出てきました。GenServer です。OTPの前にまずはこっちですね。

GenServer を使ってみる

何が適当かなとwebを漁って 最速で知る! ElixirプログラミングとErlang/OTPの始め方 が良さそうだなと、これを使って考えてみることにしました。これいきなり平行プロセスに突入する入門用ドキュメントです。hello world 出した次に使う関数が self() ですよ、あーた。

GenServer であそぶには同期用と非同期用の機能があると楽しそうなので、はじめてなElixir(13) を流用してこういうなんちゃって温度センサ風な仕様にしてみました。

  • 温度センサモジュールは定期的にPT100で温度を取得する
  • PT100の抵抗値を図るのにAD変換が10sかかる
  • 温度センサモジュールは電源が入ってから計測した温度データをリストにして持ち続けている
  • 温度センサは稀に壊れてモジュールごとオシャカになる

なあんてのを模倣するのにこんな風にしてみます。

  • 独立したプロセスとして起動されて、そのときに計測リストが空リストになる
  • 10000msタイマを持ち、タイマによりそれらしい値を温度値とする
    • 計測された値は計測リストの先頭要素になる
    • ただし0番目の値は起動時に指定されるものとする
  • プロセスは以下の問い合わせに応答する
    • 直近の計測値を返す
    • 今計測してる値を返す
    • これまでの計測リストを返す
  • プロセスはある確率で異常終了する

モジュールを書いてみる

では早速書いてみましょう。と思ったら上のままではサラッと書きにくいので、簡略にします。

  • 独立したプロセスとして起動されて、そのときに計測リストが初期値のみのリストになる
  • プロセスは以下の問い合わせに応答する
    • :last 計測リストから直近の計測値を返す
    • :next 新しい計測値を測定して返すとともに計測リストの先頭に加える
      • 新しい計測値は直近の計測値 + 1.0 とする
    • :list これまでの計測リストを返す
    • :flush 計測リストを最新の値のみにする

この簡単なプロセスを GenServer で書くとこうなります。

temp3.ex
defmodule Temp3 do
  use GenServer

  def init(initial_temp) do
    {:ok, [initial_temp]}
  end

  def handle_call(:next, _from, temp_list) do
    temp = hd(temp_list) + 1.0
    {:reply, temp, [temp | temp_list]}
  end

  def handle_call(:last, _from, [head | tail]) do
    {:reply, head, [head | tail]}
  end

  def handle_call(:list, _from, list) do
    {:reply, list, list}
  end

  def handle_cast(:flush, [head | _tail]) do
    {:noreply, [head]}
  end

  def test_sensor() do
    {:ok, pid} = GenServer.start_link(Temp3, 0.0)
    IO.inspect(GenServer.call(pid, :last))
    IO.inspect(GenServer.call(pid, :next))
    IO.inspect(GenServer.call(pid, :last))
    IO.inspect(GenServer.call(pid, :next))
    IO.inspect(GenServer.call(pid, :last))
    IO.inspect(GenServer.call(pid, :next))
    IO.inspect(GenServer.call(pid, :last))
    IO.inspect(GenServer.call(pid, :list))
    GenServer.cast(pid, :flush)
    IO.inspect(GenServer.call(pid, :list))
    nil
  end
end
iex(1)> Temp3.test_sensor()
0.0
1.0
1.0
2.0
2.0
3.0
3.0
[3.0, 2.0, 1.0, 0.0]
[3.0]
nil
iex(2)> 

いやあ、簡単に書けました。いや、正確に言うと書くのにはちょっと骨がいったのですが、出来上がりがシンプルになりました。

同期で値を返さない/非同期で値を返す… のはできるのか

書き間違いのときに以下の作法に気が付きました。

  • handle_call のときは {:reply, 返り値、維持する状態} で応答する
  • handle_cast のときは {:noreply, 維持する状態} で応答はしない

ではこれ、こんな風にかけるのでしょうか。

  • handle_call のときに {:noreply, 維持する状態} で応答しない
  • handle_cast のときは {:reply, 返り値、維持する状態} で応答する

同期だけど値を返さないことは可能か

handle_call のときに {:noreply, 維持する状態} で応答する、のをやってみます。

  def handle_cast(:flush, [head | _tail]) do
    {:noreply, [head]}
  end

↑を↓に変更してみましょう。

  def handle_call(:flush, _from, [head | _tail]) do
    {:noreply, [head]}
  end

こんな風になります。値がかえらないので IO.inspect は外します。

temp4.ex
defmodule Temp4 do
  use GenServer

  def init(initial_temp) do
    {:ok, [initial_temp]}
  end

  def handle_call(:next, _from, temp_list) do
    temp = hd(temp_list) + 1.0
    {:reply, temp, [temp | temp_list]}
  end

  def handle_call(:last, _from, [head | tail]) do
    {:reply, head, [head | tail]}
  end

  def handle_call(:list, _from, list) do
    {:reply, list, list}
  end

  def handle_call(:flush, _from, [head | _tail]) do
    {:noreply, [head]}
  end

  def test_sensor() do
    {:ok, pid} = GenServer.start_link(Temp4, 0.0)
    IO.inspect(GenServer.call(pid, :last))
    IO.inspect(GenServer.call(pid, :next))
    IO.inspect(GenServer.call(pid, :last))
    IO.inspect(GenServer.call(pid, :next))
    IO.inspect(GenServer.call(pid, :last))
    IO.inspect(GenServer.call(pid, :next))
    IO.inspect(GenServer.call(pid, :last))
    IO.inspect(GenServer.call(pid, :list))
    GenServer.call(pid, :flush)             # ここ
    nil
  end
end

これを実行してみます。

iex(1)> Temp4.test_sensor()
0.0
1.0
1.0
2.0
2.0
3.0
3.0
[3.0, 2.0, 1.0, 0.0]
** (exit) exited in: GenServer.call(#PID<0.106.0>, :flush, 5000)
    ** (EXIT) time out
    (elixir) lib/gen_server.ex:924: GenServer.call/3
    temp4.ex:35: Temp4.test_sensor/0
iex(1)> 

これ [3.0, 2.0, 1.0, 0.0] を出力してからややしばらく何も起こらず、最後に exit してしまいます。同期してるのに値が返ってこないので呼び出した側がずっと待っている状態になるんですね。ご丁寧に 5000ms でタイムアウトするように仕掛けてあるようです。

非同期だけど値を返すことは可能か

  def handle_call(:next, _from, temp_list) do
    temp = hd(temp_list) + 1.0
    {:reply, temp, [temp | temp_list]}
  end

↑を↓に変更してみましょう。

  def handle_cast(:next, temp_list) do
    temp = hd(temp_list) + 1.0
    {:reply, temp, [temp | temp_list]}
  end

プログラム全体はこうなります。

temp5.ex
defmodule Temp5 do
  use GenServer

  def init(initial_temp) do
    {:ok, [initial_temp]}
  end

  def handle_cast(:next, temp_list) do
    temp = hd(temp_list) + 1.0
    {:reply, temp, [temp | temp_list]}
  end

  def handle_call(:last, _from, [head | tail]) do
    {:reply, head, [head | tail]}
  end

  def handle_call(:list, _from, list) do
    {:reply, list, list}
  end

  def handle_cast(:flush, [head | _tail]) do
    {:noreply, [head]}
  end
end

これ、ワタシ的には意外なところでひっかかりました。コンパイルで注意されるのです。

iex(1)> c "temp5.ex"
warning: clauses with the same name and arity (number of arguments) should be grouped together, "def handle_cast/2" was previously defined (temp5.ex:8)
  temp5.ex:21

[Temp5]
iex(2)> 

これ「同じ関数名で同じ数の引数は一緒にしとけ」って言ってます。具体的にここでは
def handle_cast(:next, temp_list) do
def handle_cast(:flush, [head | _tail]) do
が近くにないと文句を言ってます。

ならばと、こうしてみると warning は出なくなります。へえ。

temp6.ex
defmodule Temp6 do
  use GenServer

  def init(initial_temp) do
    {:ok, [initial_temp]}
  end

  def handle_call(:last, _from, [head | tail]) do
    {:reply, head, [head | tail]}
  end

  def handle_call(:list, _from, list) do
    {:reply, list, list}
  end

  def handle_cast(:next, temp_list) do
    temp = hd(temp_list) + 1.0
    {:reply, temp, [temp | temp_list]}
  end

  def handle_cast(:flush, [head | _tail]) do
    {:noreply, [head]}
  end

ではテストしてみます。今度は1行ごと手でテスト行を入れてみます。

iex(1)> c "temp6.ex"
[Temp6]
iex(2)> {:ok, pid} = GenServer.start_link(Temp6, 0.0)
{:ok, #PID<0.108.0>}
iex(3)> GenServer.cast(pid, :next)
:ok
iex(4)> 
01:18:00.337 [error] GenServer #PID<0.108.0> terminating
** (stop) bad return value: {:reply, 1.0, [1.0, 0.0]}
Last message: {:"$gen_cast", :next}
State: [0.0]
** (EXIT from #PID<0.101.0>) shell process exited with reason: bad return value: {:reply, 1.0, [1.0, 0.0]}

子プロセスに非同期で仕事を渡して :ok ってところまでは動きます。その後に値が返ってくると異常とみなして子プロセスを terminate してしまうようです。bad return value と言われているのが {:reply, 1.0, [1.0, 0.0]} なので、まさにこれを返したかったのですが、「非同期でって言われてるんだから、そんなもん受け取るつもりはねえ」ということのようです。

まとめ

  • GenServer を使うと平行プロセスがキレイに書ける(ような気がする)
  • 同期・非同期の通信ができる
  • 同期のときは値を返さないとならないし、非同期のときは値を返してはならない

GenStateMachine との関係

はじめてなElixir(9) で、GenStateMachine とか start_link とか call とか cast とか、どういうネーミングなんだろうと思ってたんですね。これ GenServer 由来だったのですね。普通に理解する順番は GenServer が先で GenStateMachine が後なんでしょうが、私は逆にやってしまったようです。

参考文献

最速で知る! ElixirプログラミングとErlang/OTPの始め方【第二言語としてのElixir】 GenServerから始めるOTP

Elixirスクール OTPの並行性

Elixir 1.7.3 GenServer

6
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
6
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?