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

[C++] IOCP 2: NrRuntime의 Winsock I/O Pipeline과 Lifetime

TL;DR — NrRuntime은 NrWin32Socket으로 Winsock 호출과 socket 상태를 관리하고, operation별 I/O Context로 OVERLAPPED와 buffer의 수명을 completion까지 유지합니다. 서버에서는 IOCP completion을 Session Actor의 Mailbox에 넣어 session별 mutable state를 직렬화하며, TCP byte stream의 partial receive/send와 close 이후 late completion을 이 경계 안에서 처리합니다.

Table of contents

Open Table of contents

들어가며

IOCP를 이용해 비동기 I/O를 등록하는 것만으로 서버의 네트워크 구조가 완성되지는 않습니다. 비동기 작업을 실제 서버에 적용하려면 다음 문제를 애플리케이션에서 해결해야 합니다.

이 글에서는 C++ 서버 프로젝트인 NrRuntime의 코드를 따라가며 위 문제를 어떤 ownership과 상태 전이로 처리했는지 정리합니다. 서버의 AcceptEx 경로와 native client runtime의 ConnectEx 경로를 모두 살펴보지만, 중심은 accepted connection을 관리하는 Runtime Session Actor입니다.

이 글에서 다루는 내용:

사전 지식: TCP socket, Windows OVERLAPPED I/O, IOCP의 completion key와 OVERLAPPED*에 대한 기본 이해를 전제로 합니다.


1. NrRuntime 네트워크 처리 구조

NrRuntime의 네트워크 흐름은 다음 순서로 살펴볼 수 있습니다.

