KoreanFoodie's Study

[C++ 게임 서버] 3-12. Completion Port 모델 본문

Game Dev/Game Server

[C++ 게임 서버] 3-12. Completion Port 모델

GoldGiver 2023. 10. 18. 21:37

Rookiss 님의 '[C++과 언리얼로 만드는 MMORPG 게임 개발 시리즈] Part4: 게임 서버' 를 들으며 배운 내용을 정리하고 있습니다. 관심이 있으신 분은 꼭 한 번 들어보시기를 추천합니다!

[C++ 게임 서버] 3-12. Completion Port 모델

핵심 :

1. IOCP 모델은 스레드마다 갖고 있는 APC 대신, Completion Port 를 중앙에서 하나 관리한다.

2. 스레드는 Alertable Wait 상태로 돌입하는 것 대신, 서버는 결과를 처리할 수 있을 때 GetQueuedCompletionStatus 를 호출하여 정보를 조회해 알맞은 동작을 수행한다.

3. 만약 특정 쓰레드가 특정 이벤트를 한 번 더 받길 원한다면, iocpHandle 과 바인딩한 함수 내부에서 WSARecv 등을 호출해 이벤트를 더 받을 수 있도록 설정해 주어야 한다.

드디어... 세번째 챕터의 마지막이자 제일 중요한, IOCP 모델에 대해서 알아보자.

먼저, 바로 전에 알아보았던 Overlapped (콜백 기반) 모델을 요약해보면, 다음과 같은 특징을 지니고 있었다 :

 Overlapped 모델 (Completion Routine 콜백 기반)
 - 비동기 입출력 함수 완료되면, 쓰레드마다 있는 APC 큐에 일감이 쌓임
 - Alertable Wait 상태로 들어가서 APC 큐 비우기 (콜백 함수)
 단점) APC큐 쓰레드마다 있다! Alertable Wait 자체도 조금 부담!
 단점) 이벤트 방식 소켓:이벤트 1:1 대응

스레드가 Alertable Wait 상태로 자주 진입하는 것에서 발생하는 성능상의 부담과, APC 큐가 스레드마다 존재한다는 것이 던점이었다. 또한, 이벤트 방식으로 만들게 되면 소켓과 이벤트에 1 대 1 대응을 시켜야 했다.

 

반면, IOCP (Completion Port) 모델의 특징은 다음과 같다 :

 IOCP (Completion Port) 모델
 - APC -> Completion Port (쓰레드마다 있는건 아니고 1개. 중앙에서 관리하는 APC 큐?)
 - Alertable Wait -> CP 결과 처리를 GetQueuedCompletionStatus
 쓰레드랑 궁합이 굉장히 좋다!

기존에 사용했던 APC 대신, 중앙에서 관리하는 Completion Port 1개로 이제 스레드의 이벤트 및 송수신을 관리하게 된다.

그리고 스레드는 이제 Alertable Wait 상태로 가는 것이 아니라, GetQueuedCompletionStatus 를 호출해 현재 상태를 확인하게 된다.

코드를 보고 이해하는 것이 더 빠를 것 같으니, 바로 코드를 보자 🙂

vector<Session*> sessionManager;

// CP 생성
HANDLE iocpHandle = ::CreateIoCompletionPort(INVALID_HANDLE_VALUE, NULL, 0, 0);

// WorkerThreads
for (int32 i = 0; i < 5; i++)
    GThreadManager->Launch([=]() { WorkerThreadMain(iocpHandle); });

먼저, 세션 벡터를 만들어 주고, CreateIoCompletionPort 함수를 이용해 iocp 핸들을 만들어 준다.

CreateIoCompletionPort 함수는 첫 인자로 INVALID_HANDLE_VALUE 를 사용하면 Completion Port 를 생성하고, 인자로 실제 소켓을 넣어주면 해당 소켓을 CP 에 등록하는 용도로 사용된다. 한가지 함수가 생성과 등록을 전부 한다고 이해하면 된다. 😉

WorkerThreadMain 함수에서는 iocpHandle 을 받아 적절한 동작을 수행하는데, iocpHandle 을 통해 WorkerThreadMain 함수가 호출되는 시점은, 스레드가 OS 에게 감지하길 원했던 네트워크 이벤트가 발동되었을 때일 것이다.

WorkerThreadMain 함수에 대한 건 말미에 적도록 하겠다. 😅

 

