본문 바로가기
컴퓨터/Server

Reactor / Proactor 패턴

by 김짱쌤 2015. 4. 12.

Event Handling Pattern

I/O 이벤트 통지방식의 기본을 배웠다. 좀더 깊은 이해를 위해 원론적인 부분을 생각할 시간이 필요하다. 이벤트 핸들링 패턴을 보면서 그 내용을 따라가 보자. 우선 이벤트 핸들링에는 몇 가지 주요 동작이 있다.

  • 이벤트 핸들링을 위한 객체들을 초기화(Intiate)하고,
  • 여러 통로에서 들어오는 이벤트들을 수신(recieve)하고,
  • 이벤트들을 대응할 객체별로 분할하고(demultiplex),
  • 개체에게 이벤트를 발송(dispatch)해서,
  • 그 개체가 이벤트에 걸맞는 작업을 수행(process events)한다.

이 일련의 동작으로 동시다발적으로 들어오는 각각의 입력에 대해서 적절한 대응을 수행할 수 있다. 다양한 방법으로 이 추상적인 패턴을 구현하는 것이 가능하다.

Reactor 패턴



reactor패턴은 이벤트 핸들 패턴의 전형적인 모습이다. application이 능동적으로 계속해서 처리하기위한 루프를 도는 것이 아니라, 이벤트에 반응하는 객체(reactor)를 만들고, 사건(이벤트)이 발생하면 application대신 reactor가 반응하여 처리하는 것이다. reactor는 이벤트가 발생하길 기다리고, 이벤트가 발생하면 event handler에게 이벤트를 발송한다. 따로 application에서 event를 대기하고 분할하는 작업을 하지 않아도 동작할 수 있기 때문에 이벤트 multiplexing을 구현하는데 좋은 구조이다. 그 동작 구조를 위의 형식에 맞춰서 설명해본다.

  • 이벤트에 반응하는 reactor를 만들고 reactor에 이벤트를 처리할 event handler들을 등록한다. (initiate)
  • reactor는 이벤트가 발생하기를 기다린다. (receive)
  • 이벤트가 발생하면 이벤트를 처리할 event handler단위로 분할한다.(demultiplex)
  • 분할된 이벤트를 해당 event handler에게 발송한다. (dispatch)
  • event handler에 알맞은 method를 사용하여 이벤트를 처리한다. (process event)

reactor 패턴의 I/O 통지모델은 직접 I/O 이벤트를 대기하는 동기형 multiplex 통지모델이다. select나 epoll, WSAEventSelect 등이 이에 해당한다. 그중에서 epoll이 위의 패턴을 설명하기 편한 사례이다. 파일 디스크립터를 등록받아 FD별로 event handler를 만들고, wait 호출로 이벤트 대기상태에 있다가, 이벤트가 발생하면 바로 연결한 event handler에 등록된 fd와 이벤트 종류들을 포함한 이벤트 구조체를 리턴한다. 그럼 사용자는 해당 구조체에 알맞은 함수를 사용하여 이벤트를 처리한다.

리엑터 패턴에서 리엑터는 등록된 이벤트 핸들러들을 들고 관리해야한다. 때문에 가지고 있을 수 있는 이벤트 핸들러 개수가 제한된다. select에서 FD_SET의 개수가 제한되는 것도 이런 문제라고 생각한다. 동시에 수많은 I/O 요청이 발생하는 경우 이벤트 핸들러가 엄청 많아지는데 그때 각각을 관리하게 되면 눈에 띄게 성능이 저하될 것이다. 만약 관리할 수 있는 범위를 넘게 되어버리면 서버가 터져버릴 지도 모른다!

