나만의 작은 도서관
[C++] 게임서버 탐구 일지 #8. 소켓 모델 - Overlapped 모델 (이벤트 기반, 콜백 기반) 본문
[C++] 게임서버 탐구 일지 #8. 소켓 모델 - Overlapped 모델 (이벤트 기반, 콜백 기반)
pledge24 2025. 1. 7. 23:19목차
- Overlapped 입출력 방식의 사용
- Overlapped 모델의 두 가지 방식: 이벤트 기반, 콜백 기반
- Overlapped 모델을 이용한 서버 만들기
- Overlapped 모델의 문제점
- 깃헙 코드
- 참고 자료
Overlapped 입출력 방식의 사용
이전까지 Select 계열의 두 모델(Select 모델, WSAEventSelect 모델)을 정리했었다. 이번에는 윈도우에서 사용할 수 있는 Overlapped 입출력 방식을 활용한 소켓 모델을 정리해 보도록 하겠다.
Overlapped 모델
Overlapped 입출력 방식은 윈도우 운영체제에서 제공하는 고성능 파일 입출력 방식으로, 파일 입출력을 위해 제공되었다. 이를 소켓 입출력에도 사용할 수 있도록 만든 것이 Overlapped 모델이다.
Overlapped?
Overlapped는 '중첩되었다' 라는 뜻으로 해석된다. 뜻에서 알 수 있듯, Overlapped 모델은 커널 영역에서 실행되는 소켓 입출력 작업이 유저 영역에서 실행되는 작업과 병렬적으로, 즉, 중첩되어 실행되는 모델이다. 따라서 Overlapped 모델은 비동기 입출력 소켓 함수를 사용하며, 논블로킹 방식으로 진행된다.
Overlapped 모델의 두 가지 방식: 이벤트 기반, 콜백 기반
Overlapped 모델은 소켓 작업에 대한 완료 통지 방식에 따라 이벤트 기반 또는 콜백 기반으로 구분된다. 두 방식의 작동 방식은 다음과 같다.
Overlapped 모델 작동 과정 (이벤트 기반)
- 비동기 입출력을 지원하는 소켓 생성 + 통지받기 위한 이벤트 객체 생성
- 비동기 입출력 함수 호출 (1에서 만든 이벤트 객체를 같이 넘겨준다)
- 비동기 입출력 작업이 즉시 완료되지 않았다면, WSA_IO_PENDING 오류 코드 발생
- 비동기 입출력 작업이 완료되었다면, 운영체제는 이벤트 객체를 signaled 상태로 전환하여 비동기 입출력 작업이 완료되었음을 통지. (예를 들어 WSARecv() 함수의 경우, 넘겨준 유저 레벨의 수신 버퍼에 데이터를 복사해 넣는 작업까지 끝난 시점에 수신 이벤트 신호를 signaled 상태로 전환한다.)
- WSAWaitForMultipleEvents() 함수와 같은 이벤트 감지 함수를 통해 이벤트 객체의 signal 감지
- WSAGetOverlappedResult() 함수를 호출해서 비동기 입출력 작업 성공 여부 확인 및 데이터 처리
Overlapped 모델 작동 과정 (콜백 기반)
- 비동기 입출력을 지원하는 소켓 생성
- 비동기 입출력 함수 호출 (완료 루틴(Completion Routine, 콜백 함수를 의미한다)의 시작 주소를 넘겨준다)
- 비동기 입출력 작업이 즉시 완료되지 않았다면, WSA_IO_PENDING 오류 코드 발생
- 비동기 입출력 작업이 완료되면, 운영체제는 APC 큐에 결과를 저장. (APC 큐(Asynchronous Procedure Call Queue)는 비동기 입출력 결과를 저장하기 위해 운영체제가 각 쓰레드마다 할당하는 메모리 영역을 의미한다)
- 특정 함수( ex. WaitForSingleObjectEx(), WaitForMultipleObjectEx(), SleepEx(), ...)를 호출하여 비동기 입출력 함수를 호출한 쓰레드를 Alertable Wait 상태로 전환 (쓰레드는 Alertable Wait 상태일 때만 완료 루틴을 호출할 수 있다.)
- 호출한 쓰레드가 Alertable wait 상태이고 APC 큐에 데이터가 존재한다면, 운영체제는 APC 큐에 저장된 결과 데이터(완료 루틴의 주소)를 꺼낸 다음, 꺼낸 데이터를 토대로 완료 루틴을 호출한다. 해당 과정은 APC 큐가 빌 때까지 반복한다.
- 완료 루틴을 전부 호출했다면, 쓰레드는 Alertable wait 상태에서 빠져나온다.
이벤트 기반 Overlapped 모델을 이용한 서버 만들기
아래 예제들에서 구현한 서버의 기능은 다음과 같다.
서버 기능
- 클라이언트로부터 수신받은 데이터 크기 출력
Listen 소켓 생성
Overlapped 모델에서 Listen 소켓을 만드는 부분은 달라지지 않으므로 코드는 생략하겠다.
세션 및 이벤트 객체 생성
이벤트 기반의 Overlapped 모델에서 클라이언트와의 통신을 하기 위해선 이벤트를 생성해야 한다. 이때 이벤트는 WSAOVERLAPPED 구조체의 hEvent 속성에 넣어줘야 한다.
struct Session
{
SOCKET socket;
char recvBuffer[BUFSIZE] = {};
int32 recvBytes = 0;
WSAOVERLAPPED overlapped = {};
};
int main(void){
...
// 통신 시작!
while (true)
{
SOCKADDR_IN clientAddr;
int32 addrLen = sizeof(clientAddr);
SOCKET clientSocket;
while (true)
{
clientSocket = ::accept(listenSocket, (SOCKADDR*)&clientAddr, &addrLen);
if (clientSocket != INVALID_SOCKET)
break;
if (::WSAGetLastError() == WSAEWOULDBLOCK)
continue;
// 문제 있는 상황
return 0;
}
// 세션 생성
Session session = Session{ clientSocket };
// 이벤트 객체 생성: 생성한 이벤트를 WSAOVERLAPPED 구조체의 hEvent에 넣어준다.
WSAEVENT wsaEvent = ::WSACreateEvent();
session.overlapped.hEvent = wsaEvent;
cout << "Client Connected!" << endl;
...
}
::WSARecv() 함수 실행
::WSARecv()는 비동기 함수이므로, 미리 호출해 놓을 수 있다. 함수 호출 시 필요한 인자는 다음과 같다.
+) 참고로 Scatter-Gatter라는 단어가 나오는데, Scatter-Gatter는 쪼개져 있는 버퍼들을 모아서 보내는 방식을 의미한다. lpBuffers, dwBufferCount에서 배열의 시작 주소와 개수를 받는 이유는 Scatter-Gatter 방식을 사용하기 위해서이다.
// s: 비동기 입출력 소켓
// lpBuffers, dwBufferCount: WSABUF 배열의 시작 주소 + 개수 (Scatter-Gatter)
// lpNumberOfBytesRecvd: 보내고/받은 바이트 수
// lpFlags: 상세 옵션인데 0
// lpOverlapped: WSAOVERLAPPED 구조체 주소
// lpCompletionRoutine: 입출력이 완료되면 OS가 호출할 콜백 함수(이벤트 기반은 사용 X)
WSARecv(
_In_ SOCKET s,
_In_reads_(dwBufferCount) __out_data_source(NETWORK) LPWSABUF lpBuffers,
_In_ DWORD dwBufferCount,
_Out_opt_ LPDWORD lpNumberOfBytesRecvd,
_Inout_ LPDWORD lpFlags,
_Inout_opt_ LPWSAOVERLAPPED lpOverlapped,
_In_opt_ LPWSAOVERLAPPED_COMPLETION_ROUTINE lpCompletionRoutine
);
::WSARecv() 호출 시 즉시 완료되지 않으면 SOCKET_ERROR가 반환되며, WSA_IO_PENDING 오류 코드가 발생한다. 이 경우, 비동기 입출력 작업이 완료되지 않아 발생한 경우이므로, ::WSAWaitForMultipleEvents() 함수를 호출해 입출력 작업이 완료될 때까지 대기하도록 한다.
::WSAWaitForMultipleEvents()가 리턴했다면 완료된 입출력 작업이 있으므로, ::WSAGetOverlappedResult()를 통해 해당 작업의 성공 여부를 확인하고, 데이터를 처리한다.
while (true)
{
WSABUF wsaBuf;
wsaBuf.buf = session.recvBuffer;
wsaBuf.len = BUFSIZE;
DWORD recvLen = 0;
DWORD flags = 0;
if (::WSARecv(clientSocket, &wsaBuf, 1, &recvLen, &flags, &session.overlapped, nullptr) == SOCKET_ERROR)
{
if (::WSAGetLastError() == WSA_IO_PENDING)
{
// Pending
::WSAWaitForMultipleEvents(1, &wsaEvent, TRUE, WSA_INFINITE, FALSE);
::WSAGetOverlappedResult(session.socket, &session.overlapped, &recvLen, FALSE, &flags);
}
}
else
{
// TODO : 문제 있는 상황
break;
}
cout << "Data Recv Len = " << recvLen << endl;
}
콜백 기반 Overlapped 모델을 이용한 서버 만들기
아래 예제들에서 구현한 서버의 기능은 다음과 같다.
서버 기능
- 클라이언트로부터 수신받은 데이터 크기 출력
Listen 소켓 생성
Overlapped 모델에서 Listen 소켓을 만드는 부분은 달라지지 않으므로 코드는 생략하겠다.
세션 생성 및 콜백 함수 정의
콜백 기반 Overlapped 모델은 이벤트 기반처럼 이벤트를 사용하지 않는다. 따라서, 이벤트를 생성하는 코드는 작성하지 않는다. (나머지는 동일하다.)
또한 ::WSARecv() 함수의 인자로 콜백 함수를 넣어줘야 하기 때문에 콜백 함수를 선언해 준다. ::WSARecv() 함수의 인자로 넣어준 콜백 함수는 완료 루틴으로써 사용된다.
struct Session
{
SOCKET socket;
char recvBuffer[BUFSIZE] = {};
int32 recvBytes = 0;
WSAOVERLAPPED overlapped = {};
};
void CALLBACK RecvCallback(DWORD error, DWORD recvLen, LPWSAOVERLAPPED overlapped, DWORD flags)
{
cout << "Data Recv Len Callback = " << recvLen << endl;
}
int main(void){
...
// 통신 시작!
while (true)
{
SOCKADDR_IN clientAddr;
int32 addrLen = sizeof(clientAddr);
SOCKET clientSocket;
while (true)
{
clientSocket = ::accept(listenSocket, (SOCKADDR*)&clientAddr, &addrLen);
if (clientSocket != INVALID_SOCKET)
break;
if (::WSAGetLastError() == WSAEWOULDBLOCK)
continue;
// 문제 있는 상황
return 0;
}
// 세션 생성
Session session = Session{ clientSocket };
cout << "Client Connected!" << endl;
...
}
::WSARecv() 함수 실행
::WSARecv() 호출 시 즉시 완료되지 않으면 SOCKET_ERROR가 반환되며, WSA_IO_PENDING 오류 코드가 발생한다. 이 경우, 비동기 입출력 작업이 완료되지 않아 발생한 경우이므로, 완료된 입출력 작업을 받고 다음 코드로 이동하고 싶다면 ::SleepEx(INFINITE, TRUE) 함수를 호출해 Alertable Wait 상태로 대기하도록 한다. (당연히 서버가 고도화되면 이렇게 무한정 기다리지는 않는다.)
입출력 작업이 완료되었다면 ::SleepEx() 함수로 인해 현재 쓰레드가 Alertable Wait 상태이므로, ::WSARecv() 인자로 넣어준 콜백 함수가 완료 루틴으로써 자동으로 실행된다.
while (true)
{
WSABUF wsaBuf;
wsaBuf.buf = session.recvBuffer;
wsaBuf.len = BUFSIZE;
DWORD recvLen = 0;
DWORD flags = 0;
if (::WSARecv(clientSocket, &wsaBuf, 1, &recvLen, &flags, &session.overlapped, RecvCallback) == SOCKET_ERROR)
{
if (::WSAGetLastError() == WSA_IO_PENDING)
{
// Pending
// Alertable Wait
::SleepEx(INFINITE, TRUE);
//::WSAWaitForMultipleEvents(1, &wsaEvent, TRUE, WSA_INFINITE, FALSE);
}
else
{
// TODO : 문제 있는 상황
break;
}
}
}
Overlapped 모델의 문제점
아직도 해결되지 않은 최대 이벤트 감지 개수(이벤트 기반)
Select 모델과 WSAEventSelect 모델은 한 세트에 등록할 수 있는 이벤트 최대 개수가 64개 밖에 되지 않아 접속자가 많을 경우 여러 세트를 만들어줘야 한다는 불편함이 있었다. 이벤트 기반 Overlapped 모델에서도 이 불편함은 해결되지 않았다.
Signaled 상태로 전환될 때까지 대기해야 함(이벤트 기반)
이벤트 기반에서 비동기 I/O 작업 완료 시, 운영체제는 해당 작업 신호를 Signaled 상태로 전환하기만 할 뿐, 완료된 작업에 따른 후속 처리를 해주지 않는다. 때문에, 후속 처리를 할 때 해당 작업 신호가 전환될 때까지 대기해야 하는데 이러한 대기 시간은 서버의 성능을 떨어뜨리는 요인이 된다.
모든 비동기 소켓 함수에서 사용 가능하진 않음(콜백 기반)
아쉽게도 콜백기반 Overlapped 모델을 적용할 수 없는 비동기 소켓 함수들이 존재한다. 예를 들어 AcceptEx() 비동기 소켓 함수는 콜백 함수를 넣을 수 있는 인자가 존재하지 않는다.
빈번한 Alertable Wait 상태 전환으로 인한 성능 저하(콜백 기반)
Alertable Wait 상태에서 APC 큐에 작업이 추가되면, 쓰레드는 즉시 중단되고 APC 완료 루틴을 실행한다. 이때마다 컨텍스트 스위칭 비용이 발생하며, 이러한 이유로 빈번하게 Alertable Wait 상태로 전환한다면 성능이 떨어지게 된다.
완료 작업에 대한 접근 제약(콜백 기반)
APC 큐에 저장된 결과는 APC 큐를 소유한 스레드만 확인할 수 있다. 이러한 폐쇄성은 해당 스레드에 문제가 발생했을 때 해결하기 어렵도록 만들 수 있다.
깃헙 코드
이벤트 기반 Overlapped 모델
https://github.com/pledge24/WinGameServerPractice/tree/4162bf36ba031a787fae518b04f89fc65810bcb0
콜백 기반 Overlapped 모델
https://github.com/pledge24/WinGameServerPractice/tree/ef23a7398aa63ec8dcfb54e47b50d03794f7e0fc
참고 자료
'C++ > Windows 게임서버' 카테고리의 다른 글
[C++] 게임서버 탐구 일지 #9. 소켓 모델 - IOCP 모델 (0) | 2025.01.08 |
---|---|
[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 |