Windows I/O의 심층(제3회) ── I/O 완료 포트(IOCP)와 .NET 스레드 풀: async/await의 지하실

· · Windows, Win32, I/O, IOCP, 비동기, 스레드 풀, .NET, CSharp

지난 회(제2회)는 비동기 I/O의 발행과, 완료를 받는 4가지 경로를 살펴봤습니다. 그때 ‘수많은 동시 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
  • 계속(continuation)의 행선지는 ‘캡처된 컨텍스트’가 결정합니다. UI 스레드에서 await하면 이어지는 부분은 UI 스레드로, 캡처할 것이 없으면 스레드 풀(또는 완료시킨 스레드 위)에서 이어집니다. ConfigureAwait(false)는 캡처를 멈추라는 지시일 뿐, 스레드 풀로 이동한다는 보장이 아닙니다(5.3절).6

2. 스레드로 밀어붙이는 설계는 어디서 파탄 나는가

먼저 IOCP가 풀려고 했던 문제를 확인합니다. 소박한 서버는 ‘연결 1개에 스레드 1개’로 만들 수 있습니다. 동기 I/O로 읽고, 처리하고, 쓴다. 이해하기 쉬운 설계이지만, 연결이 늘어나면 두 가지 벽에 부딪힙니다.

IOCP 모델 ── 비동기 I/O완료 큐모든 연결의 완료 통지가 모인다워커1워커2워커는 CPU 수 정도의 소수연결마다 스레드 1개 ── 동기 I/O스레드1 ── 연결1의 read 대기스레드2 ── 연결2의 read 대기스레드3 ── 연결3의 read 대기…연결 수만큼 스레드가 늘어난다대부분은 I/O 대기로 잠들어 있을 뿐

그림 1: 왼쪽은 연결 수에 비례해 스레드가 늘어난다. 오른쪽은 ‘일어난 사건’만을 소수의 스레드로 처리한다

  1. 스레드는 공짜가 아닙니다. 하나마다 스택(기본값 예약 1MB)과 커널 오브젝트를 소비하며, 수가 늘어날수록 스케줄러와 컨텍스트 스위치의 부담이 쌓입니다. 수천 개의 연결 = 수천 개의 스레드는, 그 대부분이 ‘읽을 수 있게 되기를 기다리며 잠들어 있을 뿐’이라도 비용이 큽니다.
  2. ‘실행하고 싶은 수’가 CPU 수를 넘어도 의미가 없습니다. 동시에 달릴 수 있는 스레드는 물리적으로 CPU의 수까지입니다. 그 이상의 스레드를 실행 가능 상태로 만들어도 전환 손실만 늘어날 뿐입니다.

제2회까지 I/O의 완료 통지를 ‘이벤트’나 ‘APC’로 받는 방법을 봤습니다. 하지만 이벤트 방식은 WaitForMultipleObjects의 64개 제한이나 대기 설계가 번잡해지고, APC는 발행 스레드에 묶입니다. 다수의 I/O × 소수의 스레드라는 형태에 처음부터 맞춰 설계된 것이 IOCP입니다.1

3. IOCP의 설계 ── 큐와 스레드 제어의 일체화

3.1. CreateIoCompletionPort의 두 얼굴

CreateIoCompletionPort는 이름과 달리 두 가지 일을 합니다. 포트의 신규 생성기존 포트에 핸들을 연결하는 것입니다.2

워커 스레드 그룹I/O 완료 포트연결된 핸들 ── 몇 개든 가능제어GetQueuedCompletionStatus로 대기GetQueuedCompletionStatus로 대기완료 패킷의 큐 - FIFO패킷 = 전송 바이트 수 +CompletionKey + OVERLAPPED 포인터동시성 제어실행 가능 스레드 수 ≦ 상한파일소켓명명된 파이프

그림 2: IOCP의 구성. 수많은 핸들의 완료가 하나의 큐에 모이고, 꺼내는 쪽의 스레드 수까지 제어된다

연결할 때 넘기는 CompletionKey는 ‘이 핸들로부터의 완료입니다’를 워커에 전달하기 위한 자유로운 값입니다(연결 객체의 포인터를 넣는 것이 정석입니다). 완료 패킷에는 CompletionKey와, 그 조작의 OVERLAPPED 포인터, 전송 바이트 수가 담겨 도착합니다. 어느 연결의(CompletionKey), 어느 조작이(OVERLAPPED), 어디까지 진행됐는지(바이트 수)──제2회에서 본 ‘조작의 전표’가 여기서 회수되는 것입니다.27

