dh_0e

[C++/Game Server] Completion Port 모델 (IOCP) 본문

C++/Game Server

[C++/Game Server] Completion Port 모델 (IOCP)

dh_0e 2026. 3. 27. 03:54

IOCP (Completion Port) 모델

  • APC queue 대신 Completion Port를 사용하는 모델
    • Completion Port: 스레드마다 있는 것이 아닌 유일한 포트 / 중앙에서 관리하는 APC queue 느낌
  • 멀티 스레드 환경과 궁합이 굉장히 좋음
  • Alertable Wait 대신 Completion Port 결과 처리를 위한 함수 호출
    • CreateIoCompletionPort(): Completion Port를 생성 / 소켓을 Completion Port에 등록
    • GetQueuedCompletionStatus(): OS에게 일감을 기다림

 

CICP (CreateIoCompletionPort)

HANDLE hResult = CreateIoCompletionPort(
  HANDLE    FileHandle,                 // [입력] 등록할 소켓 핸들 (생성 시에는 INVALID_HANDLE_VALUE)
  HANDLE    ExistingCompletionPort,     // [입력] 기존 CP 핸들 (생성 시에는 NULL)
  ULONG_PTR CompletionKey,              // [입력] 완료 시 돌려받을 식별 Key (보통 Session* 주소)
  DWORD     NumberOfConcurrentThreads   // [입력] 동시 실행 허용 스레드 수 (0이면 코어 개수와 동일)
);
  • 2가지 기능이 있음
    1. Completion Port 생성
      • FileHandleINVALID_HANDLE_VALUE으로 설정하면 Completion Port를 생성해서 Handle을 반환해 줌
      • 나머지 인자들은 NULL, 0으로 채워주면 됨
    2. 소켓을 Completion Port에 등록
      • FileHandle: 소켓 핸들을 넣어줌
      • ExistingCompletionPort: 생성한 IOCP Handle을 넣어줌
      • CompletionKey: 식별용 key를 넣어줌 (아무거나 넣으면 됨)
        • Key 값을 Session으로 넣어서 GetQueuedCompletionStatus() 이후 세션을 사용할 수 있게 할 수 있음
      • NumberOfConcurrentThreads: iocp에서 활용할 코어 개수 (0으로 입력하면 최대로 잡아줌)

 

GQCS (GetQueuedCompletionStatus)

BOOL bRet = GetQueuedCompletionStatus(
  HANDLE       CompletionPort,          // [입력] 지켜볼 IOCP 전광판 핸들
  LPDWORD      lpNumberOfBytesTransferred, // [출력] 실제 송수신된 데이터 크기(Byte)를 받을 주소
  PULONG_PTR   lpCompletionKey,         // [출력] 등록 시 넣었던 식별 Key를 받을 주소
  LPOVERLAPPED* lpOverlapped,           // [출력] 요청 시 던졌던 Overlapped 구조체 주소를 받을 주소
  DWORD        dwMilliseconds           // [입력] 대기 시간 (INFINITE: 무한 대기)
);
  • Completion Port에서 IO 작업이 끝날 때까지 대기하여 결과를 받아옴
    • Blocking 함수
  • Completion Port에 등록된 소켓 IO 작업 중 완료가 되면 OS가 대기 중인 스레드 중 하나를 깨움
  • 함수 인자
    • CompletionPort: IOCP Handle을 넣어줌
    • lpNumberOfBytesTransferred: 전송/수신된 데이터의 크기를 받아올 객체를 넣어줌
    • lpCompletionKey: 식별용 key를 받아옴
      • 이를 통해 Session 정보를 받아올 수 있음
    • lpOverlapped: overlapped 정보를 가져올 객체를 넣어줌
      • 마찬가지로 overlapped 정보를 가져올 수 있음
      • OverlappedEx 구조체를 만들어, type을 추가하여 어떤 IO 작업이었는지 확인 가능하게 할 수 있음
    • dwMilliseconds: IO 작업이 끝나기까지 기다릴 대기 시간을 넣어줌
      • INFINITE로 설정하면 일감이 올 때까지 무한정 대기함

 

GameServer.cpp

#include "CoreGlobal.h"
#include "pch.h"
#include <iostream>
#include "CorePch.h"
#include <atomic>
#include <mutex>
#include <Windows.h>
#include <future>
#include "ThreadManager.h"

#include <handleapi.h>
#include <ioapiset.h>
#include <urlmon.h>
#include <winSock2.h>
#include <mswsock.h>
#include <WS2tcpip.h>
#include <winnt.h>

#include "Memory.h"

#pragma comment(lib, "ws2_32.lib")

void HandleError(const char* funcName) {
	int32 errCode = ::WSAGetLastError();
	cout << "Causon Func: " << funcName << endl << "socket error : " << errCode << endl;
}

