GenServer(単体)はメッセージを逐次処理します。
たとえば、GerServer A は GenServer B や C から並行にメッセージを投げられても、そのメッセージによってトリガされる A の処理である handle_call/3, handle_cast/2 はかならず逐次的に行われます。
Erlang/Elixir はこの仕様があるため、GenServer を実装する人が排他処理を GenServer 内部で書く必要は無いわけです。Erlang/Elixir が並行処理を書きやすいといわしめる仕様の一つだと思います。
並行に行われる動作をハンドリングするために排他処理を自分で組む場合に気をつけなればいけないことに、デッドロックがあると思います。GenServer 単体であれば、上記、仕様によって排他処理の実装を GenServer に任せる(我々は実装しない)ために、実装ミスによるデッドロックはそもそも起きません。では、 GenServer 複数の場合はどうでしょうか?
これは簡単にデッドロックを起こさせることができます。ぴゃっと書いていきましょう。
複数 GenServer でデッドロックを起こしてみる
mix new dead_lock --sup して、 GenServer を書きます。 DeadLock.Worker.test/1 を呼び出すと別の DeadLock.Worker の test/1 を呼び出すという簡単なものです。
defmodule DeadLock.Worker do
use GenServer
require Logger
def test(name) do
GenServer.call(name, :test)
end
def start_link(args) do
name = Keyword.get(args, :name, __MODULE__)
GenServer.start_link(__MODULE__, args, name: name)
end
def init(args) do
name = Keyword.fetch!(args, :name)
callee = Keyword.fetch!(args, :callee)
{:ok, %{name: name, callee: callee}}
end
def handle_call(:test, _from, %{name: name, callee: callee} = state) do
Logger.info("#{name} is called, then call #{callee}.")
{:reply, __MODULE__.test(state.callee), state}
end
end
これを Application の supervisor に、名前をつけて2つぶら下げてみます。
名前は A と B です。
defmodule DeadLock.Application do
@moduledoc false
use Application
@impl true
def start(_type, _args) do
children = [
Supervisor.child_spec({DeadLock.Worker, [name: A, callee: B]}, id: A),
Supervisor.child_spec({DeadLock.Worker, [name: B, callee: A]}, id: B)
]
opts = [strategy: :one_for_one, name: DeadLock.Supervisor]
Supervisor.start_link(children, opts)
end
end
iex -S mix で dead_lock app を起動し、 DeadLock.Worker.test(A) 実行します。
iex(1)> DeadLock.Worker.test(A)
16:24:29.203 [info] Elixir.A is called, then call Elixir.B.
16:24:29.207 [info] Elixir.B is called, then call Elixir.A.
** (exit) exited in: GenServer.call(A, :test, 5000)
** (EXIT) time out
(elixir 1.19.4) lib/gen_server.ex:1142: GenServer.call/3
iex:1: (file)
16:24:34.211 [error] GenServer A terminating
** (stop) exited in: GenServer.call(B, :test, 5000)
** (EXIT) time out
(elixir 1.19.4) lib/gen_server.ex:1142: GenServer.call/3
(dead_lock 0.1.0) lib/dead_lock/worker.ex:24: DeadLock.Worker.handle_call/3
(stdlib 7.1) gen_server.erl:2470: :gen_server.try_handle_call/4
(stdlib 7.1) gen_server.erl:2499: :gen_server.handle_msg/3
(stdlib 7.1) proc_lib.erl:333: :proc_lib.init_p_do_apply/3
Last message (from #PID<0.144.0>): :test
State: %{name: A, callee: B}
Client #PID<0.144.0> is alive
(kernel 10.4.2) code_server.erl:163: :code_server.call/1
(kernel 10.4.2) code.erl:587: :code.ensure_loaded/1
(kernel 10.4.2) error_handler.erl:86: :error_handler.undefined_function/3
(elixir 1.19.4) lib/exception.ex:753: Exception.format_mfa/3
(elixir 1.19.4) lib/exception.ex:495: Exception.format_exit/2
(elixir 1.19.4) lib/exception.ex:153: Exception.format_banner/3
(iex 1.19.4) lib/iex/evaluator.ex:421: IEx.Evaluator.print_error/3
(iex 1.19.4) lib/iex/evaluator.ex:327: IEx.Evaluator.safe_eval_and_inspect/3
16:24:34.211 [error] GenServer B terminating
** (stop) exited in: GenServer.call(A, :test, 5000)
** (EXIT) time out
(elixir 1.19.4) lib/gen_server.ex:1142: GenServer.call/3
(dead_lock 0.1.0) lib/dead_lock/worker.ex:24: DeadLock.Worker.handle_call/3
(stdlib 7.1) gen_server.erl:2470: :gen_server.try_handle_call/4
(stdlib 7.1) gen_server.erl:2499: :gen_server.handle_msg/3
(stdlib 7.1) proc_lib.erl:333: :proc_lib.init_p_do_apply/3
Last message (from A): :test
State: %{name: B, callee: A}
Client A is alive
(kernel 10.4.2) logger_config.erl:67: :logger_config.allow/2
(stdlib 7.1) gen_server.erl:2785: :gen_server.error_info/7
(stdlib 7.1) gen_server.erl:2744: :gen_server.terminate/9
(stdlib 7.1) proc_lib.erl:333: :proc_lib.init_p_do_apply/3
すると、呼び出し元の iex プロセスには timeout が飛んできます。
すごく単純な例なので、何が起きているかは明白で、
- iex プロセスが
DeadLock.Worker.test(A)を呼び出す - A は handle_call で
DeadLock.Worker.test(B)を呼び出す - B は handle_call で
DeadLock.Worker.test(A)を呼び出す
A は test(B) の処理をしている最中に B から test(A) を呼び出されるため、 GenServer の逐次処理の仕組みによって、B からの test(A) の処理を開始することができません。これによって、 timeout が起きます。※ GenServer.call/3 の デフォルトタイムアウトは 5 秒です。
「こんな書き方しないよ」、そう、おっしゃられると思います。私もそう思います。
ただ、 Appilcation 配下に複数の Supervisor をぶら下げ、それらを連携させて機能を発揮するようなコードを書く場合には注意が必要かもしれません。上の例のように childlen の関係が平たく書かれている場合にはすぐに気がつけますが、意図せず GenServer.call を複数サーバー間でループさせてしまったらどうでしょうか?このようなデッドロックは容易に起きえます。そして、このデッドロックはコンパイルによって検出することはできません。つまり、設計で防ぐしか無いのです。気をつけないといけませんね(自戒をこめて)
といったところで、複数 「GenServer でデッドロックを起こしてみる」でした。
以上です。