본문으로 건너뛰기
뒤로가기

[C++] IOCP 1: OVERLAPPED I/O와 Completion Port

TL;DR — IOCP를 이해하려면 먼저 Windows의 OVERLAPPED I/O를 이해해야 합니다. OVERLAPPED는 비동기 operation 하나의 요청과 완료를 연결하고, IOCP는 여러 handle에서 완료된 operation을 하나의 completion port로 전달합니다. 주요 Winsock과 IOCP API의 파라미터가 이 과정에서 어떤 역할을 하는지 살펴봅니다.

Table of contents

Open Table of contents

들어가며

이번 글은 특정 서버 구현보다 다음 Windows API의 계약을 이해하는 데 집중합니다.

동기 I/O와 비동기 I/O
-> OVERLAPPED 구조체와 operation lifetime
-> Winsock overlapped API와 파라미터
-> IOCP 생성과 socket handle 연결
-> completion packet 처리

이 글에서 다루는 내용:


1. OVERLAPPED I/O

동기 방식의 I/O는 작업의 완료 여부가 결정된 뒤 호출자에게 돌아옵니다. 작업이 오래 걸리면 호출 thread도 그동안 대기합니다.

동기 I/O

호출 thread:  I/O 요청 ───────── 대기 ───────── 완료 후 반환
장치/커널:               실제 I/O 처리

Windows의 overlapped I/O에서는 요청이 완료되지 않았더라도 함수가 먼저 반환할 수 있습니다. 호출 thread는 다른 일을 진행하고, 나중에 해당 operation의 완료 결과를 확인합니다.

overlapped I/O

호출 thread:  I/O 요청 -> 반환 -> 다른 작업 ─────── 완료 확인
장치/커널:                 실제 I/O 처리 ────────┘

여기서 중요한 점은 함수 호출의 반환과 I/O operation의 완료가 서로 다른 사건이라는 것입니다. 이 둘을 연결하기 위해 OVERLAPPED 구조체를 사용합니다.

1.1 OVERLAPPED는 operation 하나의 문맥입니다

OVERLAPPED는 대략 다음 정보를 포함합니다.

typedef struct _OVERLAPPED
{
    ULONG_PTR Internal;
    ULONG_PTR InternalHigh;
    union
    {
        struct
        {
            DWORD Offset;
            DWORD OffsetHigh;
        };
        PVOID Pointer;
    };
    HANDLE hEvent;
} OVERLAPPED;minwinbase.h
필드역할
Internal, InternalHigh운영체제가 operation의 내부 상태와 결과를 기록하는 영역입니다. 애플리케이션이 직접 변경하지 않습니다.
Offset, OffsetHigh파일 I/O에서 읽거나 쓸 위치를 지정합니다. socket I/O에서는 0으로 초기화합니다.
hEventevent 기반으로 완료를 기다릴 때 사용할 수 있습니다. IOCP 방식에서는 일반적으로 nullptr로 둡니다.

구조체는 사용 전에 0으로 초기화하고, 동시에 진행 중인 operation마다 별도의 OVERLAPPED를 사용합니다.

OVERLAPPED overlapped{};main.cpp

하나의 file 또는 socket handle에 여러 비동기 operation을 등록할 수 있습니다. handle이 같더라도 각 작업의 상태와 결과는 서로 다르므로 다음 관계가 성립합니다.

Outstanding operation 하나에는 완료 전까지 독점적으로 사용하는 OVERLAPPED 하나가 필요합니다.

1.2 완료를 확인하는 방법

overlapped I/O의 완료는 여러 방법으로 확인할 수 있습니다.

방식동작
EventOVERLAPPED::hEvent에 event를 넣고 signal을 기다립니다.
Result queryGetOverlappedResult 또는 WSAGetOverlappedResult로 특정 operation의 결과를 확인합니다.
Completion routineWinsock completion routine으로 callback을 받습니다.
IOCP여러 handle의 completion packet을 하나의 completion port에서 꺼냅니다.
방식완료를 기다리는 대상스레드 특성주 용도
EventOVERLAPPED::hEvent기다린 스레드가 결과 확인적은 수의 operation
Result query특정 OVERLAPPEDpolling하거나 직접 block단일 작업 상태 확인
Completion routinecallback등록 스레드의 alertable wait 필요스레드 종속 콜백 모델
IOCPcompletion port queue여러 worker가 처리 가능다수 소켓을 다루는 서버

