학습자료(~2017)/네트워크

[네트워크] 윈속 프로그래밍 - IOCP 펌

단세포소년 2012. 1. 11. 11:10
반응형

윈속 프로그래밍 리뷰 - 1

앞으로 몇 회에 걸쳐 윈도우 기반의 서버 프로그래밍 기법에 대해 살펴볼 것이다. 개념적인 설명보다는 실무에 바로 적용될 수 있는 내용들 위주로 설명할 것이다. 이후 대량의 접속을 커버할 수 있는 실전 예제를 만들어 보는 것으로, 독자의 수준을 크게 한 단계 업그레이드시키는 것이 이 글의 궁극적인 목표이다. 독자가 아직 윈속 API나 멀티 쓰레드에 대해 익숙하지 않다면 참고 서적을 옆에 두고 따라오는 것이 좋을 것이다. 
서버 프로그래밍을 하다 보면 정말 웃지 못할 사건들을 많이 접하게 된다. 클라이언트 접속이 1000개까지는 문제가 없다가 1001번째에 갑자기 크래시(crash)가 발생하기도 하고, 서버를 구동시킨 지 2~3일이 지난 시점부터 조금씩 리소스가 새기도 한다. 부지런한 개발자들은 MSDN의 버그 리포트와 테크니컬 기사를 샅샅이 뒤져보며 운영체제 또는 하드웨어의 버그이길 간절히 바란다. 

그러나 아쉽게도 이런 경우의 대부분이 본인의 실수에서 비롯되므로 유용한 단서를 찾기는 힘들다. 몇몇 뛰어난 프로그래머들은 몇 날 몇 일을 밤새워 고생하다 갑자기 무언가 드디어 알아챘다는 듯 멀쩡히 죄 없는 PC를 포맷하기도 한다(필자도 과거에 일말의 희망을 걸고 수차례 포맷해 본 경험이 있다. 물론 다시 설치한다고 해결될 성질의 것이 결코 아니었다). 앞으로의 설명은 서버의 성능이나 최적화 문제보다는 안정적이고 유연한 네트워킹 환경을 구축하는 것에 초점을 맞춰 진행할 것이다.

소켓 API 리뷰
초보 네트워크 개발자들이 겪는 문제들의 많은 부분이 소켓과 TCP의 특성을 제대로 이해하지 못한 것에서 시작한다. 다소 지루할 수도 있겠지만, 먼저 각 소켓 API의 중요한 점을 되짚어 보는 것으로 시작하겠다.

<리스트 1> 소켓 생성 함수
SOCKET socket(
int af, 
int type, 
int protocol 
);
SOCKET WSASocket(
int af,
int type,
int protocol,
LPWSAPROTOCOL_INFO lpProtocolInfo,
GROUP g,
DWORD dwFlags
);

소켓을 생성하는 함수는 두 가지가 있다. <리스트 1>처럼 WSASocket 쪽이 좀더 다양한 프로토콜을 열거할 수 있다. 하지만 우리가 만들 서버는 인터넷 TCP 프로토콜만을 사용하기 때문에 어느 것을 사용해도 문제없다. 단 WSASocket으로 소켓을 생성하는 경우 dwFlags 파라미터에 WSA_FLAG_OVERLAPPED를 넘겨 오버랩드 속성을 가지도록 해야 한다. 그렇지 않으면 이후 다룰 WSASend, WSARecv 등의 오버랩드 호출은 무시될 것이다. 오버랩드 I/O에 관한 내용은 추후 자세히 설명하겠다.

int bind(
SOCKET s, 
const struct sockaddr FAR *name, 
int namelen 
);

빈 소켓만으로는 아무것도 할 수 없다. 소켓과 로컬 주소를 연결시킨 뒤에야 비로소 네트워크 통신을 할 수 있는데, bind가 이러한 역할을 해 준다. 먼저 name 파라미터를 자세히 살펴보자(<리스트 2>).

<리스트 2> name 파라미터 
struct sockaddr {
unsigned short sa_family
char sa_data[14]};
struct sockaddr_in{
short sin_family
unsigned short sin_port
IN_ADDR sin_addr
char sin_zero[8]};


sockaddr은 bind할 주소를 지정하는데 쓰이는 16바이트 크기의 구조체다. 소켓에는 다양한 주소 패밀리(AF_UNIX, AF_INET, AF_IMPLINK, ...)와 각각의 하위 프로토콜이 존재한다. 각 주소 패밀리에 따라 주소 지정 방법이 다를 수 있는데, 우리는 인터넷 프로토콜(AF_INET)을 사용하므로 AF_INET의 주소 지정을 쉽게 하기 위해 우측의 sockaddr_in을 사용한다.

sockaddr_in의 sin_addr에 보통 ADDR_ANY를 집어넣어 멀티홈드 호스트(예 : 여러 LAN 카드가 꽂혀 있는 호스트)의 특정 네트워크를 신경쓰지 않고 프로그래밍한다. 그러나 성능이나 보안 측면을 강화시키기 위해 특정 네트워크의 주소를 입력할 수 있다.






윈속이 제공하는 Name Resolution 함수 중 하나인 gethostbyname를 사용해 로컬 호스트의 네트워크를 열거할 수 있다.

HOSTENT *he = gethostbyname( host_name );
he->h_addr_list[0]; // 첫 번째(예 : LAN CARD #1)
he->h_addr_list[1]; // 두 번째(예 : LAN CARD #2)
he->h_addr_list[2]; // 세 번째(예 : CABLE MODEM)

다음으로 sockaddr_in의 port를 살펴보자. 이 값이 0이면 시스템은 적당한 포트를 찾아 맵핑해 준다. 윈도우 2000에서의 기본 값은 1024~5000 사이의 값인데, 부족할 경우 TCP/IP 관련 레지스트리 키(MaxUserPort)의 최대 값을 변경할 수 있다.

MaxUserPort
Key: HKEY_LOCAL_MACHINESYSTEMCurrentControlSetServicesTcpipParameters
Value Type: REG_DWORDmaximum port number
Valid Range: 500065534 (decimal)
Default: 0x1388 (5000 decimal)

이 키 외에도 MaxFreeTcbs와 MaxHashTableSize 등을 조절해 맵핑될 소켓 수를 조절할 수 있다. 관심있는 독자는 「Microsoft Windows 2000 TCP/IP Implementation Details」를 참고하기 바란다.

그리고 당연하겠지만 같은 주소로의 두 번째 bind는 실패한다. 간혹, 이전 bind된 소켓을 분명히 닫았음에도 두 번째 bind가 실패하는 경우가 있는데, 이것은 이전 소켓이 실제로 완전히 닫히지 않고 TIME_WAIT 상태에 머물러 있기 때문이다. 서버를 재시작하는 경우에도 발생할 수 있는데, 이런 상황을 피하려면 setsockopt 함수를 사용해 SO_REUSEADDR 옵션을 셋팅하면 된다. TCP 상태에 대해 잘 모르고 있다면 TCP/IP 서적 등을 참고해 반드시 숙지하기 바란다.

int connect(
SOCKET s, 
const struct sockaddr FAR *name, 
int namelen 
);

상대방 호스트에 접속하기 위해 connect를 호출한다. connect를 호출하기 위해 bind할 필요는 없다. 소켓을 생성한 뒤 바로 connect를 호출하면 자동으로 시스템이 지정하는 포트로 bind되는데, 이 역시 1024~5000 사이의 값을 가진다. 스트레스 테스트(stress test)를 하면 이 정도의 연결이 부족할 수 있다. 따라서 필요하다면 앞서 언급한대로 레지스트리 값을 적당한 값으로 설정해 주자.









