C++ Window IOCP Server

DevCho1107

·

2023. 4. 6. 12:54

이 글은 이직을 준비하는 과정에서 머릿속 개념 정리 및 포트폴리오를 위해 공부하는 과정에서 작성하였다. 

학습에 참고한 유튜브 강의 '최흥배 - C++ 게임서버 개발' 이 많은 도움이 되었다. 

 

참고 링크 #1 : https://github.com/jacking75/edu_cpp_IOCP 

 

GitHub - jacking75/edu_cpp_IOCP: IOCP 실습

IOCP 실습. Contribute to jacking75/edu_cpp_IOCP development by creating an account on GitHub.

github.com

참고 링크 #2 : https://youtu.be/RMRsvll7hrM

깃허브에 단계별로 학습목표 나와있어서 너무 좋다..

 

 

 첫 며칠 동안 윈도우에서 제공하는 IOCP 속 API에 대한 이해가 주된 문제였다. 

3년에 가까운 시간동안 두개의 프로젝트를 하면서 엔진을 들여다 봐야 할 순간이 꽤나 있었지만, 컨텐츠 개발에 들어가는 시간이 80% 이상이라고 봐야했고, 그 외의 업무들 또한 엔진코드를 집중적으로 볼 필요가 없는 일들이었다. (사실 변명이고 내가 공부를 게을리 한 탓)

 

 이직 준비를 시작하는 시점에서 '그래도 내가 컨텐츠 작업하고 있는 서버엔진이 IOCP 모델로 만들어져 있는데, 만들 수는 있어야 되는거 아닌가?' 라는 생각을 하게되었다. 

게임 내 다양한 컨텐츠에 대한 이해도와 작업속도와 노하우를 갖췄지만 이는 지극히 실무적이고 경험적인 것들일 뿐이었다. 원론적이고 기초적인 개념에 대한 목마름이 찾아오기 시작했고 이직에 필요한 포트폴리오로 IOCP 서버를 완벽히 이해하고 만들어 보겠다고 생각했다. 

 

 일단 나는 비동기 동기의 개념에 대해 다시 정리할 필요가 있었다. 기존에 회사에서 사용하는 엔진의 경우 게임 내 컨텐츠에 관련된 부분은 단일스레드로 처리했고, 작업을 할때 데이터레이스 혹은 다중스레드에 관련된 고민을 할 필요가 없었다. 분산 구조의 서버로써 디비서버(다른 프로세스), 게이트서버 등 잘 짜여진 환경 덕분이었다. 다만, 한 가지 특이점으로는 NPC 길찾기의 경우 게임서버에 길찾기 전용 스레드가 있었기 때문에, 해당 부분은 멀티스레딩에 대한 처리가 필요했다. 

(대략적으로는 NPC길찾기 스레드가 담당하는 자료구조에 크리티컬섹션으로 락을 걸고 push 한 뒤, 길찾기 스레드가 꺼내가서 길찾기를 하고, 위에 말한 단일스레드(컨텐츠 메인스레드) 가 읽어갈 수 있게끔 결과에 push 하는 정도) 

 

그리고 소켓 프로그래밍에 대한 부분도 다시 공부해야했다. 

예를들어 달달 외워도 모자랄 수준인 WSAStartup, bind, listen, accept, send ... 예전에 소켓프로그래밍을 공부한 뒤, 시간이 지나면서 자연스럽게 까먹었던게 이유였다.

 

지금부터 아래 순서대로 IOCP 를 공부하면서 정리한 것들을 적어보려한다.

1. IOCP 특징

구글, 각종 글에도 많이 나와있지만 다시 한번 특징을 요약하자면 Windows 에서 제공하는 기술, 멀티스레드 환경에서 비동기 I/O, MMOPRG 게임서버의 경우 많이 사용(여러 세션 수용가능), IOCP 객체를 통한 쉬운 관리 등.  

 

2. IOCP API  

  • CreateIoCompletionPort (IOCP 객체 생성 및 스레드 생성, 소켓과 IOCP 객체 연결)
  • GetQueuedCompletionStatus (비동기 작업 결과 확인)
  • PostQueuedCompletionStatus (비동기 작업 추가)

위에 세개가 전부다. 

 

3. 흐름 

일단 기존 소켓프로그래밍에 등장하는 기본적인 구성에서 위의 API, 작업자 스레드, 그리고 비동기 accept, send, recv 함수가 추가된다. 

그리고 서버의 리슨소켓을 생성할 때 비동기 I/O가 가능하게끔 WSA_FALG_OVERLAPPED 옵션으로 생성해준다. 

