IOCP를 쓰는 이유

문제 정의

  • 동접이 커질수록 Select/이벤트 방식은 "소켓/핸들 관리 비용"이 빠르게 증가합니다.
  • IOCP는 "완료된 작업만 큐로 전달"하여 워커가 필요한 일만 처리하게 만듭니다.
  • 즉, 대규모 서버에서 컨텍스트 스위칭과 불필요한 검사 비용을 줄이는 방향입니다.

핵심 구조

flowchart TB
    A[Accept/Connect 처리] --> B[소켓을 IOCP에 연결]
    B --> C[WSARecv/WSASend 등록]
    C --> D[커널이 I/O 완료]
    D --> E[Completion Port 큐 적재]
    E --> F[워커: GQCS로 꺼내 처리]
    F --> G[게임 로직/패킷 처리]
    G --> C

구성요소 한눈에 보기

구성요소역할
Accept 스레드(또는 AcceptEx 루프)신규 연결 수락 후 세션 생성
IOCP 핸들완료 통지를 모으는 커널 큐
워커 스레드들GetQueuedCompletionStatus로 완료 처리
I/O 컨텍스트OVERLAPPED + 버퍼 + 작업 종류 메타데이터

필수 API와 생성 순서

Completion Port 생성

HANDLE iocp = CreateIoCompletionPort(INVALID_HANDLE_VALUE, nullptr, 0, 0);
  • INVALID_HANDLE_VALUE를 넘기면 "포트 생성" 모드입니다.
  • 마지막 인자(Concurrency)는 0이면 시스템 권장값(보통 논리 코어 수 기준)을 사용합니다.

소켓을 IOCP에 연결

CreateIoCompletionPort(
    reinterpret_cast<HANDLE>(clientSocket),
    iocp,
    reinterpret_cast<ULONG_PTR>(session),
    0);
  • 세 번째 인자가 Completion Key입니다(보통 Session*).
  • 이후 해당 소켓의 완료는 같은 IOCP 큐로 도착합니다.

비동기 요청 등록

함수역할
WSARecv수신 요청 등록
WSASend송신 요청 등록
GetQueuedCompletionStatus완료 이벤트 꺼내기
PostQueuedCompletionStatus제어 이벤트(종료 신호 등) 푸시

Completion Key와 OVERLAPPED의 역할 분리

왜 둘을 분리해야 하는가

  • Completion Key는 "어느 세션인가?"를 식별합니다.
  • lpOverlapped는 "무슨 작업인가?"를 식별합니다.
  • 둘을 분리하면 세션/작업 타입 분기가 단순해지고 디버깅이 쉬워집니다.

권장 컨텍스트 구조

enum class IoType { Recv, Send, Accept };

struct OverlappedEx {
    OVERLAPPED ov{};
    IoType ioType = IoType::Recv;
    WSABUF wsaBuf{};
    char buffer[8192]{};
    // 필요 시 owner(Session*), sequence, timestamp 등 추가
};

첫 번째 멤버 규칙

  • OVERLAPPED를 첫 멤버로 두면 LPOVERLAPPED -> OverlappedEx* 복원이 단순합니다.
  • 팀 규칙으로 고정해두면 캐스팅 실수를 크게 줄일 수 있습니다.

워커 스레드 루프와 GQCS 해석

기본 루프

for (;;) {
    DWORD bytes = 0;
    ULONG_PTR key = 0;
    OVERLAPPED* ov = nullptr;

    BOOL ok = GetQueuedCompletionStatus(iocp, &bytes, &key, &ov, INFINITE);

    if (!ok && ov == nullptr) {
        // 포트 종료/깨우기 실패 등 제어 경로
        break;
    }

    Session* session = reinterpret_cast<Session*>(key);
    OverlappedEx* ctx = reinterpret_cast<OverlappedEx*>(ov);
    if (!session || !ctx) { continue; }

    // ioType/bytes/error 기준으로 분기 처리
}

반환 결과 해석 표

