dh_0e

[C++/Game Server] Overlapped Model (Callback 함수 기반) 본문

C++/Game Server

[C++/Game Server] Overlapped Model (Callback 함수 기반)

dh_0e 2026. 3. 25. 02:48

Overlapped 모델 (Completion Routine: 콜백 함수 기반)

  • 비동기 입출력을 지원하는 소켓 생성 + 비동기 입출력 함수 호출 (완료 루틴의 시작 주소를 넘겨줌)
  • 바로 완료되지 않으면, WSA_IO_PENDING 반환
    • 비동기 IO가 완료되면, OS는 완료 루틴(Callback 함수) 호출
    • 하지만 스레드가 중요한 작업 중에 완료 루틴이 호출돼서 방해받으면 안 됨
      • 비동기 입출력 함수를 호출하여 스레드를 Alertable Wait 상태로 만듦
        • Alertable Wait 상태: 완료 루틴을 받을 수 있는 상태
        • Alertable Wait 상태로 만들어주는 함수들
          • WaitForSingleObjectEx(), WaitForMultipleObjectsEx(), SleepEx(), WSAWaitForMultipleEvents()
      • 완료 루틴 호출이 "모두" 끝나면, 스레드는 Alertable Wait 상태에서 빠져나옴

 

Callback 함수

Callback 함수 인자

  • 함수 인자
    1. dwError: 오류 발생 시 0이 아닌 값이 return 됨
    2. cbTransferred: 전송 바이트 수가 return 됨
    3. lpOverlapped: 비동기 입출력 함수 호출 시 넘겨준 WSAOVERLAPPED 구조체의 ptr
    4. dwFlags: 0 (사용 안 함)
  • 다음 양식을 맞춰 Callback 함수를 만들어 비동기 IO 함수에 함수 포인터를 넣어줘야 함
void CALLBACK RecvCallback(DWORD dwError, DWORD recvLen, LPWSAOVERLAPPED lpOverlapped, DWORD dwFlags) {
	cout << "Data Recv Len Callback = " << recvLen << endl;
}
  • CALLBACK은 함수의 호출 규약(_sdcall)을 의미 (안 넣어도 상관 X)

 

GameServer.cpp (bind, listen 이후부터)

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

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

		if (::WSAGetLastError() == WSAEWOULDBLOCK)
			continue;

		// Error
        return 0;
	}

	Session session = Session{clientSocket};
	WSAEVENT wsaEvent = ::WSACreateEvent();
	session.overlapped.hEvent = wsaEvent;
    
	cout << "Connected to Client!" << endl;

	while (true) {
		::WSAResetEvent(wsaEvent);
		WSABUF wsaBuf;
		wsaBuf.buf = session.recvBuffer;
		wsaBuf.len = BUFFER_SIZE;

		DWORD recvBytes = 0;
		DWORD flags = 0;
		// 비동기 recv
		if (::WSARecv(clientSocket, &wsaBuf, 1, &recvBytes, &flags, &session.overlapped, RecvCallback) ==
			SOCKET_ERROR) {
			if (::WSAGetLastError() == WSA_IO_PENDING) {
				// Pending (recv 지연중)
				// 스레드를 Alertable Wait 상태로 바꿔줘야 함
				::SleepEx(INFINITE, TRUE);
                // ::WSAWaitForMultipleEvents(1, &wsaEvent, true, WSA_INFINITE, TRUE); 
                // WSAWait..() 함수도 Alertable Wait 상태로 바꿔줌 (== SleepEx)
			} else {
				// TODO: 문제 있는 상황
				break;
			}
		} else {
			cout << "Recv Data! Len =" << recvBytes << endl;
		}
	}

	::closesocket(session.socket);
	::WSACloseEvent(wsaEvent);
}
  • SleepEx() 함수가 현재 스레드를 Alertable Wait 상태로 바꿔주고, 스레드는 APC queue에 들어온 Callback 함수를 기다렸다가 호출함
    • APC queue: 각 스레드마다 독립적으로 존재하는 Callback 함수를 위한 queue
    • 스레드는 모든 콜백 함수를 호출하여 APC queue를 비운 뒤, Alertable Wait 상태에서 빠져나옴

 

Callback 기반의 장점 (vs Event 기반)

  • Event 기반에선 다수의 event를 일일이 체크하는 식으로 만들었었는데, 애초에 WSAWaitForMultipleEvents()크기 한도가 있기 때문에, 이를 맞춰서 사용하기 번거로움
  • Callback 기반에선 Alertable Wait 상태에 들어간 스레드모든 Callback 함수를 처리해 주기 때문에 아주 편함

 

RecvCallback 인자 사용법

  • RecvCallback()의 인자들은 사실상 가치 있는 정보가 없음
  • overlappedSession 구조체의 맨 처음으로 위치시켜 Callback 함수에서 Session의 ptr을 구해서 사용할 수 있음
    • ex) clientSocket이 여러 개 있을 때, Callback 함수가 어떤 client를 대상으로 호출되었는지 알고 싶을 때 
struct Session {
	WSAOVERLAPPED overlapped = {};
	SOCKET socket;
	char recvBuffer[BUFFER_SIZE] = {};
	int32 recvBytes = 0;
};

void CALLBACK RecvCallback(DWORD dwError, DWORD recvLen, LPWSAOVERLAPPED lpOverlapped, DWORD dwFlags) {
	cout << "Data Recv Len Callback = " << recvLen << endl;
	Session* session = (Session*)lpOverlapped;
}

Callback 함수 내에서 Session의 정보(socket, recv 내용)를 알 수 있음

  • 나중에 IOCP에서도 이런 방식으로 정보를 넘겨줌

 

장단점

  • 장점: 성능
  • 단점
    • 모든 비동기 소켓 함수에서 사용 가능하진 않음 (AcceptEx 등에는 콜백 루틴이 없음)
    • 빈번한 Alertable Wait 전환으로 인한 성능 저하 (IOCP에 비해)
    • APC Queue가 각 스레드마다 존재함
      • 스레드 종속성 (작업을 요청한 스레드와 처리하는 스레드가 일치해야 함)
      • 부하 분산 불가능 (특정 스레드에 작업이 몰릴 수 있음)
      • Alertable Wait 상태로 가는 Context Switching도 부담이 될 수 있음

 

Reactor / Proactor Pattern

  • Reactor Pattern: 작업 가능 상태 요청 후 대기하다가 작업 준비 완료 신호를 받고 IO 작업을 실행 / 동기식 Non-Blocking IO
    1. 이벤트를 감시(Select)
    2. 이벤트 발생 시 핸들러 호출
    3. 핸들러 내부에서 IO 함수(recv, send) 실행 (데이터 복사가 유저 타임에 발생)
  • Proactor Pattern: 작업 요청 후 다른 작업 하다가 작업이 완료되면 커널이 통보 / 비동기식 IO
    1. 비동기 I/O 요청 (Overlapped I/O 등)
    2. 커널이 백그라운드에서 I/O 수행 및 완료
    3. 완료 알림(Completion)을 받고 결과만 처리 (데이터 복사가 커널 타임에 완료)
분류 관련 모델 및 기술 특징
Reactor Select, WSAEventSelect 커널은 "이 소켓에 데이터가 왔다"는 상태만 알려줌. 유저가 직접 recv 해야 함.
Proactor Overlapped I/O, IOCP 커널이 직접 데이터를 버퍼에 복사까지 완료한 후 통보함. (Windows의 핵심 모델)