윈속 프로그래밍 리뷰 -2

박범진  (oranze@wemade.com)
int listen(
SOCKET s,
int backlog
);

listen은 소켓에 TCP 접속을 받아들일 수 있는 속성을 부여해 준다. backlog 파라미터는 동시에 접속이 몰렸을 때를 처리하기 위한 큐의 크기인데, 보통 시스템의 최대 값을 지정해서 쓴다. 윈속 2.0 이전 버전에서 이 값의 최대 값은 5였는데, 이것은 접속 요청이 최대 5개까지 큐될 수 있다는 것을 뜻한다. 윈속 2가 등장하면서 SOMAXCONN이라는 상수 값을 사용하는데, 내부적으로 윈도우 2000 서버는 200개, 프로는 5개까지 설정된다. 접속 처리를 위해 accept를 호출하면, backlog 큐의 첫 번째 노드가 삭제되면서 다른 접속 요청을 큐에 넣을 수 있다. backlog 큐가 가득 차면 클라이언트의 connect 호출은 WSAECONNREFUSED 에러를 리턴한다.

SOCKET accept(
SOCKET s,
struct sockaddr FAR *addr,
int FAR *addrlen
);

accept는 서버 소켓의 접속 큐에서 첫 번째 노드를 가져와 소켓을 생성한 뒤 리턴한다. 리턴된 소켓은 s 파라미터와 동일한 속성을 가진다는 것을 기억해 두자.

<리스트 3> 리턴 함수의 사용
int send(
SOCKET s,
const char FAR *buf,
int len,
int flags
);
int recv(
SOCKET s,
char FAR *buf,
int len,
int flags
);

TCP 패킷을 주고받을 때 사용한다. 가장 빈번하게 호출되는 함수인 만큼 초보 네트워크 개발자들이 주의해야 하는 부분이다. TCP는 신뢰할 수 있는(reliable) 스트림 기반의 프로토콜이다. 여기서 스트림 기반이라는 것에 주목할 필요가 있다. 수신자는 언제나 송신자가 전송한 만큼 받기 마련이지만, 이것이 곧 send, recv 함수의 호출 횟수까지 같다는 것을 뜻하지는 않는다.

전송된 패킷은 인터넷의 수많은 게이트웨이를 경유하면서 상대방에게 도착하는데, send 호출과는 관계없이 패킷이 뭉쳐오기도 하고 완전히 조각난 상태로 도착하기도 한다. TCP는 보낸 순서대로 끝까지 도착하는 것을 보장하는 것이지, 전송 횟수까지 보장하는 것은 아니다. 그야 말로 스트리밍 송수신이다. 따라서 반드시 송수신자 간에 패킷의 완료 여부를 알 수 있도록 사인을 해 두어야 한다. 보통 패킷의 앞이나 뒤에 이를 확인할 수 있도록 구조를 잡는다. 다음의 의사 코드(pseudo code)는 일반적으로 소켓 수신을 처리하는 방법을 보여준다.

// TCP 수신 처리 방법
ret = recv( s, buf, sizeof( buf ), 0 );
if ( ret <= 0 ) // ret 에러 처리

// 패킷이 잘려 올 수 있기 때문에 이전 패킷과 합친다.
queue.add( buf, ret );

// 패킷이 뭉쳐 올 수도 있으므로 완료 패킷이 없어질 때까지 반복한다.
while ( queue.has_completion_packet() ){
process_completion_packet( queue.get_completion_packet() );
// 처리한 패킷은 큐에서 삭제한다.
queue.remove_completion_packet();}

또 다른 주의해야 할 점은 send, recv 함수의 리턴 값을 명확히 처리해 두는 것이다. 넌블러킹 소켓에서 send 호출은 우리가 생성한 버퍼(스택 또는 힙)를 커널 버퍼(소켓 버퍼)로 복사하고 커널 버퍼에 복사된 크기를 리턴한다. 이 때 커널 버퍼의 공간이 부족하여 요청한 크기와 리턴된 크기가 다를 수 있는데, 이런 경우 보통 네트워크 지연으로 판단해 접속을 끊거나, 사용자가 만든 송신 큐에 임시로 보관해 두고 다음 송신이 가능해졌을 때 재전송하는 방법으로 해결한다. recv 함수는 보통 수신된 패킷 크기를 리턴하며, 0을 리턴하는 경우 정상적으로 접속이 종료되었다는 것으로 볼 수 있다. 단, 서버가 강제로 접속을 끊는 경우 recv는 SOCKET_ERROR를 리턴하면서 GetLastError() 함수로 WSAECONNRESET와 같은 에러 코드를 얻을 수 있다. 에러를 처리해 두면 send, recv 함수가 왜 실패했는지 명확해지기 때문에, 이후 네트워크 에러가 발생했을 때 어떻게 대처해야 할 것인가는 어렵지 않게 판단할 수 있다.

int shutdown(
SOCKET s,
int how
);
int closesocket(
SOCKET s
);


서버 프로그래밍을 할 때 주의해야 할 부분 중 하나가 소켓을 닫을 때의 처리이다. 안전하게 종료하기 위해서는 모든 데이터를 전송한 뒤 접속을 끊으려 할 때 shutdown 호출을 사용해 이 사실을 상대방에게 알려줘야 한다. 물론 상대방도 마찬가지다. 이러한 처리를 Graceful Closure라고 하며 <표 1>처럼 종료 처리를 한다(MSDN의 Graceful Shutdown, Linger Options, and Socket Closure 참조).

 <1> Graceful Closure 과정
클라이언트 측 서버 측
shutdown( s, SD_SEND ) 호출
- 상대 호스트에 FIN 세그먼트 전송
 
  FD_CLOSE 통보 받음
  남은 데이터 모두 전송
서버로부터 받은 데이터 처리 shutdown( s, SD_SEND ) 호출
- 상대 호스트에 FIN 세그먼트 전송
⑤ FD_CLOSE 통보 받음 closesocket 호출
closesocket 호출  


<표 1>과 같이 shutdown을 사용하면 남아 있는 데이터를 보낼 기회를 제공함으로서 소켓의 연결 종료를 제어할 수 있다. 그런데 아직 한 가지 고려해 볼 문제가 남아 있다. shutdown이나 closesocket 모두 ACK(TCP Handshake)를 확인하지 않고 리턴한다는 점이다. 그렇다면 어떻게 우리가 전송한 데이터가 정말로 보내졌는지 확인할 수 있을까?

Graceful Closure 설명을 하면서 Linger 옵션에 관한 설명을 빠뜨릴 수 없다. Linger 옵션은 closesocket 호출로 소켓이 닫히면서 남아 있는 데이터 전송을 어떻게 다룰 것인가를 설정한다. TCP는 상대방으로부터 보낸 패킷에 대한 ACK를 받아야 전송이 완료된 것으로 간주한다. <표 1>에서 서버 측을 보면 상대방으로부터 FIN 세그먼트를 확인한 뒤 데이터를 보내고 shutdown 호출 후 closesocket 호출로 마침내 소켓을 닫는다. 정상적인 과정이지만 내부적으로 FIN ACK는 물론 이전에 전송한 데이터조차 ACK를 받지 못했을 가능성이 있다. 다행히도 시스템은 기본적으로 소켓이 닫힌 후의 클로징 핸드세이크를 처리할 시간(2MSL)을 준다.