대상은 ‘파일’에 국한되지 않습니다. 소켓, 명명된 파이프, 메일슬롯 등 오버랩 I/O를 다룰 수 있는 핸들이라면 무엇이든 연결할 수 있습니다.1 제1회에서 본 ‘모든 것이 파일로 보인다’는 설계가 여기서도 작동합니다.

3.2. 완료 패킷의 여정

워커 스레드포트의 큐(FIFO)커널(IRP 완료)워커 스레드포트의 큐(FIFO)커널(IRP 완료)GetQueuedCompletionStatus로 대기패킷을 보고 완료 처리(계속의 실행・다음 I/O의 발행 등)큐에 패킷이 남아 있으면기다리지 않고 다음을 받는다완료 패킷을 쌓는다(바이트 수 / CompletionKey / OVERLAPPED)대기 중인 스레드를 1개 깨워 넘겨준다처리를 마치고 다시 GetQueuedCompletionStatus

그림 3: 완료 패킷은 FIFO로 쌓이고, 워커는 꺼내서 처리하는 루프를 돈다

비동기 I/O가 완료되면 완료 패킷이 포트의 큐에 FIFO 순서로 쌓입니다. 워커는 GetQueuedCompletionStatus를 호출해 패킷을 하나 받고, 처리가 끝나면 다시 호출한다──이 루프가 IOCP 프로그래밍의 골격입니다.17 한 번에 여러 패킷을 모아서 꺼내는 GetQueuedCompletionStatusEx도 있어, 고빈도 I/O에서는 호출 횟수를 줄일 수 있습니다.8

여기서 워커 루프의 단골 버그를 먼저 잡아 둡니다. GetQueuedCompletionStatusFALSE를 반환하더라도, 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개가 동시에 연결될 수 있는 포트는 1개뿐입니다).1 ‘전속 워커 팀이 포트에 붙는다’는 그림으로 기억해 두면 정확합니다.

3.3. 스레드는 LIFO로 깨워진다

여기서부터가 IOCP 설계의 묘미입니다. 패킷은 FIFO로 쌓이지만, 대기 중인 스레드는 LIFO로 깨워집니다. 즉 가장 최근까지 일하고 있던 스레드가 다음 패킷도 집습니다.1

대기 스레드 - LIFO 스택P1도 P2도 P3도, 비어 있으면먼저 스레드A로A가 차 있을 때만좀처럼 깨어나지 않는다스레드A - 직전까지 실행, 따뜻함스레드B - 한동안 잠들어 있음스레드C - 계속 잠들어 있음큐 - P1 → P2 → P3 - FIFO

그림 4: LIFO 해제. 바쁠 때일수록 같은 스레드가 계속 돌고, 한가한 스레드는 잠든 채로 있을 수 있다

이 설계의 이점은 두 가지입니다.

  • 컨텍스트 스위치가 일어나지 않습니다. 큐에 패킷이 남아 있는 한, 처리를 마친 스레드가 GetQueuedCompletionStatus를 호출하면 기다리지 않고 바로 다음 패킷을 받아 계속 달립니다. 문서는 동시성 값 1인 시나리오에서 ‘스레드 전환은 발생하지 않는다’고 명시하고 있습니다.1
  • 캐시가 따뜻한 채로 사용할 수 있습니다. 같은 스레드가 계속 도므로, 스택이나 스케줄링 상태가 CPU 캐시에 남아 있을 가능성이 높아집니다. 잠들어 있는 스레드는 부하의 정점에 대비한 예비로 저렴하게 유지됩니다.

3.4. 동시성 값 ── ‘실행 가능’을 센다

포트 생성 시의 NumberOfConcurrentThreads동시성 값입니다. 이것은 ‘그 포트에 연결된 실행 가능(runnable) 상태의 스레드 수‘의 상한이며, 상한에 도달해 있는 동안은 추가 스레드가 패킷을 받을 수 없습니다.1 0을 넘기면 시스템의 프로세서 수가 사용되며, 문서도 전체적으로 최선의 최댓값은 CPU 수라고 밝히고 있습니다.21

