-
GenServer를 어떻게 종료할 것인가?기술 동향 2021. 8. 25. 18:26
https://alexcastano.com/how-to-stop-a-genserver-in-elixir/
원문입니다.
Why and how a GenServer stops?
The different possibilities to stop a GenServer and how to handle the termination process.
alexcastano.com
terminate/2 콜백
GenServer가 중지되는 이유를 살펴보기 전에 terminate/2 콜백을 살펴보겠습니다. 필수 사항은 아니며 필요할 때만 구현할 수 있지만 중단하기 전에 무언가를 하고 싶다면 도움이 될 수 있습니다. terminate/2 콜백이 실행된 이후에는 GenServer의 상태를 영원히 잃게 되므로 state를 변경할 마지막 기회입니다.
이 함수는 두 개의 인수를 수신합니다. 첫 번째 인수는 종료 이유이고 두 번째 인수는 프로세스의 마지막 상태입니다.
How a GenServer can stop itself
defmodule Buggy do use GenServer @impl true def init(state) do {:ok, state} end @impl true def handle_info({:div, num}, state) do state = state + 1 IO.inspect(":div received, updating state to: #{state}") IO.inspect("Divission = 100 / #{num} = #{100 / num}") {:noreply, state} end @impl true def terminate(reason, state) do IO.inspect("terminate/2 callback") IO.inspect({:reason, reason}) IO.inspect({:state, state}) end end
GenServer 프로세스 자체에서 중지하는 방법과 다른 프로세스에서 GenServer를 중지하는 두 가지 방법이 있습니다. 첫 번째 것부터 시작하겠습니다.
iex> {:ok, pid} = GenServer.start(Buggy, 0) {:ok, #PID<0.139.0>} iex> send(pid, {:div, 10}) {:div, 10} ":div received, updating state to: 1" "Divission = 100 / 10 = 10.0" iex> send(pid, {:div, 0}) {:div, 0} ":div received, updating state to: 2" "terminate/2 callback" {:reason, {:badarith, [ {Buggy, :handle_info, 2, [file: 'buggy.ex', line: 13]}, {:gen_server, :try_dispatch, 4, [file: 'gen_server.erl', line: 680]}, {:gen_server, :handle_msg, 6, [file: 'gen_server.erl', line: 756]}, {:proc_lib, :init_p_do_apply, 3, [file: 'proc_lib.erl', line: 226]} ]}} {:state, 1} ## Error log """ 18:00:28.809 [error] GenServer #PID<0.139.0> terminating ** (ArithmeticError) bad argument in arithmetic expression buggy.ex:13: Buggy.handle_info/2 (stdlib 3.13) gen_server.erl:680: :gen_server.try_dispatch/4 (stdlib 3.13) gen_server.erl:756: :gen_server.handle_msg/6 (stdlib 3.13) proc_lib.erl:226: :proc_lib.init_p_do_apply/3 Last message: {:div, 0} State: 1 """
terminate /2 콜백에 의해 수신된 상태가 마지막 handle_call/2 콜백이 호출되기 전의 마지막 상태라는 것을 확인하는 것은 흥미롭습니다. 업데이트된 상태를 반환할 기회가 없었으므로 이것이 GenServer 동작이 할 수 있는 최선입니다. 이 패턴을 더 많이 볼 것이므로 terminate /2 콜백을 개발할 때 염두에 두십시오.
iex> {:ok, pid} = GenServer.start(Raising, 0) {:ok, #PID<0.150.0>} iex> send(pid, :raising) ":raising received, updating state to: 1" :raising "terminate/2 callback" {:reason, { %RuntimeError{message: "Raising!"}, [ {Raising, :handle_info, 2, [file: 'raising.ex', line: 13]}, {:gen_server, :try_dispatch, 4, [file: 'gen_server.erl', line: 680]}, {:gen_server, :handle_msg, 6, [file: 'gen_server.erl', line: 756]}, {:proc_lib, :init_p_do_apply, 3, [file: 'proc_lib.erl', line: 226]} ]}} {:state, 0} ## Error log """ 18:08:25.685 [error] GenServer #PID<0.150.0> terminating ** (RuntimeError) Raising! raising.ex:13: Raising.handle_info/2 (stdlib 3.13) gen_server.erl:680: :gen_server.try_dispatch/4 (stdlib 3.13) gen_server.erl:756: :gen_server.handle_msg/6 (stdlib 3.13) proc_lib.erl:226: :proc_lib.init_p_do_apply/3 Last message: :raising State: 0 """
Raise Exception은 위와 비슷합니다. Log를 남기며 프로세스가 죽습니다.
Return :stop in a callback
defmodule Stop do use GenServer @impl true def init(state) do {:ok, state} end @impl true def handle_info({:quit, reason}, state) do state = state + 1 IO.inspect(":quit received, updating state to: #{state}") {:stop, reason, state} end @impl true def handle_info({:exit, reason}, _state) do IO.inspect(":exit received") exit(reason) end @impl true def terminate(reason, state) do IO.inspect("terminate/2 callback") IO.inspect({:reason, reason}) IO.inspect({:state, state}) end end
Exception이 발생하던 아니면 인위적으로 Exception을 발생 시키던 상관 없습니다. 그러나 대부분의 경우 GenServer를 중지하는 가장 적절한 방법은 거의 모든 콜백에서 {:stop, reason, state} 튜플을 반환하는 것입니다.
iex> {:ok, pid} = GenServer.start(Stop, 0) {:ok, #PID<0.160.0>} iex> send(pid, {:quit, :normal}) {:quit, :normal} ":quit received, updating state to: 1" "terminate/2 callback" {:reason, :normal} {:state, 1}
State도 업데이트가 되고 Log도 남기지 않습니다.
Kernel.exit/1
{:stop, reason, state} 튜플을 반환하는 것과 매우 유사한 동작을 합니다. 이 함수는 현재 프로세스(이 경우에는 GenServer)에 종료 신호를 보냅니다.
iex> {:ok, pid} = GenServer.start(Exit, 0) {:ok, #PID<0.192.0>} iex> send(pid, {:exit, :normal}) {:exit, :normal} ":exit received" "terminate/2 callback" {:reason, :normal} {:state, 0}
{:stop, reason, state} 튜플을 반환하는 것과 마찬가지로 Log를 남기지 않고 죽습니다. 다만 handle_info/2 에서 exit/1로 빠져나와 별도의 State를 관리하는 리턴 값이 없으므로 State를 변경할 수는 없습니다.
Important note about the “reason”
{:stop, reason, state} 튜플을 반환하는 것과 exit(reason)을 통해 우리는 GenServer를 중지하는 "reason"를 조작할 수 있습니다. 이 이유는 OTP에 대한 다른 중요한 의미가 있습니다.
"정상적인" reason 값으로 간주되는 세 가지 값이 있습니다.
- :normal
- :shutdown
- {:shutdown, term}
만약 reason이 위의 세 값과 다르면 어떻게 될까요? GenServer Document에 따르면
“정상적인 reason값이 아닌” 다른 이유로 종료하면 비정상으로 간주되어 충돌로 처리됩니다. 이는 OTP 기본 Supervisor 동작이 시작되고 오류 보고서가 내보내지는 등의 작업을 의미합니다.” 이렇게 말이죠
iex> {:ok, pid} = GenServer.start(Exit, 0) {:ok, #PID<0.204.0>} iex> send(pid, {:exit, :my_own_reason}) {:exit, :my_own_reason} ":exit received" "terminate/2 callback" {:reason, :my_own_reason} {:state, 0} ## Error log """ 18:34:29.527 [error] GenServer #PID<0.204.0> terminating ** (stop) :my_own_reason exit.ex:12: Exit.handle_info/2 (stdlib 3.13) gen_server.erl:680: :gen_server.try_dispatch/4 (stdlib 3.13) gen_server.erl:756: :gen_server.handle_msg/6 (stdlib 3.13) proc_lib.erl:226: :proc_lib.init_p_do_apply/3 Last message: {:exit, :my_own_reason} State: 0 """
{:stop, :my_own_reason, state}도 마찬가지입니다.
How a GenServer can be stopped externally
우리는 이미 GenServer가 스스로를 멈출 수 있는 방법을 보았습니다. 이제 다른 프로세스가 동일한 작업을 수행할 수 있는 방법을 살펴보겠습니다.
GenServer.stop/3
이 함수는 PID(또는 GenServer 이름으로 간주될 수 있는 모든 값)를 수신하여 중지 reason와 timeout을 수신합니다. timeout 매개변수로 인해 상상할 수 있듯이 이 기능은 동기식이며 GenServer가 완전히 중지될 때까지 계속되지 않습니다.
defmodule GenServerStop do use GenServer @impl true def init(state) do {:ok, state} end @impl true def terminate(reason, state) do IO.inspect("terminate/2 callback") IO.inspect({:reason, reason}) IO.inspect({:state, state}) IO.inspect("sleep before terminating") Process.sleep(1_000) IO.inspect("really terminating") end end iex> {:ok, pid} = GenServer.start(GenServerStop, 0) {:ok, #PID<0.127.0>} iex> GenServer.stop(pid) "terminate/2 callback" {:reason, :normal} {:state, 0} "sleep before terminating" "really terminating" :ok
reason에 대한 기본값은 :normal이고 timeout에 대한 기본값은 5_000입니다. 다시 말하지만, 종료 Reason이 사용자 정의라면 다음과 같이 기록됩니다.
iex> {:ok, pid} = GenServer.start(GenServerStop, 0) {:ok, #PID<0.131.0>} iex> GenServer.stop(pid, :abnormal) "terminate/2 callback" {:reason, :abnormal} {:state, 0} "sleep before terminating" "really terminating" # Error log """ 09:35:49.180 [error] GenServer #PID<0.131.0> terminating ** (stop) :abnormal Last message: [] State: 0 """ :ok
종료할 시간이 충분하지 않으면 GenServer.stop/3 함수가 반환되지 않고 반환되기 전에 충돌이 발생합니다. 그러나 terminate/2는 완료됩니다. 다음을 확인하세요
iex> {:ok, pid} = GenServer.start(GenServerStop, 0) {:ok, #PID<0.135.0>} iex> GenServer.stop(pid, :small_timeout, 100) "terminate/2 callback" {:reason, :small_timeout} {:state, 0} "sleep before terminating" # Our current iex process exit because timeout, Error log: """ ** (exit) exited in: GenServer.stop(#PID<0.135.0>, :small_timeout, 100) ** (EXIT) time out (elixir 1.10.3) lib/gen_server.ex:981: GenServer.stop/3 """ # The GenServer process finishes anyway some milliseconds later "really terminating" # Error log because is not a normal reason: """ 09:38:39.020 [error] GenServer #PID<0.135.0> terminating ** (stop) :small_timeout Last message: [] State: 0 """
Link to another process
defmodule Link do use GenServer @impl true def init(state) do IO.inspect({:init, self()}) {:ok, state} end @impl true def handle_info({:link, link}, state) do Process.link(link) {:noreply, state} end @impl true def terminate(reason, state) do IO.inspect("terminate/2 callback in pid #{inspect(self())}") IO.inspect({:reason, reason}) IO.inspect({:state, state}) end end
iex> {:ok, pid_1} = GenServer.start(Link, 0) {:init, #PID<0.120.0>} {:ok, #PID<0.120.0>} iex> {:ok, pid_2} = GenServer.start(Link, 0) {:init, #PID<0.122.0>} {:ok, #PID<0.122.0>} iex> send(pid_1, {:link, pid_2}) {:link, #PID<0.122.0>} iex> GenServer.stop(pid_2, :my_own_reason) terminate/2 callback in pid #PID<0.122.0> {:reason, :my_own_reason} {:state, 0} # Error log from pid 2: """ 18:57:10.499 [error] GenServer #PID<0.122.0> terminating ** (stop) :my_own_reason Last message: [] State: 0 """ :ok iex> Process.alive?(pid_1) false
우리는 pid_2를 중지했고 그것이 terminate/2 콜백을 실행하는 것을 확인할 수 있습니다. pid_1의 경우에도 마찬가지입니다. pid_1로 실행 중인 GenServer가 pid_2에 연결되어 종료되었습니다. 흥미롭게도 pid_1에 대해 terminate/2 콜백이 전혀 호출되지 않았습니다.
반면 GenServer.stop(pid_2, :normal) 을 사용하면 pid_2는 종료되지만 pid_1은 살아있습니다. 이 경우는 pid_2의Supervisor Strategy에 따라 다르게 동작합니다.
:permanent - the child process is always restarted.
:temporary - the child process is never restarted, regardless of the supervision strategy: any termination (even abnormal) is considered successful.
:transient - the child process is restarted only if it terminates abnormally, i.e., with an exit reason other than :normal, :shutdown, or {:shutdown, term}.
Trapping Exit Signal
이 기본 동작을 수정할 수 있는 한 가지 방법은 Exit Signal을 Trapping하는 것입니다. 프로세스가 죽으면 연결된 프로세스는 종료 신호를 받습니다. 두 가지 가능성이 있습니다.
- 프로세스는 종료 신호를 트랩하지 않으므로 동일한 이유로 종료됩니다.
- 프로세스는 종료 신호를 트랩하므로 일반 메시지로 변환됩니다.
defmodule TrapLink do use GenServer @impl true def init(state) do # Trap exits Process.flag(:trap_exit, true) {:ok, state} end @impl true def handle_info({:link, link}, state) do Process.link(link) {:noreply, state} end def handle_info(msg, state) do IO.inspect({:handle_info, self(), msg, state}) {:noreply, state} end @impl true def terminate(reason, state) do IO.inspect("terminate/2 callback in pid #{inspect(self())}") IO.inspect({:reason, reason}) IO.inspect({:state, state}) end end
마지막 경우의 예는 다음과 같습니다.
iex> {:ok, pid_1} = GenServer.start(TrapLink, 0) {:ok, #PID<0.187.0>} iex> {:ok, pid_2} = GenServer.start(TrapLink, 0) {:ok, #PID<0.189.0>} iex> send(pid_1, {:link, pid_2}) {:link, #PID<0.189.0>} iex> GenServer.stop(pid_2, :normal) "terminate/2 callback in pid #PID<0.189.0>" {:reason, :normal} {:state, 0} # This message is received by the pid_1 {:handle_info, {:EXIT, #PID<0.189.0>, :normal}, 0} :ok iex> Process.alive?(pid_1) true
이번에는 pid_1에서 실행 중인 GenServer가 살아남았습니다. 죽지 않았기 때문에 terminate/2 콜백도 호출되지 않았습니다. 대신 handle_info/2 콜백에서 {:EXIT, #PID<0.189.0>, :normal} 메시지를 받았습니다. "정상적인" 이유가 아닌 경우에도 동일한 동작이 발생합니다. 위는 정상적인 종료, 아래는 비정상 종료의 예입니다.
iex> {:ok, pid_1} = GenServer.start(TrapLink, 0) {:ok, #PID<0.198.0>} iex> {:ok, pid_2} = GenServer.start(TrapLink, 0) {:ok, #PID<0.200.0>} iex> send(pid_1, {:link, pid_2}) {:link, #PID<0.200.0>} iex> GenServer.stop(pid_2, :abnormal) "terminate/2 callback in pid #PID<0.200.0>" {:reason, :abnormal} {:state, 0} # Error log from pid_2: """ 19:28:14.982 [error] GenServer #PID<0.200.0> terminating ** (stop) :abnormal Last message: [] State: 0 """ # This message is received by the pid_1 {:handle_info, {:EXIT, #PID<0.200.0>, :abnormal}, 0} :ok
물론, handle_info/2 콜백에서 우리는 이전에 본 다른 방법 중 하나를 사용하여 GenServer를 중지하기로 결정할 수 있었습니다. 그러나 요점은 연결된 프로세스가 죽을 때 종료 신호를 트랩하면 GenServer가 Trap한 프로세스는 죽지 않는다는 것입니다. 이 작업은 수동으로 수행해야 합니다.
Process.exit/2
우리는 Exit Signal에 대해 배웠습니다. 모든 프로세스는 다른 프로세스에 종료 신호를 보낼 수 있습니다. 이를 위해Process.exit/2 함수를 사용할 수 있습니다. 이전 예에서와 같이 이 함수를 호출한 결과는 신호가 트랩되었는지 여부에 따라 달라집니다.
GenServer.stop(pid, :normal)은 해당 pid를 죽이지만 Process.exit(pid, :normal)은 해당 pid를 죽이지 않습니다. 헷갈리지 마세요~
defmodule ExternalExit do use GenServer @impl true def init(state) do {:ok, state} end @impl true def handle_info(:trap, state) do Process.flag(:trap_exit, true) {:noreply, state} end def handle_info(msg, state) do IO.inspect({:info, msg, state}) {:noreply, state} end @impl true def terminate(reason, state) do IO.inspect("terminate/2 callback") IO.inspect({:reason, reason}) IO.inspect({:state, state}) end end iex> {:ok, pid} = GenServer.start(ExternalExit, 0) {:ok, #PID<0.156.0>} iex> Process.exit(pid, :abnormal) true iex> Process.alive?(pid) false iex> {:ok, pid} = GenServer.start(ExternalExit, 0) {:ok, #PID<0.160.0>} iex> Process.exit(pid, :shutdown) true iex> Process.alive?(pid) false
그리고 다시 말하지만 외부에서 Process.exit/2를 이용해 GenServer를 죽이는 경우, GenServer는 Exit Signal로 terminate/2 콜백을 실행하지 않습니다. 신호를 받자마자 죽습니다. 위의 다른 경우와는 다르게 :shutdown reason으로 프로세스를 죽이면 normal과 유사하게 동작하지 않습니다. 오히려 비정상 reason 처럼 동작합니다. 즉, :normal reason만이 “정상적인 이유”로 프로세스를 종료할 수 있습니다.
normal의 의미는 Internal Exit 이면 정상적 종료로 해석되고External Exit면 종료되지 않습니다. 외부에서 “정상 종료”라는 것을 할 수 없다고 보기 때문입니다.
shutdown의 경우 Internal Exit면 의도한 정상 종료로 해석되고 External Exit이면 외부에서 누군가가 shutdown한 것이므로 비정상으로 해석합니다.반면 신호가 트랩되면 수동으로 원하지 않는 한 죽지 않습니다.
iex> {:ok, pid} = GenServer.start(ExternalExit, 0) {:ok, #PID<0.164.0>} iex> send(pid, :trap) :trap iex> Process.exit(pid, :abnormal) {:info, {:EXIT, #PID<0.111.0>, :abnormal}, 0} true iex> Process.alive?(pid) true
Process.exit/2 기능을 사용할 때 이전 동작에 대한 두 가지 예외가 있습니다. 첫째, normal reason를 사용할 때입니다. 이 경우 문서에는 다음과 같이 나와 있습니다.
“이유가 atom :normal이면 pid가 종료되지 않습니다(pid가 호출 프로세스인 경우가 아니면:normal로 종료됨). 종료를 트래핑하는 경우 종료 신호는 {:EXIT, from, :normal} 메시지로 변환되어 해당 메시지 대기열로 전달 된다.” iex> {:ok, pid} = GenServer.start(ExternalExit, 0) {:ok, #PID<0.169.0>} iex> Process.exit(pid, :normal) true iex> Process.alive?(pid) true iex> send(pid, :trap) :trap iex> Process.exit(pid, :normal) {:info, {:EXIT, #PID<0.111.0>, :normal}, 0} true iex> Process.alive?(pid) true
종료 신호를 트래핑하지 않은 경우에도 프로세스가 죽지 않았음을 알 수 있습니다!
다른 예외는 :kill 이유와 함께 사용할 때입니다.
이유가 atom :kill인 경우, 즉 Process.exit(pid, :kill)이 호출되면 트래핑할 수 없는 종료 신호가 pid로 전송되어 무조건reason :killed와 함께 종료됩니다.
iex> {:ok, pid} = GenServer.start(ExternalExit, 0) {:ok, #PID<0.185.0>} iex> send(pid, :trap) :trap iex> Process.exit(pid, :kill) true iex> Process.alive?(pid) false
결론
GenServer를 종료하는 100가지 다른 방법이 있을 수 있습니다. 우리는 그것들 모두를 살펴보지는 않았지만 가장 일반적인 것들을 공부했습니다. 상황에 가장 적합한 것을 사용하십시오.
마지막 권장 사항으로, terminate/2 콜백이 항상 실행되는 것은 아니므로 맹목적으로 신뢰하지 마십시오. GenServer가 죽을 때 코드를 실행해야 하는 경우 종료 신호를 캡처하는 다른 연결된 프로세스를 만드는 것이 가장 좋습니다. 코드는 "링크 프로세스" 예제와 매우 유사합니다.
추가)
terminate/2 콜백
1. GenServer 외부에서 Process.exit(:abnormal) 로 죽였을 경우, terminate/2 콜백이 호출되지 않는다.
2. Link Process가 abnormal 로 종료되면 Linked Process 는 terminate/2 콜백이 호출되지 않는다.
'기술 동향' 카테고리의 다른 글
Macro 101 (0) 2022.08.08 Debugging using Livebook (0) 2022.07.21 Elixir Process (0) 2021.06.28 Process Register (0) 2021.04.05 Ecto Composable Query (0) 2020.11.16