← 문제로

40. IAsyncEnumerable<T> / await foreach 의 상태 머신·취소·ConfigureAwait

난이도 상
내 리뷰 · C#
해설 · C#

해설 — IAsyncEnumerable / await foreach 의 상태 머신·취소·ConfigureAwait

난이도: 상

요약

async + yield return 메서드는 컴파일러가 비동기 이터레이터 상태 머신으로 변환해 IAsyncEnumerable<T> 를 반환한다. 소비자의 await foreachGetAsyncEnumerator → 반복 MoveNextAsync(ValueTask) → DisposeAsync 를 호출한다. [EnumeratorCancellation] + WithCancellation 은 열거 시작 시점의 토큰을 생산자 파라미터로 주입하는 장치다.

핵심 개념

  • IAsyncEnumerable<T>.GetAsyncEnumerator(CancellationToken)IAsyncEnumerator<T> (MoveNextAsync() : ValueTask<bool>, Current, DisposeAsync() : ValueTask).
  • yield return + await = 지연 실행 + 비동기 대기의 결합. 각 반복이 비동기.

단계별 내부 동작

  1. 상태 머신 변환 — 컴파일러는 StreamScoresIAsyncStateMachine 구현 구조체로 바꾼다. 동기 이터레이터(IEnumerator)와 달리, MoveNext 대신 MoveNextAsyncValueTask 를 반환한다. ValueTask 인 이유: 다수의 반복에서 대개 동기 완료(값이 이미 준비됨)라 매 반복 Task 힙 할당을 피하려는 것(할당 절감). yield return 지점과 await 지점 모두가 상태 머신의 재진입 포인트(state 번호)로 저장되어, 대기 후 재개 시 지역 변수(i 등)가 필드로 보존된다.
  2. await foreach 의 호출 순서GetAsyncEnumerator(token) 한 번 → 루프마다 await MoveNextAsync() 가 true 면 Current 사용 → false 면 종료 → 어떤 경로로 나가든(정상 종료·break·예외) finally 에서 await DisposeAsync(). break 하면 즉시 DisposeAsync 가 불려 생산자의 남은 finally/정리(예: DB 커서 닫기)가 실행된다. 이 결정적 정리가 스트리밍 자원 누수를 막는 핵심.
  3. 취소 토큰 바인딩 — 생산자의 ct 파라미터에 [EnumeratorCancellation] 를 달면, 컴파일러가 생성한 GetAsyncEnumerator(CancellationToken) 로 넘어온 토큰이 그 ct연결된다. 소비자가 StreamScores(total) 로 만들 때 넘긴 토큰이 아니라, .WithCancellation(external) 로 준 토큰이 열거 시작 시 GetAsyncEnumerator 를 통해 주입된다(두 토큰이 다르면 링크됨). 즉 취소는 "스트림 생성 시점"이 아니라 "열거 시작 시점"에 묶인다. 여기서는 StreamScores 호출에 토큰을 안 줬어도 externalct 로 흘러들어 ThrowIfCancellationRequestedFetchPage(i, ct) 에 적용된다.
  4. ConfigureAwait(false)await foreach ... ConfigureAwait(false)ConfiguredCancelableAsyncEnumerable 로 감싸서 소비자 쪽 MoveNextAsync/ DisposeAsync await 재개를 원래 SynchronizationContext 로 되돌리지 않게 한다 (스레드풀에서 재개 → 컨텍스트 전환 비용·데드락 회피). 단 이는 소비자 측 await 에만 적용되고, 생산자 내부의 await FetchPage 에는 전파되지 않는다. 생산자 내부에서도 컨텍스트 무시가 필요하면 그쪽 await 에 직접 ConfigureAwait(false) 를 붙여야 한다. 서버 코드엔 SynchronizationContext 가 대개 없어 실질 영향은 작지만, 라이브러리 코드에선 관례적으로 붙인다.

흔한 오해·함정

  • "StreamScores(total, token) 로 토큰을 줘야만 취소된다" → WithCancellation + [EnumeratorCancellation] 조합이면 열거 시점 토큰이 주입된다(둘 다 주면 링크).
  • ValueTask 를 두 번 await 하거나 저장했다 재사용하면 안 됨(한 번만 소비 규칙).
  • breakDisposeAsync 가 자동 호출됨을 잊고 생산자 finally 를 안 넣으면 커서/연결 누수.

면접 포인트

  • 동기 이터레이터(IEnumerator)와 비동기 이터레이터(ValueTask MoveNextAsync)의 차이와 ValueTask 채택 이유(할당 절감).
  • [EnumeratorCancellation] 이 토큰을 열거 시작 시점에 주입하는 메커니즘.
  • await foreach 의 결정적 DisposeAsync(break/예외 포함)와 스트리밍 자원 정리.