상태의미기본 대응
ok == TRUE완료 정상 수신ioType 기준 처리
ok == FALSE + ov != nullptrI/O 완료는 왔지만 실패 코드 포함에러 로그 후 세션 정리/복구
ok == FALSE + ov == nullptr제어/종료 경로 가능성워커 종료 조건 확인

바이트 수 해석

  • Recv 완료에서 bytes == 0은 상대의 graceful close로 해석하는 것이 일반적입니다.
  • bytes > 0이면 파싱/로직 처리 후 다음 수신 요청을 재등록합니다.

실무 처리 규칙 (Recv/Send)

Recv 규칙

  • 세션당 최소 1개의 WSARecv는 항상 걸려 있어야 합니다.
  • 완료 처리 후 재등록이 빠지면 그 세션 수신은 즉시 멈춥니다.
  • 처리량이 높으면 세션당 다중 Recv outstanding 여부를 정책으로 명시해야 합니다.

Send 규칙

  • 여러 스레드가 동시에 WSASend를 던지면 순서/동시성 관리가 필요합니다.
  • 일반적으로 "세션별 송신 큐 + 전송 중 플래그" 패턴을 사용합니다.
  • 한 번의 Send 완료로 모두 전송됐다고 가정하지 말고 잔여 바이트를 확인하세요.

Accept 연계

  • 학습 단계에서는 accept 후 IOCP 등록으로 충분합니다.
  • 실서비스에서는 AcceptEx + 초기 Recv 선등록으로 accept 병목을 줄이는 패턴을 자주 씁니다.

수명 관리와 종료 시나리오

왜 위험한가

  • 세션을 먼저 해제했는데 해당 세션의 완료 이벤트가 늦게 오면 댕글링 포인터가 됩니다.

권장 정책

  • 세션 객체에 참조 카운트(또는 shared_ptr 기반 수명 관리)를 둡니다.
  • "I/O 등록 시 +1, 완료 처리 후 -1" 정책을 명시해 종료 시점을 통제합니다.
  • 종료 시에는 새 I/O 등록을 막고, 남은 완료를 회수 처리한 뒤 자원을 해제합니다.

제어 이벤트

  • 워커 종료는 PostQueuedCompletionStatus로 종료 토큰을 넣어 깨우는 방식이 안전합니다.

성능/운영 체크포인트

스레드 수

  • 워커 수는 코어 수를 기준으로 시작하고, 측정으로 조정하세요.
  • 무작정 스레드를 늘리면 락 경쟁/문맥 전환 비용이 먼저 증가합니다.

관측 지표

  • 큐 처리 지연(완료 후 처리까지 시간)
  • 초당 완료 건수(Recv/Send 분리)
  • 세션당 outstanding I/O 수
  • 오류 코드 분포(예: 연결 끊김, 타임아웃, 취소)

디버깅 힌트

  • "완료는 오는데 로직이 안 돈다"면 ioType 분기와 재등록 누락부터 확인
  • "간헐 크래시"면 수명 관리(세션/컨텍스트 해제 타이밍)부터 확인

강의 시 유의사항

강조 포인트

  • IOCP의 본질은 "비동기 완료 큐 + 워커 분배"입니다.
  • Completion Key(세션 식별)와 Overlapped(작업 식별)의 분리가 핵심입니다.
  • 성능보다 먼저 정합성(수명, 재등록, 종료 정책)을 고정해야 합니다.

자주 하는 오해

오해바로잡기
IOCP 쓰면 자동으로 고성능설계가 나쁘면 큐 지연/락 경쟁으로 느려진다
GetQueuedCompletionStatus 실패면 바로 버그ov != nullptr 실패 완료 경로를 구분해야 한다
세션 해제는 소켓 닫자마자 가능늦게 도착한 완료까지 고려한 수명 정책이 필요

체크 질문 (스스로 답해보기)

  • Completion Key와 lpOverlapped를 분리해 쓰는 이유는 무엇인가?
  • GetQueuedCompletionStatus의 3가지 결과를 어떻게 분기 처리할 것인가?
  • 서버 종료 시 워커 스레드를 안전하게 깨우고 종료하는 절차는 무엇인가?

profile
李家네_공부방

0개의 댓글