// Main Thread = Accept 담당
while (true)
{
	SOCKADDR_IN clientAddr;
	int32 addrLen = sizeof(clientAddr);

	SOCKET clientSocket = ::accept(listenSocket, (SOCKADDR*)&clientAddr, &addrLen);
	if (clientSocket == INVALID_SOCKET)
		return 0;

	// xnew 를 사용해 StompAllocator 를 사용했다. 테스트용으로...
	Session* session = xnew<Session>();
	session->socket = clientSocket;
	sessionManager.push_back(session);

	cout << "Client Connected !" << endl;

	// 소켓을 CP에 등록
	::CreateIoCompletionPort((HANDLE)clientSocket, iocpHandle, /*Key*/(ULONG_PTR)session, 0);

	WSABUF wsaBuf;
	wsaBuf.buf = session->recvBuffer;
	wsaBuf.len = BUFSIZE;

	// 여기서는 IO_TYPE 을 '읽기'에만 적용한다
	OverlappedEx* overlappedEx = new OverlappedEx();
	overlappedEx->type = IO_TYPE::READ;

	// 세션 메모리를 해제하면 clientSocket 이 망가지므로, SharedPtr 를 사용하는 것이 좋다
	DWORD recvLen = 0;
	DWORD flags = 0;
	::WSARecv(clientSocket, &wsaBuf, 1, &recvLen, &flags, &overlappedEx->overlapped, NULL);

	// 유저가 게임 접속 종료! 
	// -> 사용하던 세션의 메모리를 함부로 해제하면, 크래시가 발생할 수도 있다!
	//Session* s = sessionManager.back();
	//sessionManager.pop_back();
	//xdelete(s);
	
	// 이제 소켓을 닫거나, 이벤트를 닫을 필요도 없다
	//::closesocket(session.socket);
	//::WSACloseEvent(wsaEvent);
}

// 쓰레드는 사용후 항상 Join 을 해주는 습관을 들이는 것이 좋다.
GThreadManager->Join();

// 윈속 종료
::WSACleanup();

음... 그리고 나면, while 루프를 돌면서 소켓 등록 및 세션 생성을 해 준다.

선행 작업이 끝나면 CreateIoCompletionPort 함수를 이용해 소켓을 CP 에 등록하고, WSARecv 를 호출해 이벤트를 받도록 설정한다.

그럼 끝인가..? 라는 생각이 들 수 있는데... 아까 iocpHandle 에 WorkerThreadMain 을 바인드 했던 것을 기억할지 모르겠다. 즉, 연결한 소켓이 특정 네트워크 이벤트를 받을 수 있는 상황이 되면, OS 는 CP(Completion Port)에서 소켓에 연결된 핸들을 찾아 연결된 함수 (여기서는 WorkerThreadMain) 을 호출할 것이다.

 

그럼 마지막으로, WorkerThreadMain 함수의 구현을 보고 마무리 하자 🤗

void WorkerThreadMain(HANDLE iocpHandle)
{
	while (true)
	{
		DWORD bytesTransferred = 0;
		Session* session = nullptr;
		OverlappedEx* overlappedEx = nullptr;

		// WSARecv 를 통해, 이벤트를 감지했고, 아래 함수를 통해 세션 및 Overlapped 관련 정보를 얻는다 
		BOOL ret = ::GetQueuedCompletionStatus(iocpHandle, &bytesTransferred,
			(ULONG_PTR*)&session, (LPOVERLAPPED*)&overlappedEx, INFINITE);

		if (ret == FALSE || bytesTransferred == 0)
		{
			// TODO : 연결 끊김
			continue;
		}

		// 실제로는 여기서 IO_TYPE 을 검사해서 이것저것 작업을 할 것이다
		ASSERT_CRASH(overlappedEx->type == IO_TYPE::READ);

		cout << "Recv Data IOCP = " << bytesTransferred << endl;

		// 이제 정보를 제대로 받았으니, 
		// WSARecv 함수를 한 번 더 호출해서 WorkerThreadMain 이 동작할 수 있도록 만들어 준다.
		WSABUF wsaBuf;
		wsaBuf.buf = session->recvBuffer;
		wsaBuf.len = BUFSIZE;

		DWORD recvLen = 0;
		DWORD flags = 0;
		::WSARecv(session->socket, &wsaBuf, 1, &recvLen, &flags, &overlappedEx->overlapped, NULL);
	}
}

주석으로 설명이 잘 되어 있지만 부연 설명을 하자면, GetQueuedCompletionStatus 함수를 통해 잠자고 있던 쓰레드를 하나 깨워 관련 정보를 얻고, 원하는 작업을 처리하게 된다.

그리고 만약 해당 쓰레드가 이벤트를 또 받길 원한다면, WSARecv 를 한 번 더 호출해 주어야 한다! 😮

비유하자면... 먼저 main 함수에서 낚싯대를 던졌고(WSARecv 함수를 호출), 고기를 잡은 다음 낚싯대를 한 번 더 던지는(WorkerThreadMain 에서 WSARecv 함수를 호출)하는 것이라고 이해하면 된다 🤣

Comments