수정 이력(4건, 최종 수정 2026년 09월 03일)
이 글에 적용한 변경 사항의 기록입니다. 보관해 둔 수정 전 버전은 DOI가 부여된 고정 URL에서 읽을 수 있습니다.
- Codex 리뷰에 따라 상담·문의 링크에 /ko/ 로케일 접두를 붙였습니다. 본문의 기술적인 주장은 바꾸지 않았습니다.
- 이 기사의 지식 맵을 다시 살펴, 본문이 말하는 범위보다 넓어져 있던 관계를 고쳤습니다. 본문의 주장은 바꾸지 않았습니다.
- 기사 서두에 「이 기사의 지식 맵」을 추가했습니다. 본문에서 다루는 개념 사이의 관계를 한 장의 그림으로 조망할 수 있습니다. 관계의 전체 목록(근거 URL·확신도·확인일 포함)과 주요 개념의 정의는 지식 맵 상세 페이지에 정리했고, 기계 가독 데이터를 JSON-LD와 Turtle로 공개하고 있습니다.
- ConfigureAwait(false)를 스레드 풀로 간다고 오해한 코드와, 의도대로 쓴 코드의 대비 예를 5.3절에 추가했습니다. 아울러 서두의 세 가지 의문이 어느 절에서 답해지는지에 대한 안내와, 완료 포트 워커 루프의 골격 코드를 추가했습니다.
- 최초 공개
이 글을 인용하기(DOI(등록된 아카이브): 10.5281/zenodo.22175296)
아래 DOI는 이전에 등록된 아카이브를 가리키며 현재 본문과 다를 수 있습니다. 현재 본문을 참조할 때는 이 페이지의 URL을 사용하세요.
Go Komura (2026). 「Windows I/O의 심층(제3회) ── I/O 완료 포트(IOCP)와 .NET 스레드 풀: async/await의 지하실」. 합동회사 코무라소프트. https://comcomponent.com/ko/blog/windows-iocp-dotnet-threadpool/
- DOI(등록된 아카이브)
- 10.5281/zenodo.22175296
- DOI(마지막 등록 버전)
- 10.5281/zenodo.22175297
지난 회(제2회)에서는 비동기 I/O의 발행과, 완료를 받는 네 갈래를 봤습니다. 그때 「수많은 동시 I/O를 적은 스레드로 받는 본래 해법」으로 이름만 꺼낸 것이 I/O 완료 포트(IOCP)입니다.
왜 웹 서버는 수천 개의 동시 연결을 십여 개 스레드로 처리할 수 있는가. 왜 async/await의 I/O 대기는 「스레드를 소비하지 않는다」고 단언할 수 있는가. 왜 await의 이어지는 코드는 어떤 때는 UI 스레드에서, 어떤 때는 스레드 풀에서 달리는가──이 세 가지 물음의 답은 모두 IOCP라는 하나의 설계로 모입니다. 이번 회는 연재 가운데 .NET 개발자에게 가장 직접적으로 닿는 회입니다.
연재 「Windows I/O의 심층」의 제3회입니다. 전체 구성은 제1회 서두에 있습니다.
긴 글이므로, 어느 물음이 어디에서 답해지는지를 먼저 밝힙니다.
- 물음 1: 수천 개의 동시 연결을 십여 개 스레드로 처리할 수 있는 이유 → 3장(완료 큐와 스레드 수 제어를 하나로 묶은 IOCP의 설계)에서 답합니다.
- 물음 2: I/O 대기가 「스레드를 소비하지 않는다」고 단언할 수 있는 이유 → 5.1〜5.2절(await 한 왕복의 전체 그림과, 대기 시간에 스레드가 없다는 말의 정확한 의미)에서 답합니다.
- 물음 3:
await이어지는 코드의 실행 스레드를 정하는 것 → 5.3절(캡처된 컨텍스트와ConfigureAwait(false)의 진짜 의미)에서 답합니다.
1. 먼저 결론
- IOCP는 「완료 통지 큐」와 「스레드 수 제어」를 하나로 묶은 메커니즘입니다. 완료 패킷은 FIFO로 큐에 쌓이고, 워커 스레드가
GetQueuedCompletionStatus로 꺼냅니다(3장).1 - 스레드는 LIFO로 깨어납니다. 직전까지 일하던 「따뜻한」 스레드가 다음 패킷도 집어 가므로, 큐가 차 있는 한 컨텍스트 스위치는 거의 일어나지 않습니다(3.3절).1
- 동시성 값은 「실행 가능한 스레드 수」의 상한입니다. 권장 출발점은 CPU 수(0을 지정하면 프로세서 수). 실행 중인 스레드가 블로킹되면, 대기 중인 스레드를 깨워 빈자리를 메웁니다(3.4절).12
- 포트는 자체 통지에도 쓸 수 있습니다.
PostQueuedCompletionStatus로 I/O와 무관한 패킷을 쌓을 수 있으므로, 워커에 대한 일 의뢰나 종료 지시도 같은 큐로 흘릴 수 있습니다(4장).3 - 새 서버 구현이라면 생 IOCP보다 Windows 스레드 풀 API(
CreateThreadpoolIo)가 권장됩니다. 내부는 IOCP 그대로이고, 스레드 관리를 대신해 줍니다(4장).1 - .NET 스레드 풀은 워커 스레드와 I/O 완료 스레드의 2층 구조이며, 비동기 I/O 핸들은 스레드 풀(의 IOCP)에 묶입니다.
await의 I/O 대기에는 스레드가 없고, 완료 후의 계속만 스레드에 올라탑니다(5장).456 - 계속의 행선지는 「캡처된 컨텍스트」가 정합니다. UI 스레드에서
await하면 이어지는 코드는 UI 스레드로, 캡처할 것이 없으면 스레드 풀(또는 완료시킨 스레드 위)에서 이어집니다.ConfigureAwait(false)는 캡처를 그만두라는 지시이지, 스레드 풀로 간다는 보장이 아닙니다(5.3절).6
그림의 실선은 항상 성립하는 관계, 점선은 조건이 붙는 관계입니다(성립 조건은 상세 페이지의 관계별 설명에 적혀 있습니다). 관계 전체 목록(총 32건, 근거와 확신도 포함)과 주요 개념의 정의는 지식 맵 상세 페이지에 정리되어 있습니다(일본어). 데이터: JSON-LD / Turtle
2. 스레드로 밀어붙이는 설계는 어디서 무너지는가
먼저 IOCP가 풀려고 한 문제를 확인합니다. 소박한 서버는 「연결 하나당 스레드 하나」로 쓸 수 있습니다. 동기 I/O로 읽고, 처리하고, 씁니다. 알기 쉬운 설계이지만, 연결이 늘어나면 두 벽에 부딪힙니다.
flowchart TB
subgraph A["연결마다 스레드 1개(동기 I/O)"]
T1["스레드 1: 연결 1의 read 대기"]
T2["스레드 2: 연결 2의 read 대기"]
T3["스레드 3: 연결 3의 read 대기"]
TN["…연결 수만큼 스레드가 늘어남<br/>대부분은 I/O 대기로 잠들어 있을 뿐"]
end
subgraph B["IOCP 모델(비동기 I/O)"]
Q["완료 큐<br/>(모든 연결의 완료 통지가 모임)"]
W1["워커 1"]
W2["워커 2"]
WN["워커는 CPU 수 정도의 소수"]
Q --> W1
Q --> W2
Q --> WN
end
그림 1: 왼쪽은 연결 수에 비례해 스레드가 늘어난다. 오른쪽은 「일어난 일」만 소수 스레드로 처리한다
- 스레드는 공짜가 아니다. 하나마다 스택(기본 예약 1MB)과 커널 객체를 소비하고, 수가 늘수록 스케줄러와 컨텍스트 스위치 부담이 쌓입니다. 수천 연결 = 수천 스레드는, 그 대다수가 「읽을 수 있을 때까지 잠들어 있을 뿐」이어도 비쌉니다.
- 「실행하고 싶은 수」가 CPU 수를 넘어도 의미가 없다. 동시에 달릴 수 있는 스레드는 물리적으로 CPU 수까지입니다. 그 이상의 스레드를 실행 가능하게 해도 전환 손실만 늘어납니다.
제2회까지는 I/O 완료 통지를 「이벤트」나 「APC」로 받는 방법은 봤습니다. 그러나 이벤트 방식은 WaitForMultipleObjects의 64개 제한이나 대기 설계가 번거로워지고, APC는 발행 스레드에 묶입니다. 다수의 I/O × 소수의 스레드라는 형태에 처음부터 맞춰 설계된 것이 IOCP입니다. 1
3. IOCP의 설계 ── 큐와 스레드 제어의 일체화
3.1. CreateIoCompletionPort의 두 얼굴
CreateIoCompletionPort는 이름과 달리 두 일을 합니다. 포트의 신규 생성과 기존 포트에 핸들을 연결하는 일입니다. 2
flowchart LR
subgraph SRC["연결한 핸들(몇 개든)"]
H1["파일"]
H2["소켓"]
H3["명명된 파이프"]
end
subgraph PORT["I/O 완료 포트"]
Q["완료 패킷 큐(FIFO)<br/>패킷 = 전송 바이트 수 +<br/>CompletionKey + OVERLAPPED 포인터"]
C["동시성 제어<br/>실행 가능 스레드 수 ≦ 상한"]
end
subgraph W["워커 스레드 군"]
G1["GetQueuedCompletionStatus로 대기"]
G2["GetQueuedCompletionStatus로 대기"]
end
H1 --> Q
H2 --> Q
H3 --> Q
Q --> G1
Q --> G2
C -.제어.-> W
그림 2: IOCP의 구성. 수많은 핸들의 완료가 하나의 큐에 모이고, 꺼내는 쪽의 스레드 수까지 제어된다
연결할 때 넘기는 CompletionKey는 「이 핸들에서 온 완료입니다」를 워커에게 전하기 위한 자유로운 값입니다(연결 객체 포인터를 넣는 것이 정석입니다). 완료 패킷에는 CompletionKey와 그 작업의 OVERLAPPED 포인터, 전송 바이트 수가 담겨 도착합니다. 어느 연결의(CompletionKey), 어느 작업이(OVERLAPPED), 얼마나 진행됐는지(바이트 수)──제2회에서 본 「작업 전표」가 여기서 회수되는 셈입니다. 27
대상은 「파일」에 한정되지 않습니다. 소켓, 명명된 파이프, 메일슬롯 등 Overlapped I/O를 말할 수 있는 핸들이면 무엇이든 연결할 수 있습니다. 1 제1회에서 본 「모든 것이 파일처럼 보인다」는 설계가 여기서도 살아 있습니다.
3.2. 완료 패킷의 여정
sequenceDiagram
participant DRV as 커널(IRP 완료)
participant Q as 포트의 큐(FIFO)
participant W as 워커 스레드
Note over W: GetQueuedCompletionStatus로 대기
DRV->>Q: 완료 패킷을 쌓음<br/>(바이트 수 / CompletionKey / OVERLAPPED)
Q->>W: 대기 중인 스레드를 하나 깨워 건넴
Note over W: 패킷을 보고 완료 처리<br/>(계속 실행·다음 I/O 발행 등)
W->>Q: 처리를 마치고 다시 GetQueuedCompletionStatus
Note over Q: 큐에 패킷이 남아 있으면<br/>기다리지 않고 다음을 받음
그림 3: 완료 패킷은 FIFO로 쌓이고, 워커는 꺼내서 처리하는 루프를 돈다
비동기 I/O가 완료되면 완료 패킷이 포트 큐에 FIFO 순으로 쌓입니다. 워커는 GetQueuedCompletionStatus를 호출해 패킷을 하나 받고, 처리가 끝나면 다시 호출합니다──이 루프가 IOCP 프로그래밍의 골격입니다. 17 한 번에 여러 패킷을 모아 꺼내는 GetQueuedCompletionStatusEx도 있어, 고빈도 I/O에서는 호출 횟수를 줄일 수 있습니다. 8
여기서 워커 루프의 정석 버그를 먼저 없앱니다. GetQueuedCompletionStatus가 FALSE를 반환해도, OVERLAPPED 포인터가 non-NULL로 돌아왔다면 그것은 「실패한 I/O의 완료 패킷을 꺼냈다」는 뜻입니다. 7 실패한 작업에도 뒷정리(오류 처리와, 제2회에서 본 전표·버퍼 해제)가 필요하므로 이 패킷은 처리해야 합니다. 「패킷 자체를 꺼내지 못했다」(타임아웃, 포트 닫힘 등)고 말할 수 있는 것은 OVERLAPPED가 NULL일 때뿐입니다. if (!GetQueuedCompletionStatus(...)) break;처럼 대충 쓰면 실패한 I/O를 모두 놓쳐 누수시킵니다.
골격을 그대로 옮길 수 있는 형태로 둡니다. 판정 순서가 앞 설명과 그대로 대응합니다.
/* IOCP 워커 루프의 골격(C / Win32) */
for (;;) {
DWORD bytes = 0;
ULONG_PTR key = 0;
OVERLAPPED *ov = NULL;
BOOL ok = GetQueuedCompletionStatus(port, &bytes, &key, &ov, INFINITE);
if (!ok && ov == NULL) {
/* 패킷을 꺼내지 못함(포트가 닫힌 경우 등). 여기만 빠져도 되는 조건 */
break;
}
if (!ok) {
/* ov != NULL → 「실패한 I/O의 완료 패킷」을 꺼냈다.
뒷정리(오류 처리·전표와 버퍼 해제)가 필요하므로 빠져나가지 않고 처리한다 */
DWORD err = GetLastError();
handle_failed_io(key, ov, err);
continue;
}
if (key == SHUTDOWN_KEY) {
/* PostQueuedCompletionStatus로 쌓은 종료용 패킷(4장) */
break;
}
handle_completed_io(key, ov, bytes); /* 일반 완료 처리. 짧게 유지(3.4절) */
}
ok == FALSE를 한 분기로 처리하지 않고, ov가 NULL인지로 둘로 나눈다는 것이 핵심입니다. 타임아웃을 붙인 경우(INFINITE가 아닐 때)에도 판정은 같고, 시간 초과는 ok == FALSE이면서 ov == NULL로 나타납니다.
참고로, 어떤 스레드가 GetQueuedCompletionStatus를 처음 호출하면 그 스레드는 그 포트에 연결됩니다(한 스레드가 동시에 연결될 수 있는 포트는 하나). 1 「전속 워커 팀이 포트에 붙는다」는 그림으로 기억하면 정확합니다.
3.3. 스레드는 LIFO로 깨어난다
여기서부터가 IOCP 설계의 묘미입니다. 패킷은 FIFO로 쌓이지만, 기다리는 스레드는 LIFO로 깨어납니다. 즉 가장 최근까지 일하던 스레드가 다음 패킷도 집어 갑니다. 1
flowchart TB
Q["큐: P1 → P2 → P3 (FIFO)"]
subgraph TH["대기 스레드(LIFO 스택)"]
A["스레드 A(직전까지 실행, 따뜻함)"]
B["스레드 B(한동안 잠들어 있음)"]
C["스레드 C(계속 잠들어 있음)"]
end
Q -->|"P1도 P2도 P3도, 비어 있으면<br/>먼저 스레드 A로"| A
B -.->|"A가 바쁠 때만"| Q
C -.->|"거의 깨어나지 않음"| Q
그림 4: LIFO 해제. 바쁠수록 같은 스레드가 계속 돌고, 한가한 스레드는 잠든 채로 둘 수 있다
이 설계의 이익은 두 가지입니다.
- 컨텍스트 스위치가 일어나지 않는다. 큐에 패킷이 남아 있는 한, 처리를 마친 스레드가
GetQueuedCompletionStatus를 호출하면 기다리지 않고 그대로 다음 패킷을 받아 계속 달립니다. 문서는 동시성 값 1인 시나리오에서 「스레드 전환은 발생하지 않는다」고 명시합니다. 1 - 캐시가 따뜻한 채로 쓰인다. 같은 스레드가 계속 도므로, 스택이나 스케줄링 상태가 CPU 캐시에 남아 있을 가능성이 높아집니다. 잠든 스레드는 부하 피크에 대비한 예비로 싸게 유지됩니다.
3.4. 동시성 값 ── 「실행 가능」을 센다
포트 생성 시의 NumberOfConcurrentThreads가 동시성 값입니다. 이것은 「그 포트에 연결된 실행 가능(runnable)한 스레드 수」의 상한이며, 상한에 도달한 동안에는 추가 스레드가 패킷을 받지 못합니다. 1 0을 넘기면 시스템의 프로세서 수가 쓰이고, 문서 또한 전체로서 최선의 최댓값은 CPU 수라고 합니다. 21
영리한 점은, 이 숫자가 「깨어 있는 스레드」가 아니라 「실행 가능한 스레드」를 센다는 것입니다.
flowchart TB
P["큐에 패킷이 도착한다"]
Q{"실행 가능한 스레드 수는<br/>동시성 값 미만인가"}
RUN["대기 스레드를 깨워 처리시킨다"]
HOLD["아무도 깨우지 않고 큐에 둔다<br/>(실행 중인 스레드가 가지러 온다)"]
BLK["실행 중인 스레드가<br/>다른 이유로 대기 상태에 들어갔다"]
COMP["실행 가능 수가 줄어든 만큼<br/>대기 스레드를 깨워 보충한다"]
P --> Q
Q -->|미만| RUN
Q -->|상한| HOLD
BLK --> COMP
그림 5: 동시성 제어. 상한은 「실행 가능한 수」이므로, 누군가 블로킹하면 자동으로 보충된다
실행 중인 워커가 어떤 대기(락, 페이지 폴트, 실수로 쓴 동기 I/O)에 들어가면 실행 가능 수가 줄어들므로, 시스템은 대기 중인 스레드를 깨워 빈자리를 메웁니다. 1 그래서 워커를 「CPU 수 딱 맞게」만 만들지 않고, 동시성 값보다 여분의 스레드를 대기시켜 두는 것이 정석입니다. 처리에 긴 계산이 섞이면 동시성 값 자체를 올리는 선택도 있고, 최종적으로는 프로파일링으로 조정하라는 것이 문서의 입장입니다. 1
다만 보충은 만능이 아닙니다. 블로킹했던 스레드가 나중에 깨어나면, 그 순간만 실행 가능 수가 상한을 넘습니다(문서도 이 초과를 언급합니다). 1 완료 처리는 짧게 유지한다는 것이 대원칙이며, 이것은 6장의 .NET에서도 같은 형태로 살아 있습니다.
4. 도구상자 ── 포트를 지탱하는 API들
PostQueuedCompletionStatus── I/O를 발행하지 않고 자체 완료 패킷을 큐에 쌓을 수 있습니다. 3 워커에게 일을 맡기기, 셧다운 지시(워커 수만큼 종료용 패킷──흔히 「독약」패킷이라고 부릅니다──를 쌓기), 다른 스레드에서의 통지──I/O 완료와 자체 메시지를 같은 큐·같은 루프로 처리할 수 있다는 점은 설계를 크게 단순하게 해 줍니다.GetQueuedCompletionStatusEx── 완료 패킷을 한 번에 여러 개 꺼냅니다. 패킷당 한 호출의 오버헤드가 드러나는 고빈도 I/O에서 유효합니다. 8SetFileCompletionNotificationModes── 제2회 5장에서 본 「비동기로 발행했는데 동기 완료된」 경우에서, 포트에 패킷을 쌓지 않는 모드를 고를 수 있습니다(FILE_SKIP_COMPLETION_PORT_ON_SUCCESS). 동기 완료 결과는 그 자리에서 이미 알고 있으니, 큐를 다시 거치는 것은 낭비라는 고속화입니다. 9- Windows 스레드 풀 API ──
CreateThreadpoolIo/StartThreadpoolIo는 내부에서 IOCP를 쓰면서 스레드 생성·관리를 대신합니다. Microsoft는 신규 서버 앱에는 우선 이쪽을 검토하고, 동시성 값이나 스레드 관리를 명시적으로 제어하고 싶을 때만 생 IOCP를 쓰라고 권합니다. 1 그리고 .NET 스레드 풀 또한, 바로 이 「IOCP + 스레드 관리 자동화」를 .NET 런타임으로 구현한 것입니다.
함정도 세 가지만 짚어둡니다. (1) 워커 안에서 오래 블로킹하지 말 것(3.4절의 보충은 성능 저하를 완화할 뿐입니다). (2) 완료 패킷 식별은 CompletionKey(핸들 단위)와 OVERLAPPED(작업 단위)의 두 단으로 한다──제2회의 「전표」수명 관리(완료될 때까지 해제하지 않음)는 여기서도 생명선입니다. (3) 미완료 I/O가 남은 채로 핸들을 닫지 말 것──cleanup 동작(제1회 6장)과 취소 절차(제2회 6장)가 그대로 해당됩니다.
5. .NET 스레드 풀 ── IOCP 위에 선 2층 구조
여기서부터가 본론인 「async/await의 지하실」입니다.
.NET 스레드 풀에는 두 종류의 스레드가 있습니다. Task.Run이나 계속을 실행하는 워커 스레드와, 비동기 I/O의 완료를 받는 I/O 완료 스레드입니다. ThreadPool.GetAvailableThreads(out workerThreads, out completionPortThreads)가 두 숫자를 따로 반환하는 것은, 내부가 실제로 2층이기 때문입니다. 4
그리고 Windows에서 스레드 풀은 자체 I/O 완료 포트를 가지고 있습니다. OS 핸들을 이 포트에 연결하는 현재의 저수준 API가 ThreadPoolBoundHandle.BindHandle이며, 묶은 핸들에 대한 비동기 I/O는 NativeOverlapped(바로 제2회의 OVERLAPPED의 .NET 쪽 모습)와 짝으로 다룹니다(오래된 ThreadPool.BindHandle도 같은 역할로 남아 있지만, 새로 쓴다면 이쪽입니다). FileStream이나 Socket이 비동기 모드 핸들을 열면 내부에서 이런 연결이 이루어집니다. 5 즉:
- 제2회의 「비동기 모드 핸들 + OVERLAPPED」가 발행 메커니즘
- 이 기사의 IOCP가 완료를 받는 메커니즘
- .NET 스레드 풀의 I/O 완료 스레드가
GetQueuedCompletionStatus루프를 도는 워커 팀
이라는 대응으로, Win32의 그림이 그대로 .NET의 그림이 됩니다.
5.1. await ReadAsync의 한 왕복·완전판
제2회 그림 7에서 「진짜 비동기 I/O」라고만 쓴 상자 속을, 이번에는 끝까지 엽니다.
sequenceDiagram
participant U as 호출 스레드<br/>(UI 스레드 등)
participant K as 커널<br/>(IRP 발행〜완료)
participant Q as 스레드 풀의 IOCP
participant IO as I/O 완료 스레드
participant C as 계속의 실행 장소
U->>K: ReadAsync가 비동기 읽기를 발행<br/>(OVERLAPPED 상당을 붙여)
K-->>U: ERROR_IO_PENDING(바로 돌아옴)
Note over U: await가 미완료 Task에 계속을 등록하고<br/>스레드를 놓음(UI라면 다음 메시지 처리로)
Note over K: 디바이스가 일하는 중<br/>이 동안, 기다리는 스레드는 어디에도 없다
K->>Q: 완료 패킷을 쌓음
Q->>IO: LIFO로 하나 깨워 건넴
Note over IO: 결과(바이트 수·상태)를 확정하고<br/>Task를 완료시켜 계속을 스케줄
IO->>C: 캡처한 컨텍스트로 던짐<br/>(UI 스레드로 / 없으면 스레드 풀에서 실행)
Note over C: await 이어지는 코드가 달린다
그림 6: await 한 왕복의 전체. 스레드가 일하는 것은 「발행」과 「완료 후」뿐이며, 대기 시간은 스레드 제로
5.2. 「I/O 대기는 스레드를 소비하지 않는다」의 정확한 의미
이 그림에서 강조하고 싶은 것은, 발행부터 완료까지, 그 완료를 기다리기만 하는 스레드는 사용자 모드에도 커널에도 존재하지 않는다는 점입니다. Microsoft의 async 해설(async in depth)도 I/O bound Task에 대해 「그 완료를 기다리기만 하는 스레드는 어디에도 없다」는 것을, 디바이스 드라이버와 인터럽트까지 내려가 설명합니다. 6 다만 커널 안에서 드라이버가 처리의 일부를 시스템 워커 스레드에 맡기는 장면은 있습니다. 그러나 그것은 요청을 앞으로 진행시키기 위한 짧은 일이지, 완료를 블로킹하고 계속 기다리는 스레드는 어디에도 없다──여기가 보장되는 범위입니다.
제1회부터 쌓아 온 말로 바꾸면──IRP는 스레드가 아니라 데이터 구조로서 디바이스 스택에 머물고(제1회), 발행은 ERROR_IO_PENDING으로 바로 돌아오며(제2회), 완료는 인터럽트→완료 패킷이라는 이벤트 연쇄로 도착합니다(이 기사). 「기다린다」는 상태를 유지하는 데 스레드라는 비싼 자원이 필요 없는 구조인 것입니다.
그래서 async/await를 올바르게 쓴 앱은 「동시에 10,000건의 I/O가 날아가는」 상태를 스레드 십여 개로 유지할 수 있습니다. 반대로 말하면 이 성질은 I/O bound Task만의 것입니다. Task.Run으로 감싼 CPU 처리는 당연히 워커 스레드를 하나 점유하고, 제2회 7장의 「겉보기 비동기」도 뒤에서 스레드를 재웁니다.
5.3. 계속은 어디서 달리는가
그림 6의 마지막 화살표──「계속을 어디로 던질 것인가」에는 분명한 규칙이 있습니다. 6
flowchart TB
A["Task가 완료되어, 계속을 실행하고 싶다"]
Q1{"await한 시점에<br/>SynchronizationContext나<br/>비기본 TaskScheduler를 캡처했는가"}
Q2{"ConfigureAwait(false)를<br/>붙였는가"}
UI["캡처한 곳으로 다시 던진다<br/>예: UI 스레드의 메시지 루프나<br/>그 TaskScheduler 위에서 실행"]
TP["특정 장소로 되돌릴 의무 없음<br/>완료시킨 스레드에서 동기적으로 이어지거나<br/>스레드 풀 스레드에서 실행"]
A --> Q2
Q2 -->|"예"| TP
Q2 -->|"아니요"| Q1
Q1 -->|"예 (WPF/WinForms의 UI 스레드 등)"| UI
Q1 -->|"아니요 (콘솔/ASP.NET Core 등)"| TP
그림 7: 계속의 행선지. 「await 뒤에 그대로 UI를 만질 수 있는」 것은, 캡처한 컨텍스트로 다시 던지기 때문이다
- WPF/WinForms의 UI 스레드에서
await하면SynchronizationContext가 캡처되고, 이어지는 코드는 UI 스레드로 돌아갑니다. 그래서await직후에 컨트롤을 만져도 스레드 위반이 되지 않습니다. 이 설계의 실무 쪽은 「WPF/WinForms의 async와 UI 스레드를 한 장으로 정리」에서 다뤘습니다. - 캡처되는 것은
SynchronizationContext만이 아니라, 비기본TaskScheduler위에서await한 경우 그 스케줄러도 대상입니다. 둘 다 없는 장소(콘솔, ASP.NET Core, 스레드 풀 위)에서는 특정 장소로 되돌릴 의무가 없으므로, 계속은 스레드 풀에서 달리거나 Task를 완료시킨 스레드 위에서 그대로 동기적으로 이어집니다. ConfigureAwait(false)는 「돌아가지 않아도 된다」는 명시이지, 「반드시 스레드 풀로 옮긴다」는 보장이 아닙니다. 이미 완료된 Task를 await한 경우(제2회에서 본 동기 완료도 여기에 포함됩니다)에는 대기가 생기지 않고 현재 스레드에서 그대로 이어집니다. 라이브러리 코드에서의 구분은 「C# async/await 실무 판단표」를 참고하십시오.
이 마지막 한 점은 오해가 많으므로 코드로 나란히 둡니다. 먼저 ConfigureAwait(false)를 「스레드 풀로 옮기는 지시」라고 생각하고 쓴 코드입니다.
// 나쁜 예: 「ConfigureAwait(false)를 붙였으니, 이 앞은 스레드 풀에서 달린다」는 오해
private async void OnLoadClick(object sender, EventArgs e)
{
string csv = await File.ReadAllTextAsync(path).ConfigureAwait(false);
// 기대: 여기는 스레드 풀이므로 UI가 멈추지 않는다
// 실제: await한 시점에 Task가 이미 완료됐으면 대기가 생기지 않고,
// UI 스레드 그대로 이어진다 → 이 무거운 처리로 UI가 멈춘다
var rows = ParseHeavy(csv);
// 게다가, 비동기로 완료된 경우의 이어지는 코드는 UI 스레드가 아니다
resultLabel.Text = $"{rows.Count} 건"; // → 스레드 위반 예외가 날 수 있다
}
ConfigureAwait(false)가 말하는 것은 「캡처한 컨텍스트로 되돌리지 않아도 된다」뿐입니다. 「어디서 달릴지」는 지정하지 않으므로, UI 스레드 그대로 이어질 수도 있고 I/O 완료 스레드나 스레드 풀 스레드에서 이어질 수도 있습니다. 둘 다 가능하다는 것이 이 코드가 망가진 이유입니다.
의도를 나눠 쓰면 다음과 같습니다.
// 좋은 예: 「UI로 되돌릴지/되돌리지 않을지」와 「무거운 처리를 어디서 할지」를 따로 지시한다
private async void OnLoadClick(object sender, EventArgs e)
{
// UI 코드에서는 캡처시킨 채로 둔다(이어지는 코드는 UI 스레드로 돌아간다)
string csv = await File.ReadAllTextAsync(path);
// CPU를 쓰는 처리를 스레드 풀로 보내고 싶다면 Task.Run으로 명시한다
var rows = await Task.Run(() => ParseHeavy(csv));
// 여기는 확실히 UI 스레드. 안전하게 컨트롤을 만질 수 있다
resultLabel.Text = $"{rows.Count} 건";
}
// 라이브러리 쪽(UI가 없는 코드)에서는 반대로, 「돌아갈 필요가 없다」는 것을 밝힌다
public async Task<string> ReadConfigAsync(string path)
{
string text = await File.ReadAllTextAsync(path).ConfigureAwait(false);
return text.Trim(); // 호출 측 컨텍스트에 의존하지 않는다
}
판단 기준은 단순합니다. UI를 만지는 코드에서는 캡처시킨 채로 두고, 행선지를 바꾸고 싶을 때는 Task.Run으로 명시합니다. ConfigureAwait(false)는 「호출 측이 어느 컨텍스트여도 안전하게 동작한다」는 것을 밝히기 위한, 라이브러리 쪽 도구로 생각하십시오.
5.4. 막힘의 정체 ── 스레드 풀 기아
마지막으로, 이 지하실이 막히는 패턴을 하나만. 계속이나 워커 안에서 동기적으로 기다리면(동기 I/O, Task.Result/Wait(), 긴 락 대기) 그 스레드는 막힌 채로 남습니다. IOCP 자신의 보충(3.4절)은 대기 중인 예비 스레드가 있는 한 즉시 먹힙니다. 그러나 예비가 바닥난 다음은 스레드 풀이 새 스레드를 천천히만 주입하는 영역입니다. 부하가 걸린 순간에 「계속을 실행하고 싶은데 실행할 스레드가 없다」는 기아(starvation)가 일어나, 앱 전체가 둔해집니다.
조사의 입구는 두 가지입니다. ThreadPool.GetAvailableThreads로 워커/I/O 완료 스레드의 여유를 보는 것. 4 그리고 이벤트 트레이스로 스레드 풀과 블로킹의 실태를 잡는 것──절차는 「PerfView와 dotnet-trace로 「느림」을 특정한다」에 정리했습니다. 예방은 단순하고, async의 길은 async인 채로 끝까지(sync-over-async를 섞지 않는다), 이것뿐입니다.
6. 정리
- IOCP는 완료 큐(FIFO)와 스레드 수 제어를 일체화한 메커니즘입니다. 수많은 핸들의 완료를 하나의 포트에 모으고,
GetQueuedCompletionStatus루프로 처리합니다. 17 - 스레드는 LIFO로 해제되므로, 바쁠수록 같은 스레드가 계속 돌고 컨텍스트 스위치와 캐시 미스가 최소화됩니다. 1
- 동시성 값은 「실행 가능 스레드 수」의 상한이며, 출발점은 CPU 수(0 지정). 실행 중인 스레드가 블로킹되면 대기 스레드로 보충되지만, 완료 처리는 짧게 유지하는 것이 대원칙입니다. 12
PostQueuedCompletionStatus로 자체 패킷도 흘릴 수 있습니다. 신규 구현에서는 Windows 스레드 풀 API(내부는 IOCP)가 제1 후보입니다. 31- .NET 스레드 풀은 워커 스레드 + I/O 완료 스레드의 2층 구조이며, 비동기 핸들은 풀의 IOCP에 묶입니다.
await의 I/O 대기에는 스레드가 없고, 완료 후의 계속만 스레드에 올라탑니다. 456 - 계속의 행선지는 캡처한 컨텍스트에 달립니다(UI 스레드로 돌아감 / 스레드 풀에서 계속).
ConfigureAwait(false)는 그 캡처를 그만두라는 지시입니다. 6 - 막힘의 정체는 거의 동기 대기가 섞여 생기는 스레드 풀 기아입니다. async의 길은 async인 채로 끝까지 통과시키십시오.
다음은 제4회 「캐시 관리자 ── 당신의 WriteFile은 언제 디스크에 도착하는가」입니다. 제2회에서 「캐시에 올라가 있으면 동기 완료된다」고 썼고, 이번에도 캐시의 그림자가 몇 번 비쳤습니다. 다음은 그 캐시 본체──지연 쓰기, 미리 읽기, FILE_FLAG_NO_BUFFERING, 그리고 「썼다고 생각한 데이터가 전원 차단으로 사라지는」 조건──을 정면에서 다룹니다.
관련 기사
- Windows I/O의 심층(제1회) ── 모든 읽기쓰기는 IRP가 된다: I/O 시스템의 전체 모습
- Windows I/O의 심층(제2회) ── 동기 I/O와 비동기 I/O: OVERLAPPED의 진짜 의미
- C# async/await 실무 판단표 - Task.Run과 ConfigureAwait
- WPF/WinForms의 async와 UI 스레드를 한 장으로 정리
- TCP에서 Send한 단위마다 Receive할 수 있다는 오해 ── 바이트 스트림으로 다루기 위한 수신 설계
- PerfView와 dotnet-trace로 「느림」을 특정한다 ── .NET 성능 조사의 실무 입문
- 평범한 Windows에서 소프트 실시간성을 가능한 한 구현하기 위한 실천 가이드
관련 상담 영역
합동회사 고무라소프트에서는, 다수의 동시 연결·동시 I/O를 다루는 Windows 앱/서버 설계와 「스레드 풀이 막힌다」「비동기화했는데 빨라지지 않는다」와 같은 성능 문제의 원인 조사를 다룹니다.
참고 링크
-
Microsoft Learn, I/O Completion Ports. I/O 완료 포트가 멀티프로세서 시스템에서 다수의 비동기 I/O 요청을 처리하기 위한 효율적인 스레딩 모델을 제공한다는 점, 비동기 I/O 완료 시 완료 패킷이 FIFO 순으로 포트 큐에 쌓인다는 점, 대상이 디스크상 파일에 한정되지 않고 소켓·명명된 파이프·메일슬롯 등 Overlapped I/O를 지원하는 임의의 핸들이라는 점, 포트에서 대기하는 스레드가 LIFO 순으로 해제되며 동시성 값 1에서 큐가 차 있는 경우에는 스레드 전환이 발생하지 않는다는 점, 스레드가 처음 GetQueuedCompletionStatus를 호출하면 그 포트에 연결되고 동시에 하나의 포트에만 연결될 수 있다는 점, 동시성 값이 실행 가능 스레드 수를 제한하고 전체로서 최선의 최댓값은 CPU 수라는 점, 실행 중인 스레드가 다른 이유로 대기 상태에 들어가면 대기 중인 스레드가 완료 패킷을 처리할 수 있다는 점(그리고 블로킹했던 스레드가 깨어날 때 일시적으로 상한을 넘을 수 있다는 점), 신규 서버 애플리케이션에는 우선 Windows 스레드 풀 API(CreateThreadpoolIo 등. 내부에서 IOCP를 사용)를 검토해야 한다는 점에 대해. ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 ↩8 ↩9 ↩10 ↩11 ↩12 ↩13 ↩14 ↩15 ↩16 ↩17 ↩18 ↩19 ↩20
-
Microsoft Learn, CreateIoCompletionPort function. CreateIoCompletionPort가 I/O 완료 포트의 신규 생성과 기존 포트에 핸들을 연결하는 일을 모두 한다는 점, 연결 시 CompletionKey(사용자 정의 값)를 지정할 수 있고 완료 패킷에 포함된다는 점, NumberOfConcurrentThreads가 완료 패킷을 병렬 처리할 수 있는 스레드 수의 상한이며 0을 지정하면 시스템의 프로세서 수가 쓰인다는 점에 대해. ↩ ↩2 ↩3 ↩4 ↩5
-
Microsoft Learn, PostQueuedCompletionStatus function. PostQueuedCompletionStatus가 비동기 I/O를 시작하지 않고 애플리케이션 고유의 완료 패킷을 I/O 완료 포트 큐에 쌓을 수 있다는 점, 이로써 포트가 I/O 완료 수신에 더해 프로세스 안 다른 스레드로부터의 통신 창구로도 쓰인다는 점에 대해. ↩ ↩2 ↩3
-
Microsoft Learn, The managed thread pool. .NET 스레드 풀이 워커 스레드와 비동기 I/O 완료용 스레드를 제공한다는 점, ThreadPool.GetAvailableThreads로 워커 스레드와 I/O 완료 스레드의 사용 가능 수를 따로 얻을 수 있다는 점, 스레드 풀 스레드에서 장시간 블로킹을 해서는 안 된다는 점에 대해. ↩ ↩2 ↩3 ↩4
-
Microsoft Learn, ThreadPoolBoundHandle.BindHandle method. ThreadPoolBoundHandle.BindHandle이 운영 체제 핸들을 시스템 스레드 풀(의 I/O 완료 포트)에 묶은 ThreadPoolBoundHandle을 반환한다는 점, 묶은 핸들에 대한 저수준 비동기 I/O를 NativeOverlapped와 조합해 수행한다는 점, 비동기 I/O의 완료 처리가 스레드 풀에 의해 이루어지게 된다는 점에 대해. ↩ ↩2 ↩3
-
Microsoft Learn, Async in depth (.NET). I/O bound Task에서는 호출이 OS에 넘어간 뒤 그 완료를 기다리기 위한 전용 스레드가 존재하지 않는다는 점(이른바 「There is no thread」), 디바이스 드라이버와 인터럽트를 거쳐 완료가 통지되고 등록되어 있던 계속이 실행된다는 점, await가 기본적으로 현재 컨텍스트(SynchronizationContext 등)를 캡처해 계속을 그곳에서 실행하고 캡처할 컨텍스트가 없으면 스레드 풀에서 실행된다는 점, ConfigureAwait(false)로 이 캡처를 무효화할 수 있다는 점에 대해. ↩ ↩2 ↩3 ↩4 ↩5 ↩6
-
Microsoft Learn, GetQueuedCompletionStatus function. GetQueuedCompletionStatus가 완료 포트 큐에서 완료 패킷을 하나 꺼낸다(없으면 기다린다)는 점, 꺼낸 결과로 전송 바이트 수·CompletionKey·OVERLAPPED 포인터를 얻는다는 점, 그리고 반환값이 FALSE여도 OVERLAPPED 포인터가 non-NULL이면 「실패한 I/O 작업의 완료 패킷을 꺼냈다」는 뜻이며 OVERLAPPED가 NULL인 경우에만(타임아웃 등) 패킷을 꺼내지 못했다는 뜻이라는 점에 대해. ↩ ↩2 ↩3 ↩4
-
Microsoft Learn, GetQueuedCompletionStatusEx function. GetQueuedCompletionStatusEx가 여러 완료 패킷을 한 번에 꺼낼 수 있다는 점, 꺼낸 엔트리 수가 반환된다는 점에 대해. ↩ ↩2
-
Microsoft Learn, SetFileCompletionNotificationModes function. FILE_SKIP_COMPLETION_PORT_ON_SUCCESS에 의해, I/O가 즉시 성공해 결과가 그 자리에서 확정된 경우 완료 포트에 패킷을 쌓지 않는 동작을 고를 수 있다는 점에 대해. ↩
관련 기사
같은 태그를 공유하는 최신 기사입니다. 더 가까운 주제로 지식을 넓힐 수 있습니다.
Windows I/O의 심층(제2회) ── 동기 I/O와 비동기 I/O: OVERLAPPED의 진짜 의미
Windows의 동기 I/O와 비동기 I/O(overlapped I/O)를 그림으로 설명하는 연재의 제2회입니다. FILE_FLAG_OVERLAPPED의 의미, 완료 통지의 네 가지 방식, 비동기인데도 동기 완료되는 조건, 취소 절차, .NET과...
Windows I/O의 심층(제4회) ── 캐시 관리자: WriteFile은 언제 디스크에 도달하는가
Windows의 캐시 관리자를 그림으로 설명하는 연재 제4회입니다. 파일 매핑으로 구현된 캐시, read-ahead와 lazy write, FlushFileBuffers와 FILE_FLAG_NO_BUFFERING의 용도 구분, 전원 차단으로 데이...
Windows I/O 내부 구조(제1회) ── 모든 읽기·쓰기는 IRP가 된다: I/O 시스템의 전체 그림
Windows I/O 시스템을 밑바닥부터 설명하는 연재의 제1회입니다. Object Manager namespace, driver·device·file 세 가지 object, IRP 수명 주기, CloseHandle 뒤에서 일어나는 일까지를 그림...
Windows impersonation token을 올바르게 다루기 ── 스레드 단위 권한 차용과 안전한 되돌리기
Windows impersonation token에 대해 access token, primary token, thread token, impersonation level, RevertToSelf, .NET의 WindowsIdentity.RunIm...
Windows 프린터 드라이버 제공 종료 ── 업무 앱의 장표·라벨 인쇄는 어떻게 대비할 것인가
Microsoft는 v3/v4 프린터 드라이버의 제공 종료를 단계적으로 진행하고 있으며, 2026년 7월부터는 IPP 클래스 드라이버가 우선됩니다. Windows protected print mode에서 무엇이 사라지는지, 업무 앱의 장표·라벨 ...
관련 토픽
이 기사와 가까운 토픽 페이지입니다. 기사를 출발점 삼아 관련 서비스와 다른 기사로 이어집니다.
Windows 기술 토픽
Windows 개발, 장애 조사, 기존 자산 활용에 관한 KomuraSoft LLC 기사를 모은 토픽 허브입니다.
이 주제와 연결되는 서비스
이 기사는 다음 서비스 페이지로 이어집니다. 가까운 입구부터 확인해 주세요.
Windows 앱 개발
상주 처리, 장비 연동, 운영 로그, 유지 보수 가능한 구조가 필요한 Windows 데스크톱 애플리케이션을 지원합니다.
자주 묻는 질문
이 기사 주제에 대해 상담 시 자주 나오는 질문을 모았습니다.
- I/O 완료 포트(IOCP)란 무엇인가요?
- 수많은 비동기 I/O의 완료 통지를 하나의 큐에 모으고, 그것을 처리하는 스레드의 동시 실행 수까지 함께 제어하는 Windows 커널 메커니즘입니다. CreateIoCompletionPort로 포트를 만들고 파일이나 소켓 같은 핸들을 연결하면, 비동기 I/O가 완료될 때마다 완료 패킷이 포트의 FIFO 큐에 쌓입니다. 워커 스레드는 GetQueuedCompletionStatus로 큐에서 패킷을 꺼내 처리합니다. 핵심은 이것이 단순한 통지 큐가 아니라 「실행 가능한 스레드 수를 동시성 값 이하로 유지하는」 스케줄링 기구를 겸한다는 점입니다. 수많은 동시 I/O를 적은 수의 스레드로 효율적으로 처리할 수 있어, Windows 서버 구현과 .NET 스레드 풀의 기반이 됩니다.
- IOCP의 동시성 값(동시 실행 수)은 얼마로 해야 하나요?
- Microsoft 문서는 전체로서 최선의 최댓값은 컴퓨터의 CPU 수라고 말합니다. CreateIoCompletionPort의 NumberOfConcurrentThreads에 0을 넘기면 시스템의 프로세서 수가 쓰이므로, 망설이면 0이 출발점입니다. 이 값은 「실행 가능 상태인 스레드 수」의 상한이지, 대기 중인 스레드 수의 상한이 아닙니다. 실행 중인 스레드가 어떤 이유로 대기 상태에 들어가면 시스템은 대기 중인 다른 스레드를 깨워 빈자리를 메우므로, 처리에 긴 계산이나 블로킹이 섞이면 동시성 값을 크게 잡아 동시에 처리되는 패킷 수를 늘리는 선택도 있습니다. 최종적으로는 프로파일링과 함께 조정하는 것이 권장됩니다.
- 왜 async/await의 I/O 대기는 스레드를 소비하지 않는다고 말할 수 있나요?
- I/O를 발행한 뒤 완료될 때까지, 그 작업을 돌보기 위한 전용 스레드가 어디에도 없기 때문입니다. 연재 제1회·제2회에서 본 것처럼, 발행된 요청은 IRP로 디바이스 스택을 타고 흐르고, 호출은 ERROR_IO_PENDING으로 바로 돌아옵니다. await는 그 시점에서 미완료 Task에 계속(continuation)을 등록하고 스레드를 놓을 뿐입니다. 디바이스가 하드웨어로 일하는 동안, 사용자 모드에도 커널에도 「기다리기만 하는 스레드」는 없습니다. 완료되면 완료 패킷이 스레드 풀의 IOCP에 쌓이고, 그때 비로소 I/O 완료 스레드가 짧게 동작해 등록되어 있던 계속을 스케줄합니다. 즉 스레드가 쓰이는 것은 발행 순간과 완료 후 후처리뿐이며, 기다리는 시간 자체는 스레드 제로로 진행됩니다.
- await의 이어지는 부분(계속, continuation)은 어느 스레드에서 실행되나요?
- 기본값은 await한 시점의 SynchronizationContext(또는 TaskScheduler)를 캡처하고, 계속을 그곳으로 다시 던지는 것입니다. WPF나 WinForms의 UI 스레드에서 await했다면 이어지는 코드는 UI 스레드에서 달리므로, await 뒤에 바로 컨트롤을 만질 수 있습니다. 캡처할 컨텍스트가 없는 경우(콘솔 앱, ASP.NET Core, 스레드 풀 위의 코드 등)에는, 계속은 스레드 풀 스레드에서 실행되거나 Task를 완료시킨 스레드 위에서 그대로 이어집니다. ConfigureAwait(false)를 붙이면 캡처를 그만두지만, 이것은 「반드시 스레드 풀로 간다」는 보장이 아니라 「특정 장소로 되돌리지 않아도 된다」는 지시입니다. 이미 완료된 Task를 await한 경우에는 대기가 생기지 않고 현재 스레드에서 동기적으로 이어집니다. 라이브러리 코드에서 ConfigureAwait(false)가 권장되는 이유는, UI 스레드로의 불필요한 왕복을 피하고 특정 컨텍스트에 대한 의존이나 교착의 싹을 자르기 위해서입니다.
- IOCP의 워커 스레드나 .NET의 I/O 완료 스레드에서 오래 블로킹하면 어떻게 되나요?
- 당장 망가지지는 않지만, 메커니즘이 가정한 범위에서 벗어나 성능이 떨어집니다. IOCP는 실행 중인 스레드가 대기 상태에 들어가면 대기 중인 스레드를 깨워 보충하지만, 보충된 스레드만큼 동시 실행 수가 부풀어 컨텍스트 스위치가 늘어납니다. 블로킹이 상시화되면 큐에 패킷이 쌓여 완료 처리 전체가 늦어집니다. .NET에서도 마찬가지로, I/O 완료 스레드나 계속 안에서 동기 I/O나 Task.Result 같은 대기를 하면 스레드 풀 기아(starvation)를 부릅니다. 완료 처리·계속은 짧게 두고, 무거운 일은 밖으로 빼는 것이 원칙입니다. ThreadPool.GetAvailableThreads로 워커 스레드와 I/O 완료 스레드의 여유를 볼 수 있으므로, 막힘 조사에 쓸 수 있습니다.