네 가지 방식의 핵심 차이는 비동기 I/O가 끝났다는 사실을 어느 스레드가, 어떤 형태로 전달받는지에 대한 부분입니다.

IOCP는 개별 operation이나 Event를 각각 기다리지 않습니다. 여러 socket handle을 하나의 completion port에 연결하고, 완료된 작업을 completion packet으로 만들어 한곳에 모으는 방식입니다.

socket A ─┐
socket B ─┼─ overlapped I/O 완료 ─→ IOCP completion queue
socket C ─┘                              │
                                        ├─ worker 1
                                        ├─ worker 2
                                        └─ worker 3

그래서 IOCP를 사용하면 I/O operation을 요청(등록)한 스레드가 직접 I/O를 관리할 필요없이, Completion Port에 접근할 수 있는 어느 worker든 I/O 작업의 결과(completion packet)를 꺼내 처리할 수 있게 됩니다.

1.3 OVERLAPPED와 buffer의 lifetime

이와 같이 IOCP를 사용하는 비동기 I/O 작업의 경우 OVERLAPPED 구조체의 lifetime 관리가 중요한 부분을 차지하게 됩니다.

I/O를 요청한 실행 주체와, Completion Port에서 completion packet을 꺼내 I/O 결과를 확인하는 실행 주체가 다를 수 있기 때문에 OVERLAPPED 구조체를 packet 도착 시점까지 확실하게 보장할 수 있어야 합니다.

다음 코드는 WSARecv 호출 뒤 함수가 끝나면서 context도 파괴될 수 있으므로 잘못되었습니다.

void PostReceive(SOCKET socket)
{
    IoContext context{};
    WSARecv(socket, &context.bufferView, 1, nullptr, &context.flags,
            &context.overlapped, nullptr);

    // 함수 scope를 벗어나면 overlapped가 파괴될 수 있음
}main.cpp

WSARecv 함수를 호출하면 I/O 작업은 비동기로 진행이 되고, 그 결과는 추후에 Completion Port를 통해 확인해야 합니다. 따라서 위의 코드는 함수 scope를 벗어나면서 매개변수로 전달된 socket이 가지고 있는 OVERLAPPED가 함께 사라질 수 있습니다.

따라서 lifetime은 함수 scope가 아니라 operation 상태로 정의합니다.

Created
-> Posted
-> Pending
-> Completion observed
-> Processed
-> Destroyed 또는 Reused

OVERLAPPED, receive buffer와 send buffer는 completion을 확인하기 전에 이동하거나 파괴하면 안됩니다. send buffer의 내용도 operation이 완료되기 전에는 변경하면 안됩니다.


2. Winsock에서 OVERLAPPED I/O 준비하기

Winsock에서도 Windows의 overlapped I/O model을 사용합니다. 기본 흐름은 다음과 같습니다.

WSAStartup
-> overlapped I/O socket 생성(flag: WSA_FLAG_OVERLAPPED)
-> WSARecv 또는 WSASend에 OVERLAPPED 전달
-> immediate completion 또는 WSA_IO_PENDING 확인
-> 선택한 completion 방식으로 결과 처리
-> closesocket
-> WSACleanup

2.1 WSAStartup: Winsock 초기화

WSADATA winsockData{};
const int result = WSAStartup(MAKEWORD(2, 2), &winsockData);main.cpp
int WSAStartup(
    WORD      wVersionRequested,
    LPWSADATA lpWSAData);
파라미터예제 값의미
wVersionRequestedMAKEWORD(2, 2)애플리케이션이 요청하는 Winsock 버전입니다.
lpWSAData&winsockData초기화 결과와 사용 가능한 Winsock 정보를 받을 구조체입니다.

성공하면 0을 반환합니다. 프로그램을 종료하는 시점이 되면 startup 이후에 생성한 socket을 모두 정리한 뒤 WSACleanup()을 호출하면 됩니다.

2.2 WSASocketW: overlapped socket 생성