또 다른 문제는 멀티쓰레드 환경에서 활용도가 높지 않다는 점이다. 하나의 reactor는 하나의 쓰레드만 사용할 수 있다. 멀티 쓰레드를 사용하려면 쓰레드별 reactor를 사용해야되는데, 여기서 OS의 스케쥴링 능력을 활용할 수 없다는 단점이 발생한다. 특정 쓰레드에 부하가 걸리면 다른 쓰레드로 I/O 요청이 넘어가야 하는데, 이 기능은 프로그래머가 직접 구현하는 수 밖에 없다. 직접 구현한다 하더라도 OS가 지원해주는 스케쥴링보다 뛰어나기란 매우 어려울 것이다.

Proactor 패턴



Reactor 패턴의 문제점은 event handler가 비대해지는 경우에 발생한다. reactor가 event handler를 많이 들고 있어야 하는 이유는 이벤트에 반응하기위해서 각 클라이언트의 상태를 지속적으로 관찰해야하기 때문이다. 생각을 좀 바꿔서 이 문제를 해결하려고 한게 Proactor 패턴이다.

Proactor 패턴은 이벤트를 수동적으로 기다리지 않는다. 오히려 능동적으로 비동기 작업을 지시한다. 그러면 비동기 프로세스가 가능한 일거리들을 demultiplexing한 뒤, 작업까지 비동기로 처리한다. 그리고 작업이 완료되면 비동기 프로세스가 completion dispatch에게 이벤트를 넘기고 dispatcher는 적절한 completion handler(Queue)에 이벤트를 dispatch한다. Reactor에서 event가 작업이 가능함을 알리는 event였다면, Proactor에서 event는 작업의 완료를 알리는 event이다. completion handler에 event가 dispatch되면 completion handler는 미리 정해진 콜백을 호출하여 process event를 처리한다.

  • proactor는 비동기 작업을 지시하고 완료 이벤트를 받을 completion handler를 등록한다.(initiate)
  • 비동기 프로세스가 가능한 작업을 대기한다. 혹은 작업이 발생하면 깨어나 처리한다(receive)
  • 가능한 작업들이 생기면 비동기 프로세스가 작업을 분리하여 비동기적으로 처리한다. (demultiplex / work processing)
  • 작업이 완료되면 비동기 프로세스는 completion dispatcher에게 완료 정보를 넘기고 dispatcher는 정보를 이벤트로 만들어서 적절한 completion handler에게 발송한다.(dispatch)
  • completion handler는 받은 이벤트의 정보를 토대로 정해진 콜백을 호출하여 process event를 처리한다.(process event)

proactor 패턴에서 필수적인 것은 비동기 명령을 처리하는 프로세스이다. 직접 구현하는 방법도 있겠지만, 운영체제의 최적화된 스케쥴링의 도움을 받는 것이 현명하다. 최신 운영체제들(windows, 솔라리스)이 이 역할을 담당하여, proactor 패턴의 이벤트 통지모델을 지원한다. 그중 대표적인것이 windows의 IOCP이다. IOCP에서 유저는 completion handler에 해당하는 CompletionPort를 만들고 WSARecv/Send를 호출하여 Proactive한 I/O 명령을 요청한다. 그리고 GetQueuedCompletionStatus를 호출하여 Completion handler에 이벤트가 왔는지 확인하고, 있으면 완료된 이벤트에 적절한 process를 수행한다.

proactor 패턴은 event handler 같은게 없어도 작업이 발생하면 비동기 프로세스가 잘 스케쥴된 환경에서 작업을 알아서 척척 처리하므로, reactor 패턴의 고질적인 문제점을 해결할 수 있었다. 또한 application은 completion handler에 도착한 이벤트만 처리하면 되기 때문에, 복잡한 조건체크같은것 없이도 간결하게 마치 일반 싱글 쓰레드 환경처럼 구성할 수 있다. 대신 비동기 작업이 순차적으로 동작하지 않아서, 순서를 조정하는데 어려움을 겪을 수 있고, 마찬가지의 이유에서 디버깅이 어렵다는 단점이 있다.

비동기 작업 프로세스를 구성해보자.