Linger 옵션은 이 시간을 조절할 수 있게 하는데 일반적인 경우에 Linger 옵션을 설정할 필요는 없다. Linger를 설정하는 경우 블러킹 소켓에선 closesocket 호출시 블럭될 수 있고, 넌블러킹 소켓은 closesocket에서 WSAEWOULDBLOCK을 리턴하므로 완료되기까지 수차례 호출해야 한다는 단점이 있다. 간혹 이 시간을 0으로 설정하기도 하는데 이것을 핸드 클로저(Hand Closure)라고 하며, 이 때 서버는 closesocket 즉시 해당 소켓에 관한 모든 리소스를 반납한다. 이 경우 상대방은 모든 데이터를 수신하지 못한 채 WSAECONNRESET 에러를 받기 때문에 특별한 경우가 아니라면 권장하지 않는다. <표 2>는 MSDN에서 발췌한 것으로 Linger 옵션에 따른 closesocket 작동 방식을 나타낸다.

 <2> linger 옵션에 따른 closesocket 작동 방식
Option Interval Type of Close Wait for Close?
SO_DONTLINGER Do not care Graceful No
SO_LINGER Zero Hard No
SO_LINGER Nonzero Graceful Yes

<표 2> Iinger 옵션에 따른 closesocket 방식

오버랩드 I/O
오버랩드(overlapped) I/O란 문자 그대로 중첩된 입출력을 뜻한다. CPU에 비해 디스크나 통신 디바이스의 입출력에 걸리는 속도는 대단히 느리기 때문에 오버랩드 I/O를 사용해 디바이스 입출력시에 걸리는 시간 지연을 피할 수 있다. 물론 윈속은 이미 여러 가지 비동기 입출력 방법을 제공하고 있어, 굳이 오버랩드 I/O를 사용하지 않더라도 거의 같은 성능의 비동기 입출력을 구현할 수 있다. 잠시 후 소개할 IOCP(IO Completion Port)와 함께 사용되기 때문에 한 번쯤 거쳐야 할 관문 정도로만 생각해 두고 부담없이 진행해 나가도록 하자. send, recv 대신 WSASend, WSARecv를 사용해 오버랩드 I/O를 할 수 있다.

<리스트 4> WSASend. WSARecv 함수
int WSASend(
SOCKET s,
LPWSABUF lpBuffers,
DWORD dwBufferCount,
LPDWORD lpNumberOfBytesSent,
DWORD dwFlags,
LPWSAOVERLAPPED lpOverlapped,
LPWSAOVERLAPPED_COMPLETION_ROUTINE
lpCompletionRoutine
);
int WSARecv(
SOCKET s,
LPWSABUF lpBuffers,
DWORD dwBufferCount,
LPDWORD lpNumberOfBytesRecvd,
LPDWORD lpFlags,
LPWSAOVERLAPPED lpOverlapped,
LPWSAOVERLAPPED_COMPLETION_ROUTINE
lpCompletionRoutine
);


함수 파라미터의 구체적인 사용법은 이후에 논하기로 하고, 지금은 WSAOVERLAPPED 구조체를 사용해 함수를 호출한다는 것 정도만 알아두자. 넌블러킹 소켓에서와 마찬가지로 오버랩드를 사용한 WSASend, WSARecv 호출은 특별한 이상이 없는 한 WSAEWOULDBLOCK을 리턴한다. 오버랩드 I/O의 완료 여부를 확인하려면 다음의 함수를 호출하면 된다.

BOOL WSAGetOverlappedResult(
SOCKET s,
LPWSAOVERLAPPED lpOverlapped,
LPDWORD lpcbTransfer,
BOOL fWait,
LPDWORD lpdwFlags
);

사실은 바로 IOCP를 설명해도 되지만, 오버랩드 I/O를 설명하면서 그냥 지나치면 WSAGetOverlappedResult가 섭섭해 할까봐 한번 등장시켜 보았다. 바로 뒤에 설명할 IOCP를 사용해 완료 통보를 받게 되면, 더 이상 이 함수는 설 자리가 없어지기 때문에 독자의 기억 속에 그리 오래 머물 것 같진 않다. 오버랩드 I/O의 다양한 쓰임새나 윈속의 다른 비동기 입출력 방법에 대해 자세히 알고 싶다면, 마이크로소프트 프레스의 「Network Programming for Microsoft Windows」를 참고하기 바란다.
 



윈속 프로그래밍 리뷰 -3
박범진  (oranze@wemade.com)
디바이스 입출력 완료 통보 포트, IOCP
IOCP는 디바이스의 입출력 완료를 통보하기 위한 포트로서, 빠른 입출력 통보 외에 최적화된 쓰레드 풀링 기술을 포함하고 있다. 디바이스와 IOCP를 연결하는 데 개수 제한이 없고, 최적화된 쓰레드 풀링을 통해 고성능 서버를 구축하는데 큰 도움이 되기 때문에, 현재 많은 윈도우 서버 프로그래머들의 사랑을 받고 있는 귀여운 녀석이기도 하다. 제공되는 성능에 비해 사용법 자체는 의외로 간단해 프로그래머는 IOCP를 만들고, 적절한 수의 워커 쓰레드를 생성한 다음 입출력 완료 통보를 기다리기만 하면 된다.

HANDLE CreateIoCompletionPort (
HANDLE FileHandle,
HANDLE ExistingCompletionPort,
ULONG_PTR CompletionKey,
DWORD NumberOfConcurrentThreads
);

IOCP를 만들어 주는 좀 웃기는(?) 함수다. 이 함수는 사실상 두 가지 역할을 하는데, 하나는 이름 그대로 IOCP를 생성하는 것이고(네 번째 파라미터만 사용), 다른 하나는 오버랩드 속성을 지닌 소켓과 IOCP를 연결하는 것이다(앞의 세 파라미터만 사용). 「Programming Server-Side Applications for Microsoft Windows 2000」의 저자 제프리 리처(Jeffrey Richter)도 언급한 것이지만, 함수를 왜 저렇게 만들어 놨는지 도저히 이해되지 않는 부분이다. 어쨌든 꽤 중요한 함수이기에 다음의 일련의 흐름을 보면서 IOCP 체계를 확실히 이해해 둘 필요가 있다.

ꊱ IOCP를 만든다
HANDLE h = CreateIoCompletionPort( INVALID_HANDLE_VALUE, NULL, 0, 0);

처음엔 당연히 IOCP를 만들어야 한다. 먼저 IOCP를 만들 때는 앞의 세 파라미터가 쓰이지 않으므로 가볍게 INVALID_HANDLE_VALUE, 0, 0을 넘겨주자. NumberOfConcurrentThreads에 오버랩드 I/O를 처리하기 위해 동시에 실행할 수 있는 쓰레드의 수를 지정하는데, 0을 넘기면 시스템은 설치된 프로세서(CPU)의 수만큼 할당한다.

ꊲ IOCP를 감시할 쓰레드를 생성한다
SYSTEM_INFO si
GetSystemInfo( &si );

numThreads = si.dwNumberOfProcessors * 2;

for ( i = 0; i < numThreads; i++ )
_beginthreadex( NULL, 0, WorkerThread, ... );

IOCP의 완료 통보를 받을 쓰레드를 생성한다. 좀 이상한 부분이 눈에 띄지 않는가? IOCP를 만들 때 CPU 수만큼의 쓰레드가 동시에 돌아갈 수 있도록 그 수를 제한해 놓고선, 정작 쓰레드는 그 두 배만큼 만들고 있다. 이는 워크 쓰레드가 Wait 상태에 다다를 때(예 : Sleep 호출) IOCP가 또 다른 쓰레드에 완료 통보를 해주기 때문에 여분의 쓰레드를 미리 만들어 두는 것이다. 두 배라고 한 것은 필자 맘대로 정한 수치이고 서버의 구현 방법이나 서비스 내용에 따라 적절한 값을 찾는 것이 좋다.