영리한 점은 이 수가 ‘깨어 있는 스레드’가 아니라 ‘실행 가능한 스레드’를 센다는 것입니다.

미만상한큐에 패킷이 도착실행 가능한 스레드 수가동시성 값 미만인가대기 스레드를 깨워 처리시킨다아무도 깨우지 않고 큐에 둔다실행 중인 스레드가 가지러 온다실행 중인 스레드가다른 이유로 대기 상태에 들어감실행 가능 수가 줄어든 만큼대기 스레드를 깨워 보충한다

그림 5: 동시성 제어. 상한은 ‘실행 가능한 수’이므로, 누군가 블로킹하면 자동으로 보충된다

실행 중인 워커가 무언가의 대기(락, 페이지 폴트, 무심코 쓴 동기 I/O)에 들어가면 실행 가능 수가 줄어들므로, 시스템은 대기 중인 스레드를 깨워 빈자리를 메웁니다.1 그래서 워커를 ‘CPU 수만큼만’ 만드는 것이 아니라, 동시성 값보다 많은 스레드를 대기시켜 두는 것이 정석입니다. 처리에 긴 계산이 섞인다면 동시성 값 자체를 올리는 선택도 있고, 최종적으로는 프로파일링으로 조정하라는 것이 문서의 입장입니다.1

다만 보충은 만능이 아닙니다. 블로킹됐던 스레드가 나중에 깨어나면 그 순간만큼은 실행 가능 수가 상한을 넘습니다(문서도 이 초과에 대해 언급하고 있습니다).1 완료 처리는 짧게 유지한다가 대원칙이며, 이는 6장의 .NET에서도 같은 형태로 작동합니다.

4. 도구 상자 ── 포트를 지탱하는 API들

  • PostQueuedCompletionStatus ── I/O를 발행하지 않고 자체 완료 패킷을 큐에 쌓을 수 있습니다.3 워커에 대한 작업 의뢰, 종료 지시(워커 수만큼 종료용 패킷──흔히 ‘독이 든 만두’ 패킷이라 불립니다──를 쌓음), 다른 스레드로부터의 통지──I/O의 완료와 자체 메시지를 같은 큐・같은 루프로 처리할 수 있는 것은 설계를 크게 단순화해 줍니다.
  • GetQueuedCompletionStatusEx ── 완료 패킷을 한 번에 여러 개 꺼냅니다. 패킷 1개당 호출 1번의 오버헤드가 부담이 되는 고빈도 I/O에서 효과적입니다.8
  • SetFileCompletionNotificationModes ── 제2회 5장에서 본 ‘비동기로 발행했는데 동기 완료했다’는 경우에, 포트에 패킷을 쌓지 않는 모드를 선택할 수 있습니다(FILE_SKIP_COMPLETION_PORT_ON_SUCCESS). 동기 완료의 결과는 그 자리에서 알 수 있으므로 큐를 다시 거치는 것 자체가 낭비──라는 고속화입니다.9
  • Windows 스레드 풀 API ── CreateThreadpoolIo / StartThreadpoolIo는 내부에서 IOCP를 사용하면서 스레드의 생성・관리를 대신 맡아 줍니다. 마이크로소프트는 새로운 서버 애플리케이션에는 먼저 이쪽을 검토하고, 동시성 값이나 스레드 관리를 명시적으로 제어하고 싶을 때만 원시 IOCP를 사용할 것을 권장합니다.1 그리고 .NET의 스레드 풀도, 바로 이 ‘IOCP + 스레드 관리 자동화’를 .NET 런타임으로 구현한 것입니다.

함정도 세 가지만 짚어 둡니다. (1) 워커 안에서 장시간 블로킹하지 않는다(3.4절의 보충은 성능 저하를 완화할 뿐입니다). (2) 완료 패킷의 식별은 CompletionKey(핸들 단위)와 OVERLAPPED(조작 단위)의 2단계로 이뤄진다──제2회의 ‘전표’ 수명 관리(완료까지 해제하지 않음)는 여기서도 생명선입니다. (3) 미완료 I/O가 남은 채로 핸들을 닫지 않는다──cleanup의 동작(제1회 6장)과 취소의 작법(제2회 6장)이 그대로 적용됩니다.