const int32 BUFFER_SIZE = 1000;

struct Session {
	SOCKET socket;
	char recvBuffer[BUFFER_SIZE] = {};
	int32 recvBytes = 0;
};

enum IO_TYPE {
	READ,
	WRITE,
	ACCEPT,
	CONNECT,
};

struct OverlappedEx {
	WSAOVERLAPPED overlapped = {};
	IO_TYPE type = IO_TYPE::READ;  // 0
};

void WorkerThreadMain(HANDLE iocpHandle) {
	while (true) {
		DWORD bytesTransferred = 0;
		Session* session = nullptr;
		OverlappedEx* overlappedEx = nullptr;
		bool ret = ::GetQueuedCompletionStatus(iocpHandle, &bytesTransferred, (ULONG_PTR*)&session,
											   (LPOVERLAPPED*)&overlappedEx, INFINITE);

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

		ASSERT_CRASH(overlappedEx->type == IO_TYPE::READ);	// 현재 recv만 작업중

		cout << "Recv Data! Len =" << bytesTransferred << endl;

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

		DWORD recvBytes = 0;
		DWORD flags = 0;
		// Recv가 아니라면 overlappedEx->type을 변경해서 넣어줘야 함
		::WSARecv(session->socket, &wsaBuf, 1, &recvBytes, &flags, &overlappedEx->overlapped, NULL);
	}
}

int main() {
	WSADATA wsaData;
	if (::WSAStartup(MAKEWORD(2, 2), &wsaData) != 0)
		return 0;

	SOCKET listenSocket = ::socket(AF_INET, SOCK_STREAM, 0);
	if (listenSocket == INVALID_SOCKET)
		return 0;

	SOCKADDR_IN serverAddr;
	::memset(&serverAddr, 0, sizeof(serverAddr));
	serverAddr.sin_family = AF_INET;
	serverAddr.sin_addr.s_addr = htonl(INADDR_ANY);
	serverAddr.sin_port = htons(7777);

	if (::bind(listenSocket, (SOCKADDR*)&serverAddr, sizeof(serverAddr)) == SOCKET_ERROR)
		return 0;

	if (::listen(listenSocket, SOMAXCONN) == SOCKET_ERROR)
		return 0;

	cout << "Accept" << endl;

	// 모든 세션(Client)을 관리해주는 session 관리자가 있다고 가정
	vector<Session*> sessionManager;

	HANDLE iocpHandle = ::CreateIoCompletionPort(INVALID_HANDLE_VALUE, NULL, 0, 0);

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

	while (true) {
		SOCKADDR_IN clientAddr;
		int clientAddrLen = sizeof(clientAddr);

		// 추후에 accept도 비동기로 바꾸면 코드가 깔끔해질 예정
		SOCKET clientSocket = ::accept(listenSocket, (SOCKADDR*)&clientAddr, &clientAddrLen);
		if (clientSocket == INVALID_SOCKET)
			return 0;

		Session* session = new Session();
		session->socket = clientSocket;
		sessionManager.push_back(session);

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

		::CreateIoCompletionPort((HANDLE)clientSocket, iocpHandle, (ULONG_PTR)session, 0);

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

		OverlappedEx* overlappedEx = new OverlappedEx();
		overlappedEx->type = IO_TYPE::READ;

		DWORD recvBytes = 0;
		DWORD flags = 0;
		::WSARecv(clientSocket, &wsaBuf, 1, &recvBytes, &flags, &overlappedEx->overlapped, NULL);

		// ::closesocket(session.socket);
		// ::WSACloseEvent(wsaEvent);
		// ---------------------------------------
	}
	GThreadManager->Join();

	// 윈속 종료
	::WSACleanup();
	return 0;
}
  • Main Thread
    • 각 클라이언트에 대한 역할: Accept() 후 WSARecv()을 한 번 호출하고 끝
    • Accept & WSARecv만 담당하므로 Non-Blocking이 필요 없음

 

CompletionKey vs Overlapped vs iocpHandle 

CompletionKey: 손님의 이름표 (혹은 테이블 번호)

  • 손님(clientSocket)이 카페(Server)에 들어와서 자리를 잡을 때 딱 한 번 부여받는 고유 ID
  • "이 손님은 7번 테이블 손님이다"라는 정보는 그 손님이 나갈 때까지 변하지 않음

OVERLAPPED 구조체: 개별 주문서 (진동벨 그 자체)

  • OVERLAPPED는 하나의 I/O 요청이 누구의 것인지, 어떤 상태인지를 담고 있는 개별 진동벨
  • 역할: "이 데이터는 A번 세션의 것이고, 현재 읽기 작업 중이다"라는 정보를 담음
  • 특징: WSARecv를 호출할 때마다 매번 던져줘야 함. 주문을 10번 하면 진동벨도 10개가 필요하듯, I/O 요청마다 별도의 OVERLAPPED 객체(혹은 이를 확장한 OverlappedEx)가 필요