// WSASocket 을 사용해도 됨. W는 Unicode 식별 가능함
SOCKET socket = WSASocketW(
    AF_INET,
    SOCK_STREAM,
    IPPROTO_TCP,
    nullptr,
    0,
    WSA_FLAG_OVERLAPPED);main.cpp
SOCKET WSASocketW(
    int                 af,
    int                 type,
    int                 protocol,
    LPWSAPROTOCOL_INFOW lpProtocolInfo,
    GROUP               g,
    DWORD               dwFlags);
파라미터예제 값의미
afAF_INETIPv4 주소 체계를 사용합니다. IPv6라면 AF_INET6을 사용합니다.
typeSOCK_STREAM연결 지향 byte stream socket을 생성합니다.
protocolIPPROTO_TCPTCP protocol을 지정합니다.
lpProtocolInfonullptr기존 protocol 정보 구조체를 복제하지 않습니다.
g0socket group을 사용하지 않습니다.
dwFlagsWSA_FLAG_OVERLAPPED이 socket에서 overlapped I/O를 사용할 수 있게 합니다.

실패하면 INVALID_SOCKET을 반환합니다. WSA_FLAG_OVERLAPPED는 socket을 non-blocking mode로 바꾸는 옵션이 아니라, overlapped operation을 사용할 수 있는 속성입니다.

2.3 WSABUF: I/O 결과를 기록할 buffer

WSARecvWSASendWSABUF 배열로 하나 이상의 memory 구간을 전달받습니다.

I/O 작업의 결과는 WSABUFbuf 멤버에 저장되며, 사용자가 애플리케이션에서 정의한 버퍼를 사용하면 됩니다.

typedef struct _WSABUF
{
    ULONG len;
    CHAR* buf;
} WSABUF;
std::array<char, 1024> storage{};
WSABUF bufferView{
    static_cast<ULONG>(storage.size()),
    storage.data(),
};main.cpp

WSABUF는 memory를 소유하지 않고 주소와 길이만 가리킵니다. 따라서 buffer 역시 OVERLAPPED와 마찬가지로 completion packet을 읽는 Worker까지 안전하게 전달될 수 있도록 lifetime을 관리해야 합니다.


3. WSARecvWSASend 파라미터

3.1 Operation context 구성

OVERLAPPED만으로는 완료된 operation이 receive인지 send인지, 어떤 buffer를 사용했는지 알 수 없습니다. 그래서 일반적으로 애플리케이션에서 필요한 정보를 같이 소유하는 context를 만들어 사용합니다.

enum class IoOperation
{
    Receive,
    Send,
};

struct IoContext
{
    // Context의 첫 멤버는 반드시 OVERLAPPED가 되어야 한다.
    OVERLAPPED overlapped{};
    IoOperation operation = IoOperation::Receive;
    WSABUF bufferView{};
    DWORD flags = 0;
    std::array<char, 1024> storage{};
};main.cpp

이 예제에서는 IoContext가 operation completion까지 같은 주소에서 살아 있다고 가정합니다.

3.2 WSARecv: receive operation 등록

int WSARecv(
    SOCKET                             s,
    LPWSABUF                           lpBuffers,
    DWORD                              dwBufferCount,
    LPDWORD                            lpNumberOfBytesRecvd,
    LPDWORD                            lpFlags,
    LPWSAOVERLAPPED                    lpOverlapped,
    LPWSAOVERLAPPED_COMPLETION_ROUTINE lpCompletionRoutine);
파라미터예제 값의미
sconnectedSocketreceive를 요청할 연결된 socket입니다.
lpBuffers&context.bufferView수신할 memory 구간을 표현하는 WSABUF 배열입니다.
dwBufferCount1전달한 WSABUF의 개수입니다.
lpNumberOfBytesRecvdnullptrIOCP에서는 최종 byte 수를 completion packet으로 받습니다.
lpFlags&context.flagsreceive 동작을 조절하고 결과 flag를 받을 값입니다. 일반 TCP receive는 0으로 시작합니다.
lpOverlapped&context.overlapped이번 receive operation을 식별할 구조체입니다.
lpCompletionRoutinenullptrcompletion routine 대신 IOCP를 사용합니다.
int PostReceive(SOCKET connectedSocket, IoContext& context)
{
    context.overlapped = {};
    context.operation = IoOperation::Receive;
    context.flags = 0;
    context.bufferView.buf = context.storage.data();
    context.bufferView.len =
        static_cast<ULONG>(context.storage.size());

    return WSARecv(
        connectedSocket,
        &context.bufferView,
        1,
        nullptr,
        &context.flags,
        &context.overlapped,
        nullptr);
}main.cpp