bool IOCPServer::Init(const UINT32 ui32ThreadCount)
{
	WSADATA wsaData;

	int nRet = WSAStartup(MAKEWORD(2, 2), &wsaData);
	if (0 != nRet)
	{
		printf("[에러] WSAStartup()함수 실패 : %d\n", WSAGetLastError());
		return false;
	}

	//연결지향형 TCP , Overlapped I/O 소켓을 생성
	_ListenSocket = WSASocket(AF_INET, SOCK_STREAM, IPPROTO_TCP, NULL, NULL, WSA_FLAG_OVERLAPPED);

	if (INVALID_SOCKET == _ListenSocket)
	{
		printf("[에러] socket()함수 실패 : %d\n", WSAGetLastError());
		return false;
	}

	_ui32ThreadCount = ui32ThreadCount;

	printf("소켓 초기화 성공\n");
	return true;
}

bool IOCPServer::BindandListen(int i32Port)
{
	SOCKADDR_IN		stServerAddr;
	stServerAddr.sin_family = AF_INET;
	stServerAddr.sin_port = htons(i32Port); //서버 포트를 설정한다.		
	stServerAddr.sin_addr.s_addr = htonl(INADDR_ANY);

	//위에서 지정한 서버 주소 정보와 cIOCompletionPort 소켓을 연결한다.
	int nRet = bind(_ListenSocket, (SOCKADDR*)&stServerAddr, sizeof(SOCKADDR_IN));
	if (0 != nRet)
	{
		printf("[에러] bind()함수 실패 : %d\n", WSAGetLastError());
		return false;
	}

	nRet = listen(_ListenSocket, 5);
	if (0 != nRet)
	{
		printf("[에러] listen()함수 실패 : %d\n", WSAGetLastError());
		return false;
	}

	//CompletionPort객체 생성 요청을 한다.
	_hIOCP = CreateIoCompletionPort(INVALID_HANDLE_VALUE, NULL, NULL, _ui32ThreadCount);
	if (NULL == _hIOCP)
	{
		printf("[에러] CreateIoCompletionPort()함수 실패: %d\n", GetLastError());
		return false;
	}

	//연결
	auto hIOCPHandle = CreateIoCompletionPort((HANDLE)_ListenSocket, _hIOCP, (UINT32)0, 0);
	if (nullptr == hIOCPHandle)
	{
		printf("[에러] listen socket IOCP bind 실패 : %d\n", WSAGetLastError());
		return false;
	}

	printf("서버 등록 성공..\n");
	return true;
}

 

기본적인 소켓생성 ~ IOCP 객체 연결까지의 과정이다. 

이 후 클라이언트 풀 동적할당 및 I/O워크 스레드 생성, 클라이언트 연결 대기를 위한 Accept 스레드 생성 등을 진행한다. 

bool IOCPServer::StartServer(const UINT32 _ui32ClientCount)
{
	CreateClient(_ui32ClientCount);

	//접속된 클라이언트 주소 정보를 저장할 구조체
	bool bRet = CreateWokerThread();
	if (false == bRet) {
		return false;
	}

	bRet = CreateAccepterThread();
	if (false == bRet) {
		return false;
	}

	printf("서버 시작\n");
	return true;
}

이제 제일 중요한 작업스레드 함수를 보면, 아래와 같다. 