ꊳ 소켓과 IOCP를 연결시킨다
CreateIoCompletionPort( (HANDLE) my_socket, iocp_handle, completion_key, 0 )

오버랩드 I/O를 IOCP로 통보받기 위해 소켓 핸들과 IOCP 핸들을 연결시켜야 한다. 세 번째 파라미터인 completion_key는 나중에 오버랩드 I/O에 대한 완료 통보를 받을 때, 어떤 소켓으로부터의 완료 통보인지 식별할 수 있게 해주는 것으로 보통 소켓을 포함하고 있는 객체의 주소를 넘긴다. 그리고 앞서 언급했듯 마지막 파라미터는 쓰지 않는다.

ꊴ IOCP를 감시(+_+)한다
WorkerThread()
{
while ( TRUE )
{
GetQueuedCompletionStatus(
iocp_handle, // HANDLE CompletionPort
&bytes_transferred, // LPDWORD lpNumberOfBytes
&completion_key, // PULONG_PTR lpCompletionKey
&overlapped, // LPOVERLAPPED *lpOverlapped
INFINITE ); // WORD dwMilliseconds

// completion_key와 오버랩드를 보면
// 어떤 소켓의 오버랩드 I/O인지 구별할 수 있다.
}
};

처음 보는 함수가 나타났다. 이미 독자도 예상하고 있겠지만 GetQueuedCompletionStatus가 IOCP의 부름(Thread Wake-Up)을 받기 위해 기다리고 있다. 이 함수를 통해 어떤 소켓의 어떤 호출인지, 또 얼마만큼 전송이 되었고 에러 코드는 무엇인지 등을 확인할 수 있다.

ꊵ WSASend, WSARecv 등의 오버랩드 I/O를 시작한다
WSASend(
s, &wsabuf, 1,
&bytes_transferred, 0, &overlapped, NULL );

WSARecv(
s, &wsabuf, 1,
&bytes_transferred, &flag, &overlapped, NULL );

패킷을 주고받기 위해 오버랩드 구조체를 이용한다. WSASend, WSARecv 각각의 파라미터에 주의를 기울일 필요가 있는데, 이에 관한 자세한 설명은 다음 번에 직접 네트워크 라이브러리를 구현하면서 자세히 설명하기로 하고 지금은 IOCP 체계를 이해하는 것에 초점을 맞추자.






두 개의 CPU가 설치된 윈도우 2000에서 <그림 2>와 같은 IOCP 서버가 실행 중이라고 가정해 보자. IOCP를 만들 때 NumberOfConcurrentThreads에 0을 넘겨 동시 쓰레드(concurrent thread)의 수가 두 개가 되도록 했다. #1은 이미 완료 통보를 받아 해당 객체의 송수신을 처리 중이고, #2가 지금 막 완료 통보를 받고 있다. 이렇게 되면 정확히 두 개의 쓰레드가 동시에 실행중인 것이며, IOCP 큐에 완료 통보가 도착하더라도 IOCP는 다른 쓰레드(#3)에 완료 통보를 하지 않는다. 이 시점에서 발생할 수 있는 두 가지 시나리오를 세워 보았다.

◆ 시나리오 1 - #1이 완료 통보 처리를 마침
완료 처리가 끝났기 때문에, #1은 다시 GetQueuedCompletionStatus 함수를 호출한다. 이때 IOCP는 큐에 쌓여 있던 다른 완료 통보를 다시 #1에 넘겨준다. 먼저 기다리고 있던 #3에 넘기지 않는 이유는 쓰레드 컨텍스트 스위칭을 줄이기 위해서다.

◆ 시나리오 2 - #1이 처리 도중 Sleep을 호출
프로그래머가 무슨 생각으로 Sleep을 호출했는지는 모르겠지만 어쨌든 쓰레드 Wait 상태에 돌입한다. 이 때 기다리고 있던 #3이 IOCP로부터 완료 통보를 받는다. 이 시점의 실제 동시 쓰레드 수는 2+1(Wait State)이며, #1이 잠에서 깨어날 경우 순간적으로 IOCP를 만들 때 지정했던 동시성 수의 범위를 초과할 수 있다. 이후 IOCP는 다시 동시 쓰레드의 수가 2가 되도록 조절한다. 이러한 이유로 IOCP 생성시에 지정해 준 NumberOfConcurrentThreads의 수보다 실제로 많은 워커 쓰레드를 생성하는 것이다.

IOCP를 이용한 서버 구현시 주의사항
많은 개발자들이 범하는 대부분의 실수는 멀티 쓰레드와 비동기 입출력의 부족한 이해에 기인한다. 멀티 쓰레드 프로그래밍만 하더라도 어렵고 복잡한데, 여기에 비동기 입출력까지 더해지니 네트워크 개발자들이 겪을 그 혼란은 충분히 짐작할 만하다. 이번엔 IOCP를 이용해 서버 네트워크 코드를 구현할 때 특히 주의해야 점을 알아보기로 하자.

에러 코드를 반드시 확인한다
WSASend, WSARecv 등을 통해 오버랩드 I/O를 할 때 정상적인 경우 WSAEWOULDBLOCK을 리턴한다. 그러나 원격 호스트가 접속을 끊거나(WSAECONNRESET), 가상 회선에 문제가 발생했을 때(WSAECONNABORTED)와 같은 문제는 빈번히 발생한다. 이 경우 별 수 없이 이쪽에서도 접속을 끊는 수밖에 없다. 골치 아픈 부분은 WSAENOBUFS와 같은 에러를 만나는 경우다. 다음 호에서 구현을 통해 자세히 알아보겠지만, 시스템 리소스(커널 리소스) 제한에 걸리게 되면 오버랩드 I/O는 ‘WSAENOBUFS 에러’를 내뱉으며 실패한다. 마찬가지로 그냥 접속을 끊으면 되는 것 아니냐? 라고 반문하겠지만, 그것이 클라이언트가 아니라 대량의 클라이언트가 접속한 상황에서의 서버간 송수신에서 발생하는 것이라면 더욱 심각해진다. 대량의 클라이언트가 접속한 상황에서는 언제든지 시스템 리소스가 바닥날 수 있기 때문에 클라이언트의 연결을 적절히 분산시킬 수 있는 메커니즘이 필요하며, 불가피한 경우 클라이언트의 접속을 제한해야 한다.

참조 카운트를 유지한다
오버랩드 호출을 걸어두고, 완료 통보를 받기도 전에 오버랩드 버퍼나 소켓 객체가 삭제되어서는 안된다. 또한 한 객체에 대해 둘 이상의 오버랩드 호출이 있는 경우엔 반드시 참조 카운트를 유지해야 하며, 객체를 제거해야 하는 경우에 이 참조 카운트가 0인지 확인해야 한다. 참조 카운트를 유지하지 않고 완료 통보가 아직 더 남아있는 상태에서 객체를 삭제하면, 당연한 것이지만 그 다음 완료 통보시 엉뚱한 메모리 위치를(IOCP로 말하자면 CompletionKey나 OverlappedPointer) 가리켜 크래시를 발생시킨다. 원인을 모르고 객체가 삭제된 것에 분개해 정적 메모리 관리 등으로 당장 급한 불을 끄는 것은 근본적인 해결책이 될 수 없다.