WSARecv가 완료되면 transferred bytes가 0인지도 확인해야 합니다. TCP stream에서 오류 없이 0byte가 완료되면 peer의 graceful close를 의미합니다.

3.3 WSASend: send operation 등록

int WSASend(
    SOCKET                             s,
    LPWSABUF                           lpBuffers,
    DWORD                              dwBufferCount,
    LPDWORD                            lpNumberOfBytesSent,
    DWORD                              dwFlags,
    LPWSAOVERLAPPED                    lpOverlapped,
    LPWSAOVERLAPPED_COMPLETION_ROUTINE lpCompletionRoutine);
파라미터예제 값의미
sconnectedSocketsend를 요청할 연결된 socket입니다.
lpBuffers&context.bufferView전송할 memory 구간을 표현하는 WSABUF 배열입니다.
dwBufferCount1전달한 WSABUF의 개수입니다.
lpNumberOfBytesSentnullptrIOCP에서는 최종 byte 수를 completion packet으로 받습니다.
dwFlags0일반 TCP send에서는 별도 flag를 사용하지 않습니다.
lpOverlapped&context.overlapped이번 send operation을 식별할 구조체입니다.
lpCompletionRoutinenullptrcompletion routine 대신 IOCP를 사용합니다.
int PostSend(SOCKET connectedSocket, IoContext& context)
{
    context.overlapped = {};
    context.operation = IoOperation::Send;
    context.bufferView.buf = context.storage.data();
    context.bufferView.len =
        static_cast<ULONG>(context.storage.size());

    return WSASend(
        connectedSocket,
        &context.bufferView,
        1,
        nullptr,
        0,
        &context.overlapped,
        nullptr);
}main.cpp

send operation이 완료될 때까지 storage의 주소와 내용을 유지해야 합니다. WSASend completion은 peer 애플리케이션이 데이터를 처리했다는 확인이 아니라, local transport가 send buffer를 처리했다는 의미입니다.

3.4 즉시 완료, pending과 등록 실패

WSARecvWSASend의 반환 결과는 다음처럼 구분합니다.

return 0
-> operation이 즉시 완료됨

return SOCKET_ERROR
&& WSAGetLastError() == WSA_IO_PENDING
-> operation이 정상적으로 시작됨
-> 나중에 완료 결과가 전달됨

그 밖의 오류
-> operation 등록 실패
-> 완료 통지가 오지 않음

IOCP에 연결된 socket은 별도로 completion notification mode를 변경하지 않았다면 즉시 완료된 operation도 completion packet으로 전달됩니다. 따라서 성공적으로 시작된 operation은 즉시 완료 여부와 관계없이 같은 IOCP completion 경로에서 마무리하는 편이 단순합니다.

const int result = PostReceive(socket, context);

if (result == SOCKET_ERROR)
{
    const int error = WSAGetLastError();
    if (error != WSA_IO_PENDING)
        HandlePostFailure(error);
}main.cpp

WSAGetLastError()는 실패를 확인한 직후 같은 thread에서 호출해야 합니다.


4. Winsock 확장 함수 : AcceptExConnectEx

AcceptExConnectEx는 Microsoft가 Winsock에 추가한 확장 함수입니다.

WSAAccept, WSAConnect 함수는 OVERLAPPED 구조체를 매개변수로 받지 않으므로, 비동기 I/O를 위해 확장 함수를 사용해야 합니다.

지금까지 알아본 함수의 관계도를 알아보면 다음과 같습니다. Ws2_32.lib를 이용해 Ws2_32.dll에 구현된 WSA 계열의 함수를 이용해왔습니다.

그러나 AcceptEx, ConnectExWs2_32.dll이 아니라 다른 dll에 구현되어 있습니다. 그래서 비동기 accept, connect를 위한 확장 함수는 WSAIoctl을 통해 직접 질의를 하여 함수 포인터를 가져와 사용해야 합니다.

