Elixir(엘릭서) 동시성 프로그래밍 - 1. 경량 프로세스

에단(손형규)·2022년 5월 17일
1

(1) 일반적인 동시성 프로그래밍

(프로세스의 힙(공유),스택으로 설명 추가)
일반적으로 어플리케이션에서 동시성을 구현하기 위해서는 스레드나 운영체제 프로세스를 만들어 사용해야 합니다.

이렇게해서 만들경우 데드락, 원자성 위반, 순서 위반 버그와 같은 여러가지 문제점이 발생할 여지가 많습니다.

또한 일반적으로 프로세스는 비용이 크기때문에 많이 생성할 경우 성능면에서 좋지 않을 수 있습니다.

하지만 Elixir에서는...

Actor Model을 사용하고 그 기반이 되는 Erlang VM구조 덕분에 위와 같은 문제들을 걱정하지 않아도 됩니다.

Actor란 다른 프로세스와 데이터를 전혀 공유하지 않는 독립적인 프로세스를 말합니다.

또한 Elixir는 Erlang이 제공하는 프로세스를 사용하는데,

Erlang 프로세스는 일반적인 프로세스와 마찬가지로 모든 CPU에서 실행되지만 부하는 매우 적습니다.

프로세스 간 메시지를 전달하는 예제를 통해 어떻게 프로세스 간 메시지를 전달하는지 100만개의 프로세스를 생성하여 부하는 어느정도 걸리는지 테스트 해보겠습니다.

(2)프로세스간 메시지 전달

이제부터 실제 프로세스를 생성하여 메시지를 전달해보도록 하겠습니다.

우선 공식 홈페이지에서 Elixir를 설치해주세요.
https://elixir-lang.org/install.html

그런 다음 사용하시는 IDE를 실행해주세요. 저는 VS코드를 사용했습니다.

다음 명령어로 Elixir 프로젝트를 하나 생성해줍니다.

mix new elixir_process_test

그런 다음 lib 디렉토리 및에 elixir_process_test 디렉토리를 하나 만들고
MessageProcess.ex 파일을 하나 만들어 줍니다.

그리고 MessageProcess.ex 파일에 다음 코드를 입력해주세요.

defmodule MessageProcess do
    def send_message(pid) do
        send(pid, {:ok, "Hello", self(), pid})
    end

    def wait_message do
        receive do
            {:ok, message, from, to} ->
            IO.puts "#{message} : #{inspect from} to #{inspect to}"
        end
    end
end

이렇게 정의한 모듈로 객체를 만들듯이 프로세스를 만들수 있습니다.

코드에 대해서 간단히 설명하자면...
(Elixir 문법에 대한 내용은 이후의 글에서 다룰 예정이니 우선은 봐주세요!😀)

send_message/1 함수를 통해 프로세스에서 메시지를 보낼 수 있도록 합니다.
send_message/1 함수는 pid(process id)를 파라미터로 받고, 내부에서 send/2 함수로 pid와 보낼 메시지(4-튜플)를 인자로하여 지정한 프로세스로 메시지를 보냅니다.

wait_message/0 함수는 메시지가 들어올때까지 프로세스가 대기하도록 합니다.
자신의 pid가 호출됨과 동시에 {:ok, message, from, to} 이러한 형태(4-튜플)의 값을 받게 되면 패턴매칭이라는게 일어나서 IO.puts를 통해 콘솔에 메시지를 출력하게됩니다.

위의 코드를 저장 후 터미널에서 먼저 iex를 통해 프로젝트를 실행시켜줍시다.

iex -S mix

*iex는 Elixir의 대화형 셸(REPL)로 전체 프로젝트를 컴파일하고 실행하는데도 사용할수 있고, 실행중인 어플리케이션에 접근하는데도 사용됩니다.

그런 다음 프로세스들을 확인해보기 위해 Erlang의 서버 모니터링 툴을 실행 시켜줍니다.

iex에서 다음의 명령어를 실행시키고, Processes탭으로 이동해주세요.

:observer.start()

(맥OS에서는 wxWidgets가 설치되어 있어야합니다.)

이제 프로세스간 메시지를 보내보도록 합시다..!

Elixir에서 spawn/3 함수를 통해 가장 간단하게 프로세스를 생성할 수 있습니다.

iex에서 다음 코드를 순서대로 실행시켜, 메시지를 대기하는 프로세스를 두개 만들어 봅시다.

변수 pid_1과 pid_2에는 spawn/3이 반환하는 프로세스의 식별자(pid)가 바인딩됩니다.

pid_1 = spawn(MessageProcess, :wait_message, [])
pid_2 = spawn(MessageProcess, :wait_message, [])

Pid <0.1324.0>,<0.1616.0>으로 메시지를 대기하는 프로세스가 생성된 것을 확인할 수 있습니다.

이제 pid_1에 바인딩되어있는 Pid <0.1324.0> 프로세스로 메시지를 보내봅시다.

iex에서 다음 코드를 실행시켜 주세요.

spawn(MessageProcess, :send_message, [pid_1])

콘솔에 메시지가 잘 출력되는 것을 확인 할 수 있습니다.