5. .NET 스레드 풀 ── IOCP 위에 세워진 2층 구조

여기서부터가 본론인 ‘async/await의 지하실’입니다.

.NET의 스레드 풀에는 두 종류의 스레드가 있습니다. Task.Run이나 계속(continuation)을 실행하는 워커 스레드와, 비동기 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’라고만 적었던 상자 안을, 이번에는 끝까지 열어 봅니다.

계속(continuation)의 실행 장소I/O 완료 스레드스레드 풀의 IOCP커널(IRP 발행~완료)호출 스레드(UI 스레드 등)계속(continuation)의 실행 장소I/O 완료 스레드스레드 풀의 IOCP커널(IRP 발행~완료)호출 스레드(UI 스레드 등)await가 미완료 Task에 계속을 등록하고스레드를 넘긴다(UI라면 다음 메시지 처리로)디바이스가 작업 중이 사이, 기다리는 스레드는 어디에도 없다결과(바이트 수・상태)를 확정하고Task를 완료시키고, 계속을 스케줄링await 이후의 코드가 실행된다ReadAsync가 비동기 읽기를 발행(OVERLAPPED에 해당하는 것을 첨부)ERROR_IO_PENDING(즉시 반환)완료 패킷을 쌓는다LIFO로 1개 깨워 넘긴다캡처한 컨텍스트로 던진다(UI 스레드로 / 없으면 스레드 풀에서 실행)

그림 6: await 한 번 왕복의 전체. 스레드가 일하는 것은 ‘발행’과 ‘완료 후’뿐이며, 대기 시간은 스레드 제로

5.2. ‘I/O 대기는 스레드를 소비하지 않는다’의 정확한 의미

이 그림에서 강조하고 싶은 것은, 발행부터 완료까지, 그 완료를 기다리기 위한 스레드는 사용자 모드에도 커널에도 존재하지 않는다는 점입니다. 마이크로소프트의 async 해설(async in depth)도 I/O 바운드 Task에 대해 ‘그 완료를 기다리기 위한 스레드는 어디에도 없다’는 것을, 디바이스 드라이버와 인터럽트까지 내려가서 설명하고 있습니다.6 다만 커널 안에서 드라이버가 처리의 일부를 시스템 워커 스레드에 맡기는 경우는 있습니다. 그러나 그것은 요청을 앞으로 진행시키기 위한 짧은 작업이지, 완료를 블로킹하며 계속 기다리는 스레드는 어디에도 없다──이것이 보장되는 범위입니다.

제1회부터 쌓아 온 것을 다시 말하면──IRP는 스레드가 아니라 데이터 구조로서 디바이스 스택에 머물고(제1회), 발행은 ERROR_IO_PENDING으로 즉시 반환되며(제2회), 완료는 인터럽트→완료 패킷이라는 이벤트의 연쇄로 도착합니다(이번 글). ‘기다린다’는 상태를 유지하는 데 스레드라는 비싼 자원이 필요 없는 구조인 것입니다.

그래서 async/await를 올바르게 사용한 앱은 ‘동시에 10,000건의 I/O가 날아다니고 있다’는 상태를, 스레드 십여 개로 유지할 수 있습니다. 반대로 말하면 이 성질은 I/O 바운드 Task만의 것입니다. Task.Run으로 감싼 CPU 처리는 당연히 워커 스레드를 1개 점유하고, 제2회 7장의 ‘겉보기만의 비동기’도 뒤에서 스레드를 잠들게 하고 있습니다.

5.3. 계속(continuation)은 어디서 실행되는가

그림6의 마지막 화살표──’계속을 어디로 던질 것인가’에는 명확한 규칙이 있습니다.6

아니오예 - WPF/WinForms의 UI 스레드 등아니오 - 콘솔/ASP.NET Core 등Task가 완료되어 계속을 실행하고 싶다await한 시점에서SynchronizationContext나비기본 TaskScheduler를 캡처했는가ConfigureAwait(false)를붙였는가캡처한 곳으로 다시 던진다예 - UI 스레드의 메시지 루프나그 TaskScheduler 위에서 실행특정 장소로 돌려보낼 의무 없음완료시킨 스레드에서 동기적으로 이어지거나스레드 풀의 스레드에서 실행

그림 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. 병목의 정체 ── 스레드 풀 기아