우리 프로그램

    ├─ Ws2_32.lib / Ws2_32.dll
    │     ├─ WSAStartup
    │     ├─ WSASocket
    │     ├─ WSARecv / WSASend
    │     └─ WSAIoctl

    └─ WSAIoctl을 통해 Winsock provider에 질의

              ├─ AcceptEx 구현 주소
              └─ ConnectEx 구현 주소

확장 함수를 불러오기 위해 필요한 정보는 mswsock.h에 있습니다. 이 헤더에는 확장 함수의 타입과 GUID를 가지고 있습니다.

LPFN_ACCEPTEX       // 함수 포인터 타입
LPFN_CONNECTEX      // 함수 포인터 타입

WSAID_ACCEPTEX      // AcceptEx를 요청하는 GUID
WSAID_CONNECTEX     // ConnectEx를 요청하는 GUID

그래서 실제 확장 함수를 불러오는 과정은 다음과 같습니다.

WSAIoctl은 Ws2_32.dll을 통해 호출

해당 socket의 provider에 GUID 전달

provider가 실제 함수 주소 반환

우리 코드가 그 주소를 acceptEx 변수에 저장

acceptEx(...) 호출

mswsock.h는 선언부이고, 실제 확장 함수의 provider는 mswsock.dll 입니다. 이 dll은 Windows11 기준 이미 설치되어 있으며, 이 dll에 구현된 확장 함수 포인터를 런타임에 한 번 불러와 함수 포인터를 통해 간접 호출하면 됩니다.

실제로 확장 함수를 로드하는 코드는 다음과 같이 작성할 수 있습니다.

#include <winsock2.h> // 일반 winsock api
#include <mswsock.h>  // 확장 함수 타입, GUID

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

// 함수 포인터 타입과 GUID를 원하는 확장 함수에 맞게 사용
GUID functionId = WSAID_ACCEPTEX;
LPFN_ACCEPTEX acceptEx = nullptr;
DWORD bytesReturned = 0;

const int result = WSAIoctl(
    listenSocket,
    SIO_GET_EXTENSION_FUNCTION_POINTER,
    &functionId,
    sizeof(functionId),
    &acceptEx,
    sizeof(acceptEx),
    &bytesReturned,
    nullptr,
    nullptr);main.cpp
WSAIoctl 파라미터예제 값의미
slistenSocket확장 함수를 제공할 Winsock provider의 socket입니다.
dwIoControlCodeSIO_GET_EXTENSION_FUNCTION_POINTERGUID에 해당하는 확장 함수 주소를 요청합니다.
lpvInBuffer&functionIdWSAID_ACCEPTEX 또는 WSAID_CONNECTEX를 전달합니다.
cbInBuffersizeof(functionId)입력 GUID 크기입니다.
lpvOutBuffer&acceptEx반환된 함수 포인터를 저장합니다.
cbOutBuffersizeof(acceptEx)출력 함수 포인터 크기입니다.
lpcbBytesReturned&bytesReturned출력 buffer에 기록된 byte 수입니다.
lpOverlappednullptr함수 포인터 조회 자체는 동기 방식으로 처리합니다.
lpCompletionRoutinenullptr조회 작업에 completion routine을 사용하지 않습니다.

여기에서 WSASocketW으로 만든 socket을 매개 변수로 전달합니다. 정확한 의미는 ‘이 소켓을 담당하는 provider에게 질의를 전달’ 하기 위함입니다. 사용되는 라이브러리의 역할을 정리하면 다음과 같습니다.

따라서 지금까지 사용한 Winsock API 의 실제 내부 구현부는 일반적인 Windows 개발환경에서는 mswsock.dll에 있는 구현부를 통해 실행되는 것이라는 것을 알 수 있습니다.

만약 그렇지 않았다면 WSASocket으로 만든 소켓을 확장 함수 질의에 사용할 수 없었을 것입니다.

4.1 AcceptEx의 주요 파라미터

BOOL AcceptEx(
    SOCKET       sListenSocket,
    SOCKET       sAcceptSocket,
    PVOID        lpOutputBuffer,
    DWORD        dwReceiveDataLength,
    DWORD        dwLocalAddressLength,
    DWORD        dwRemoteAddressLength,
    LPDWORD      lpdwBytesReceived,
    LPOVERLAPPED lpOverlapped);
