프로세스: 엘릭서의 병행성과 메시지 전달

프로세스: 엘릭서의 병행성과 메시지 전달

엘릭서에서는 모든 코드가 프로세스(process) 안에서 돌아가요. 프로세스는 서로 격리되어 있고, 동시에 실행되며, 메시지 전달로 서로 소통해요. 이 프로세스는 OS 프로세스가 아니라 아주 가볍기 때문에 수만 개를 동시에 띄워도 자연스러워요. 이 장에서는 spawn으로 프로세스를 만들고, send·receive로 메시지를 주고받으며, 실패를 전파하는 링크(link)와 상태를 유지하는 방법까지 살펴볼게요.

출처: Processes — Elixir 공식 getting-started 가이드

본문

엘릭서에서는 모든 코드가 프로세스 안에서 실행돼요. 프로세스는 서로 격리되고, 동시에 실행되며, 메시지 전달로 통신해요. 프로세스는 엘릭서 병행성의 기초일 뿐 아니라, 분산·내결함성 프로그램을 만드는 수단이기도 해요.

엘릭서의 프로세스를 운영체제 프로세스와 혼동하면 안 돼요. 엘릭서 프로세스는 메모리와 CPU 면에서 아주 가벼워요(다른 언어의 스레드와 비교해도요). 덕분에 수만 개, 때로는 수십만 개의 프로세스가 동시에 도는 것도 드물지 않아요.

이 장에서는 새 프로세스를 만드는 기본 구성 요소와, 프로세스 사이에서 메시지를 보내고 받는 법을 배울 거예요.

프로세스 만들기

새 프로세스를 만드는 기본 메커니즘은 자동으로 임포트되는 spawn/1 함수예요.

iex> spawn(fn -> 1 + 2 end)
#PID<0.43.0>

spawn/1은 다른 프로세스에서 실행할 함수를 받아요.

spawn/1이 PID(프로세스 식별자)를 돌려준다는 점을 눈여겨보세요. 이 시점에 방금 만든 프로세스는 아주 죽었을 가능성이 높아요. 생성된 프로세스는 주어진 함수를 실행하고, 함수가 끝나면 종료하거든요.

iex> pid = spawn(fn -> 1 + 2 end)
#PID<0.44.0>
iex> Process.alive?(pid)
false

참고: 여러분이 받을 프로세스 식별자는 예시와 다를 가능성이 높아요.

현재 프로세스의 PID는 self/0으로 얻을 수 있어요.

iex> self()
#PID<0.41.0>
iex> Process.alive?(self())
true

메시지를 보내고 받을 수 있게 되면 프로세스는 훨씬 재미있어져요.

메시지 보내고 받기

프로세스에 메시지를 보낼 때는 send/2, 받을 때는 receive/1을 써요.

iex> send(self(), {:hello, "world"})
{:hello, "world"}
iex> receive do
...>   {:hello, msg} -> msg
...>   {:world, _msg} -> "won't match"
...> end
"world"

메시지가 프로세스에 보내지면 그 프로세스의 **메일박스(mailbox)**에 저장돼요. receive/1 블록은 현재 프로세스의 메일박스를 훑어 주어진 패턴 중 하나에 매칭되는 메시지를 찾아요. receive/1case/2처럼 가드와 여러 절을 지원해요.

메시지를 보내는 프로세스는 send/2에서 블록되지 않아요. 받는 쪽 메일박스에 메시지를 넣고 계속 진행하죠. 특히 프로세스는 자기 자신에게도 메시지를 보낼 수 있어요.

메일박스에 어떤 패턴에도 매칭되는 메시지가 없으면, 현재 프로세스는 매칭되는 메시지가 도착할 때까지 기다려요. 타임아웃도 지정할 수 있어요.

iex> receive do
...>   {:hello, msg}  -> msg
...> after
...>   1_000 -> "nothing after 1s"
...> end
"nothing after 1s"

메시지가 이미 메일박스에 있을 거라 예상한다면 타임아웃을 0으로 줄 수 있어요.

이제 모두 합쳐 프로세스 사이에서 메시지를 주고받아 볼게요.

iex> parent = self()
#PID<0.41.0>
iex> spawn(fn -> send(parent, {:hello, self()}) end)
#PID<0.48.0>
iex> receive do
...>   {:hello, pid} -> "Got hello from #{inspect pid}"
...> end
"Got hello from #PID<0.48.0>"

