GenServer를 어떻게 종료할 것인가?
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 콜백이 호출되지 않는다.