IOCP 읽는 순서: 수신과 완료 → 접속 수락 → 워커 구성 → 송신 → 종료
완료 패킷을 읽는 코드는 짧지만, 그 뒤에 어떤 일을 넣느냐에 따라 서버의 응답성이 달라집니다. 예를 들어 수신 완료를 처리하던 워커가 DB 응답을 2초 기다리면 그동안 다른 완료는 누가 처리할까요?
이 글에서는 완료를 꺼내는 워커와 실제 업무를 처리하는 실행기를 중심으로 구성을 잡습니다. 먼저 스레드 수의 의미를 이해하고, 연결별 순서와 작업 분배를 살펴본 뒤 생성·종료 코드를 연결합니다.
concurrency의 실행 제어와 워커 스레드 생성 수의 구분
앱이 worker 16개를 만들고 IOCP concurrency를 8로 지정해도 OS가 스레드 8개만 생성하거나 나머지를 삭제하지 않습니다. 워커는 앱이 생성하고 GQCS를 기다립니다. concurrency는 포트와 연결된 스레드의 실행을 조절하는 스케줄링 입력입니다.
이는 애플리케이션 요청 수나 연결별 상호 배제의 상한이 아닙니다. 워커가 블로킹에 들어가고 다시 실행되는 상황까지 실제 스케줄링 계약을 확인해야 하며, 도메인 동시성을 정확히 8개로 제한하는 세마포어 대용으로 사용하지 않습니다.
완료 큐 순서와 worker 깨움 순서의 구분
IOCP 완료 패킷의 큐잉과 대기 워커를 깨우는 정책은 구분합니다. 최근 실행한 워커를 재사용하는 LIFO 성격의 깨움은 캐시 지역성과 전환 비용을 줄이는 데 도움이 될 수 있지만 사용자 연결별 공정성을 자동으로 보장하지 않습니다.
| 값 | 조절하는 대상 | 별도 필요한 것 |
|---|---|---|
| 워커 수 | 생성한 실행 스레드 | 스택·수명·종료 |
| port concurrency | 포트 실행 스케줄링 | 요청별 자원 제한 |
| dequeue batch | 한 번 수집하는 항목 수 | 항목 처리 시간 예산 |
| 후속 스레드 풀 크기 | 별도 CPU·블로킹 실행 | 큐·바이트·기한 상한 |
같은 연결의 여러 완료가 다른 워커에서 처리될 수 있으므로 completion key가 같다는 사실로 연결 필드 수정이 직렬화되지 않습니다.
완료 배치 dequeue와 애플리케이션 소유권 이전
GQCSEx로 100개를 가져왔다면 그 항목은 포트 큐에서 이미 빠졌습니다. 일부만 처리하고 포트 대기로 돌아간다고 나머지가 포트에 자동 복귀하지 않습니다. 자체 batch 저장소나 큐가 컨텍스트·버퍼 참조를 유지해야 합니다.
그림을 글로 읽기
- IOCP 완료 큐
- 완료 수집 worker: 항목별 결과 식별
- 제한된 후속 큐: 연결별 순서 · 바이트 예산
- 파싱·도메인 처리: 끝난 뒤 마지막 참조 반환
- IOCP 완료 큐 → 완료 수집 worker: dequeue
- 완료 수집 worker → 제한된 후속 큐: 참조 소유 이전
- 제한된 후속 큐 → 파싱·도메인 처리: 실행 기회
함수 성공이 항목 전부 성공이라는 뜻도 아닙니다. 각 항목 오류를 따로 해석하고 실패 완료도 종결해야 합니다. 배치가 가득 찰 때까지 불필요하게 기다리는 정책은 한산한 큐의 짧은 요청을 늦춥니다.
완료 처리의 개수·시간·연결별 예산
짧은 완료 100개와 압축 해제 하나가 같은 비용일 수는 없습니다. 수집 개수 상한과 실제 파싱·콜백 실행 시간 예산을 나누고 긴 작업은 분할하거나 제한된 별도 실행기로 보냅니다. 비선점 콜백 하나가 매우 길면 다음 항목을 줄이는 것만으로 지연 상한을 만들 수 없습니다.
연결 하나가 계속 데이터를 생산하면 round-robin ready 연결이나 연결별 처리 quota로 다른 연결에도 기회를 줄 수 있습니다. 동점·우선순위·기아 정책은 별도이며, 특정 worker의 처리 개수가 균등한 것과 사용자 지연이 공정한 것은 다릅니다.
후속 실행기 큐의 포화와 유입 제어
완료 수집을 빠르게 만들어도 후속 스레드 풀이 감당하지 못하면 대기와 buffer 보유가 그쪽으로 이동합니다. 후속 큐의 개수·바이트·나이·실행 수를 제한하고 포화되면 새 I/O 제출·수락·상위 유입을 조절해야 합니다. 이미 dequeue한 완료를 조용히 버려서는 안 됩니다.
같은 연결의 메시지 적용 순서가 필요하면 단일 연결 executor나 논리 순번을 사용합니다. 완료 도착 순서가 원래 메시지 순서라는 가정을 하지 않습니다. 실행기 경계를 넘을 때 취소·세대·필수 결과의 기록 책임도 유지합니다.
collect(entry):
context = identify_and_retain(entry)
result = decode_individual_completion(entry)
if bounded_followup_queue_accepts(context, result):
transfer_reference_to_followup()
else:
retain_locally_and_apply_overload_policy()
reduce_new_submission()
이 모형에서 포화 정책은 실제로 제한된 저장 공간과 진행 경로가 있어야 합니다. 로컬 보류 큐를 또 무한히 만드는 것은 해결이 아닙니다. 미리 제출할 작업 수와 완료를 보관할 공간을 함께 예약하는 방법도 고려합니다.
초기 IOCP 워커 구성
처음부터 ‘CPU 수의 두 배’ 같은 공식을 고정하기보다, 포트 하나와 오래 살아 있는 워커 몇 개로 시작하는 편이 이해하기 쉽습니다. 포트 생성의 concurrency는 0으로 두고, 앱에서 논리 프로세서 수 정도의 워커를 만드는 것을 측정 출발점으로 삼을 수 있습니다. 이는 최적값도 모든 하드웨어의 권장값도 아닙니다. 각 워커는 같은 포트에서 GQCS를 기다립니다.
std::vector<std::thread> workers;
for (unsigned i = 0; i < workerCount; ++i) {
workers.emplace_back([port] { runCompletionLoop(port); });
}
이 코드에서 스레드를 만드는 것은 std::thread입니다. CreateIoCompletionPort의 숫자를 8로 넣었다고 워커 8개가 자동 생성되지 않습니다. 반대로 16개를 만들었다고 16개 모두가 언제나 완료를 병렬 처리하는 것도 아닙니다.
수신 완료 워커의 일은 오류 확인, 연결 상태 확인, 제한된 프레임 파싱, 후속 작업 등록 정도로 짧게 유지합니다. 여기서 동기 DB 요청을 2초 기다리면 해당 워커의 진행이 멈춥니다. IOCP가 다른 대기 워커를 실행할 수는 있지만, DB 연결 한도나 게임 로직의 순서를 대신 관리하지는 않습니다. 블로킹 작업은 크기가 제한된 별도 실행기로 보내고 결과를 연결의 직렬 실행 경로로 돌려보냅니다.
연결별 처리 순서와 직렬화 경계
연결마다 수신을 하나만 걸면 수신 버퍼를 이어 붙이기 쉽습니다. 송신도 연결별 queue에서 한 작업만 진행하도록 시작하면, 앞 메시지의 미전송 부분을 처리한 뒤 다음 메시지를 보낼 수 있습니다. 그러나 수신 하나와 송신 하나는 동시에 완료될 수 있어 closing, 파서 상태, 송신 큐를 아무 워커나 무보호로 바꾸면 안 됩니다.
연결별 mutex를 쓰는 구현은 짧은 상태 변경과 제출·종료 경계를 잠금으로 묶습니다. 연결별 직렬 실행기를 쓰는 구현은 완료 결과를 그 실행기로 넘겨 순서를 정합니다. 어느 쪽이든 DB를 기다리는 동안 연결 잠금을 계속 잡거나, 후속 큐를 무한히 늘리는 방식은 피해야 합니다. 직렬화 대상을 전체 서버로 잡으면 서로 무관한 연결까지 기다립니다.
마지막 완료 회수와 워커 종료 순서
종료 순서는 새 연결과 새 I/O 제출 차단, 남은 연결의 취소·종료 요청, 완료 및 후속 작업 회수, 워커 종료 패킷 게시, join, 포트 close입니다. 워커가 먼저 사라지면 취소된 I/O의 완료를 회수할 사람이 없어집니다.
// 모든 I/O와 후속 작업의 회수가 끝났다는 전제입니다.
for (std::size_t i = 0; i < workers.size(); ++i) {
if (!PostQueuedCompletionStatus(port, 0, StopWorkerKey, nullptr)) {
handleStopPostFailure(GetLastError());
// 실패를 무시하고 무한 join하지 않도록 별도 장애 종료 정책이 필요합니다.
}
}
for (auto& worker : workers) worker.join();
CloseHandle(port);
각 워커가 종료 패킷 하나를 받으면 즉시 loop를 빠져나가는 규약이므로 워커 수만큼 게시합니다. 이 발췌는 정상 종료 경로이며, 포트 장애와 게시 실패 시 강제 종료·진단 정책까지 구현한 완성 서버는 아닙니다.
IOCP·후속 실행기의 종료와 측정 범위
새 제출을 막고 커널 완료와 후속 처리 큐를 모두 회수한 뒤 워커를 닫습니다. 후속 큐가 살아 있는데 IOCP 작업 카운터만 0이라고 버퍼를 해제하면 안 됩니다. 실행기 간 서로의 종료 콜백을 기다리는 고리도 피해야 합니다.
작은 완료만 있는 부하, 큰 파싱 혼합, 한 연결 폭주, 많은 유휴 연결에서 워커 수·concurrency·batch·시간 예산을 독립적으로 바꿉니다. 완료 수집 대기와 후속 큐 대기, 짧은 요청 p99, CPU·문맥 전환·원본 오류를 같이 봅니다. 실제 Windows 실행 결과는 별도 환경에서 검증해야 하며 설정값 자체를 성능 개선의 증거로 삼지 않습니다.
재현 연습에서는 완료 100개 중 한 항목만 긴 파싱을 수행하도록 하고, GQCS에서 꺼낸 항목을 별도 후속 큐로 넘기는 경우와 수집 worker가 직접 처리하는 경우를 비교합니다. 예상 결과는 배치 dequeue 뒤 미처리 항목이 포트로 자동 복귀하지 않으며, 후속 큐가 가득 차면 buffer 참조를 보유한 주체가 명확해야 한다는 것입니다. 연결 하나의 완료를 연속으로 공급했을 때 다른 연결의 p99가 악화되면 개수 균등이 아니라 연결별 quota·round-robin 경계가 필요합니다.