데드락을 주의한다
IOCP의 워커 쓰레드만을 이용해 서비스 코드를 구현할 때 주의해야 할 사항이 있다. 주로 샘플 소스로 쓰이는 에코(echo) 서버나, 실제로 IOCP로 구현되어 있는 IIS(Internet Information Server)와 같은 서버는 객체간 상호 참조가 발생되지 않아 이러한 문제는 없다. 그러나 채팅 서버와 같은 상호 참조(즉, 한 객체가 다른 객체에 직접적인 접근이 일어나는 것)가 발생하는 서비스에서는 양방향 상호 참조가 동시에 일어나는 경우에 데드락(dead-lock)이 발생할 수 있다. 따라서 동기화에 각고의 노력을 기울여야 하며, 이것보다는 패킷을 처리하는 전용 쓰레드를 따로 두어 일괄적으로 처리하는 방법을 권한다.

다양한 의견 기다리며
이번 호에서는 본격적인 구현에 앞서 필요한 내용들을 쭉 살펴봤다. 지면 관계상 조금 빠르게 진행된 감이 있는데 부족한 부분은 참고자료를 살펴보기 바란다. 필자도 부족한 부분이 많기 때문에 오해하고 있는 부분이 있거나, 잘못된 코드를 제공할 수 있다. 이런 부분이 발견되면 즉시 연락해 바로잡을 수 있도록 도와주길 바란다. 그리고 이번 기사에 대한 질책이나 조언, 다양한 의견을 접할 수 있다면 앞으로 좋은 기사를 쓰는데 큰 도움이 될 것이다. 부담없이 연락해 주길....



















서버 프로그래밍을 하다 보면 정말 웃지 못할 사건들을 많이 접하게 된다. 클라이언트 접속이 1000개까지는 문제가 없다가 1001번째에 갑자기 크래시(crash)가 발생하기도 하고, 서버를 구동시킨 지 2~3일이 지난 시점부터 조금씩 리소스가 새기도 한다. 부지런한 개발자들은 MSDN의 버그 리포트와 테크니컬 기사를 샅샅이 뒤져보며 운영체제 또는 하드웨어의 버그이길 간절히 바란다.
그러나 아쉽게도 이런 경우의 대부분이 본인의 실수에서 비롯되므로 유용한 단서를 찾기는 힘들다. 몇몇 뛰어난 프로그래머들은 몇 날 며칠을 밤새워 고생하다 갑자기 무언가 드디어 알아챘다는 듯 멀쩡히 죄 없는 PC를 포맷하기도 한다(필자도 과거에 일말의 희망을 걸고 수차례 포맷해 본 경험이 있다. 물론 다시 설치한다고 해결될 성질의 것이 결코 아니었다). 앞으로의 설명은 서버의 성능이나 최적화 문제보다는 안정적이고 유연한 네트워킹 환경을 구축하는 것에 초점을 맞춰 진행할 것이다.

소켓 API 리뷰
네트워크 개발자들이 겪는 문제들의 많은 부분이 소켓과 TCP의 특성을 제대로 이해하지 못한 것에서 시작한다. 다소 지루할 수도 있겠지만, 먼저 각 소켓 API의 중요한 점을 되짚어 보는 것으로 시작하겠다.
소켓을 생성하는 함수는 두 가지가 있다. <리스트 1>처럼 WSA Socket 쪽이 좀더 다양한 프로토콜을 열거할 수 있다. 하지만 우리가 만들 서버는 인터넷 TCP 프로토콜만을 사용하기 때문에 어느 것을 사용해도 문제없다. 단 WSASocket으로 소켓을 생성하는 경우 dwFlags 파라미터에 WSA_FLAG_OVERLAPPED를 넘겨 오버랩드 속성을 가지도록 해야 한다. 그렇지 않으면 이후 다룰 WSASend, WSARecv 등의 오버랩드 호출은 무시될 것이다. 오버랩드 I/O에 관한 내용은 추후 자세히 설명하겠다.

int bind(
SOCKET s,
const struct sockaddr FAR *name,
int namelen
);

빈 소켓만으로는 아무 것도 할 수 없다. 소켓과 로컬 주소를 연결시킨 뒤에야 비로소 네트워크 통신을 할 수 있는데, bind가 이러한 역할을 해 준다. 먼저 name 파라미터를 자세히 살펴보자(<리스트 2>).
sockaddr은 bind할 주소를 지정하는데 쓰이는 16바이트 크기의 구조체다. 소켓에는 다양한 주소 패밀리(AF_UNIX, AF_INET, AF_IMPLINK, ...)와 각각의 하위 프로토콜이 존재한다. 각 주소 패밀리에 따라 주소 지정 방법이 다를 수 있는데, 우리는 인터넷 프로토콜(AF_INET)을 사용하므로 AF_INET의 주소 지정을 쉽게 하기 위해 우측의 sockaddr_in을 사용한다.
보통 sockaddr_in의 sin_addr 필드에 ADDR_ANY를 집어넣는데, 이것은 멀티홈드 호스트(예 : 여러 LAN 카드가 꽂혀 있는 호스트)의 특정 네트워크 주소를 선택하지 않겠다는 뜻이다. 그러나 성능이나 보안 측면을 강화시키기 위해 특정 네트워크의 주소를 입력할 수 있다.
윈속이 제공하는 Name Resolution 함수 중 하나인 gethostb yname를 사용해 로컬 호스트의 네트워크를 열거할 수 있다.

HOSTENT *he = gethostbyname( host_name );
he->h_addr_list[0]; // 첫 번째(예 : LAN CARD #1)
he->h_addr_list[1]; // 두 번째(예 : LAN CARD #2)
he->h_addr_list[2]; // 세 번째(예 : CABLE MODEM)

다음으로 sockaddr_in의 포트를 살펴보자. 이 값이 0이면 시스템은 적당한 포트를 찾아 맵핑해 준다. 윈도우 2000에서의 기본 값은 1024~5000 사이의 값인데, 부족할 경우 TCP/IP 관련 레지스트리 키(MaxUserPort)의 최대 값을 변경할 수 있다.

MaxUserPort
Key: HKEY_LOCAL_MACHINESYSTEMCurrentControlSetServicesTcpipParameters
Value Type: REG_DWORDmaximum port number
Valid Range: 500065534 (decimal)
Default: 0x1388 (5000 decimal)

이외에도 MaxFreeTcbs와 MaxHashTableSize 등을 조절해 맵핑될 소켓 수를 조절할 수 있다. 관심있는 독자는 「Microsoft Win dows 2000 TCP/IP Implementation Details」를 참고하기 바란다.
그리고 당연하겠지만 같은 주소로의 두 번째 bind는 실패한다. 간혹, 이전에 bind된 소켓을 분명히 닫았음에도 두 번째 bind가 실패하는 경우가 있는데, 이것은 이전 소켓이 실제로 완전히 닫히지 않고 TIME_WAIT 상태에 머물러 있기 때문이다. 서버를 재시작하는 경우에도 발생할 수 있는데, 이런 상황을 피하려면 setsockopt 함수를 사용해 SO_REUSEADDR 옵션을 셋팅하면 된다. TCP 상태에 대해 잘 모르고 있다면 TCP/IP 서적 등을 참고해 반드시 숙지하기 바란다.

int connect(
SOCKET s,
const struct sockaddr FAR *name,
int namelen
);