iocpHandle: 전광판 (진동벨이 울리는 수신 장치)

  • iocpHandle은 수많은 진동벨(Overlapped) 중 어떤 것이 울렸는지 한데 모아서 알려주는 전광판
  • 역할: 수천 개의 진동벨을 일일이 들여다볼 필요 없이, 전광판(GetQueuedCompletionStatus)만 보고 있으면 "방금 5번 벨 울렸다"라고 알려줌
  • 특징: 여러 스레드가 이 전광판 하나를 같이 쳐다보고 있다가, 주문이 완료되는 대로 가장 먼저 손이 남는 스레드가 튀어나가서 업무를 처리

 

Problem) 유저가 Accept 이후 접속 종료

// Problem) 유저가 접속 종료함
Session* s = sessionManager.back();
sessionManager.pop_back();
delete s;
  • Main Thread의 첫 번째 WSARecv() 다음 줄에 위 코드를 넣어서, "유저가 WSASend()를 한 이후에, 접속을 종료하여 session이 사라진 상황"을 가정
  • GQCS() 이후 값을 확인하면 session에 쓰레기 값이 들어가 문제가 발생하지만 CRASH가 나진 않음

CRASH는 일어나지 않았지만 값을 받지 못하는 상황이 발생

  • Completion Port에 넣은 session, overlapped들은 IO 작업이 걸려있는 상황에선 삭제가 불가능하게 만들어줘야 함
    • Reference count 같은 방법을 사용해야 함
  • 이전에 만든 Stomp Allocator를 사용하여 Use-After-Free 오류를 찾아 CRASH를 유도
 

[C++/Game Server] Stomp Allocator (Use-after-free & Overflow 오류 검증)

Use-After-Free 오류용 Stomp Allocatornew & deleteint main() { SYSTEM_INFO info; ::GetSystemInfo(&info); cout _hp = 100; delete test; test->_hp = 200; return 0;}test->_hp=200; 에서 CRASH가 나지 않음new, delete 같은 힙 할당이 유동적으로

dh-0e.tistory.com

메모리 할당을 xnew(), xdelete()로 변경하여 CRASH 유도

  • Stomp Allocator: Session 메모리를 해제(xdelete)하는 순간 커널 함수(VirtualFree)를 이용해 해당 메모리가 위치한 페이지 자체의 Read/Write 권한을 박탈

  • 이후 권한이 박탈당한 페이지(xdelete 된 session이 저장되었던 페이지)에 접근 시도를 감지하여 CRASH를 내줌

 

PQCS (PostQueuedCompletionStatus)

BOOL bRet = PostQueuedCompletionStatus(
  HANDLE       CompletionPort,          // [입력] 신호를 보낼 대상 IOCP 핸들
  DWORD        dwNumberOfBytesTransferred, // [입력] GQCS가 받을 바이트 수 (임의 지정)
  ULONG_PTR    dwCompletionKey,         // [입력] GQCS가 받을 Key (임의 지정)
  LPOVERLAPPED lpOverlapped             // [입력] GQCS가 받을 Overlapped 주소 (임의 지정)
);
  • 일감을 Completion Port에 억지로 집어넣는 함수
    • GQCS (Get...): 바구니에 일감이 들어올 때까지 기다렸다가 하나 꺼내옴 (소비자)
    • PQCS (Post...): 바구니에 내가 직접 일감을 하나 던져 넣음 (생성자)
  • 사용 사례
    1. 워커 스레드 종료 (퇴근 신호)
      • bytesTransferred에 0을 넣거나, 특정 예약된 CompletionKey를 PQCS로 던지면, GQCS에서 대기하던 스레드가 깨어나서 종료 신호임을 인지하고 루프를 빠져나가게 설계되어 있음
    2. 사용자 정의 이벤트 처리
      • 네트워크 I/O는 아니지만, 워커 스레드들이 처리해줬으면 하는 로직(예: 로직 엔진의 특정 이벤트, 타이머 종료 등)이 있을 때 IOCP 큐를 이용해 일을 시킬 수 있음
      • while(true) 루프를 돌며 GQCS에서 무한 대기 중인 워커 스레드들은 프로그램이 끝날 때까지 깨어나지 않음
        • 이때 메인 스레드에서 프로그램을 종료하기 위해 스레드들을 모두 종료하라는 신호를 보내야 하는데, 이때 PQCS를 사용
        • 보통 이 경우로 많이 사용함