파라미터의미
sListenSocketbindlisten을 완료한 listener socket입니다.
sAcceptSocket아직 bind 또는 connect하지 않은 별도의 socket입니다. 연결이 수락되면 이 socket이 client connection을 나타냅니다.
lpOutputBufferinitial receive data와 local/remote address가 기록될 buffer입니다.
dwReceiveDataLength연결 수락과 함께 받을 초기 데이터 크기입니다. 0이면 초기 데이터를 기다리지 않습니다.
dwLocalAddressLengthlocal address 영역 크기입니다. protocol address 크기보다 16byte 크게 잡습니다.
dwRemoteAddressLengthremote address 영역 크기입니다. protocol address 크기보다 16byte 크게 잡습니다.
lpdwBytesReceived즉시 완료된 경우 받은 초기 데이터 크기입니다.
lpOverlappedaccept operation을 식별할 OVERLAPPED입니다.

완료 후 accepted socket에 SO_UPDATE_ACCEPT_CONTEXT를 적용해야 listener socket의 context를 정상적으로 상속받습니다.

4.2 ConnectEx의 주요 파라미터

BOOL ConnectEx(
    SOCKET       s,
    const sockaddr* name,
    int          namelen,
    PVOID        lpSendBuffer,
    DWORD        dwSendDataLength,
    LPDWORD      lpdwBytesSent,
    LPOVERLAPPED lpOverlapped);
파라미터의미
s아직 연결되지 않았지만 미리 local address에 bind한 socket입니다.
name연결할 remote address입니다.
namelenremote address 구조체의 크기입니다.
lpSendBuffer연결 직후 함께 보낼 optional data입니다. 사용하지 않으면 nullptr입니다.
dwSendDataLengthoptional send data 크기입니다. 사용하지 않으면 0입니다.
lpdwBytesSent즉시 완료된 경우 전송된 byte 수입니다.
lpOverlappedconnect operation을 식별할 OVERLAPPED입니다.

완료 후 socket에 SO_UPDATE_CONNECT_CONTEXT를 적용해야 일반적인 connected socket API와 option을 정상적으로 사용할 수 있습니다.

2부에서는 NrWin32Socket이 이 함수 포인터를 언제 로드하고, NrListener와 client transport가 accept/connect completion을 어떤 상태 전이로 처리하는지 살펴봅니다.


5. IOCP는 여러 OVERLAPPED 완료를 모으는 역할

OVERLAPPED가 operation 하나의 비동기 상태를 연결한다면, IOCP는 여러 handle에서 완료된 operation을 worker가 꺼낼 수 있는 하나의 completion port로 모읍니다.

5.1 CreateIoCompletionPort: port 생성

HANDLE CreateIoCompletionPort(
    HANDLE    FileHandle,
    HANDLE    ExistingCompletionPort,
    ULONG_PTR CompletionKey,
    DWORD     NumberOfConcurrentThreads);

먼저 새로운 completion port를 생성합니다.

HANDLE iocp = CreateIoCompletionPort(
    INVALID_HANDLE_VALUE,
    nullptr,
    0,
    0);main.cpp
파라미터생성 시 값의미
FileHandleINVALID_HANDLE_VALUEfile/socket 연결 없이 새 port만 생성합니다.
ExistingCompletionPortnullptr기존 port가 없으므로 새 handle을 요청합니다.
CompletionKey0handle을 연결하지 않으므로 사용되지 않습니다.
NumberOfConcurrentThreads0시스템 processor 수를 기본 concurrency 값으로 사용합니다.

5.2 socket handle을 기존 IOCP에 연결

같은 함수를 다시 호출하여 socket handle을 port에 연결합니다.

const ULONG_PTR completionKey =
    reinterpret_cast<ULONG_PTR>(connection);

HANDLE associatedPort = CreateIoCompletionPort(
    reinterpret_cast<HANDLE>(connectedSocket),
    iocp,
    completionKey,
    0);main.cpp
