나만의 작은 도서관
[C++] 게임서버 탐구 일지 #9. 소켓 모델 - IOCP 모델 본문
목차
- Overlapped 모델의 아쉬운 성능
- IOCP 모델의 특징
- IOCP 모델의 작동 과정
- IOCP 모델을 이용한 서버 만들기
- 깃헙 코드
- 참고 자료
Overlapped 모델의 아쉬운 성능
Overlapped 모델도 나름 성능이 준수하지만, 이전 글에서 알아봤듯, 이벤트 기반 / 콜백 기반 둘 다 성능을 떨어뜨리는 요인을 가지고 있었다. 물론, 그럼에도 Overlapped 모델은 준수한 성능을 보여주지만 게임서버에서 '준수한 성능' 정도로는 용납되지 않는다. 서버의 성능이 중요한 게임서버는 더 좋은 성능을 가진 모델이 있다면 그 모델을 써야할 것이다.
따라서, 오늘은 게임 회사에서 자주 사용할 정도로 성능이 좋은 IOCP 모델에 대해 알아보도록 하겠다.
IOCP 모델의 특징
IOCP 모델이란?
IOCP는 I/O Completion Port의 약자로, 입출력 작업 시, Completion Port(줄여서 CP)를 핵심으로 이용하는 모델을 IOCP 모델이라 부른다. CP는 콜백 기반의 Overlapped 모델을 이해하고 있으면 알기 쉬운데, 그 이유는 CP가 글로벌하게 있는 하나의 APC 큐처럼 작동하기 때문이다. 즉, 비동기 I/O 작업이 완료되었을 때 Overlapped 모델에서 APC 큐에 완료 작업을 넣었던 것처럼, IOCP 모델에서는 CP에 완료 작업(IOCP에선 완료 패킷이라 부른다)을 넣는다.
멀티쓰레드 환경에 친화적
지금까지 알아본 소켓 모델 중 IOCP 모델이 가장 멀티쓰레드 환경에 친화적인 모습을 보인다. 아래에서 알아보겠지만 IOCP 모델은 비동기 I/O 작업을 호출하는 역할과 완료된 I/O 작업을 처리하는 역할이 분리되어 있기 때문에 1) 각 역할에 서로 다른 쓰레드를 배치하여 작업을 처리할 수 있다. 게다가, 2) 완료된 I/O 작업을 처리하는 쓰레드는 어느 쓰레드의 완료된 작업인지 상관없이 CP에서 가져와 작업을 처리할 수 있다.
컨텍스트 스위칭 비용이 적음
접속한 유저가 많다고 각 유저마다 쓰레드를 배치시킨다면 잦은 문맥 교환으로 인해 성능이 좋지 못할 것이다. 그래서 높은 성능으로 수많은 접속을 유지하려면, 쓰레드를 사용하되 생성한 쓰레드의 재사용 빈도를 높여야 할 것이다. IOCP 모델은 Release 상태(실행 상태)인 쓰레드 수를 제한한 다음, LIFO(Last-In, First-Out) 방식으로 관리함으로써 쓰레드의 재사용 빈도 + Hit 가능성을 높였다. 이로 인해 문맥 교환 빈도가 줄어 컨텍스트 스위칭 비용이 다른 모델들에 비해 적다.
IOCP 모델의 작동 과정
- IOCP(입출력 완료 포트) 생성: CreateIoCompletionPort() 함수(일명 CICP)를 통해 CP를 하나 생성해 준다.
- 워커 쓰레드(작업자 쓰레드)를 생성 및 IOCP에 연결: 워커 쓰레드는 완료한 I/O 작업을 처리하는 쓰레드들이다. 운영할 워커 쓰레드의 개수는 수동으로 설정해야 하는데, 일반적으로 코어 개수 * 2개를 생성한다(추가 설명 2 참고). 생성을 했다면 CP와 연결하여 해당 CP의 워커 쓰레드임을 시스템에게 알려준다.
- Listen 소켓 생성: 클라이언트 소켓의 연결 요청을 Accept 할 Listen 소켓을 생성한다.
- 클라이언트 소켓을 CP에 등록: Listen 소켓을 통해 얻은 클라이언트 소켓을 CP와 등록한다. 등록하지 않으면 해당 소켓의 I/O 작업이 완료되어도 CP에 완료 패킷이 추가되지 않는다.
- 비동기 입출력 시작(1번): 쓰레드(워커 쓰레드 아님)에서 WSARecv()와 같은 비동기 입출력 함수를 호출하면 윈도우 I/O 시스템에서 비동기 입출력 작업 수행을 요청한다. 이후 쓰레드는 비동기 작업이 완료되길 기다리지 않고 다음 작업을 수행한다. (비동기 방식)
- 예약 걸어두기(2번): 워커 쓰레드는 GetQueuedCompletionStatus() 함수를 호출하여 CP에 쌓인 일감(완료 패킷을 의미)이 있으면 받겠다는 의미로 Wait Thread Queue에 들어간다.
- 비동기 입출력 완료(3번): 윈도우 I/O 시스템이 비동기 입출력 작업을 완료했다면, 해당 작업을 호출한 소켓과 연결된 CP를 찾아 일감을 넣는다(추가 설명 3 참고).
- 일감을 작업할 쓰레드 배정: 성공적으로 일감을 CP에 넣었다면, 시스템은 실행 중인 쓰레드 수가 상한선에 도달했는지 Release Thread Queue에서 확인한다. 만약 상한선에 도달한 상태라면 실행 중인 쓰레드 수가 줄어들 때까지 대기하고, 그렇지 않다면 Wait Thread Queue에서 가장 최근에 사용한 쓰레드를 깨운 다음(LIFO 방식), 일감을 넘겨준다.
- 일감 처리(4번) : 시스템에 의해 깨워진 쓰레드는 넘겨받은 일감을 처리한다. 처리가 끝났다면 다시 예약을 걸어두며, 2~4번 과정을 반복할 준비를 한다. 이때, 운이 좋으면 대기 상태로 가지 않고 연달아 일감을 처리할 수도 있다.
추가 설명 1. IOCP 커널 오브젝트 구성
CICP 함수로 CP를 생성하면 커널 모드에서 위 그림과 같이 5가지를 가진 IOCP 객체를 하나 생성한다. 각각은 다음가 같다.
- 장치 리스트: 연결된 장치의 데이터 저장하는 데이터 구조. 소켓 데이터 등을 저장하며, 비동기 입출력 작업이 완료되었을 때 장치 리스트를 통해 작업을 호출한 소켓과 연결된 IOCP를 찾는다.
- I/O Completion Queue: 완료 패킷을 담는 데이터 구조. CP를 말한다.
- 대기 쓰레드 큐(Waiting Thread Queue): 대기 상태인 쓰레드들의 정보를 저장하고 있는 데이터 구조. GetQueuedCompletionStatus() 함수 호출 후 일감을 못 받아간 쓰레드는 이곳으로 들어온다. 쓰레드 재사용 빈도를 높이기 위해 이름이 큐임에도 불구하고 LIFO 구조를 가진다.
- 릴리즈 쓰레드 리스트(Release Thread List): 실행 상태인 쓰레드들의 정보를 저장하고 있는 리스트. 쓰레드가 실행 상태로 전환되면 이곳에 쓰레드 정보가 저장된다.
- 일시 정지 쓰레드 리스트(Pause Thread List): 중지 상태인 쓰레드들의 정보를 저장하고있는 리스트. 쓰레드가 중지 상태로 전환되면 이곳에 쓰레드 정보가 저장된다.
추가 설명 2. 워커 쓰레드의 개수를 (코어 개수 * 2)개로 설정하는 이유
기본적으로 각 코어가 1개의 쓰레드를 실행해야 컨텍스트 스위칭이 발생하지 않는다. 그래서 이상적인 워커 쓰레드 개수는 코어 개수만큼 생성하는 것이지만 실제로는 코어 개수보다 많은 (코어 개수 * 2)개 정도를 생성한다. 여러 가지 이유가 있겠지만 코어 개수만큼만 워커 쓰레드를 생성하면 정지 상태 쓰레드가 생겼을 때 실행 중인 쓰레드 개수가 상한선에 도달하지 않았음에도 남은 워커 쓰레드가 없어 완료 패킷을 처리할 수 없는 상황이 생기기 때문이다.
코어 개수가 N개일 때, 워커 쓰레드가 N개, 동시에 실행 가능한 쓰레드 수(상한선)가 N개라고 가정해보자. 위 그림처럼 N개의 워커 쓰레드가 실행 중일 때, 어떤 이유로 쓰레드가 정지 상태로 되어 일시 정지 쓰레드 리스트로 내려갔다면, 실행 중인 쓰래드 개수가 N-1개가 되어, CP에 들어있는 일감을 받아올 수 있는 상황이 된다.
하지만 그럴 수 없다. 왜냐하면 깨울 쓰레드가 더 이상 남아있지 않기 때문. 워커 쓰레드를 더 만들어 두었다면 상한선에 도달하지 않았음에도 일감을 받을 수 없는 지금 같은 상황은 없었을 것이다. 이러한 이유로 정지 상태인 쓰레드가 N개가 되어도 상한선까지 쓰레드를 실행시킬 수 있는, 코어 개수 * 2개만큼 워커 쓰래드를 준비해 두는 것이다.
추가 설명 3. CP는 하나가 아닐 수 있다.
약간 의아해 할 수도 있는데, CP는 반드시 하나만 존재해야 하는 건 아니다. CP는 여러 개 생성할 수도 있으며, 각 CP에 소켓을 분산하여 등록할 수도 있다. (참고로 같은 소켓을 서로 다른 CP들에 동시에 등록할 수는 없다) 그렇기 때문에 CICP 함수를 통해 소켓을 등록할 때도 미리 만들어둔 IOCP 핸들을 넣어주는 것이고, 윈도우 I/O 시스템이 굳이 CP의 장치 리스트에 가서 연결된 소켓이 맞는지 확인하는 것이다.
추가 설명 Ex. PostQueuedCompletionStatus()
다음 목차에서 다루지 않을 함수라 여기서 다루도록 한다. CP에서 일감을 받기 위해 예약을 걸어두는 GQCS와 달리, PostQueuedCompletionStatus(일명 PQCS) 함수는 CP에 일감을 집어넣을 수 있다. 즉, PQCS 함수를 사용하면 CP는 윈도우 I/O 시스템을 거치지 않고 프로세스의 다른 쓰레드로부터 통신을 받을 수 있다는 것이다.
이 기능을 이용해 서버 관리자는 원격으로 커맨드를 보내 특정 작업을 수동으로 서버에 알릴 수 있다. 대표적인 예시는 아래와 같다.
1. 서버 초기화 작업
위에서 알아보았듯, IOCP 모델은 서버 초기화 시 클라이언트 요청을 처리할 워커 스레드를 여러 개 생성한다. 이 때, PQCS 함수를 사용해서 각 워커 스레드에 초기화 작업을 할당할 수 있다.
// 서버 관리자가 서버 초기화 커맨드를 전송하면, 워커 스레드가 호출한 GQCS 함수에 의해 아래 작업을 실행
for (int i = 0; i < numWorkerThreads; ++i) {
PostQueuedCompletionStatus(hIOCP, 0, (ULONG_PTR)INIT_COMMAND, nullptr);
}
2. 타이머 기반 작업
몬스터 리젠과 같이 특정 시간 간격으로 반복해야하는 이벤트인 경우에도 PQCS 함수를 사용할 수 있다.
// 서버 관리자가 몬스터 리젠 커맨드를 전송하면, 워커 스레드가 호출한 GQCS 함수에 의해 아래 작업을 실행
void TimerThread(HANDLE hIOCP) {
while (true) {
Sleep(1000); // 1초 대기
PostQueuedCompletionStatus(hIOCP, 0, (ULONG_PTR)TIMER_EVENT, nullptr);
}
}
3. 서버 종료 작업
서버 종료 시 워커 스레드를 안전하게 종료한 다음, 서버를 종료해야한다. 이 때도 PQCS 함수를 사용해서 각 워커 스레드를 안전하게 종료할 수 있다.
// 서버 관리자가 서버 종료 커맨드를 전송하면, 워커 스레드가 호출한 GQCS 함수에 의해 아래 작업을 실행
for (int i = 0; i < numWorkerThreads; ++i) {
PostQueuedCompletionStatus(hIOCP, 0, (ULONG_PTR)SHUTDOWN_COMMAND, nullptr);
}
IOCP 모델을 이용한 서버 만들기
아래 예제에서 만들 서버의 특징은 다음과 같다.
- 클라이언트로부터 수신받은 데이터 크기 출력
Listen 소켓 생성
IOCP 모델에서 Listen 소켓을 만드는 부분은 Overlapped 모델에서 달라지지 않으므로 코드는 생략하겠다.
세션 매니저 및 IOCP 생성
각 클라이언트 소켓의 세션을 관리할 세션 매니저와 IOCP를 생성하기 전에 Session, IO_TYPE, OverlappedEx를 선언한다. 각각의 용도는 다음과 같다.
- Session 구조체: 클라이언트 소켓 세션. 소켓과 수신 버퍼 데이터를 다룬다.
- IO_TYPE 열거형: 작업의 타입을 표현한다. 워커 쓰레드가 일감을 받았을 때 IO_TYPE을 통해 일감의 타입을 파악한다.
- OverlappedEx 구조체: GQCS 함수나 WSARecv() 함수같은 경우, OVERLAPPED 계열 타입을 인자로 넘겨줘야한다. 이 때 일감의 타입(IO_TYPE)도 같이 넘겨주기 위해 구조체로 선언해두었다.
struct Session
{
SOCKET socket = INVALID_SOCKET;
char recvBuffer[BUFSIZE] = {};
int32 recvBytes = 0;
};
enum IO_TYPE
{
READ,
WRITE,
ACCEPT,
CONNECT,
};
struct OverlappedEx
{
WSAOVERLAPPED overlapped = {};
int32 type = 0; // read, write, accept, connect ...
};
세션 매니저와 CP를 생성해준다. 이때, CP는 CICP 함수로 생성하는데, 첫번째 인자를 INVALID_HANDLE_VALUE를 넣어주고, 나머지 3개를 아래와 같이 밀어주고 호출하면, 소켓이 등록되지 않은 CP가 만들어진다. (리턴값으로 생성된 CP를 다룰수 있는 핸들을 반환한다.)
int main(){
...
// 세션 매니저 생성
vector<Session*> sessionManager;
// CP 생성
HANDLE iocpHandle = ::CreateIoCompletionPort(INVALID_HANDLE_VALUE, NULL, 0, 0);
}
워커 쓰레드 생성
워커 쓰레드를 생성한다. 아래 예제에서는 5개의 워커 쓰레드를 생성하였으며, 각 쓰레드가 실행할 로직을 람다식으로 넣어주었다. (GTreadManager->Launch()는 미리 만들어둔 쓰레드 관리자이다. 여기서는 따로 설명하지 않겠다.)
// WorkerThreads
for (int32 i = 0; i < 5; i++)
GThreadManager->Launch([=]() { WorkerThreadMain(iocpHandle); });
아래 WorkerThreadMain함수는 워커 쓰레드가 실행할 로직이다. GQCS 함수를 while문 안에 넣어줌으로써 쓰레드는 루프를 계속 돌며 CP에서 일감을 받아오게되고, 받아온 일감 타입이 READ일때 클라이언트로부터 수신받은 데이터 크기를 출력하는 작업을 하는 방식으로 코드가 짜여있다. 해당 코드는 RECV 일감만 들어온다는 가정하에 만들어진 코드이므로, 일감 타입이 READ가 아닐 경우 오류로 판단하여 크래쉬를 낸다(ASSERT_CRASH())
READ 작업이 완료되었으면, 다음 데이터를 수신하기 위해 WSARecv() 함수를 호출해준다.
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);
cout << "Recv Data IOCP = " << bytesTransferred << endl;
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);
}
}
Accept() + 클라이언트 소켓을 CP에 등록
이어서 메인 쓰레드 로직을 살펴보겠다. 이전 모델들과 동일하게 listen 소켓이 감지한 클라이언트 소켓을 accept 한다. accept 이후, 해당 클라이언트 소켓의 세션을 만들어 세션 매니저에 추가하고, CICP 함수를 통해 클라이언트 소켓을 CP에 등록한다. (CICP 함수는 CP 생성뿐만 아니라, 소켓을 CP에 등록시킬 수도 있다. )
// 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;
Session* session = new Session();
session->socket = clientSocket;
sessionManager.push_back(session);
cout << "Client Connected !" << endl;
// 소켓을 CP에 등록
// 1) 클라이언트 소켓
// 2) iocp 핸들
// 3) Key 값: GQCS를 할 때 구분하기 위한 값이며, 아무거나 넣어줘도 되지만 키값으로 세션을 넘겨주기
// 위해 session 주소를 넣어준다.
::CreateIoCompletionPort((HANDLE)clientSocket, iocpHandle, /*Key*/(ULONG_PTR)session, 0);
...
::WSARecv() 함수 실행
수신 작업에 대한 일감을 받아야 워커 쓰레드가 WSARecv() 호출하기 때문에, WSARecv() 함수를 호출해준다. (시동을 거는 느낌이라고 보면 된다)
이제 여기까지 작성했다면 각 워커 쓰레드가 완료된 수신 작업을 처리하고 다시 WSARecv()를 호출하는 작업을 반복할 것이다.
WSABUF wsaBuf;
wsaBuf.buf = session->recvBuffer;
wsaBuf.len = BUFSIZE;
OverlappedEx* overlappedEx = new OverlappedEx();
overlappedEx->type = IO_TYPE::READ; // 일감 타입이 READ임을 저장
// ADD_REF
DWORD recvLen = 0;
DWORD flags = 0;
::WSARecv(clientSocket, &wsaBuf, 1, &recvLen, &flags, &overlappedEx->overlapped, NULL);
깃헙 코드
https://github.com/pledge24/WinGameServerPractice/tree/b126ab22294d32270680535568bdd988896c1ed6
참고 자료
https://dbehdrhs.tistory.com/85
https://learn.microsoft.com/ko-kr/windows/win32/fileio/i-o-completion-ports
'C++ > Windows 게임서버' 카테고리의 다른 글
[C++] 게임서버 탐구 일지 #8. 소켓 모델 - Overlapped 모델 (이벤트 기반, 콜백 기반) (0) | 2025.01.07 |
---|---|
[C++] 게임서버 탐구 일지 #7. 소켓 모델 - WSAEventSelect 모델 (0) | 2025.01.04 |
[C++] 게임서버 탐구 일지 #6. 소켓 모델 - Select 모델 (1) | 2025.01.04 |
[C++] 게임서버 탐구 일지 #5. Winsock TCP 서버 - 논블로킹 (0) | 2024.12.28 |
[C++] 게임서버 탐구 일지 #4. 다양한 소켓 옵션 (1) | 2024.12.28 |