Computer Science 기본 지식/소켓 프로그래밍

[게임 서버 프로그래밍 교과서] IOCP 서버

로파이 2021. 8. 19. 21:06

논 블로킹 소켓의 장점

1. 블로킹이 없으므로 중도 취소가 가능하다.

2. 생성하는 스레드 개수가 적고 동시에 많은 소켓을 다룰 수 있다.

3. 연산량이 줄어들고 스레드 개수가 적어지므로 호출 스택 메모리도 낭비되지 않는다.

 

논 블로킹 소켓의 단점

1. 소켓 I/O 함수가 리턴한 코드가 WOULD_BLOCK인 경우 재시도 호출 낭비가 발생한다.

2. 소켓 I/O 함수를 호출할 때 입력하는 데이터 블록에 대한 복사 연산이 발생한다.

3. 재호출 API가 복잡할 수 있다.

 

구체적으로 1) 사용자 프로세스에서 소켓 버퍼로의 데이터 복사에 대한 비용2) send()와 같은 API 함수 호출 비용(송신 버퍼가 꽉 차있는 경우)이 문제가 될 수 있다.

 

Overlapped I/O 혹은 비동기 I/O

 

Overlapped I/O에 관한 구조체를 준비하고 데이터를 담아서 송신한다. Overlapped I/O는 구조체를 통해 진행 중인 상태 현황을 알 수 있도록 해준다. 

Oerlapped I/O에 대한 송수신 처리는 운영체제에서 백그라운드로 진행되기 때문에 (비동기로) 구조체에 포함된 데이터와 상태 객체를 변경하면 안된다.

 

전용함수

- WSASend

- WSASendTo

- WSARecv

- WSARecvFrom

- ConnectEx

- AcceptEx

 

ConnectEx와 AcceptEx를 사용하기 위해서는 따로 전용 AcceptEx 함수 포인터를 가져와야한다.

그 이후 연결된 소켓에 대한 추가 호출이 있다.

 

Epoll (리눅스)

소켓이 I/O 가능 상태가 되면 이를 감지해서 사용자에게 알리고 어떤 소켓인지 알려준다.

 

- 사용법

1. Epoll 객체를 만든다.

2. 여러 소켓을 Epoll에 등록한다.

3. 모든 소켓에 대한 select() 대신 Epoll에서 발생한 이벤트를 꺼내오는 함수를 호출

events = epoll.wait(100ms);

4. 루프를 돌며 각 이벤트에 대하여 연결된 소켓과 사용자 데이터에 대한 처리를 한다.

 

"I/O 가능 상태"라는 것은 송신 버퍼가 빈 공간이 있어 송신이 가능하거나 수신 버퍼에 1바이트 이상 데이터가 있는 상태를 의미한다. 따라서 송신 상황에서 빈 공간 상황일 가능성이 높으므로 epoll 이벤트가 필요 이상으로 발생하고 이는 루프를 돌며 CPU 자원을 낭비한다.

 

작업이 완료되었을 때만 알림을 받을 수 있다면, 그 때마다 송수신을 할 수 가 있다. 이 때 주의해야할 점은 알림을 받은 이후 중간에 또다른 입출력 작업이 완료되었을 때 이를 캐치하기 어려울 수 있으므로, 반복문으로 WOULD_BLOCK 즉, 논블로킹 소켓에 대한 완료 I/O가 없음을 확인해야한다.

 

IOCP (Input/Output Completion Port)

1. IOCP 객체를 만든다.

2. IOCP에 소켓과 사용자 데이터(클라이언트 소켓, 스레드 객체 등)를 등록한다.

3. IOCP 완료 알림을 대기한다.

4. 완료 이벤트를 루프로 돌며 등록한 사용자 데이터를 참조하여 데이터를 수신한다.

5. 다시 Overlapped I/O로 등록한다.

 

IOCP 생성

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

  1. 첫번째 인자 : 등록할 핸들(소켓)
  2. 두번째 인자 : 기존 생성했던 IOCP
  3. 세번째 인자 : 사용자 데이터
  4. 네번째 인자 : IOCP를 사용중인 스레드 개수

- IOCP 객체를 새로 생성할 때,

HANDLE m_hIocp = CreateIoCompletionPort(INVALID_HANDLE_VALUE, NULL, 0, iThreadCount);

 