상대방 호스트에 접속하기 위해 connect를 호출한다. connect를 호출하기 위해 bind할 필요는 없다. 소켓을 생성한 뒤 바로 connect를 호출하면 자동으로 시스템이 지정하는 포트로 bind되는데, 이 역시 1024~5000 사이의 값을 가진다. 스트레스 테스트(stress test)를 하면 이 정도의 연결이 부족할 수 있다. 따라서 필요하다면 앞서 언급한대로 레지스트리 값을 적당한 값으로 설정해 주자.

int listen(
SOCKET s,
int backlog
);

listen은 소켓에 TCP 접속을 받아들일 수 있는 속성을 부여해 준다. backlog 파라미터는 동시에 접속이 몰렸을 때를 처리하기 위한 큐의 크기인데, 보통 시스템의 최대 값을 지정해서 쓴다. 윈속 2.0 이전 버전에서 이 값의 최대 값은 5였는데, 이것은 접속 요청이 최대 5개까지 큐될 수 있다는 것을 뜻한다. 윈속 2가 등장하면서 SO MAXCONN이라는 상수 값을 사용하는데, 내부적으로 윈도우 2000 서버는 200개, 프로는 5개까지 설정된다. 접속 처리를 위해 accept를 호출하면, backlog 큐의 첫 번째 노드가 삭제되면서 다른 접속 요청을 큐에 넣을 수 있다. backlog 큐가 가득 차면 클라이언트의 connect 호출은 WSAECONNREFUSED 에러를 리턴한다.

SOCKET accept(
SOCKET s,
struct sockaddr FAR *addr,
int FAR *addrlen
);

accept는 서버 소켓의 접속 큐에서 첫 번째 노드를 가져와 소켓을 생성한 뒤 리턴한다. 리턴된 소켓은 s 파라미터와 동일한 속성을 가진다는 것을 기억해 두자.
TCP 패킷을 주고받을 때 사용한다. 가장 빈번하게 호출되는 함수인 만큼 네트워크 개발자들이 주의해야 하는 부분이다. TCP는 신뢰할 수 있는(reliable) 스트림 기반의 프로토콜이다. 여기서 스트림 기반이라는 것에 주목할 필요가 있다. 수신자는 언제나 송신자가 전송한 만큼 받기 마련이지만, 이것이 곧 send, recv 함수의 호출 횟수까지 같다는 것을 뜻하지는 않는다.
전송된 패킷은 인터넷의 수많은 게이트웨이를 경유하면서 상대방에게 도착하는데, send 호출과는 관계없이 패킷이 뭉쳐오기도 하고 완전히 조각난 상태로 도착하기도 한다. TCP는 보낸 순서대로 끝까지 도착하는 것을 보장하는 것이지, 전송 횟수까지 보장하는 것은 아니다. 그야말로 스트리밍 송수신이다. 따라서 반드시 송수신자 간에 패킷의 완료 여부를 알 수 있도록 사인을 해 두어야 한다. 보통 패킷의 앞이나 뒤에 이를 확인할 수 있도록 구조를 잡는다. 다음의 의사 코드(pseudo code)는 일반적으로 소켓 수신을 처리하는 방법을 보여준다.

// TCP 수신 처리 방법
ret = recv( s, buf, sizeof( buf ), 0 );
if ( ret <= 0 ) // ret 에러 처리

// 패킷이 잘려 올 수 있기 때문에 이전 패킷과 합친다.
queue.add( buf, ret );

// 패킷이 뭉쳐 올 수도 있으므로 완료 패킷이 없어질 때까지 반복한다.
while ( queue.has_completion_packet() ){
process_completion_packet( queue.get_completion_packet() );
// 처리한 패킷은 큐에서 삭제한다.
queue.remove_completion_packet();}

또 다른 주의해야 할 점은 send, recv 함수의 리턴 값을 명확히 처리해 두는 것이다. 넌블러킹 소켓에서 send 호출은 우리가 생성한 버퍼(스택 또는 힙)를 커널 버퍼(소켓 버퍼)로 복사하고 커널 버퍼에 복사된 크기를 리턴한다. 이 때 커널 버퍼의 공간이 부족하여 요청한 크기와 리턴된 크기가 다를 수 있는데, 이런 경우 보통 네트워크 지연으로 판단해 접속을 끊거나 사용자가 만든 송신 큐에 임시로 보관해 두고 다음 송신이 가능해졌을 때 재전송하는 방법으로 해결한다. recv 함수는 보통 수신된 패킷 크기를 리턴하며, 0을 리턴하는 경우 정상적으로 접속이 종료되었다는 것으로 볼 수 있다. 단, 서버가 강제로 접속을 끊는 경우 recv는 SOCKET_ERROR를 리턴하면서 GetLastError() 함수로 WSAECONNRESET와 같은 에러 코드를 얻을 수 있다. 에러를 처리해 두면 send, recv 함수가 왜 실패했는지 명확해지기 때문에, 이후 네트워크 에러가 발생했을 때 어떻게 대처해야 할 것인가는 어렵지 않게 판단할 수 있다.

int shutdown(
SOCKET s,
int how
);
int closesocket(
SOCKET s
);

서버 프로그래밍을 할 때 주의해야 할 부분 중 하나가 소켓을 닫을 때의 처리이다. 안전하게 종료하기 위해서는 모든 데이터를 전송한 뒤 접속을 끊으려 할 때 shutdown 호출을 사용해 이 사실을 상대방에게 알려줘야 한다. 물론 상대방도 마찬가지다. 이러한 처리를 Graceful Closure라고 하며 <표 1>처럼 종료 처리를 한다(MSDN의 Graceful Shutdown, Linger Options, and Socket Closure 참조).
<표 1>과 같이 shutdown을 사용하면 남아 있는 데이터를 보낼 기회를 제공함으로써 소켓의 연결 종료를 제어할 수 있다. 그런데 아직 한 가지 고려해 볼 문제가 남아 있다. shutdown이나 closesocket 모두 ACK(TCP Handshake)를 확인하지 않고 리턴한다는 점이다. 그렇다면 어떻게 우리가 전송한 데이터가 정말로 보내졌는지 확인할 수 있을까?
Graceful Closure 설명을 하면서 Linger 옵션에 관한 설명을 빠뜨릴 수 없다. Linger 옵션은 closesocket 호출로 소켓이 닫히면서 남아 있는 데이터 전송을 어떻게 다룰 것인가를 설정한다. TCP는 상대방으로부터 보낸 패킷에 대한 ACK를 받아야 전송이 완료된 것으로 간주한다. <표 1>에서 서버 측을 보면 상대방으로부터 FIN 세그먼트를 확인한 뒤 데이터를 보내고 shutdown 호출 후 closesocket 호출로 마침내 소켓을 닫는다. 정상적인 과정이지만 내부적으로 FIN ACK는 물론 이전에 전송한 데이터조차 ACK를 받지 못했을 가능성이 있다. 다행히도 시스템은 기본적으로 소켓이 닫힌 후의 클로징 핸드세이크(Closing Handshake)를 처리할 시간(2MSL)을 준다.
Linger 옵션은 이 시간을 조절할 수 있게 하는데 일반적인 경우에 Linger 옵션을 설정할 필요는 없다. Linger를 설정하는 경우 블러킹 소켓에선 closesocket 호출시 블럭될 수 있고, 넌블러킹 소켓은 closesocket에서 WSAEWOULDBLOCK을 리턴하므로 완료되기까지 수차례 호출해야 한다는 단점이 있다. 간혹 이 시간을 0으로 설정하기도 하는데 이것을 하드 클로저(Hard Closure)라고 하며, 이 때 서버는 closesocket 즉시 해당 소켓에 관한 모든 리소스를 반납한다. 이 경우 상대방은 모든 데이터를 수신하지 못한 채 WSAECON NRESET 에러를 받기 때문에 특별한 경우가 아니라면 권장하지 않는다. <표 2>는 MSDN에서 발췌한 것으로 Linger 옵션에 따른 closesocket 작동 방식을 나타낸다.