파라미터연결 시 값의미
FileHandleconnectedSocketcompletion을 이 port로 보낼 overlapped socket입니다.
ExistingCompletionPortiocp앞에서 생성한 completion port handle입니다.
CompletionKeyconnection 주소 또는 식별자이 socket의 completion과 함께 반환할 handle 단위 값입니다.
NumberOfConcurrentThreads0기존 port에 연결할 때 port의 concurrency 값을 다시 설정하지 않습니다.

completion key는 socket을 port에 연결할 때 지정하는 handle 단위 값입니다. OVERLAPPED*WSARecv 또는 WSASend를 등록할 때 지정하는 operation 단위 값입니다.

CreateIoCompletionPort로 IOCP에 소켓을 연결하는 것 자체는 비동기 I/O를 실행하지 않습니다. 연결 이후에 해당 소켓을 이용해 비동기 I/O를 요청하면, 그 소켓을 대상으로 하는 I/O의 완료 결과가 연결된 IOCP로 전달되는 구조를 가집니다.

socket handle을 IOCP에 연결

아직 I/O는 발생하지 않음

WSARecv / WSASend 등 Overlapped I/O 등록

운영체제가 I/O 수행

완료 패킷을 연결된 IOCP queue에 삽입

worker가 GetQueuedCompletionStatus로 꺼냄

5.3 IOCP Port와 socket handle의 관계

IOCP port 하나에는 여러 socket handle을 연결할 수 있습니다. 그래서 각 socket handle에서 요청하고 운영체제에서 처리된 I/O 작업의 결과를 어떤 socket handle에서 요청한 것인지 식별하기 위한 식별값이 필요합니다.

IOCP와 소켓을 연결할 때 completion key를 같이 전달을 했습니다. 바로 이러한 이유 때문입니다.

flowchart LR
    subgraph Handles["socket handle"]
        SA["Socket A : Completion Key A"]
        SB["Socket B : Completion Key B"]
        SC["Socket C : Completion Key C"]
    end

    subgraph Operations["operation context"]
        RA["RecvContext A : OVERLAPPED + recv buffer"]
        WA["SendContext A : OVERLAPPED + send buffer"]
        RB["RecvContext B : OVERLAPPED + recv buffer"]
    end

    K["Windows I/O Manager"]
    P["IOCP Handle Completion Packet Queue"]
    W1["Worker 1 : GetQueuedCompletionStatus"]
    W2["Worker 2 : GetQueuedCompletionStatus"]

    SA -->|"associate: key A"| P
    SB -->|"associate: key B"| P
    SC -->|"associate: key C"| P

    RA -->|"WSARecv on Socket A"| K
    WA -->|"WSASend on Socket A"| K
    RB -->|"WSARecv on Socket B"| K

    K -->|"bytes + key + OVERLAPPED pointer"| P
    P --> W1
    P --> W2

5.4 GetQueuedCompletionStatus: completion 꺼내기

socket에서 요청한 비동기 작업이 완료되면 그 결과는 IOCP에 도착한다고 했습니다.

도착한 결과를 completion packet이라 부르며, Worker는 이 completion packet을 확인하기 위해 GetQueuedCompletionStatus를 사용합니다.

이 과정에서 Worker와 I/O 작업을 요청한 주체는 서로 다르기 때문에 앞서 말한 completion key를 통해 어떤 socket에서 I/O 요청을 했는지를 식별할 수 있게 됩니다.

BOOL GetQueuedCompletionStatus(
    HANDLE       CompletionPort,
    LPDWORD      lpNumberOfBytesTransferred,
    PULONG_PTR   lpCompletionKey,
    LPOVERLAPPED* lpOverlapped,
    DWORD        dwMilliseconds);
파라미터예제 값의미
CompletionPortiocpcompletion을 기다릴 port입니다.
lpNumberOfBytesTransferred&transferredBytes완료된 operation이 처리한 byte 수입니다.
lpCompletionKey&completionKeysocket을 port에 연결할 때 등록한 key를 받습니다.
lpOverlapped&completedOverlapped완료된 operation에 사용한 OVERLAPPED*를 받습니다.
dwMillisecondsINFINITEcompletion이 올 때까지 기다립니다. timeout이 필요하면 millisecond 값을 사용합니다.
DWORD transferredBytes = 0;
ULONG_PTR completionKey = 0;
OVERLAPPED* completedOverlapped = nullptr;

