Per-IO 데이터 확장 구조
왜 공부했나
기본 구조에서 GetQueuedCompletionStatus로 완료 이벤트를 꺼내는
데까지 갔다. 그런데 꺼내고 나면 바로 막힌다.
GetQueuedCompletionStatus(hIocp, &transferred, &key, &pOverlapped, INFINITE);
// 그래서... 이게 recv 완료야 send 완료야?
완료 큐는 “뭔가 하나 끝났다"만 알려준다. 어떤 소켓의, 어떤 종류의 작업이 끝났는지는 내가 붙여둔 정보로 알아내야 한다. 그 정보를 어디에 어떻게 붙이느냐가 이번 주제다.
두 종류의 컨텍스트
IOCP는 컨텍스트를 붙일 자리를 정확히 두 개 준다.
| 구분 | 붙이는 곳 | 수명 | 담는 것 |
|---|---|---|---|
| Per-Handle | CreateIoCompletionPort의 완료 키 |
소켓이 살아있는 동안 | 세션 객체, 소켓, 주소, 유저 ID |
| Per-IO | OVERLAPPED 포인터 |
I/O 요청 하나가 끝날 때까지 | 작업 종류(recv/send), 버퍼, 전송량 |
핵심은 개수가 다르다는 것이다. 세션은 소켓당 하나지만, I/O 요청은 한 소켓에 동시에 여러 개가 떠 있을 수 있다. recv 하나 걸어두고 send 두 개를 동시에 던지면 그 소켓에 대기 중인 작업이 3개다. 그래서 작업별 정보는 세션에 못 넣고, 요청마다 따로 만들어야 한다.
flowchart LR
subgraph S["세션 (Per-Handle) — 소켓당 1개"]
SOCK["SOCKET"]
UID["userId"]
end
subgraph IO["Per-IO — 요청마다 1개"]
R["recv 요청<br/>OVERLAPPED + 버퍼"]
W1["send 요청 1<br/>OVERLAPPED + 버퍼"]
W2["send 요청 2<br/>OVERLAPPED + 버퍼"]
end
S --- R
S --- W1
S --- W2
OVERLAPPED를 확장한다
WSARecv 같은 함수는 OVERLAPPED*만 받는다. 내 구조체를 넘길 자리가 없다.
그래서 반대로 한다 — OVERLAPPED를 품은 더 큰 구조체를 만들고, 그 안의
OVERLAPPED 주소를 넘긴다.
enum class IoType { Recv, Send };
struct IoContext {
OVERLAPPED overlapped; // 반드시 첫 멤버 (캐스팅으로 되돌리려면)
IoType type;
WSABUF wsaBuf;
char buffer[BUF_SIZE];
};완료가 올라오면 받은 OVERLAPPED*를 다시 IoContext*로 되돌린다.
DWORD transferred; ULONG_PTR key; OVERLAPPED* pOv;
BOOL ok = GetQueuedCompletionStatus(hIocp, &transferred, &key, &pOv, INFINITE);
Session* pSession = reinterpret_cast<Session*>(key); // per-handle
IoContext* pIo = reinterpret_cast<IoContext*>(pOv); // per-IO
switch (pIo->type) {
case IoType::Recv: OnRecv(pSession, pIo, transferred); break;
case IoType::Send: OnSend(pSession, pIo, transferred); break;
}OVERLAPPED가 첫 멤버라 주소가 같아서 이 캐스팅이 성립한다.
첫 멤버가 아니어도 되게 하려면
첫 멤버 규칙은 조용히 깨지기 쉽다. 누가 위에 멤버 하나만 추가해도 주소가 어긋나고,
컴파일은 멀쩡히 된다. Windows SDK의 CONTAINING_RECORD 매크로를 쓰면 위치와
무관해진다.
IoContext* pIo = CONTAINING_RECORD(pOv, IoContext, overlapped);멤버 오프셋을 빼서 바깥 구조체 주소를 구하는 매크로다. 순서가 바뀌어도 알아서 맞으니 이쪽이 안전하다.
요청 걸기
void PostRecv(Session* s) {
IoContext* io = new IoContext(); // 실제로는 풀에서 꺼낸다
ZeroMemory(&io->overlapped, sizeof(OVERLAPPED)); // 매번 초기화 필수
io->type = IoType::Recv;
io->wsaBuf.buf = io->buffer;
io->wsaBuf.len = BUF_SIZE;
DWORD flags = 0;
int r = WSARecv(s->socket, &io->wsaBuf, 1, nullptr, &flags,
&io->overlapped, nullptr);
if (r == SOCKET_ERROR && WSAGetLastError() != WSA_IO_PENDING) {
// 진짜 실패 — 완료 통지가 오지 않으므로 여기서 정리해야 한다
delete io;
CloseSession(s);
}
}WSA_IO_PENDING은 에러가 아니라 정상적으로 접수됐다는 뜻이다. 이 경우 완료
통지가 반드시 올라오니 여기서 정리하면 안 된다. 반대로 그 외의 에러는 통지가
오지 않으므로 이 자리에서 정리해야 한다. 이 분기를 잘못 잡으면 누수 아니면
이중 해제다.
막혔던 부분 / 주의할 점
OVERLAPPED를 매번 0으로 밀어야 한다.
IoContext를 재사용할 때 overlapped를 초기화하지 않으면 이전 작업의 내부 상태가
남아서 예측 불가능하게 동작한다. 풀에서 꺼낼 때 ZeroMemory가 습관이 되어야 한다.
버퍼는 완료될 때까지 살아 있어야 한다.
WSABUF가 가리키는 메모리는 커널이 직접 쓴다. 지역 변수나 임시 버퍼를 넘기면
함수를 빠져나가는 순간 커널이 남의 메모리를 덮어쓴다. IoContext 안에 버퍼를
같이 두는 이유가 이것이다.
세션 해제는 대기 중인 I/O가 0이 될 때까지 미뤄야 한다.
소켓을 닫아도 이미 걸어둔 요청의 완료 통지는 (에러 형태로) 올라온다. 그 사이에
세션을 delete하면 완료 처리에서 해제된 메모리를 만진다. 세션에 대기 I/O
참조 카운트를 두고, 0이 됐을 때만 실제로 반환하는 구조가 필요하다.
transferred == 0은 정상 종료다.
GetQueuedCompletionStatus가 FALSE를 반환해도 전송량이 0이면 상대가 접속을
끊은 것이다. 앞 글에서도 짚었지만, 이 분기를 에러와 섞으면 좀비 세션이 남는다.
send를 동시에 여러 개 던지면 순서가 보장되지 않는다.
WSASend를 연달아 호출하면 완료 순서가 요청 순서와 다를 수 있다. 패킷 순서가
중요하면 세션당 송신 큐를 두고 한 번에 하나씩만 보내거나, 여러 버퍼를
WSABUF 배열로 묶어 한 번에 보내야 한다.
정리
- 완료 큐는 “끝났다"만 알려준다. 무엇이 끝났는지는 내가 붙인 컨텍스트로 안다
- Per-Handle(완료 키) = 소켓당 1개, Per-IO(OVERLAPPED) = 요청당 1개. 개수가 다르니 섞으면 안 된다
OVERLAPPED를 품은 구조체를 만들고CONTAINING_RECORD로 되돌린다. 첫 멤버 캐스팅은 되지만 깨지기 쉽다WSA_IO_PENDING은 성공이다. 이 분기가 누수와 이중 해제를 가른다- 수명 규칙 세 가지:
OVERLAPPED·버퍼는 완료까지 생존, 세션은 대기 I/O가 0일 때 해제, 재사용 전OVERLAPPED초기화
다음은 IoContext를 매번 new하지 않도록 버퍼 풀 / 오브젝트 풀을 붙이는 부분.
동시 접속이 늘면 여기가 바로 병목이 된다.