- 이후에 I/O overapped로 등록할 소켓을 추가할 때,

CreateIoCompletionPort((HANDLE)hSocket, m_hIocp, (ULONG_PTR)userPtr, m_iThreadCount);

 

IOCP 완료 알림 체크

BOOL WINAPI GetQueuedCompletionStatusEx( _In_ HANDLE CompletionPort, _Out_ LPOVERLAPPED_ENTRY lpCompletionPortEntries, _In_ ULONG ulCount, _Out_ PULONG ulNumEntriesRemoved, _In_ DWORD dwMilliseconds, _In_ BOOL fAlertable );

  1. 첫번째 인자 : IOCP 핸들
  2. 두번째 인자 : OVERLAPPED_ENTRY 배열 (발생한 이벤트에 대한 사용자 데이터 및 Overlapped 상태 정보를 담는다.)
  3. 세번째 인자 : 최대 이벤트 개수
  4. 네번째 인자 : 발생한 이벤트 개수
  5. 다섯번째 인자 : 대기 시간
  6. 여섯번째 인자 : 대기 시간에 따른 반환 여부

 

리스닝 소켓

서버에서 클라이언트의 연결 요청을 Overlapped I/O로 처리하는 함수 AcceptEx의 함수 포인터를 받아오는 방법이다.

https://docs.microsoft.com/en-us/windows/win32/api/winsock2/nf-winsock2-wsaioctl

int WSAAPI WSAIoctl( SOCKET s, DWORD dwIoControlCode, LPVOID lpvInBuffer, DWORD cbInBuffer, LPVOID lpvOutBuffer, DWORD cbOutBuffer, LPDWORD lpcbBytesReturned, LPWSAOVERLAPPED lpOverlapped, LPWSAOVERLAPPED_COMPLETION_ROUTINE lpCompletionRoutine );

 

- 함수 포인터 타입 : LPFN_ACCEPTEX

AcceptEX의 함수 포인터 타입

리스닝 소켓의 클래스를 정의한다면 내부에 Overlapped I/O가 가능한 Accept에 대한 함수 포인터를 멤버로 두고 Accept 호출시 최초로 이 함수 포인터를 초기화해야한다.

LPFN_ACCEPTEX m_pAcceptEx = nullptr;
//...
if (m_pAcceptEx == nullptr)
{
	DWORD iBytes = 0;
	UUID uuid = UUID(WSAID_ACCEPTEX);
    
	WSAIoctl(m_hSocket, SIO_GET_EXTENSION_FUNCTION_POINTER,
          &uuid, sizeof(UUID),
          &m_pAcceptEx,
          sizeof(m_pAcceptEx),
          &iBytes,
          nullptr,
          nullptr);

  if (m_pAcceptEx == nullptr)
  {
  	throw Exception("Getting AcceptEx ptr Failed");
  }
}

 

그 이후 해당 AcceptEx를 호출해서 Overlapped I/O를 펜딩시켜놓으면 Completion Port의 완료 알림으로 클라이언트의 연결 요청을 수락할 수 있다. 

char ignored[200];
DWORD ignored2 = 0;

bool ret = m_pAcceptEx(m_hSocket, // Listening Socket
                      _tAcceptCandidateSocket.m_hSocket, // Accepted Socket
                      &ignored,
                      0,
                      50,
                      50,
                      &ignored2,
                      &m_tReadOverlappedStruct) == TRUE;

 

AcceptEx를 사용해서 클라이언트 소켓을 바로 사용하는 것이 아니라 setsockopt를 통해 추가적으로 수락한 클라이언트 소켓에 대하여 AcceptEx를 호출한 리스닝 소켓에 대한 정보를 업데이트 해야한다.

sockaddr_in ignore1;
sockaddr_in ignore3;
INT ingnore2, ignore4;

char ignore[1000] = {};
GetAcceptExSockaddrs(ignore,
  0,
  50,
  50,
  (sockaddr**)&ignore1,
  &ingnore2,
  (sockaddr**)&ignore3,
  &ignore4);

// m_hSocket : Accepted Socket (클라이언트)
// _tListenSocket : Listening Socket (서버)
setsockopt(m_hSocket, SOL_SOCKET, SO_UPDATE_ACCEPT_CONTEXT,
(char*)&_tListenSocket.m_hSocket, sizeof(_tListenSocket.m_hSocket));

IOCP 에코 서버 로직