일반적으로 proactor 패턴을 사용할때는 OS에서 지원해주는 비동기 작업 프로세스를 사용하지만, 직접 구성하는 것도 가능하다. 우선 application의 쓰레드 제어권 밖에있는 별도의 쓰레드에서 동작해야한다. 따라서 비동기 작업을 처리하는 전담 쓰레드를 별도로 만들어서 사용하는 방법으로 구현해야한다. 그리고 기존의 비동기 작업 프로세스의 기능을 수행할 수 있도록 프로그래밍한다.

  • appliction에서 작업이 요청되면, 작업을 전담할 새로운 쓰레드를 생성해야한다.
  • 지나친 생성은 문제를 발생시키니, 쓰레드를 제어하면서 생성할 수 있도록 쓰레드 풀을 만들어서 관리한다.
  • 유저의 명령을 즉각 수행하지 못하는 경우가 발생하므로, 명령 큐를 만들어서 사용한다.
  • 작업이 완료되면 completion handler에 통지를 줘야한다. completion dispatcher 역할을 담당하는 쓰레드를 생성한다.
  • 워커 쓰레드들이 작업 완료시 이 쓰레드로 완료 이벤트를 넘긴다.
  • 이 이벤트는 dispatcher가 가지고 있는 완료 큐에 담긴다.
  • dispatcher가 루프를 돌면서 완료된 이벤트를 적절한 completion handler에 분배한다.

More Proactive!

Proactor와 Reactor의 차이점을 알고나니, 지금까지 사용한 IOCP 서버 구조의 이상한 점을 발견할 수 있었다. WSASend, WSARecv하는 부분은 분명 Proactive한 I/O 작업을 수행하는데, accept하는 main(listen) thread는 왜 while문 하나 끼고 블로킹한 루프를 도는 시대착오적 방식으로 accept를 수행하는가? proactive한 accept가 불가능한 것인가? 그렇게 생각해보면 proactive한 accept라는것이 어려울 것같다는 생각이 들기도 하지만, 검색해보니 이미 잘 만들어진 proactive accept인 AcceptEx가 존재하고 있었다.

AcceptEx

accept를 proactive한 방식으로 구현한 함수. 최신 운영체제 및 개발자 도구에서 지원한다. 미리 여러개의 소켓을 만들어 놓고(소켓 풀을 활용하면 좋겠다.), 여러개의 accept 명령을 비동기로 예약함. accept 가능한 연결이 발생하면 비동기 프로세스가 accept를 수행하고 완료된 결과를 complete handler에게 통지. 통지 받은 complete handler는 연결된 클라이언트에게 이후 작업을 처리하면 깔끔하게 모든걸 proactive하게 구성할 수 있다. 함수의 구성은 다음과 같다.

BOOL AcceptEx( 
   _In_ SOCKET sListenSocket, //listen 소켓
   _In_ SOCKET sAcceptSocket, //새로 연결될 소켓
   _In_ PVOID lpOutputBuffer, //연결시 받은 첫 데이터 블록 + 연결된 주소를 받을 버퍼
   _In_ DWORD dwReceiveDataLength, //연결시 받을 데이터(주소제외) 크기, 0이면 데이터 수신 안함
   _In_ DWORD dwLocalAddressLength, //로컬 주소 받는 버퍼 크기, 최소 16바이트 이상
   _In_ DWORD dwRemoteAddressLength, //원격 주소 받을 버퍼 크기, 최소 16바이트 이상
   _Out_ LPDWORD lpdwBytesReceived, //수신할 총 데이터의 바이트 단위 크기
   _In_ LPOVERLAPPED lpOverlapped //overlapped 구조체 포인터
);
//반환 값 : 에러시 FALSE, 성공시 TRUE 
//WSAGetLastError() : ERROR_IO_PENDING (작업 진행중), WSAECONNRESET(accept하기전에 종료됨)

