24. IOCP(I/O Completion Port)와 워커 스레드 풀의 내부 동작
난이도 최상내 리뷰 · C++
해설 · C++
해설 — IOCP(I/O Completion Port)와 워커 스레드 풀의 내부 동작
난이도: 최상
요약
Windows IOCP는 "커널이 관리하는 완료 큐 + 커널이 협조하는 스레드 스케줄링 힌트(concurrency value)"로 구성된다. 워커 스레드는 GetQueuedCompletionStatus를 호출해 큐에서 완료 통지를 꺼내며, 커널은 concurrency value를 넘지 않는 선에서만 스레드를 "실행 가능" 상태로 깨운다. 워커 스레드 수를 concurrency value보다 훨씬 많이 만들어두는 이유는, 블로킹/대기하는 스레드가 생겨도 나머지가 즉시 대체 투입될 수 있도록 여유 풀을 확보하기 위해서다.
핵심 개념
- 완료 큐(Completion Queue): 비동기 I/O(recv, send, AcceptEx 등)가 커널 레벨에서 완료되면, 그 결과(OVERLAPPED 포인터, 전송 바이트 수 등)가 IOCP 객체의 완료 큐에 FIFO로 쌓인다.
- GetQueuedCompletionStatus: 워커 스레드가 이 함수를 호출해 큐에서 완료 항목을 하나 꺼낸다. 큐가 비어 있으면 스레드는 커널에 의해 대기 상태로 전환된다(폴링이 아니라 이벤트 기반 슬립).
- Concurrency Value(동시성 힌트):
CreateIoCompletionPort의 마지막 인자. "이 IOCP에 대해 커널이 동시에 실행 가능(runnable) 상태로 유지할 스레드 수의 상한 힌트"다. 워커 스레드 개수 자체를 제한하는 게 아니라, 그 중 몇 개까지를 스케줄러가 "깨어 있게" 둘지를 제어한다. - 워커 풀과의 결합: 워커 스레드 수(N) ≥ concurrency value(C)로 설계하는 것이 정석이다. N을 C보다 크게 잡아두면, C개가 실행 중인 상태에서 그 중 하나가 블로킹 콜(파일 I/O, 락 대기 등)에 들어가더라도 커널이 대기 중이던 다른 워커를 즉시 깨워 C개를 유지한다.
단계별 내부 동작
- 워커 스레드 N개가 모두
GetQueuedCompletionStatus를 호출하고 큐를 기다리는 상태로 블록된다. - 비동기 I/O 완료 이벤트가 커널 큐에 쌓이면, 커널은 "현재 실행 중(runnable)인 이 IOCP 소속 스레드 수"를 확인한다.
- 실행 중인 스레드 수가 concurrency value 미만이면, 대기 중이던 워커 중 하나(일반적으로 가장 최근에 대기한 스레드, LIFO에 가까운 순서)를 깨워 완료 항목을 넘긴다. 이미 concurrency value만큼 실행 중이면, 새 완료 항목은 큐에 쌓인 채로 대기하고 아무 스레드도 깨우지 않는다.
- 깨어난 스레드가 콜백/처리 로직을 실행하는 도중 블로킹 콜에 들어가면(예: 동기 파일 I/O, Sleep, 락 대기), 커널 관점에서 그 스레드는 "실행 중이지만 실질적으로 progress가 없는" 상태가 된다. IOCP는 이 경우를 감지해(정확히는 해당 스레드가 커널 대기 상태로 들어가면) concurrency 카운트에서 빠진 것으로 간주하고, 대기 중이던 다른 워커 스레드를 추가로 깨워 concurrency value를 다시 채운다.
- 완료 처리 콜백 안에서 다음 비동기 I/O(예: 다음 recv)를 다시 걸고 그 결과가 다시 완료 포트로 흘러들어오게 하는 체이닝 패턴은, 하나의 연결에 대한 "요청→완료→재요청" 사이클을 스레드를 점유하지 않고 반복시킬 수 있게 해준다. 콜백이 끝나면 워커는 다시
GetQueuedCompletionStatus로 돌아가 다른 연결의 완료 항목을 처리할 수 있어, 소수의 워커 스레드로 수만 개의 연결을 처리하는 것이 가능해진다. - 워커 스레드 수를 CPU 코어 수의 배수(예:
cpu_count * 4)로 넉넉히 잡는 이유는 위 4번의 "블로킹 시 대체 투입"을 위한 여유분을 확보하기 위함이다. 하지만 실제로 동시에 "유의미하게 실행 중"인 스레드 수는 커널이 concurrency value 근처로 계속 조절하므로, 과도한 컨텍스트 스위칭 없이 CPU 코어 수에 맞는 병렬성을 유지한다.
흔한 오해·함정
- "워커 스레드 수 = 동시에 실행되는 스레드 수"라고 오해하기 쉽다. 실제로는 워커 수는 상한이고, concurrency value가 "동시에 활성으로 유지할 스레드 수"를 결정한다.
- concurrency value를 CPU 코어 수로 설정하면 CPU 바운드 작업에는 적절하지만, 콜백 안에서 블로킹 콜을 섞어 쓰면(디스크 I/O, DB 동기 호출 등) 오히려 스레드 전환 오버헤드가 늘어날 수 있다 — 콜백은 최대한 논블로킹으로 유지해야 IOCP의 장점을 살릴 수 있다.
- 이 문제의 시뮬레이션 코드는
std::condition_variable을 그대로 썼기 때문에 실제로는 concurrency 제한이 전혀 걸리지 않는다 —cv_.notify_one()이 대기 중인 스레드 중 하나만 깨우고mutex_로 큐 접근이 직렬화되므로, 실행해보면 오히려 매번 특정 워커 하나가 큐를 빠르게 소진하며 처리하고(콜백에 실제 지연이 없어 순식간에 끝남) 나머지 워커는 대기 상태로 남는 모습을 보인다. 실제 Windows IOCP라면 concurrencyHint=2인 상황에서 동시 실행 스레드 수가 2 근처로 유지되도록 커널이 능동적으로 스레드를 깨우고 조절하지만, 이 시뮬레이션은 그런 조절 로직이 없다 — 이 차이가 바로 "커널이 관여하는 스케줄링 힌트"와 "유저 모드 조건변수"의 근본적 차이다.
면접 포인트
- concurrency value가 "워커 스레드 개수 제한"이 아니라 "동시 활성 스레드 수 힌트"라는 점을 정확히 구분해서 설명할 수 있는가.
- 블로킹 콜이 섞였을 때 커널이 어떻게 대체 스레드를 투입해 concurrency를 유지하는지 설명할 수 있는가.
- 완료 콜백 안에서 다음 비동기 I/O를 재요청하는 체이닝 패턴이 왜 게임 서버의 표준적인 네트워크 처리 방식인지(스레드-커넥션 비율을 극단적으로 낮출 수 있다는 점) 설명할 수 있는가.