오버랩드 I/O
오버랩드(overlapped) I/O란 문자 그대로 중첩된 입출력을 뜻한다. CPU에 비해 디스크나 통신 디바이스의 입출력에 걸리는 속도는 대단히 느리기 때문에 오버랩드 I/O를 사용해 디바이스 입출력시에 걸리는 시간 지연을 피할 수 있다. 물론 윈속은 이미 여러 가지 비동기 입출력 방법을 제공하고 있어, 굳이 오버랩드 I/O를 사용하지 않더라도 거의 같은 성능의 비동기 입출력을 구현할 수 있다. 잠시 후 소개할 IOCP(IO Completion Port)와 함께 사용되기 때문에 한 번쯤 거쳐야 할 관문 정도로만 생각해 두고 부담없이 진행해 나가도록 하자. send, recv 대신 WSASend, WSARecv를 사용해 오버랩드 I/O를 할 수 있다.
함수 파라미터의 구체적인 사용법은 이후에 논하기로 하고, 지금은 WSAOVERLAPPED 구조체를 사용해 함수를 호출한다는 것 정도만 알아두자. 넌블러킹 소켓에서와 마찬가지로 오버랩드를 사용한 WSASend, WSARecv 호출은 특별한 이상이 없는 한 WSAE WOULDBLOCK을 리턴한다. 오버랩드 I/O의 완료 여부를 확인하려면 다음의 함수를 호출하면 된다.

BOOL WSAGetOverlappedResult(
SOCKET s,
LPWSAOVERLAPPED lpOverlapped,
LPDWORD lpcbTransfer,
BOOL fWait,
LPDWORD lpdwFlags
);

사실은 바로 IOCP를 설명해도 되지만, 오버랩드 I/O를 설명하면서 그냥 지나치면 WSAGetOverlappedResult가 섭섭해 할까봐 한번 등장시켜 보았다. 바로 뒤에 설명할 IOCP를 사용해 완료 통보를 받게 되면, 더 이상 이 함수는 설 자리가 없어지기 때문에 독자의 기억 속에 그리 오래 머물 것 같진 않다. 오버랩드 I/O의 다양한 쓰임새나 윈속의 다른 비동기 입출력 방법에 대해 자세히 알고 싶다면, 마이크로소프트 프레스의 「Network Programming for Microsoft Windows」를 참고하기 바란다.

디바이스 입출력 완료 통보 포트, IOCP
IOCP는 디바이스의 입출력 완료를 통보하기 위한 포트로서, 빠른 입출력 통보 외에 최적화된 쓰레드 풀링 기술을 포함하고 있다. 디바이스와 IOCP를 연결하는 데 개수 제한이 없고, 최적화된 쓰레드 풀링을 통해 고성능 서버를 구축하는 데 큰 도움이 되기 때문에, 현재 많은 윈도우 서버 프로그래머들의 사랑을 받고 있는 귀여운 녀석이기도 하다. 제공되는 성능에 비해 사용법 자체는 의외로 간단해 프로그래머는 IOCP를 만들고, 적절한 수의 워커 쓰레드를 생성한 다음 입출력 완료 통보를 기다리기만 하면 된다.

HANDLE CreateIoCompletionPort (
HANDLE FileHandle,
HANDLE ExistingCompletionPort,
ULONG_PTR CompletionKey,
DWORD NumberOfConcurrentThreads
);

IOCP를 만들어 주는 좀 웃기는(?) 함수다. 이 함수는 사실상 두 가지 역할을 하는데, 하나는 이름 그대로 IOCP를 생성하는 것이고(네 번째 파라미터만 사용), 다른 하나는 오버랩드 속성을 지닌 소켓과 IOCP를 연결하는 것이다(앞의 세 파라미터만 사용). 「Program ming Server-Side Applications for Microsoft Windows 2000」의 저자 제프리 리처(Jeffrey Richter)도 언급한 것이지만, 함수를 왜 저렇게 만들어 놨는지 도저히 이해되지 않는 부분이다. 어쨌든 꽤 중요한 함수이기에 다음의 일련의 흐름을 보면서 IOCP 체계를 확실히 이해해 둘 필요가 있다.

짾 IOCP를 만든다

HANDLE h = CreateIoCompletionPort( INVALID_HANDLE_VALUE, NULL, 0, 0 );

처음엔 당연히 IOCP를 만들어야 한다. 먼저 IOCP를 만들 때는 앞의 세 파라미터가 쓰이지 않으므로 가볍게 INVALI D_HANDLE_ VALUE, 0, 0을 넘겨주자. NumberOfConcurrent Threads에 오버랩드 I/O를 처리하기 위해 동시에 실행할 수 있는 쓰레드의 수를 지정하는데, 0을 넘기면 시스템은 설치된 프로세서(CPU)의 수만큼 할당한다.

짿 IOCP를 감시할 쓰레드를 생성한다

SYSTEM_INFO si
GetSystemInfo( &si );

numThreads = si.dwNumberOfProcessors * 2;

for ( i = 0; i < numThreads; i++ )
_beginthreadex( NULL, 0, WorkerThread, ... );

IOCP의 완료 통보를 받을 쓰레드를 생성한다. 좀 이상한 부분이 눈에 띄지 않는가? IOCP를 만들 때 CPU 수만큼의 쓰레드가 동시에 돌아갈 수 있도록 그 수를 제한해 놓고선, 정작 쓰레드는 그 두 배만큼 만들고 있다. 이는 워크 쓰레드가 Wait 상태에 다다를 때(예 : Sleep 호출) IOCP가 또 다른 쓰레드에 완료 통보를 해주기 때문에 여분의 쓰레드를 미리 만들어 두는 것이다. 두 배라고 한 것은 필자 맘대로 정한 수치이고 서버의 구현 방법이나 서비스 내용에 따라 적절한 값을 찾는 것이 좋다.

쨁 소켓과 IOCP를 연결시킨다

CreateIoCompletionPort( (HANDLE) my_socket, iocp_handle, completion_key, 0 )

오버랩드 I/O를 IOCP로 통보받기 위해 소켓 핸들과 IOCP 핸들을 연결시켜야 한다. 세 번째 파라미터인 completion_key는 나중에 오버랩드 I/O에 대한 완료 통보를 받을 때, 어떤 소켓으로부터의 완료 통보인지 식별할 수 있게 해주는 것으로 보통 소켓을 포함하고 있는 객체의 주소를 넘긴다. 그리고 앞서 언급했듯 마지막 파라미터는 쓰지 않는다.

쨂 IOCP를 감시(+_+)한다

WorkerThread()
{
while ( TRUE )
{
GetQueuedCompletionStatus(
iocp_handle, // HANDLE CompletionPort
&bytes_transferred, // LPDWORD lpNumberOfBytes
&completion_key, // PULONG_PTR lpCompletionKey
&overlapped, // LPOVERLAPPED *lpOverlapped
INFINITE ); // WORD dwMilliseconds

// completion_key와 오버랩드를 보면
// 어떤 소켓의 오버랩드 I/O인지 구별할 수 있다.
}
};
처음 보는 함수가 나타났다. 이미 독자도 예상하고 있겠지만 Get QueuedCompletionStatus가 IOCP의 부름(Thread Wake-Up)을 받기 위해 기다리고 있다. 이 함수를 통해 어떤 소켓의 어떤 호출인지, 또 얼마만큼 전송이 되었고 에러 코드는 무엇인지 등을 확인할 수 있다.