inspect/1 함수는 데이터 구조의 내부 표현을 보통 출력용 문자열로 변환해줘요. receive 블록이 실행될 때 우리가 만든 송신 프로세스는 이미 죽어 있을 수도 있어요. 그 프로세스의 유일한 명령이 메시지를 보내는 것이었으니까요.

셸에서는 flush/0 헬퍼가 꽤 유용해요. 메일박스의 모든 메시지를 비우고 출력해주죠.

iex> send(self(), :hello)
:hello
iex> flush()
:hello
:ok

엘릭서에서 프로세스를 만들 때 대부분 링크된 프로세스로 만들어요. spawn_link/1의 예시를 보여주기 전에, 먼저 spawn/1으로 시작한 프로세스가 실패하면 어떤 일이 생기는지 볼게요.

iex> spawn(fn -> raise "oops" end)
#PID<0.58.0>

[error] Process #PID<0.58.00> raised an exception
** (RuntimeError) oops
    (stdlib) erl_eval.erl:668: :erl_eval.do_apply/6

그저 오류를 기록할 뿐 부모 프로세스는 계속 실행돼요. 프로세스가 격리되어 있기 때문이에요. 한 프로세스의 실패가 다른 프로세스로 전파되길 원한다면 둘을 링크해야 해요. 이건 spawn_link/1로 할 수 있어요.

iex> self()
#PID<0.41.0>
iex> spawn_link(fn -> raise "oops" end)

