참조
들어가기 전
이전 게시글에서는 딱딱한 예제들만 이용해왔다. 이번 게시글에서는 erlang을 이용한 간단한 동시성 어플리케이션을 하나 작성한다.
1. Understanding the Problem
요구사항은 다음과 같다.
- 이벤트를 추가함. 이벤트에는 마감일, 이벤트 이름, 설명이 추가됨. (Name, Description, to_go)
- 이벤트 마감일이 되면 경고를 전송
- 이벤트 이름으로 이벤트 검색 및 취소.
- Persistence Disk 없음. 코드가 실행되는 동안 코드를 업데이트 할 수 있어야 함.
- 어플리케이션과의 상호 작용은 Command Line을 통해서 이루어짐. 추후에는 다른 수단을 사용할 수 있도록 확장되어야 함.
1.1 아키텍쳐
이 어플리케이션의 구조는 다음과 같이 작성한다.
1.1.1 Event Server
- 클라이언트에게서 구독 요청을 받아줌.
- 이벤트에게서 발생하는 알람을 구독자에게 전송
- 이벤트를 추가하는 메세지를 받고, 이벤트 프로세스를 생성해줌.
- 이벤트를 취소하는 메세지를 받고, 이벤트 프로세스를 종료
- 클라이언트에 의해서 종료될 수 있음.
- 쉘을 통해 코드를 다시 로딩할 수 있음.
1.1.2 Client
- 이벤트 서버를 구독하고 알림으로 메세지를 수신함.
- 서버에 이벤트를 추가 / 취소 요청
- 서버 모니터링 및 필요한 경우 이벤트 서버 종료
1.1.3 Event
- 실행 대기 중인 알림을 나타냄. 기본적으로 이벤트 서버에 연결된 타이머임.
- 시간이 다 되면 이벤트 서버로 메세지를 보냄.
- 취소 메세지를 받으면 종료됨.
각 컴포넌트에서 발생하는 상호 작용을 정리하면 다음과 같다.
2. Defining the Protocol
위에서는 Component끼리 어떤 통신을 하는지를 설정했다. 여기서는 Component끼리 통신하는데 사용하는 구체적인 프로토콜을 정리한다.
2.1 Client - EventServer
- Client → EventServer로 {subscribe, self()}를 보냄.
- EventServer는 ok로 응답
클라이언트 - EventServer는 Monitor를 이용해 서로 모니터링한다. 서로 Link를 하지 않는 이유는 수많은 클라이언트가 있다고 가정했을 때, 하나의 클라이언트가 죽은 것이 EventServer로 종료 전파 되는 것이 타당하지 않기 때문이다. EventServer는 클라이언트 없이도 동작할 수 있기 때문이다.
- Client → EventServer : {add, Name, Description, Timeout} 요청을 보냄.
- EventServer는 응답으로 ok / {error, Reason} 둘 중에 하나를 응답함.
Client는 이벤트 서버에게 새로운 이벤트 추가를 요청할 수 있다.
- Client → EventServer : {cancel, Name} 요청을 보냄.
- EventServer는 ok를 응답함.
- EventServer → Client : Event의 Duedate이 초과하면, EventServer는 Client에게 Notifiaction을 전송함.
- Notification의 전송 방식은 {done, Name, Description}임.
- Client → EventServer : 종료를 위해 shutdown으로 종료 요청을 보낼 수 있음.
서버가 죽는 경우, Client는 EventServer에 대한 monitor를 가지고 있기 때문에 EventServer는 종료되었다는 응답을 보낼 필요가 없다.
2.2. EventServer - Event
이벤트 서버와 이벤트는 Link로 연결해두는 것이 좋을 수 있다. 그 이유는 EventServer 없이 Event는 의미가 없기 때문이다. EventServer는 Event를 시작할 때 '이름'과 '기한', '설명'을 부여해준다. 이벤트는 특정 시간이 되면 EventServer에게 Notification을 전송해야한다.
- Event → EventServer : Event가 완료된 시점에 EventServer로 Noti를 전송함.
- EventServer → Event : Cancel 요청을 보냄.
- Event는 ok로 응답해야 함.
2.3 erlang shell - EventServer
코드의 Hot Loading이 필요하다. EventServer는 이것을 위한 API를 제공해야한다. erlang shell에서 code_change() API를 호출하면 EventServer의 코드가 리로딩 되어야 한다.
3. Lay Them Foundations
실제 코드를 작성하기 전에 erlang의 표준 디렉토리 구조를 만들어야 한다.
ebin/
include/
priv/
src/
conf/
doc/
lib/
erlang의 표준 디렉토리 구조는 위와 같다. 각각은 무엇을 의미할까?
- ebin : 컴파일된 파일이 저장되는 곳.
- include : 다른 어플리케이션에서 포함할 '.hrl' 파일을 저장하는 곳. private 'hrl' 파일은 주로 src 디렉토리 안에 보관됨.
- priv : erlang과 상호작용해야 할 수도 있는 실행 파일을 저장하는 곳
- src : 모든 .erl 파일이 저장되는 곳임.
- conf: Config 파일들을 저장하는 곳.
- doc : 문서화하기 위해 저장하는 곳
- lib : 실행에 필요한 Dependency를 넣는 곳.
4. Event 모듈 설계
%% my_event.erl
loop(S = #state{server=Server, to_go=[T|Next]}) ->
receive
{Server, Ref, cancel} ->
Server ! {Ref, ok}
after T * 1000 ->
if
Next =:= [] -> Server ! {done, S#state.name};
Next =/= [] -> loop(S#state{to_go=Next})
end
end.
normalize(Time) ->
Limit = 49*24*60*60,
[Time rem Limit | lists:duplicate(Time div Limit, Limit)].
Event 모듈은 다음과 같이 작성한다
- loop() : 실제 프로세스가 작업을 수행하는 곳이다.
- Timeout 시간 이상 후에는 서버에게 메세지를 보낸다.
- erlang에서는 49일까지만 보여줄 수 있기 때문에 이 부분을 해결하기 위해 normalize로 Dealy를 List의 숫자 형태로 바꿔준다.
메세지를 보내는데 있어서 한 가지 차이가 있는 부분이 있다.
- cancel 메세지를 받았을 때는 ServerRef를 함께 받음.
- Event의 시간이 만료되어서 메세지를 보낼 때는 ServerRef를 보내지 않음.
이것은 답장이 필요하고, 안 필요하고의 차이다. 예를 들어 EventServer가 Event에게 취소 요청을 보냈을 때는 답장을 기대하기 때문에 ServerRef를 함께 전송해주는 것이다. 반면 Event의 시간이 만료되어서 EventServer로 메세지를 보내는 것은 EventServer로부터의 응답을 기대하지 않기 때문이다.
%% my_event.erl
start(EventName, Delay) ->
spawn(?MODULE, init, [self(), EventName, Delay]).
start_link(EventName, Delay) ->
spawn_link(?MODULE, init, [self(), EventName, Delay]).
init(Server, EventName, Delay) ->
loop(#state{
server=Server,
name=EventName,
to_go=normalize(Delay)}).
MyEvent를 시작하고 초기화하는 메서드도 따로 만들어둔다.
- 프로토콜을 추상화 한다는 관점에서 의미있음.
- 시간을 normalized() 해주기 때문에 사용자는 erlang의 49일 한계에 대해서 몰라도 됨.
%% my_event.erl
cancel(Pid) ->
Ref = erlang:monitor(process, Pid),
Pid ! {self(), Ref, cancel},
receive
{Ref, ok} ->
erlang:demonitor(Ref, [flush]),
ok;
{'DOWN', Ref, process, Pid, _Reason} ->
ok
end.
MyEvent는 cancel 요청을 보낼 수 있다. 이 프로토콜을 cancel() 메서드로 추상화해서 API로 제공한다.
4.1 Event 모듈 테스트
17> c(event).
{ok,event}
19> event:start("Event", 0).
<0.103.0>
20> flush().
Shell got {done,"Event"}
ok
21> Pid = event:start("Event", 500).
<0.106.0>
22> event:cancel(Pid).
ok
위에 작성한 Event 모듈을 테스트 하기 위한 코드다.
- Event를 만들 수 있음
- 특정 시간 이후, Event가 만료된 것을 받을 수 있음.
- 특정 시간이 되기 전에 Event를 취소할 수 있음.
5. EventServer
이제 EventServer를 만들 수 있다. 가장 먼저 프로세스의 주된 작업을 처리할 loop()문을 구현하고, 해당 loop문에 프로토콜을 이용해서 스켈레톤을 만든다.
-module(evserv).
-compile(export_all).
loop(State) ->
receive
{Pid, MsgRef, {subscribe, Client}} ->
...
{Pid, MsgRef, {add, Name, Description, TimeOut}} ->
...
{Pid, MsgRef, {cancel, Name}} ->
...
{done, Name} ->
...
shutdown ->
...
{'DOWN', Ref, process, _Pid, _Reason} ->
...
code_change ->
...
Unknown ->
io:format("Unknown message: ~p~n",[Unknown]),
loop(State)
end.
Receive 절에 있는 각 패턴 매칭 구문들은 EventServer가 처리할 수 있는 프로토콜을 의미한다. EventServer에게 요청을 보내려고 하면, 반드시 위 메세지를 만족시키는 형태로 보내줘야한다.
-record(state, {events, clients}).
-record(event, {server, description="", pid, name="", to_go=0}).
init() ->
loop(#state{
events=orddict:new(),
clients=orddict:new()}).
EventServer는 EventServer를 구독한 클라이언트, EventServer가 관리하고 있는 Event에 대한 State를 가지고 있어야한다. 따라서 State의 record를 이렇게 표현할 수 있고, 초기 값을 orddict 형태로 관리할 수 있음을 알 수 있다.
- Event가 완료되면 구독자에게 알려야함.
- Event를 취소할 수 있어야 함.
{Pid, MsgRef, {subscribe, Client}} ->
Ref = erlang:monitor(process, Client),
NewClients = orddict:store(Ref, Client, State#state.clients),
Pid ! {MsgRef, ok},
loop(State#state{clients=NewClients});
- 클라이언트가 구독 요청을 했을 때, EventServer가 하는 일이다. EventServer는 클라이언트에 대한 모니터를 추가한 후, 클라이언트 딕셔너리에 저장한다.
- 만약 클라이언트가 죽으면 EventServer에 저장된 Event Monitor가 제거되기 때문에 이후에 따로 연락을 보낼 필요가 없어진다.
{Pid, MsgRef, {add, Name, Description, Timeout}} ->
EventPid = event:start(Name, Timeout),
NewEvents = orddict:store(
Name,
#event{name=Name, description=Description, pid=EventPid, to_go=Timeout},
State#state.events),
Pid ! {MsgRef, ok},
loop(State#state{events=NewEvents});
클라이언트가 새로운 이벤트 추가 요청을 했을 때, EventServer가 처리하는 일이다. EventServer는 새로운 Event를 만들고 관리목록에 추가한다.
{Pid, MsgRef, {cancel, Name}} ->
NewEvents = case orddict:find(Name, State#state.events) of
{ok, E} ->
event:cancel(E#event.pid),
Pid ! {MsgRef, ok},
orddict:erase(Name, State#state.events);
error ->
Pid ! {MsgRef, ok},
State#state.events
end,
이벤트를 취소하는 메세지를 받았을 때, EventServer는 이벤트 이름으로 이벤트가 존재하는지 검색하고, 존재하는 경우 event:cancel()을 호출해서 취소한다.
%% From Event
{done, Name} ->
case orddict:find(Name, State#state.events) of
{ok, E} ->
send_to_clients({
done,
E#event.name,
E#event.description,
State#state.clients}),
NewEvents = orddict:erase(Name, State#state.events),
loop(State#state{events=NewEvents});
error ->
loop(State)
end;
send_to_clients(Msg, ClientDict) ->
orddict:map(fun(_Ref, Pid) -> Pid ! Msg end, ClientDict).
- Event에 설정된 시간이 초과되면 EventServer에게 메세지를 보낸다. EventServer는 이 메세지를 {done, Name} 형태로 받는다.
- EventServer에게 저장된 Event라면, 저장된 이벤트 목록에서 제거하고 send_to_clients()를 호출해서 클라이언트에게 전송해준다.
{'DOWN', Ref, process, _Pid, _Reason} ->
NewClients = orddict:erase(Ref, State#state.clients),
loop(State#state{clients=NewClients});
code_change ->
?MODULE:loop(State);
Unknown ->
io:format("Unknown Message come. ~p~n", [Unknown]),
loop(State)
- 'DOWN'은 클라이언트로부터 오는 메세지이기 때문에 구독자가 종료된 것으로 볼 수 있으며, 관리하는 구독 리스트에서 제거하면 된다.
- code_change 메세지를 받으면 Hot code loading을 해줘야하는데, 이 때 자기 자신의 loop() 함수를 호출하며 현재 State를 넘겨주면 된다.
- ?MODULE:loop()에서 볼 수 있듯이 External call로 호출했다. 이것은 항상 ETS에서 최신 버전의 코드를 로딩해서 호출하겠다는 의미임.
- Unknown은 그 외에 모르는 프로토콜이 왔을 때 전달되는 메세지다.
6. Hot Code Loving
- Erlang에서는 Hot code Loading을 수행하기 위해 Code Server라는 프로세스가 존재한다.
- Code Server는 ETS Table을 담당하는 VM 프로세스다. ETS Table은 인메모리 데이터베이스 테이블을 의미한다.
Code Server는 erlang 모듈 하나당 두 가지 버전을 메모리에 저장할 수 있으며, 두 버전을 동시에 실행할 수도 있다. 모듈의 각 코드는 ETS에 저장된다. 만약 새로운 버전의 코드를 ETS에 저장하고 싶다면 c(module), l(module) 등을 이용해서 ETS에 올릴 수 있다.
6.1 erlang의 함수 call 컨셉과 Hot code loading
- local call : 내부에서 사용하는 함수 call. Atom(Args) 형태로만 호출 가능. → 프로세스에 현재 실행 중인 모듈 버전으로 호출
- external call : 외부에서 사용하는 함수 call. Module:Atom(Args) 형태로 호출 가능. → 코드 서버에서 사용 가능한 최신 버전의 코드 호출.
함수가 어떤 형태로 호출되느냐에 따라서 모듈의 버전이 다르게 호출된다. local call 형태로 호출되는 함수들은 모듈의 새로운 버전이 로드되어도 이전 프로세스의 버전을 그대로 수행한다.
그림으로 살펴보면 이런 형태로 호출한다.
- ?MODULE:myFun()은 external call 형태로 호출하는 것이기 때문에 항상 최신의 코드만 호출함.
- myFun()은 local call 형태로 호출하는 것이기 때문에 이전 프로세스의 코드만 호출함.
3> hot_code_load:start_external("external").
MyName is "external". Externa call. Version is 1.0
MyName is "internal". local call. Version is 1.0
MyName is "external". Externa call. Version is 1.0
MyName is "internal". local call. Version is 1.0
MyName is "external". Externa call. Version is 1.0
MyName is "internal". local call. Version is 1.0
MyName is "external". Externa call. Version is 1.0
MyName is "internal". local call. Version is 1.0
% hot code loading 처리함. External Call은 최신 버전이 호출되기 시작함. (1.0 -> 100.0)
4> c(hot_code_load).
{ok,hot_code_load}
MyName is "external". Externa call. Version is 100.0
MyName is "internal". local call. Version is 1.0
MyName is "external". Externa call. Version is 100.0
MyName is "internal". local call. Version is 1.0
% 두 가지 버전까지만 가질 수 있음 → 두번째 리로드 되었으므로, internal은 더 이상 실행되지 않음.
% external call은 새로운 버전을 호출하므로 100.0 -> 200.0 호출함.
5> c(hot_code_load)
{ok,hot_code_load}
MyName is "external". Externa call. Version is 200.0
MyName is "external". Externa call. Version is 200.0
MyName is "external". Externa call. Version is 200.0
MyName is "external". Externa call. Version is 200.0
6.2 여러 번 핫코드 리로드가 발생하면?
여러 번 Hot code 리로드가 발생한 경우에는 어떻게 동작할까?
- erlang은 ETS에 각 모듈당 최대 2개 버전의 코드를 저장함.
- 특정 모듈이 세번 Hot Code가 리로드되는 경우에는?
이 경우에는 모듈이 세번째 로딩되는 순간 첫번째 버전의 모듈 코드를 사용하고 있던 프로세스는 종료된다. (위에서 실제 예시를 볼 수 있음).
6.3 그래서 어떻게 해야함?
- Hot Code Loading이 필요한 코드이며, 추후에 사용할 계획이 있다면 해당 코드를 external call 형식으로 선언해준다.
?MODULE:loop(State).
erlang은 대부분 loop() 내부에 State를 넘기는 재귀 형식으로 동작하므로 다음 형식으로 작성해주면 된다.
7. Supervisor 추가하기
현재 EventServer가 죽으면 다시 살려주는 녀석은 없다. 어플리케이션의 안정성을 위해 Supervisor를 추가해서 프로세스가 죽을 때 마다 살려주는 것은 좋은 방법이 될 수 있다.
start(Mod, Args) ->
spawn(?MODULE, init, [Mod, Args]).
start_link(Mod, Args) ->
spawn_link(?MODULE, init, [Mod, Args]).
init({Mod, Args}) ->
process_flag(trap_exit, true),
loop({Mod, start_link, Args}).
loop({M, F, A}) ->
Pid = apply(M, F, A),
receive
{'EXIT', _From, shutdown} ->
exit(shutdown);
{'EXIT', Pid, Reason} ->
io:format("Process ~p exited for reason ~p~n", [Pid, Reason]),
loop({M,F,A})
end.
예를 들어 다음 Supervisor 모듈을 통해서 자식 프로세스를 생성하고 실행하는 방식도 가능하다.
- shutdown을 Reason으로 받았을 때는 원하는 동작이기 때문에 슈퍼바이저도 종료한다.
- 그 외의 Reason을 받았을 때는 원하지 않는 동작이기 때문에 죽은 프로세스를 다시 살리는 루프를 돈다.
1> c(evserv), c(sup).
{ok,sup}
% 슈퍼바이저를 통해 프로세스 실행
2> SupPid = sup:start(evserv, []).
<0.43.0>
% 슈퍼바이저에 의해 관리되는 프로세스 제거
4> exit(whereis(evserv), die).
true
Process <0.44.0> exited for reason die
% 제거되었으나 다시 살아있는 것을 확인할 수 있음.
5> exit(whereis(evserv), die).
Process <0.48.0> exited for reason die
true
6> exit(SupPid, shutdown).
true
7> whereis(evserv).
undefined
8. Namespace의 부족
└─src
├─A
├─B
erlang은 flat module structure를 가진다. 따라서 다른 패키지에 동일한 모듈 이름을 가지고 있는 경우, 서로 충돌하는 문제가 발생할 수 있다. 예를 들어 위와 같이 A, B 패키지가 있고, 패키지 안에 my_user라는 모듈이 있다고 가정해보자.
이 때, erlang 파일을 컴파일하면 ebin/ 폴더에 저장되게 되는데 패키지 없이 Beam 파일 형식으로 저장된다. 모듈 이름이 같은 경우, 이런 형태의 충돌이 발생할 수 있기 때문에 조심해야한다.
8.1 해결 방법
% PREFIX_<ModuleName>
api_event_server
order_event_server
api_event
order_event
언어 자체에서 Flat Module 구조를 제공하기 때문에 사용자가 직접 이름으로 분류를 해야한다. 다음 형태로 사용해보면 좋을 듯 하다.
'프로그래밍 언어 > erlang' 카테고리의 다른 글
erlang 공부 : What is OTP? (0) | 2023.12.13 |
---|---|
erlang 공부 : 컴파일 하기 (0) | 2023.12.13 |
erlang 공부 : A Short Visit to Common Data Structures (0) | 2023.12.11 |
erlang 공부 : Errors and Processes (0) | 2023.12.10 |
erlang 공부 : more on multiprocessing (1) | 2023.12.10 |