쨃 WSASend, WSARecv 등의 오버랩드 I/O를 시작한다

WSASend(
s, &wsabuf, 1,
&bytes_transferred, 0, &overlapped, NULL );

WSARecv(
s, &wsabuf, 1,
&bytes_transferred, &flag, &overlapped, NULL );


패킷을 주고받기 위해 오버랩드 구조체를 이용한다. WSASend, WSARecv 각각의 파라미터에 주의를 기울일 필요가 있는데, 이에 관한 자세한 설명은 다음 번에 직접 네트워크 라이브러리를 구현하면서 자세히 설명하기로 하고 지금은 IOCP 체계를 이해하는 것에 초점을 맞추자.
두 개의 CPU가 설치된 윈도우 2000에서 <그림 2>와 같은 IOCP 서버가 실행 중이라고 가정해 보자. IOCP를 만들 때 NumberOf ConcurrentThreads에 0을 넘겨 동시 쓰레드(concurrent thread)의 수가 두 개가 되도록 했다. #1은 이미 완료 통보를 받아 해당 객체의 송수신을 처리 중이고, #2가 지금 막 완료 통보를 받고 있다. 이렇게 되면 정확히 두 개의 쓰레드가 동시에 실행중인 것이며, IOCP 큐에 완료 통보가 도착하더라도 IOCP는 다른 쓰레드(#3)에 완료 통보를 하지 않는다. 이 시점에서 발생할 수 있는 두 가지 시나리오를 세워 보았다.

◆ 시나리오 1 - #1이 완료 통보 처리를 마침
완료 처리가 끝났기 때문에, #1은 다시 GetQueuedCompletion Status 함수를 호출한다. 이때 IOCP는 큐에 쌓여 있던 다른 완료 통보를 다시 #1에 넘겨준다. 먼저 기다리고 있던 #3에 넘기지 않는 이유는 쓰레드 컨텍스트 스위칭을 줄이기 위해서다.

◆ 시나리오 2 - #1이 처리 도중 Sleep을 호출
프로그래머가 무슨 생각으로 Sleep을 호출했는지는 모르겠지만 어쨌든 쓰레드 Wait 상태에 돌입한다. 이 때 기다리고 있던 #3이 IOCP로부터 완료 통보를 받는다. 이 시점의 실제 동시 쓰레드 수는 2+1(Wait State)이며, #1이 잠에서 깨어날 경우 순간적으로 IOCP를 만들 때 지정했던 쓰레드 수의 범위를 초과할 수 있다. 이후 IOCP는 다시 동시에 실행될 쓰레드 수가 2가 되도록 조절한다. 이러한 이유로 IOCP 생성시에 지정해 준 NumberOfConcurrentThreads의 수보다 실제로 많은 워커 쓰레드를 생성하는 것이다.

IOCP를 이용한 서버 구현시 주의사항
많은 개발자들이 범하는 대부분의 실수는 멀티 쓰레드와 비동기 입출력의 이해 부족에 기인한다. 멀티 쓰레드 프로그래밍만 하더라도 어렵고 복잡한데, 여기에 비동기 입출력까지 더해지니 네트워크 개발자들이 겪을 그 혼란은 충분히 짐작할 만하다. 이번엔 IOCP를 이용해 서버 네트워크 코드를 구현할 때 특히 주의해야 점을 알아보기로 하자.

에러 코드를 반드시 확인한다
WSASend, WSARecv 등을 통해 오버랩드 I/O를 할 때 정상적인 경우 WSAEWOULDBLOCK을 리턴한다. 그러나 원격 호스트가 접속을 끊거나(WSAECONNRESET), 가상 회선에 문제가 발생했을 때(WSAECONNABORTED)와 같은 문제는 빈번히 발생한다. 이 경우 별 수 없이 이쪽에서도 접속을 끊는 수밖에 없다. 골치 아픈 부분은 WSAENOBUFS와 같은 에러를 만나는 경우다. 다음 호에서 구현을 통해 자세히 알아보겠지만, 시스템 리소스(커널 리소스) 제한에 걸리게 되면 오버랩드 I/O는 ‘WSAENOBUFS 에러’를 내뱉으며 실패한다. 마찬가지로 ‘그냥 접속을 끊으면 되는 것 아니냐?’고 반문하겠지만, 그것이 클라이언트가 아니라 대량의 클라이언트가 접속한 상황에서의 서버간 송수신에서 발생하는 것이라면 더욱 심각해진다. 대량의 클라이언트가 접속한 상황에서는 언제든지 시스템 리소스가 바닥날 수 있기 때문에 클라이언트의 연결을 적절히 분산시킬 수 있는 메커니즘이 필요하며, 불가피한 경우 클라이언트의 접속을 제한해야 한다.

참조 카운트를 유지한다
오버랩드 호출을 걸어두고, 완료 통보를 받기도 전에 오버랩드 버퍼나 소켓 객체가 삭제돼서는 안된다. 또한 한 객체에 대해 둘 이상의 오버랩드 호출이 있는 경우엔 반드시 참조 카운트를 유지해야 하며, 객체를 제거해야 하는 경우에 이 참조 카운트가 0인지 확인해야 한다. 참조 카운트를 유지하지 않고 완료 통보가 아직 더 남아있는 상태에서 객체를 삭제하면, 당연한 것이지만 그 다음 완료 통보시 엉뚱한 메모리 위치를(IOCP로 말하자면 CompletionKey나 Overlapped Pointer) 가리켜 크래시를 발생시킨다. 원인을 모르고 객체가 삭제된 것에 분개해 정적 메모리 관리 등으로 당장 급한 불을 끄는 것은 근본적인 해결책이 될 수 없다.

데드락을 주의한다
IOCP의 워커 쓰레드만을 이용해 서비스 코드를 구현할 때 주의해야 할 사항이 있다. 주로 샘플 소스로 쓰이는 에코(echo) 서버나, 실제로 IOCP로 구현되어 있는 IIS(Internet Information Server)와 같은 서버는 객체간 상호 참조가 발생되지 않아 이러한 문제는 없다. 그러나 채팅 서버와 같은 상호 참조(즉, 한 객체가 다른 객체에 직접적인 접근이 일어나는 것)가 발생하는 서비스에서는 양방향 상호 참조가 동시에 일어나는 경우에 데드락(dead-lock)이 발생할 수 있다. 따라서 동기화에 각고의 노력을 기울여야 하며, 이것보다는 패킷을 처리하는 전용 쓰레드를 따로 두어 일괄적으로 처리하는 방법을 권한다.

다양한 의견 기다리며
이번 호에서는 본격적인 구현에 앞서 필요한 내용들을 쭉 살펴봤다. 지면 관계상 조금 빠르게 진행된 감이 있는데 부족한 부분은 참고자료를 살펴보기 바란다. 필자도 부족한 부분이 많기 때문에 오해하고 있는 부분이 있거나, 잘못된 코드를 제공할 수 있다. 이런 부분이 발견되면 즉시 연락해 바로잡을 수 있도록 도와주길 바란다. 그리고 이번 기사에 대한 질책이나 조언, 다양한 의견을 접할 수 있다면 앞으로 좋은 기사를 쓰는 데 큰 도움이 될 것이다. 부담없이 연락해 주길..(^^;)

[출처] 윈속 프로그래밍 리뷰

반응형