Pid<0.23320.0>로 프로세스가 생성된 후에, Pid <0.1324.0>로 메시지를 보내고 종료되었고,

메시지를 받은 Pid <0.1324.0> 프로세스도 메시지를 받은 후 종료되어 모니터링 툴에서 사라진 것을 확인 할 수 있습니다.

pid <0.1615.0>은 아직 메시지를 받지 못해 계속 대기하고 있습니다.

만약 프로세스가 메시지를 한번 받은 후, 죽지 않고 계속적으로 메시지를 수신하려면 어떻게 해야할까요?🤔

MessageProcess.ex 파일 마지막에 wait_message/0 함수를 재귀적으로 호출해 줍니다.

defmodule MessageProcess do
    def send_message(pid) do
        send(pid, {:ok, "Hello", self(), pid})
    end

    def wait_message do
        receive do
            {:ok, message, from, to} ->
            IO.puts "#{message} : #{inspect from} to #{inspect to}"
        end
        
        wait_message()
    end
end

ctrl+c로 iex를 종료 후 다시 실행시켜 위의 방법으로 메시지를 보내보면 죽지 않고 메시지를 계속 수신하는 것을 확인 할 수 있습니다.

그런데 함수에서 자기자신을 계속 다시 호출하여 메시지를 받는 방법이 바람직해 보이지 않을 수 있습니다.

많은 언어에서 이런식으로 계속 호출하다보면 함수가 종료되지않고 스택 프레임에 계속 쌓이게 되어 결국에는 메모리가 부족해지기 때문입니다.

하지만 Elixir에서는 꼬리재귀 최적화가 적용되어 있습니다.

꼬리재귀 최적화는 함수의 마지막으로 수행하는 연산이 자기 자신의 호출일때는, 함수를 추가적으로 호출하지 않고 런타임이 함수의 시작 부분으로 돌아가는 것을 말합니다.

(3)프로세스 100만개 생성해보기

앞서 Elixir의 프로세스는 부하가 매우 적다고 말씀드렸는데, 프로세스를 100만개 생성해보는 테스트를 해보겠습니다.

기존 MessageProcess.ex 파일에 create_processes/1 함수와 run/1 함수를 추가해 줍니다.

defmodule MessageProcess do
    def send_message(pid) do
        send(pid, {:ok, "Hello", self(), pid})
    end

    def wait_message do
        receive do
            {:ok, message, from, to} ->
            IO.puts "#{message} : #{inspect from} to #{inspect to}"
        end
        
        wait_message()
    end

    def create_processes(n) do
        Enum.each(1..n,  fn(_) -> spawn(MessageProcess, :wait_message , []) end)

        send(self(), n)

        receive do
            n -> "Result is #{inspect(n)}"
        end
    end
    
    def run(n) do
        :timer.tc(MessageProcess, :create_processes, [n])
    end
end

create_processes/1 함수는 n개의 프로세스를 생성한 후 자기 자신에게 메시지를 보내 종료시키는 함수입니다.

Enum.each(range, fun)로 1부터 n까지 범위를 돌며 콜백함수를 실행시킵니다. 콜백함수는 spawn/3으로 메시지를 기다리는 프로세스를 생성합니다.

send(self(), n)로 자기자신 프로세스에게 메시지를 보내고,
receive 블록으로 메시지를 받아 보기 좋게 출력하면서 프로세스를 종료시킵니다.

run/1 함수는 Erlang의 tc 내장 라이브러리를 사용해 함수 실행 시간을 측정합니다.

이제 프로세스를 n개 생성해봅시다!

iex를 이용해서 다시 프로젝트를 실행시키고 서버 모니터링 툴을 실행시켜봅시다.

iex -S mix
:observer.start()

기존 프로세스는 79개 생성 되어있습니다.

우선 프로세스를 100개 생성해봅시다.

MessageProcess.run(100)

100개를 만드는데 약 8밀리초가 걸렸습니다.

생성된 프로세스를 하나 잡아서 제대로 동작하는지 테스트 해보겠습니다.

iex에서 다음 명령어로 생성된 프로세스의 pid를 변수에 바인딩하고,
메시지를 보내봅니다.

pid = :erlang.list_to_pid('<0.268.0>')
spawn(MessageProcess, :send_message, [pid])

잘 동작하고 있습니다.

이제 프로세스를 100만개 생성해 보도록 합시다..!

먼저 ctrl+c로 iex를 종료해줍니다.

기본적으로 Erlang VM에 프로세스의 제한 기본값 있기 때문에 +P 파라미터로 프로세스 최대값을 늘려 iex를 실행 시켜줍시다.

서버 모니터링 툴도 실행 시켜주세요.

iex --erl "+P 1000000" -S mix
:observer.start()

그런다음 프로세스를 100만개 생성해봅니다.

MessageProcess.run(1000000)

프로세스를 100만개 생성하는데 약 1.4초 정도 걸렸습니다.

어플리케이션에서 수많은 작은 헬퍼 프로세스를 만들고
각 프로세스가 상태를 가지도록 코드를 설계할 수 있겠습니다.

<참고>

profile
asdasdasdasd

0개의 댓글