const BOOL succeeded = GetQueuedCompletionStatus(
    iocp,
    &transferredBytes,
    &completionKey,
    &completedOverlapped,
    INFINITE);

const DWORD error = succeeded ? ERROR_SUCCESS : GetLastError();main.cpp

GetQueuedCompletionStatusFALSE를 반환해도 completedOverlapped가 null이 아닐 수 있습니다. 이런 경우에는 I/O 작업이 실패했고, 그 실패한 결과를 completion에서 확인할 수 있게 됩니다.

따라서 GetQueuedCompletionStatus의 결과와 overlapped의 null 여부를 함께 고려하여 다음과 같이 판단을 해야 합니다.

succeeded == TRUE, overlapped != nullptr
-> 성공한 I/O completion

succeeded == TRUE, overlapped == nullptr
-> 사용자가 직접 전달한 제어용 packet 처리

succeeded == FALSE, overlapped != nullptr
-> 실패한 I/O completion
-> operation 오류 처리와 context 정리 필요

succeeded == FALSE, overlapped == nullptr
-> I/O completion을 꺼내지 못함
-> port wait 자체의 실패 또는 timeout 처리

완료된 OVERLAPPED*로 애플리케이션의 operation context를 복원합니다.

IoContext* context = CONTAINING_RECORD(
    completedOverlapped,
    IoContext,
    overlapped);main.cpp

5.5 PostQueuedCompletionStatus: application packet 등록

앞서 말한 상황에서 overlapped가 없는 completion packet이 발생할 수 있는 경로입니다.

PostQueuedCompletionStatus는 실제 I/O 없이 completion packet을 port에 직접 넣습니다.

BOOL PostQueuedCompletionStatus(
    HANDLE       CompletionPort,
    DWORD        dwNumberOfBytesTransferred,
    ULONG_PTR    dwCompletionKey,
    LPOVERLAPPED lpOverlapped);

worker 종료, command wakeup처럼 application이 정의한 control packet에 사용할 수 있습니다.


6. API 호출과 Completion의 전체 흐름

지금까지의 API를 하나의 흐름으로 연결하면 다음과 같습니다.

1. WSAStartup
2. WSASocketW(..., WSA_FLAG_OVERLAPPED)
3. CreateIoCompletionPort로 IOCP 생성
4. CreateIoCompletionPort로 socket을 IOCP에 연결
5. IoContext에 OVERLAPPED와 WSABUF 준비
6. WSARecv 또는 WSASend 등록
7. return 0 또는 WSA_IO_PENDING이면 operation 시작 성공
8. Windows가 I/O 완료 후 completion packet 등록
9. GetQueuedCompletionStatus가 bytes, key, OVERLAPPED* 반환
10. OVERLAPPED*로 IoContext 복원
11. operation 결과 처리
12. 모든 pending operation 완료 후 socket과 IOCP 정리

각 값의 책임도 다음처럼 구분할 수 있습니다.

책임 범위
Socket handleI/O endpoint이며 어떤 IOCP port에 completion을 전달할지 결정합니다.
Completion keysocket 또는 connection 단위를 식별합니다.
OVERLAPPED*개별 accept/connect/recv/send operation을 식별합니다.
WSABUFreceive 또는 send에 사용할 memory 구간을 표현합니다.
Transferred bytes해당 completion에서 실제로 처리된 byte 수입니다.

정리하며

IOCP는 OVERLAPPED I/O와 분리된 별도의 비동기 I/O model이 아닙니다. 먼저 WSARecv, WSASend, AcceptEx 또는 ConnectEx에 operation context를 등록하고, IOCP는 완료된 operation의 결과를 worker에게 전달합니다.

IOCP port에는 여러 socket handle을 연결할 수 있습니다. completion key는 handle 단위를 식별하고, OVERLAPPED*는 그 handle에서 완료된 operation을 식별합니다.

핵심 요약:

참고 자료


이 게시물은 학습한 내용을 바탕으로 초안을 작성한 뒤, LLM의 도움을 받아 내용을 검수하고 다듬어 완성되었습니다.


공유하기:

이전 글
[C++] MPSC Queue: Atomic은 MutexLock보다 빠른가
다음 글
[WinAPI] 메탈슬러그 모작: 충돌 이벤트와 지형 보정