** (EXIT from #PID<0.41.0>) evaluator process exited with reason: an exception was raised:
    ** (RuntimeError) oops
        (stdlib) erl_eval.erl:668: :erl_eval.do_apply/6

[error] Process #PID<0.289.0> raised an exception
** (RuntimeError) oops
    (stdlib) erl_eval.erl:668: :erl_eval.do_apply/6

프로세스가 링크되어 있으니, 부모인 셸 프로세스가 다른 프로세스로부터 EXIT 신호를 받아 셸이 종료된다는 메시지를 볼 수 있어요. IEx는 이 상황을 감지하고 새 셸 세션을 시작하죠.

링크는 Process.link/1을 호출해 직접 걸 수도 있어요. Process 모듈이 제공하는 다른 기능도 한번 살펴보길 권해요.

프로세스와 링크는 내결함성 시스템을 만들 때 중요한 역할을 해요. 엘릭서 프로세스는 기본적으로 격리되어 있고 아무것도 공유하지 않아요. 따라서 한 프로세스의 실패가 다른 프로세스의 상태를 절대 죽이거나 망가뜨리지 않죠. 링크는 실패 상황에서 프로세스가 관계를 맺게 해줘요. 프로세스를 슈퍼바이저(supervisor)에 링크하면, 슈퍼바이저가 프로세스가 죽는 것을 감지하고 그 자리에 새 프로세스를 시작해요.

다른 언어라면 예외를 잡아 처리해야 하겠지만, 엘릭서에서는 프로세스가 실패하게 두는 걸 오히려 자연스러워해요. 슈퍼바이저가 시스템을 제대로 재시작해 줄 거라 기대하니까요. "빠르게 실패하기(failing fast)"(때로 "let it crash"라고도 해요)는 엘릭서 소프트웨어를 작성할 때 흔한 철학이에요!

spawn/1spawn_link/1은 엘릭서에서 프로세스를 만드는 기본 원시 연산이에요. 지금까지 이것만 썼지만, 대부분은 이것들 위에 만든 추상화를 쓰게 돼요. 가장 흔한 것 중 하나인 task를 볼게요.

Task(작업)

Task는 spawn 함수 위에 만들어져 더 나은 오류 보고와 내부 관찰(introspection)을 제공해요.

iex> Task.start(fn -> raise "oops" end)
{:ok, #PID<0.55.0>}

15:22:33.046 [error] Task #PID<0.55.0> started from #PID<0.53.0> terminating
** (RuntimeError) oops
    (stdlib) erl_eval.erl:668: :erl_eval.do_apply/6
    (elixir) lib/task/supervised.ex:85: Task.Supervised.do_apply/2
    (stdlib) proc_lib.erl:247: :proc_lib.init_p_do_apply/3
Function: #Function<20.99386804/0 in :erl_eval.expr/5>
    Args: []

spawn/1·spawn_link/1 대신 Task.start/1·Task.start_link/1을 쓰면 PID만이 아니라 {:ok, pid}를 돌려받아요. 그래서 task를 슈퍼비전 트리에서 쓸 수 있어요. 게다가 TaskTask.async/1·Task.await/1 같은 편의 함수와 분산을 쉽게 해주는 기능도 제공해요.

Task와 프로세스 주변의 다른 추상화는 "Mix and OTP 가이드"에서 자세히 다룰 거예요.

상태(State)

지금까지 상태에 대해 다루지 않았어요. 애플리케이션 설정을 유지하거나, 파일을 파싱해 메모리에 담아두는 등 상태가 필요한 애플리케이션을 만든다면 그 상태를 어디에 저장할까요?

이 질문에 대한 가장 흔한 답은 프로세스예요. 무한히 루프를 돌며 상태를 유지하고 메시지를 주고받는 프로세스를 작성할 수 있죠. 예를 들어 키-값 저장소로 동작하는 새 프로세스를 시작하는 모듈을 kv.exs라는 파일로 만들어 볼게요.

defmodule KV do
  def start_link do
    Task.start_link(fn -> loop(%{}) end)
  end

  defp loop(map) do
    receive do
      {:get, key, caller} ->
        send(caller, Map.get(map, key))
        loop(map)
      {:put, key, value} ->
        loop(Map.put(map, key, value))
    end
  end
end

start_link 함수는 빈 맵으로 시작해 loop/1 함수를 실행하는 새 프로세스를 시작해요. 비공개 함수 loop/1은 메시지를 기다리다가 각 메시지에 맞는 동작을 수행하죠. defp를 써서 loop/1을 비공개로 만들었어요. :get 메시지면 호출자에게 메시지를 보내고 loop/1을 다시 호출해 새 메시지를 기다려요. :put 메시지면 주어진 keyvalue가 저장된 새 버전의 맵으로 loop/1을 호출해요.

iex kv.exs로 한번 실행해 볼게요.

iex> {:ok, pid} = KV.start_link()
{:ok, #PID<0.62.0>}
iex> send(pid, {:get, :hello, self()})
{:get, :hello, #PID<0.41.0>}
iex> flush()
nil
:ok

처음엔 프로세스의 맵에 키가 없어서, :get 메시지를 보내고 현재 프로세스의 수신함을 비우면 nil이 나와요. :put 메시지를 보내고 다시 시도해 볼게요.

iex> send(pid, {:put, :hello, :world})
{:put, :hello, :world}
iex> send(pid, {:get, :hello, self()})
{:get, :hello, #PID<0.41.0>}
iex> flush()
:world
:ok

프로세스가 상태를 유지하고, 메시지를 보내 상태를 읽거나 갱신할 수 있다는 점을 볼 수 있어요. 사실 위 pid를 아는 어떤 프로세스든 메시지를 보내 상태를 조작할 수 있어요.

pid를 등록해 이름을 붙일 수도 있어요. 그러면 이름을 아는 모두가 그 프로세스에 메시지를 보낼 수 있죠.

iex> Process.register(pid, :kv)
true
iex> send(:kv, {:get, :hello, self()})
{:get, :hello, #PID<0.41.0>}
iex> flush()
:world
:ok

프로세스로 상태를 유지하고 이름을 등록하는 패턴은 엘릭서 애플리케이션에서 아주 흔해요. 하지만 대부분 이 패턴을 직접 구현하지 않고, 엘릭서에 내장된 많은 추상화를 쓰게 돼요. 예를 들어 엘릭서는 상태를 감싸는 단순한 추상화인 Agent를 제공해요. 아까의 코드를 그대로 이렇게 쓸 수 있죠.

iex> {:ok, pid} = Agent.start_link(fn -> %{} end)
{:ok, #PID<0.72.0>}
iex> Agent.update(pid, fn map -> Map.put(map, :hello, :world) end)
:ok
iex> Agent.get(pid, fn map -> Map.get(map, :hello) end)
:world

Agent.start_link/2:name 옵션을 주면 자동으로 등록될 수도 있어요. Agent 외에도 엘릭서는 제네릭 서버(GenServer)를 만들기 위한 API, 레지스트리 등을 제공하며 모두 내부에서 프로세스로 동작해요. 이것들과 슈퍼비전 트리는 시작부터 끝까지 완전한 엘릭서 애플리케이션을 만드는 "Mix and OTP 가이드"에서 더 자세히 살펴볼 거예요.

더 알아보기