flowchart TB
    Socket["2. Socket과 I/O Context"]
    Connection["3. 연결 수립
    Accept / Connect"]
    Completion["4. IOCP 완료 전달"]
    Receive["5. Receive와 Partial Frame"]
    Send["6. Send와 Partial Send"]
    Shutdown["7. 종료와 I/O Lifetime"]

    Socket --> Connection
    Connection --> Completion
    Completion --> Receive
    Completion --> Send
    Receive --> Shutdown
    Send --> Shutdown

먼저 socket과 개별 I/O operation의 수명 차이를 확인합니다. 그다음 연결 수립과 공통 completion 전달 구조를 살펴보고, 그 위에서 receive와 send가 각각 어떻게 진행되는지 확인합니다. 마지막에는 실행 중인 I/O가 남은 상태에서 connection을 닫는 과정을 정리합니다.

이 순서는 NrRuntime의 주요 책임이 연결, completion 전달, stream 처리, lifetime 관리 순서로 이어진다는 것을 보여줍니다.


2. NrWin32Socket과 I/O Context

2.1 Session 안에 socket을 보관한다

서버 경로에서는 NrListener가 listener socket을 갖고, 연결 수락이 끝나면 accepted socket이 NrSession으로 이동합니다.

NrSessionIoActor는 이 NrSession을 소유하고 session별 I/O state를 변경하는 작업을 진행하게 됩니다.

flowchart TB
    subgraph Listener["NrListener"]
        ListenSocket["NrWin32Socket
        Listener Socket"]
    end

    subgraph Actor["NrSessionIoActor"]
        subgraph Session["NrSession"]
            SessionSocket["NrWin32Socket
            Connected Socket"]
            RecvBuffer["NrRecvBuffer"]
            PendingContext["Pending Recv / Send Context Lease"]
        end
    end

    ListenSocket --> Accept["AcceptEx
    NrAcceptIoContext"]
    SessionSocket --> IO["비동기 I/O 작업 등록"]
    IO --> Receive["WSARecv
    NrRecvIoContext"]
    IO --> Send["WSASend
    NrSendIoContext"]

이 구조에서 NrWin32Socket은 native SOCKET handle과 socket 상태를 감쌉니다.

그래서 서버에 있는 NrSession은 소유하고 있는 NrWin32Socket을 이용해 비동기 I/O 작업 요청을 할 수 있게 됩니다. NrSession은 대략적으로 다음과 같이 구성되어 있습니다.

class NrSession final
{
private:
    NrSessionKey sessionKey_ = 0;
    NrWin32Socket socket_;
    NrRecvBuffer recvBuffer_;

    NrRecvIoContextLease pendingRecv_;
    NrSendIoContextLease pendingSend_;
};NrSession.h

NrSession에는 IoContext와 관련된 멤버들이 존재합니다. 하나의 NrSession에서는 하나의 Recv, Send 비동기 I/O 요청을 할 수 있다는 제약을 주었기 때문에 각각 하나의 Context를 가지고 있습니다(one-outstanding I/O).

Socket과 각 IoContext의 책임을 살펴보면 다음과 같습니다.

대상책임유지 범위
NrWin32Socketnative socket handle과 연결 상태 관리connection lifetime
NrAcceptIoContext한 번의 AcceptEx와 accepted socket 관리accept posting부터 completion 처리까지
NrRecvIoContext한 번의 WSARecv와 writable buffer 구간 관리recv posting부터 completion 처리까지
NrSendIoContext한 번의 WSASend, payload와 현재 offset 관리frame 전체의 send completion까지

2.2 I/O Context는 completion까지 살아 있어야 한다

비동기로 처리된 I/O 작업의 결과는 IOCP에 completion으로 도착을 하게 되고, 작업의 결과를 OVERLAPPED를 통해 확인할 수 있습니다.

그래서 처음 I/O 작업을 요청할 때부터, 작업에 사용할 버퍼가 completion packet을 처리하는 시점까지 유효하도록 설계해야 합니다.

NrRuntime은 IOCP에 전달한 OVERLAPPED와 I/O buffer가 completion 전까지 파괴되지 않도록 메모리 풀을 사용합니다. 그리고 NrSession은 메모리 풀에서 확보한 저장 공간에 대한 소유권을 I/O 작업에 잠깐 빌려 쓰게(Lease) 됩니다.

I/O에 사용되는 메모리의 소유권을 정리하면 다음과 같습니다.

# Context Lease
    - 메모리 풀의 OverlappedContext 블록을 소유
    - 블록 안에 Recv/Send IoContext가 생성됨
    - Context 내부에 OVERLAPPED, WSABUF 존재

# Receive Buffer
    - WSARecv에 필요한 recv 전용 메모리 버퍼 블록을 소유
    - RecvIoContext::WSABUF는 이 버퍼의 writable 영역을 가리킴

# Send Payload
    - PayloadRef가 메모리 풀에 있는 payload 전용 블록을 소유
    - SendIoContext가 PayloadRef를 보유해 전송 완료까지 payload를 유지
┌──────────────────────────────────── NrSession ────────────────────────────────────┐
│                                                                                   │
│  [ Receive ]                                                                      │
│                                                                                   │
│  pendingRecv_                           recvBuffer_                                │
│  ┌──────────────────────┐             ┌──────────────────────┐                    │
│  │ RecvIoContextLease   │             │ NrRecvBuffer         │                    │
│  └──────────┬───────────┘             └──────────┬───────────┘                    │
│             │ owns                                │ owns                           │
└─────────────┼─────────────────────────────────────┼────────────────────────────────┘
              ▼                                     ▼
     ┌───────────────────────┐             ┌──────────────────────────┐
     │ OverlappedContext     │             │ RecvBuffer Pool Block    │
     │ Pool Block            │             │                          │
     │                       │             │ [readable][writable......]│
     │ ┌───────────────────┐ │             └────────────▲─────────────┘
     │ │ NrRecvIoContext   │ │                          │
     │ │                   │ │                          │
     │ │ OVERLAPPED        │ │                          │
     │ │ WSABUF ───────────┼─┼──── non-owning pointer ─┘
     │ └───────────────────┘ │
     └───────────────────────┘
              ▲                                     ▲
              │ acquire / return                    │ acquire / return
     ┌────────┴──────────────┐             ┌────────┴───────────────┐
     │ OverlappedContext Pool│             │ RecvBuffer Pool        │
     └───────────────────────┘             └────────────────────────┘


┌──────────────────────────────────── NrSession ────────────────────────────────────┐
│                                                                                   │
│  [ Send ]                                                                         │
│                                                                                   │
│  pendingSend_                                                                     │
│  ┌──────────────────────┐                                                         │
│  │ SendIoContextLease   │                                                         │
│  └──────────┬───────────┘                                                         │
│             │ owns                                                                │
└─────────────┼─────────────────────────────────────────────────────────────────────┘

     ┌──────────────────────────┐
     │ OverlappedContext        │
     │ Pool Block               │
     │                          │
     │ ┌──────────────────────┐ │
     │ │ NrSendIoContext      │ │
     │ │                      │ │
     │ │ OVERLAPPED           │ │
     │ │ WSABUF ──────────────┼─┼──────┐
     │ │ PayloadRef ──────────┼─┼───┐  │
     │ └──────────────────────┘ │   │  │
     └──────────────────────────┘   │  │
              ▲                     │  │
              │ acquire / return    │  │ keeps alive
     ┌────────┴──────────────┐      │  │
     │ OverlappedContext Pool│      │  │
     └───────────────────────┘      │  │
                                    ▼  ▼
                           ┌────────────────────────┐
                           │ Payload Pool Block     │
                           │ [ bytes to send...... ]│
                           └───────────▲────────────┘
                                       │ acquire / last reference return
                           ┌───────────┴────────────┐
                           │ Payload Memory Pool    │
                           └────────────────────────┘

이 구조 덕분에 I/O 작업을 요청하는 측과 I/O 작업을 처리하는 측이 작업의 맥락을 공유하면서, 작업의 lifetime은 메모리 풀에서 처리하게 만들 수 있습니다.

NrRuntime의 I/O Context는 공통 header를 첫 번째 멤버로 둡니다. Header의 첫 번째 필드는 OVERLAPPED입니다. 두 번째 멤버인 Type을 통해 IoContext의 세부 타입을 확인할 수 있고, 이를 기반으로 Recv/Send IoContext로 변환해 세부 작업을 진행할 수 있게 됩니다.

struct NrIocpIoContextHeader final
{
    OVERLAPPED overlapped{};
    NrIoOperationType type = NrIoOperationType::Unknown;
};

static_assert(offsetof(NrIocpIoContextHeader, overlapped) == 0);NrIocpIoContextHeader.h

3. 연결 수립

서버는 AcceptEx, 클라이언트는 ConnectEx를 이용해 연결을 만듭니다.

AcceptExConnectEx 모두 Winsock API의 확장 함수이기 때문에 연결을 수립한 다음에 socket context를 갱신하고 나서 일반적인 recv/send 단계로 이동하게 만들었습니다.

socket context 갱신은 TCP 연결을 완성하기 위한 작업은 아닙니다. AcceptExConnectEx가 성공한 직후라면, accepted나 connected socket은 바로 데이터 송수신에 사용할 수 있습니다.

다만, 이 시점에서는 Winsock provider에서 제공하는 API 사용에 제한이 되어 있기 때문에 accept와 connect에 사용된 listener socket의 속성을 상속받게 만들어야 합니다.

const int result = ::setsockopt(
    acceptedSocket,
    SOL_SOCKET,
    SO_UPDATE_ACCEPT_CONTEXT,
    reinterpret_cast<const char*>(&listenSocket),
    sizeof(listenSocket));

갱신 이후에는 getsockname, getpeername, getsockopt와 같은 일반 소켓 API를 사용할 수 있게 됩니다.

3.1 서버의 AcceptEx 흐름

NrListener::Start()는 다음 작업을 수행합니다.

  1. overlapped TCP socket을 생성합니다.
  2. bind()listen()을 호출합니다.
  3. listener socket을 IOCP에 연결합니다.
  4. LoadAcceptEx()로 Winsock provider의 AcceptEx 함수 포인터를 얻습니다.
  5. 준비된 accept slot마다 AcceptEx를 미리 등록합니다.
flowchart LR
    Listener["NrListener"]
    ListenSocket["Listener Socket"]
    AcceptSlot["Accept Slot
    Accepted Socket + Context"]
    AcceptEx["AcceptEx 등록"]
    Completion["Accept 완료"]
    Update["SO_UPDATE_ACCEPT_CONTEXT"]
    Association["Accepted Socket을
    IOCP에 연결"]
    Session["NrSession 생성"]

    Listener --> ListenSocket
    Listener --> AcceptSlot
    ListenSocket --> AcceptEx
    AcceptSlot --> AcceptEx
    AcceptEx --> Completion
    Completion --> Update
    Update --> Association
    Association --> Session
    Session -->|"Accept Slot 재사용"| AcceptEx

NrAcceptIoContext는 accepted socket과 AcceptEx output buffer를 직접 보유합니다.

AcceptEx는 비동기로 실행되기 때문에 호출이 반환되어도 Windows가 AcceptIoContext를 계속 사용하게 됩니다.

class NrAcceptIoContext final
{
private:
    NrIocpIoContextHeader header_;
    NrWin32Socket acceptedSocket_;
    std::array<std::uint8_t, NrAcceptBufferLength> buffer_{};
};NrAcceptIoContext.h

또, AcceptEx는 일반 accept 처럼 완료 시 새 소켓을 반환하지 않습니다. 그래서 호출 전에 미리 accepted socket으로 사용할 소켓을 만들어 전달해야 합니다. 그래서 AcceptEx를 호출하여 비동기 accept 작업이 진행/펜딩 중일 때는 다음과 같이 accepted socket은 특정 AcceptEx 작업에 종속되게 됩니다.

Accept 작업 A ── acceptedSocket A
Accept 작업 B ── acceptedSocket B
Accept 작업 C ── acceptedSocket C

각 AcceptIoContext가 소켓을 직접 보유하니, Worker에서 Context를 복구해 다음과 같은 작업을 용이하게 처리할 수 있게 됩니다.

추가적으로 buffer에는 다음과 같은 데이터가 저장됩니다.

┌─────────────────────────────────────┐
│ 초기 수신 데이터 │ 로컬 주소 │ 원격 주소 │
└─────────────────────────────────────┘

이 정보는 비동기 작업이 완료될 때까지 유효해야 하며, 완료 후에는 GetAcceptExSockaddrs를 사용해 이 buffer를 해석해야 합니다.

당연하게 이 buffer 역시 lifetime을 completion까지 보장해야 하기 때문에 IoContext 내부에 buffer를 두게 됐습니다.

현재 구현은 dwReceiveDataLength0으로 전달합니다. 그래서 buffer에는 initial payload 영역을 두지 않고 local/remote address 영역만 준비합니다. (그래서 buffer의 크기가 고정되어 있어 IoContext 블록에서 바로 소유하게 했습니다.)

이후 AcceptEx는 client connection이 들어오면 payload를 기다리지 않고 완료됩니다. accepted가 된 이후에는 서버 내부에서 Session을 관리하는 registry에 등록이 되고, 별도로 WSARecv 요청을 보내게 됩니다.

Accept completion 이후 NrListener::CompleteAccept()는 다음과 같은 절차를 진행합니다.


// AcceptedSocket을 받아오고
NrWin32Socket& acceptedSocket = acceptContext.AcceptedSocket();

// socket context를 갱신해주고
const NrStatus updateStatus =
    acceptedSocket.UpdateAcceptContext(listenSocket_);

// accepted socket을 IOCP에 연결
return iocpPort.AssociateSocket(
    acceptedSocket,
    static_cast<std::uintptr_t>(sessionKey));NrListener.cpp
AllocateSessionKey()


CompleteAccept()
├── SO_UPDATE_ACCEPT_CONTEXT
└── AssociateSocket(socket, sessionKey)


NrSession::Create()


NrSessionIoActor::Create()


sessionActorRegistry->TryRegisterActor(sessionKey, actor)


registry 등록까지 성공했으면 서버 내부 accept 절차 완료 처리를 위해 Accepted 이벤트를 Enqueue


Actor가 Accepted 이벤트를 핸들링하여 해당 Session에 WSARecv 등록

3.2 클라이언트의 ConnectEx 흐름

클라이언트의 연결은 NrClientConnection이 담당합니다.

flowchart LR
    Client["NrClientTransport"]
    Connection["NrClientConnection"]
    Socket["Client Socket 생성"]
    Bind["Any:0으로 bind"]
    Associate["IOCP에 연결"]
    ConnectEx["ConnectEx 등록"]
    Completion["Connect 완료"]
    Update["SO_UPDATE_CONNECT_CONTEXT"]
    Receive["첫 WSARecv 등록"]

    Client --> Connection
    Connection --> Socket
    Socket --> Bind
    Bind --> Associate
    Associate --> ConnectEx
    ConnectEx --> Completion
    Completion --> Update
    Update --> Receive

ConnectEx에 전달할 socket은 먼저 local address에 bind되어 있어야 합니다. NrRuntime은 IPv4 Any address와 port 0으로 bind하여 OS가 사용할 local endpoint를 선택하도록 합니다.

const NrEndpoint localEndpoint{
    NrEndpointAddressType::IPv4,
    NrIPv4Address::Any(),
    0};

socket_.Bind(localEndpoint);
iocpPort.AssociateSocket(socket_, 0);
socket_.LoadConnectEx();
socket_.PostConnect(
    connectContext_.RemoteAddress(),
    connectContext_.RemoteAddressLength(),
    connectContext_.Overlapped());NrClientConnection.cpp

Connect completion이 성공하면 Accept와 마찬가지로 미리 만들어둔 Connected Socket의 context를 갱신해주고( SO_UPDATE_CONNECT_CONTEXT를 적용) 첫 WSARecv를 등록합니다.

클라이언트는 하나의 단일 실행 흐름을 가정하기 때문에 별도의 Session Actor 없이 NrClientTransport의 completion dispatcher를 사용하게 됩니다.


4. IOCP Completion을 Session Actor로 전달한다

4.1 Completion packet에서 operation을 복원한다

NrIocpCompletionPumpGetQueuedCompletionStatus(= GQCS)를 감싼 WaitForCompletion()으로 completion packet 하나를 꺼냅니다.

NrStatus NrIocpCompletionPump::PumpOnce() noexcept
{
    NrIocpCompletionPacket packet;
    const NrStatus waitStatus = port_.WaitForCompletion(packet);

    if (waitStatus.Failed())
    {
        return waitStatus;
    }

    if (packet.overlapped == nullptr)
    {
        return handler_.HandleControlCompletion(packet);
    }

    return handler_.HandleIoCompletion(packet);
}NrIocpCompletionPump.cpp

I/O completion packet에는 다음 정보가 들어 있습니다.

NrRuntime에서의 역할
bytesTransferred이번 operation에서 실제 처리된 byte 수
completion keylistener 또는 Session을 식별하는 값
OVERLAPPED*완료된 accept/recv/send operation의 context 복원
I/O status성공, 취소 또는 native I/O 실패 구분

Server dispatcher는 OVERLAPPED*NrIocpIoContextHeader를 찾고 operation type에 따라 실제 context로 복원합니다.

NrIocpIoContextHeader* header =
    NrIocpIoContextHeader::FromOverlapped(packet.overlapped);

switch (header->Type())
{
case NrIoOperationType::Accept:
    // NrAcceptIoContext 복원
    break;
case NrIoOperationType::Recv:
    // NrRecvIoContext 복원
    break;
case NrIoOperationType::Send:
    // NrSendIoContext 복원
    break;
}NrIocpIoCompletionDispatcher.cpp

4.2 I/O Context로 Completion을 식별한다

Recv와 Send I/O를 등록하는 주체는 Session Actor입니다. Actor Worker는 먼저 메모리 풀에서 NrRecvIoContext가 들어 있는 블록의 Lease를 얻고, 이를 Session의 pending state에 저장합니다.

NrResult<NrRecvIoContextLease> pendingRecvResult =
    contextFactory_.CreateRecv(
        session.SessionKey(),
        session.RecvBuffer().WritableSpan());

session.SetPendingRecv(pendingRecvResult.TakeValue());

NrRecvIoContext& context = session.PendingRecv().Context();
const NrStatus postStatus = session.Socket().PostRecv(
    context.wsabuf,
    session.PendingRecv().Overlapped());NrSessionIoOperations.cpp

pendingRecv_에는 실제 수신 데이터가 아니라 현재 진행 중인 WSARecv 한 건을 표현하는 Context의 Lease가 들어 있습니다. 실제 데이터는 NrRecvBuffer에 기록되고, Context의 WSABUF는 그 buffer의 writable 영역을 가리킵니다.

NrSession
├── recvBuffer_
│   └── 실제 수신 데이터가 기록되는 메모리

└── pendingRecv_: NrRecvIoContextLease
    │ owns

Memory Pool Block
└── NrRecvIoContext
    ├── OVERLAPPED
    ├── operation type = Recv
    ├── sessionKey
    └── WSABUF ──────────> recvBuffer_의 writable 영역

Session에 Lease를 먼저 저장한 뒤 WSARecv를 등록하는 순서가 중요합니다. WSARecv가 즉시 완료되더라도 Session에는 이미 해당 Context가 pending state로 기록되어 있습니다.

Lease를 보유하는 동안 메모리 풀 블록은 풀에 반환되거나 다른 작업에 재사용되지 않습니다. Lease 객체의 소유권이 이동하더라도 Lease가 가리키는 블록 자체는 이동하지 않으므로, 블록 안에 생성된 NrRecvIoContext의 주소도 completion 처리까지 유지됩니다.

I/O가 완료되면 IOCP Worker는 completion packet에 포함된 OVERLAPPED*로 I/O Context Header를 복원합니다. 여기서 Header는 TCP packet header가 아니라, NrRuntime이 operation type을 기록하기 위해 OVERLAPPED와 함께 배치한 NrIocpIoContextHeader입니다.

IOCP Completion Packet
└── OVERLAPPED*


NrIocpIoContextHeader
└── operation type = Recv


NrRecvIoContext::FromOverlapped()


NrRecvIoContext*

복원된 Context는 NrIocpIoCompletionDispatcher에서 NrIoEventDispatcher로 전달됩니다.

NrRecvIoContext* context =
    NrRecvIoContext::FromOverlapped(packet.overlapped);

return dispatcher_.DispatchRecv(
    NrIoEvent::Recv(
        context->sessionKey,
        context->writableLength,
        bytesTransferred,
        packet.ioStatus),
    *context);NrIocpIoCompletionDispatcher.cpp

NrIoEventDispatcher는 복원한 Context의 주소를 contextToken으로 completion event에 기록합니다. Token은 별도로 발급한 ID가 아니라, 완료된 NrRecvIoContext 또는 NrSendIoContext의 주소입니다.

NrSessionIoCompletedEvent completed;
completed.sessionKey = event.SessionKey();
completed.bytesTransferred = event.BytesTransferred();
completed.status = event.Status();
completed.contextToken = contextTokenSource;NrIoEventDispatcher.cpp

IOCP Worker는 이 event를 해당 Session Actor의 Mailbox에 넣습니다. 이후 Actor Worker가 event를 처리할 때, 전달받은 token과 Session이 보유한 pending Context의 주소를 비교합니다.

if (completed.contextToken !=
    session_->PendingRecv().ContextToken())
{
    // 현재 기다리던 Recv completion이 아님
}NrSessionIoActor.Recv.cpp

contextToken 자체에는 소유권이 없으며 Context의 lifetime을 연장하지 않습니다. Session의 pending Lease가 메모리 풀 블록의 lifetime과 주소 안정성을 보장하고, token은 그 주소를 I/O identity로만 사용합니다.

┌──────────────── Actor Worker ────────────────┐
│ Context Lease 생성                          │
│        │                                    │
│ Session::pendingRecv_에 저장                 │
│        │                                    │
│ WSARecv(WSABUF, OVERLAPPED*) 등록            │
└────────┼────────────────────────────────────┘

┌──────────────── Windows ─────────────────────┐
│ 비동기 I/O 진행                              │
│        │                                    │
│ IOCP completion 생성                        │
└────────┼────────────────────────────────────┘

┌──────────────── IOCP Worker ────────────────┐
│ OVERLAPPED*로 I/O Context 복원               │
│        │                                    │
│ contextToken = I/O Context 주소              │
│        │                                    │
│ Actor Mailbox에 event enqueue                │
└────────┼────────────────────────────────────┘

┌──────────────── Actor Worker ────────────────┐
│ completed.contextToken                      │
│              ==                             │
│ pendingRecv_.ContextToken()                  │
│        │                                    │
│ Session state와 buffer 처리                 │
│        │                                    │
│ Lease 반환 또는 다음 I/O 등록                │
└─────────────────────────────────────────────┘

여기서 Actor Worker는 특정 OS thread를 의미하지 않습니다. 실제 실행 thread가 달라져도 Registry에 보관된 NrSessionIoActor와 그 안의 pending state는 유지됩니다. IOCP Worker는 completion을 Actor event로 변환할 뿐 Session state를 직접 변경하지 않고, 실제 상태 변경은 drain 권한을 얻은 Actor Worker 하나가 순차적으로 수행합니다.


5. Receive와 Partial Frame

5.1 TCP receive와 application frame은 같은 단위가 아니다

TCP는 메시지가 아닌 순서가 보장된 byte stream을 전달합니다. 따라서 한 번의 recv completion은 하나의 application frame과 일치하지 않습니다.

한 번의 Recv Completion으로 받은 데이터

┌────────────────┬────────────────┬─────────────────┐
│ 완성된 Frame A │ 완성된 Frame B │ Frame C의 일부 │
└────────────────┴────────────────┴─────────────────┘

반대로 frame 하나가 여러 completion으로 나뉘어 도착할 수도 있습니다.

Recv Completion 1        Recv Completion 2
┌──────────────────┐     ┌──────────────────────┐
│ Header + Payload │  +  │ 나머지 Payload bytes │
│ 일부             │     │                      │
└──────────────────┘     └──────────────────────┘

NrRuntime은 Session마다 NrRecvBuffer를 두고 completion으로 받은 byte를 누적합니다. Header와 전체 frame이 준비되었을 때만 application input으로 전달합니다.

5.2 Receive posting과 buffer lifetime

NrSessionIoOperations::PostRecv()는 다음 순서를 사용합니다.

  1. active pending recv가 없는지 확인합니다.
  2. receive buffer의 writable span을 얻습니다.
  3. 해당 span을 가리키는 NrRecvIoContextLease를 생성합니다.
  4. lease를 Session의 pending recv로 먼저 기록합니다.
  5. Session socket으로 WSARecv를 등록합니다.
  6. Native posting이 실패하면 pending lease를 즉시 해제합니다.
NrResult<NrRecvIoContextLease> pendingRecvResult =
    contextFactory_.CreateRecv(
        session.SessionKey(),
        session.RecvBuffer().WritableSpan());

session.SetPendingRecv(pendingRecvResult.TakeValue());

NrRecvIoContext& context = session.PendingRecv().Context();
const NrStatus postStatus = session.Socket().PostRecv(
    context.wsabuf,
    session.PendingRecv().Overlapped());NrSessionIoOperations.cpp

WSARecv가 가리키는 memory는 completion 전까지 이동하거나 파괴할 수 없습니다. Pending lease를 Session에 먼저 넣는 순서는 이 lifetime을 구조적으로 보장합니다.

5.3 Completion 이후 여러 frame을 반복해서 확인한다

Server에서 recv completion 자체는 먼저 Session Actor Mailbox에 enqueue됩니다. Actor가 이 event를 drain하면서 받은 byte를 buffer에 commit하고, 완성된 frame을 반복해서 파싱합니다.

flowchart TD
    Mailbox["Session Actor Mailbox에서
    Recv Completion 처리"]
    Commit["수신 byte를
    NrRecvBuffer에 Commit"]
    Header{"Header를 읽을 만큼
    byte가 있는가?"}
    Frame{"Header가 나타내는
    전체 Frame이 도착했는가?"}
    Extract["Frame 하나 추출"]
    Dispatch["Dispatch Rule에 따라
    Ingress 또는 To-World에 Enqueue"]
    Consume["전달에 성공한 Frame을
    Buffer에서 Consume"]
    Remaining["처리 후 남은 Buffer를
    다시 확인"]
    Preserve["완성되지 않은 byte를
    Buffer에 보존"]
    Post["다음 WSARecv 등록"]

    Mailbox --> Commit
    Commit --> Header
    Header -->|"아니오"| Preserve
    Header -->|"예"| Frame
    Frame -->|"아니오"| Preserve
    Frame -->|"예"| Extract
    Extract --> Dispatch
    Dispatch --> Consume
    Consume --> Remaining
    Remaining --> Header
    Preserve --> Post

한 frame을 전달한 뒤 바로 다음 WSARecv로 이동하지 않는다는 점이 중요합니다. 현재 buffer에 남은 byte를 다시 검사하여 같은 completion에 함께 들어온 Frame B도 이어서 처리합니다. 다음 frame이 완성되지 않은 지점에서 반복을 멈추고, 남은 byte를 보존한 상태로 다음 WSARecv를 등록합니다.

while (recvBuffer.ReadableBytes() > 0)
{
    NrPacketParseResult parseResult;
    packetParser.Parse(recvBuffer.ReadableSpan(), parseResult);

    if (parseResult.status == NrPacketParseStatus::NeedMoreData)
    {
        return NrStatus::Success();
    }

    // Dispatch rule에 맞는 ingress에 owning input을 전달합니다.
    ingressRegistry.TryEnqueue(dispatchLane, input);

    recvBuffer.Consume(parseResult.header.packetLength);
}NrSessionIoActor.Recv.cpp

실제 코드에서 완성된 frame은 같은 Session Actor Mailbox로 다시 들어가지 않습니다. Recv completion이 Session Actor Mailbox로 들어오고, Actor가 frame을 파싱한 뒤 dispatch rule에 따라 NrIngressRegistry 또는 NrToWorldHandoff에 application input을 전달합니다.

또한 enqueue에 성공한 frame만 receive buffer에서 consume합니다. Input 생성이나 bounded queue admission이 실패하면 해당 frame을 처리한 것으로 표시하지 않고 receive pressure close 경로로 전환합니다.


6. Send와 Partial Send

6.1 Session마다 outstanding send 하나를 유지한다

NrRuntime은 한 Session에 active send context를 하나만 허용합니다. 여러 outbound payload가 들어오면 Actor의 pending send queue에 보관하고, active send가 없을 때 다음 payload를 꺼냅니다.

flowchart TD
    Mailbox["Send Mailbox"]
    Queue["Pending Send Queue"]
    Start["현재 Payload 선택"]
    Post["남은 구간으로 WSASend 등록"]
    Completion["Send Completion"]
    Offset["완료 byte만큼 Offset 증가"]
    Remain{"보내지 않은 byte가
    남았는가?"}
    Next{"다음 Payload가
    있는가?"}
    Idle["Outstanding Send 없음"]

    Mailbox --> Queue
    Queue --> Start
    Start --> Post
    Post --> Completion
    Completion --> Offset
    Offset --> Remain
    Remain -->|"예"| Post
    Remain -->|"아니오"| Next
    Next -->|"예"| Start
    Next -->|"아니오"| Idle

이 정책은 같은 TCP connection의 전송 순서와 context lifetime을 단순하게 만듭니다. 여러 WSASend를 동시에 등록한 뒤 completion 순서와 buffer ownership을 별도로 조정하지 않아도 됩니다.

6.2 Completion byte만큼 offset을 이동한다

WSASend에 전달한 전체 buffer가 한 completion에서 모두 처리된다고 가정할 수 없습니다. NrSendIoContext는 owning payload reference와 현재 WSABUF 구간을 함께 보관합니다.

Send completion을 처리하면 다음 순서로 진행합니다.

  1. Session Key와 context token을 확인합니다.
  2. bytesTransferred만큼 현재 offset을 전진시킵니다.
  3. 전체 payload가 끝났으면 active context를 해제합니다.
  4. 남은 byte가 있으면 같은 context의 남은 span으로 WSASend를 다시 등록합니다.
  5. 전체 payload가 끝난 뒤에만 queue의 다음 payload를 시작합니다.
NrSendIoContext& context = session_->PendingSend().Context();
context.AdvanceBytesSent(completed.bytesTransferred);

if (context.IsFullySent())
{
    session_->ClearPendingSend();
    return TryPostNextPendingSend();
}

return ioOperations_->RepostPendingSend(*session_);NrSessionIoActor.Send.cpp

Close가 이미 요청된 상태에서 matching send completion이 도착하면 현재 context는 정리하지만, 남은 byte를 repost하거나 다음 queued payload를 시작하지 않습니다. 종료 경로에서 새로운 native I/O가 다시 만들어지는 것을 차단하기 위한 규칙입니다.


7. 종료와 Pending I/O Lifetime

7.1 Socket close와 operation 완료는 같은 사건이 아니다

closesocket()을 호출했다고 해서 pending WSARecvWSASend의 completion 처리가 즉시 모두 끝난 것은 아닙니다. 이미 등록한 operation은 취소 또는 실패 completion으로 IOCP에 돌아올 수 있습니다.

따라서 Session과 I/O Context를 socket close 직후 파괴하면 IOCP가 나중에 반환한 OVERLAPPED*가 해제된 memory를 가리킬 수 있습니다.

NrRuntime의 일반 Session close 흐름은 다음과 같습니다.

flowchart TD
    Close["Close 요청"]
    Stop["새로운 Recv / Send 등록 중단"]
    Channel["Send Channel 닫기"]
    SocketClose["closesocket"]
    Completion["취소·실패 Completion 처리"]
    Release["Matching Pending Context 해제"]
    Pending{"Pending Recv / Send가
    모두 0인가?"}
    Lease{"Active Actor Lease가
    없는가?"}
    Destroy["Registry에서 Actor와
    Session 제거"]

    Close --> Stop
    Stop --> Channel
    Channel --> SocketClose
    SocketClose --> Completion
    Completion --> Release
    Release --> Pending
    Pending -->|"아니오"| Completion
    Pending -->|"예"| Lease
    Lease -->|"아니오"| Lease
    Lease -->|"예"| Destroy

NrSessionIoActor::RecordCloseRequested()는 첫 close reason을 보존하고 socket을 닫습니다. Actor는 pending recv/send count를 따로 관리합니다.

void NrSessionIoActor::RefreshCloseReady() noexcept
{
    drainReport_.endReason = endReason_;
    drainReport_.closeRequested = closeRequested_;
    drainReport_.closeReady = closeRequested_ && !HasPendingIo();
}NrSessionIoActor.cpp

Close 이후 matching completion은 application work를 만드는 대신 cleanup을 수행합니다.

7.2 Registry lease도 lifetime 조건에 포함된다

Pending I/O만 0이라고 바로 Actor를 제거하지 않습니다. Actor executor가 해당 actor를 실행하기 위해 lease를 보유하고 있을 수 있기 때문입니다.

NrSessionActorRegistry는 다음 조건을 모두 만족한 뒤 일반 close 경로에서 actor entry를 제거합니다.

이 조건은 두 종류의 lifetime을 함께 보호합니다.

Lifetime보호 대상
Native I/O lifetimeOVERLAPPED, recv span, send payload와 context
Actor execution lifetimeSession, socket, mutable state와 mailbox

7.3 Server shutdown은 일반 Session close와 구분한다

개별 Session close는 matching completion을 통해 pending context를 정리한 뒤 registry에서 actor를 회수합니다. 전체 server shutdown은 범위가 더 큽니다.

Server shutdown에서는 새로운 submission을 먼저 차단하고 scheduler와 I/O worker를 중단·join한 뒤, 남은 actor와 component graph를 생성의 역순으로 정리합니다. 따라서 모든 종료를 하나의 “completion이 올 때까지 무조건 기다리는 과정”으로 해석하지 않습니다.


8. NrRuntime이 Windows API 위에 추가한 보장

Winsock과 IOCP는 비동기 operation을 등록하고 완료 결과를 전달하는 API를 제공합니다. 그 위에서 operation과 객체의 안전한 관계를 정의하는 것은 애플리케이션의 책임입니다.

NrRuntime은 다음 invariant를 둡니다.

이 구조에서 NrWin32Socket은 Windows API 호출 경계이고, I/O Context는 operation lifetime 경계이며, Session Actor는 connection별 mutable state의 실행 경계입니다.


정리하며

NrRuntime은 listener socket에 여러 AcceptEx를 미리 등록하고, 완료된 accepted socket을 Runtime Session으로 이동합니다. Native client에서는 미리 bind한 socket으로 ConnectEx를 등록하고 completion 이후 connected context를 갱신합니다.

연결 이후 각 recv/send operation은 별도의 I/O Context를 사용합니다. Server의 completion은 Session Actor Mailbox event로 변환되며, Actor drain owner가 matching context를 검증하고 receive parsing, send offset과 close state를 순차적으로 변경합니다.

TCP의 byte stream 성질 때문에 한 completion에는 여러 frame 또는 frame 일부가 들어올 수 있습니다. Receive buffer는 남은 byte를 반복해서 파싱하고 incomplete frame을 보존합니다. Send context는 partial completion만큼 offset을 이동하고 남은 구간을 다시 전송합니다.

마지막으로 socket close와 I/O lifetime 종료는 같은 사건이 아닙니다. NrRuntime은 close 이후 도착하는 matching completion으로 pending context를 정리하고, active actor lease까지 사라진 뒤 Session을 제거합니다.

핵심 요약:

참고 자료


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


공유하기:

이전 글
[C++] 고정 블록 메모리 풀: 메모리 풀은 더 빠를까?
다음 글
[C++] MPSC Queue: Atomic은 MutexLock보다 빠른가