void IOCPServer::WokerThread()
{
	//CompletionKey를 받을 포인터 변수
	iocpClient* pClientInfo = nullptr;
	//함수 호출 성공 여부
	BOOL bSuccess = TRUE;
	//Overlapped I/O작업에서 전송된 데이터 크기
	DWORD dwIoSize = 0;
	//I/O 작업을 위해 요청한 Overlapped 구조체를 받을 포인터
	LPOVERLAPPED lpOverlapped = NULL;

	while (_bOperater)
	{
		bSuccess = GetQueuedCompletionStatus(_hIOCP,
			&dwIoSize,					// 실제로 전송된 바이트
			(PULONG_PTR)&pClientInfo,		// CompletionKey
			&lpOverlapped,				// Overlapped IO 객체
			INFINITE);					// 대기할 시간

		//사용자 쓰레드 종료 메세지 처리..
		if (TRUE == bSuccess && 0 == dwIoSize && NULL == lpOverlapped)
		{
			_bOperater = false;
			continue;
		}

		if (NULL == lpOverlapped)
		{
			continue;
		}

		auto pOverlappedEx = (stOverlappedEx*)lpOverlapped;

		//client가 접속을 끊었을때..			
		if (FALSE == bSuccess || (0 == dwIoSize && IOOperation::ACCEPT != pOverlappedEx->m_eOperation))
		{
			CloseSocket(pClientInfo); //Caller WokerThread()
			continue;
		}

		if (IOOperation::ACCEPT == pOverlappedEx->m_eOperation)
		{
			pClientInfo = GetClientInfo(pOverlappedEx->_ui32RequestPoolIndex);
			if (pClientInfo->AcceptCompletion())
			{
				//클라이언트 갯수 증가
				++_i32ClientCount;

				OnConnect(pClientInfo->GetIndex());
			}
			else
			{
				CloseSocket(pClientInfo, true);  //Caller WokerThread()
			}
		}
		//Overlapped I/O Recv작업 결과 뒤 처리
		else if (IOOperation::RECV == pOverlappedEx->m_eOperation)
		{
			OnReceive(pClientInfo->GetIndex(), dwIoSize, (BYTE*)pClientInfo->GetRecvBuffer());

			pClientInfo->ReserveRecv();
		}
		//Overlapped I/O Send작업 결과 뒤 처리
		else if (IOOperation::SEND == pOverlappedEx->m_eOperation)
		{
			pClientInfo->SendCompleted(dwIoSize);
		}
		//예외 상황
		else
		{
			printf("Client Index(%d)에서 예외상황\n", pClientInfo->GetIndex());
		}
	}
}

GetQueuedCompletionStatus 함수를 통해 완료된 I/O 작업에 대한 정보를 가져오는데, pOverlappedEx 에 요청했던 Overlapped 구조체가 담아져 나온다. 

 

함수 밑쪽에 Accpet 작업 요청의 경우 해당 overlapped구조체에 있던 풀 인덱스를 통해 해당 클라이언트를 찾고, 

(1) 클라이언트 소켓과 IOCP 핸들 연결(IOCP에 등록)

(2) 클라이언트로부터 수신 가능하게끔 RecvEx를 통한 Recv 요청

(3) 유저정보 초기화 

를 진행한다. 

 

Recv의 경우 수신된 패킷의 처리를 완료 한 후, ReserveRecv 함수를 통해 계속해서 다음 패킷 수신이 가능하게끔 요청한다. 

 

Send의 경우 서버에서 해당 클라이언트에게 패킷전송을 IOCP에 요청한 부분에 대한 결과가 반환된다. 

그 전에 Send하는 부분에서 해당 클라이언트의 SendQueue 를 보고, 무조건 SendEx를 통해 발신예약을 하는 것이 아닌, 동시 SendEx 요청이 안되게끔 컨트롤 하고있다. 

 서버에서는 어떠한 이유에서든 클라이언트에게 짧은시간에 많은 패킷전송이 필요할 수 있다. 이 때 클라이언트(세션)의 SendQueue 에 쌓아놓기만 하고, GetQueuedCompletionStatus 에서 앞의 Send가 완료됬다는(queue에서 사이즈가 줄은) 경우와 Send I/O 작업이 완료된 경우 다음 보내야 할 패킷이 있는 경우 다시 SendEx 를 통해 Send를 예약한다. 

 

위 함수는 여러 스레드에서 실행된다.

때문에 Lock을 통해 Queue등의 임계영역을 방지하고있다. 

 

그리고 게임스레드의 경우 단일 스레드이다. 

게임 컨텐츠 처리 스레드는 단일 스레드인 이유는, 게임 컨텐츠 처리 과정이 대체로 순차적인 처리가 필요하기 때문이다. 게임에서는 보통 매우 정밀한 처리가 필요한 경우가 많기 때문에, 데이터 일관성을 유지하기 위해서는 순차적인 처리가 필요하다. 그러나 이런 게임 컨텐츠 처리의 경우도 멀티 스레드를 이용해 처리 할 수 있다. 대신 엄청 복잡하다고 한다.(동기화, 그 외 Nolock 기법을 위한 알고리즘 등)

언젠가 듣기로 달빛조각사 M 의 경우 멀티스레딩 특화 언어를 사용해서 컨텐츠 스레드도 여러개라고 들었는데.. 

 

그리고 내가 작업하는 서버의 경우, NPC 길찾기 스레드는 별도의 스레드다. 

 

 

 

 

 

 

'< Programming > > C++' 카테고리의 다른 글

constexpr ( generalized constant expressions )  (0) 2023.05.04
C++ 17 에서 업데이트 된 기능 정리.  (0) 2023.04.25
RingBuffer 구현예제.  (0) 2023.04.20
std::Funtion 정리  (0) 2023.04.06
override & virtual 사용하기  (0) 2020.02.03