마지막으로, 이 지하실이 막히는 패턴을 하나만 짚습니다. 계속(continuation)이나 워커 안에서 동기적으로 기다리면(동기 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
  • 계속(continuation)의 행선지는 캡처한 컨텍스트에 달려 있습니다(UI 스레드로 돌아가거나 / 스레드 풀에서 이어짐). ConfigureAwait(false)는 그 캡처를 멈추라는 지시입니다(5.3절).6
  • 병목의 정체는 거의 동기 대기의 혼입에 의한 스레드 풀 기아입니다. async의 길은 async인 채로 끝까지 관통시켜 주세요.

다음 회는 제4회 「캐시 관리자 ── 당신의 WriteFile은 언제 디스크에 도달하는가」입니다. 제2회에서 ‘캐시에 올라와 있으면 동기 완료된다’고 적었고, 이번 회에도 몇 번인가 캐시의 그림자가 어른거렸습니다. 다음은 그 캐시 본체──지연 쓰기, 미리 읽기, FILE_FLAG_NO_BUFFERING, 그리고 ‘썼을 텐데 데이터가 전원 차단으로 사라진다’는 조건──과 정면으로 마주합니다.

관련 글

관련 상담 영역

합동회사 코무라소프트에서는 다수의 동시 접속・동시 I/O를 다루는 Windows 앱/서버의 설계와, ‘스레드 풀이 막힌다’, ‘비동기화했는데 빨라지지 않는다’와 같은 성능 문제의 원인 조사를 다루고 있습니다.

참고 링크

  1. Microsoft Learn, I/O Completion Ports. I/O 완료 포트가 멀티프로세서 시스템에서 수많은 비동기 I/O 요청을 처리하기 위한 효율적인 스레딩 모델을 제공한다는 것, 비동기 I/O 완료 시 완료 패킷이 FIFO 순서로 포트의 큐에 쌓인다는 것, 대상이 디스크상의 파일에 국한되지 않고 소켓・명명된 파이프・메일슬롯 등 오버랩 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

  2. Microsoft Learn, CreateIoCompletionPort function. CreateIoCompletionPort가 I/O 완료 포트의 신규 생성과, 기존 포트에 핸들을 연결하는 것을 모두 수행한다는 것, 연결 시 CompletionKey(사용자 정의 값)를 지정할 수 있으며 완료 패킷에 포함된다는 것, NumberOfConcurrentThreads가 완료 패킷을 병렬로 처리할 수 있는 스레드 수의 상한이며 0을 지정하면 시스템의 프로세서 수가 사용된다는 것에 대해.  2 3 4 5

  3. Microsoft Learn, PostQueuedCompletionStatus function. PostQueuedCompletionStatus가 비동기 I/O를 시작하지 않고도 애플리케이션 고유의 완료 패킷을 I/O 완료 포트의 큐에 쌓을 수 있다는 것, 이를 통해 포트가 I/O 완료 수신뿐 아니라 프로세스 내 다른 스레드로부터의 통신 창구로도 쓸 수 있다는 것에 대해.  2 3

  4. Microsoft Learn, The managed thread pool. .NET의 스레드 풀이 워커 스레드와 비동기 I/O 완료를 위한 스레드를 제공한다는 것, ThreadPool.GetAvailableThreads로 워커 스레드와 I/O 완료 스레드의 이용 가능 수를 따로 얻을 수 있다는 것, 스레드 풀의 스레드에서 장시간 블로킹을 해서는 안 된다는 것에 대해.  2 3 4

  5. Microsoft Learn, ThreadPoolBoundHandle.BindHandle method. ThreadPoolBoundHandle.BindHandle이 운영체제의 핸들을 시스템 스레드 풀(의 I/O 완료 포트)에 결합한 ThreadPoolBoundHandle을 반환한다는 것, 묶은 핸들에 대한 저수준 비동기 I/O를 NativeOverlapped와 조합해 수행한다는 것, 비동기 I/O의 완료 처리가 스레드 풀에 의해 이뤄지게 된다는 것에 대해.  2 3

  6. Microsoft Learn, Async in depth (.NET). I/O 바운드 Task에서는 호출이 OS로 넘어간 후 그 완료를 기다리기 위한 전용 스레드가 존재하지 않는다는 것(이른바 ‘There is no thread’), 디바이스 드라이버와 인터럽트를 거쳐 완료가 통지되고 등록되어 있던 계속이 실행된다는 것, await가 기본적으로 현재 컨텍스트(SynchronizationContext 등)를 캡처하여 계속을 그곳에서 실행하며, 캡처할 컨텍스트가 없는 경우에는 스레드 풀에서 실행된다는 것, ConfigureAwait(false)로 이 캡처를 비활성화할 수 있다는 것에 대해.  2 3 4 5 6

  7. Microsoft Learn, GetQueuedCompletionStatus function. GetQueuedCompletionStatus가 완료 포트의 큐에서 완료 패킷을 하나 꺼낸다는 것(없으면 대기), 꺼낸 결과로 전송 바이트 수・CompletionKey・OVERLAPPED 포인터를 얻을 수 있다는 것, 그리고 반환값이 FALSE여도 OVERLAPPED 포인터가 non-NULL인 경우는 ‘실패한 I/O 조작의 완료 패킷을 꺼냈다’는 것을 의미하며, OVERLAPPED가 NULL인 경우에만(타임아웃 등으로) 패킷을 꺼내지 못했다는 것을 의미한다는 것에 대해.  2 3 4

  8. Microsoft Learn, GetQueuedCompletionStatusEx function. GetQueuedCompletionStatusEx가 여러 완료 패킷을 한 번에 꺼낼 수 있다는 것, 꺼낸 엔트리 수가 반환된다는 것에 대해.  2

  9. Microsoft Learn, SetFileCompletionNotificationModes function. FILE_SKIP_COMPLETION_PORT_ON_SUCCESS를 통해, I/O가 즉시 성공하여 결과가 그 자리에서 확정된 경우 완료 포트에 패킷을 쌓지 않는 동작을 선택할 수 있다는 것에 대해. 

같은 태그를 공유하는 최신 기사입니다. 더 가까운 주제로 지식을 넓힐 수 있습니다.

이 기사와 가까운 토픽 페이지입니다. 기사를 출발점 삼아 관련 서비스와 다른 기사로 이어집니다.

이 기사는 다음 서비스 페이지로 이어집니다. 가까운 입구부터 확인해 주세요.

자주 묻는 질문

이 기사 주제에 대해 상담 시 자주 나오는 질문을 모았습니다.

I/O 완료 포트(IOCP)란 무엇인가요?
수많은 비동기 I/O의 완료 통지를 하나의 큐에 모으고, 그것을 처리하는 스레드의 동시 실행 수까지 함께 제어하는 Windows 커널의 구조입니다. CreateIoCompletionPort로 포트를 만들고 파일이나 소켓 등의 핸들을 연결하면, 비동기 I/O가 완료될 때마다 완료 패킷이 포트의 FIFO 큐에 쌓입니다. 워커 스레드는 GetQueuedCompletionStatus로 큐에서 패킷을 꺼내 처리합니다. 포인트는 이것이 단순한 통지 큐가 아니라 '실행 가능한 스레드 수를 동시성 값 이하로 유지하는' 스케줄링 기구를 겸하고 있다는 점입니다. 수많은 동시 I/O를 소수의 스레드로 효율적으로 처리할 수 있어, Windows의 서버 구현과 .NET 스레드 풀의 기반이 됩니다.
IOCP의 동시성 값(동시 실행 수)은 얼마로 해야 하나요?
마이크로소프트 문서는 전체적으로 최선의 최댓값은 컴퓨터의 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 완료 스레드나 계속(continuation) 안에서 동기 I/O나 Task.Result 같은 대기를 하면 스레드 풀의 기아(starvation)를 초래합니다. 완료 처리·계속은 짧게 유지하고, 무거운 작업은 따로 떼어내는 것이 원칙입니다. ThreadPool.GetAvailableThreads로 워커 스레드와 I/O 완료 스레드의 여유를 관찰할 수 있으므로, 병목 조사에 활용할 수 있습니다.

저자 프로필

기사 저자의 프로필 페이지입니다.

Go Komura

합동회사 코무라소프트 대표

Windows 소프트웨어 개발, 기술 상담, 장애 조사를 중심으로 재현이 어려운 장애 조사와 기존 자산이 남아 있는 프로젝트에 강점이 있습니다.

블로그 목록으로 돌아가기