단순히 연결만 해주는 accept 작업에 그렇게 많은 리소스가 들어가는가? 라고 반문할지도 모르겠지만, 한꺼번에 엄청난 접속을 요청하는 상황을 생각해보자. 인기 온라인 게임을 해본 사람이라면 한번쯤 겪어봤으리라. 한번 accept할때마다 메인 쓰레드가 socket을 생성하고, accept를 하느라 blocking되고 커널모드로 전환되는 것 까지, 그동안 다른 유저들은 계속 대기열에 있고... 아무리 짧은 시간이라도 동시에 처리해야 하는게 엄청나게 많아진다면 문제가 된다. (대기열이 터져버렷!)

이때 proactive한 AcceptEx를 사용한다면, socket 풀을 활용하여 작업을 진행하므로, socket을 할당하는 시간을 절약할 수 있고, accept작업도 최대한 효율적으로 서버의 자원을 활용하여 빠르게 유저를 접속시킬 것이다. 비슷한 이유에서 Connect/Disconnect도 proactive하게 작성할 필요가 생길 수 있다. 아래에 이 함수들의 구성을 추가로 설명한다.

ConnectEx

BOOL PASCAL ConnectEx( 
   _In_ SOCKET s, //연결할 소켓
   _In_ const struct sockaddr *name, //연결할 sockaddr구조체
   _In_ int namelen, //연결할 sockaddr 길이
   _In_opt_ PVOID lpSendBuffer, //연결 직후 전송된 데이터가 저장될 버퍼
   _In_ DWORD dwSendDataLength, //위 버퍼의 사이즈
   _Out_ LPDWORD lpdwBytesSent, //연결 후 전송된 전체 데이터 사이즈
   _In_ LPOVERLAPPED lpOverlapped //overlapped 더 이상의 설명은 생략한다
);
//반환 값 : error시 FALSE, 성공시 TRUE
//WSAGetLastError : ERROR_IO_PENDING(작업 진행중), 
//                  WSAECONNREFUSED, WSAENETUNREACH, WSAETIMEDOUT(다른 connect에서 같은 소켓 사용중)

DisconnectEx

BOOL DisconnectEx(
   _In_ SOCKET hSocket, //종료할 소켓
   _In_ LPOVERLAPPED lpOverlapped, //overlapped 구조체...
   _In_ DWORD dwFlags, //소켓 처리방법(TF_REUSE_SOCKET하면 재사용)
   _In_ DWORD reserved //0을 쓴다.
);
//반환 값 : error시 FALSE, 성공시 TRUE
//WSAGetLastError : WSAEFAULT, WSAEINVAL(lpOverlapped, dwFlags에 잘못된 인자)
//                  WSAENOTCONN (소켓 불량 : 연결안된 소켓, 이미 closing 중)

플래그로 TF_REUSE_SOCKET를 사용하면 종료된 소켓을 재사용할 수 있다. 이것을 활용하여 소켓 풀에서 소켓을 계속해서 할당 해제 하지 않아도 효율적으로 소켓들을 운용할 수 있다. 하지만 TIME_WAIT 상태에 대한 처리가 필요하다. closesocket 호출한다 해도 끝이 아니기 때문이다.  TCP 연결종료 편을 다시 참고하자. (http://ozt88.tistory.com/19) 소켓에 할당된 버퍼들을 확실히 초기화 시키려면, 접속 종료후 대기 시간만큼 기다렸다가 재사용해야할 것이다.


'컴퓨터 > Server' 카테고리의 다른 글

패킷 여행 in TCP/IP 네트워크 스택  (0) 2015.04.12
PAGE_LOCKING  (1) 2015.04.12
windows IOCP 기초  (3) 2015.04.06
Window I/O 통지모델 : WSAAsyncSelect , WSAEventSelect, Overlapped I/O  (1) 2015.03.29
select 와 epoll  (0) 2015.03.28