← 문제로

19. Task 조합의 예외·취소 의미론(WhenAll/await rethrow/CancellationToken/미관측 예외)

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

해설 — Task 조합의 예외·취소 의미론(WhenAll/await rethrow/CancellationToken/미관측 예외)

난이도: 상

요약

await 로 faulted Task 를 기다리면 AggregateException 이 아니라 그 안의 "첫 예외" 하나만 다시 던져진다. Task.WhenAll 은 모든 예외를 Task.Exception(AggregateException)에 집계하되, await 지점에선 첫 것만 rethrow. 취소는 토큰이 일치하는 OperationCanceledException 이면 Task 가 Faulted 가 아니라 Canceled 로 끝난다. WhenAny 의 진 쪽 예외와 async void 예외는 미관측되기 쉬워 위험하다.

예측 출력

caught InvalidOperationException: A
all.IsFaulted=True inner=2
work.IsCanceled=True

핵심 개념 · 단계별

줄1~2: WhenAll 의 예외 집계 vs await 의 rethrow

  • t1("A", 10ms), t2("B", 30ms) 둘 다 InvalidOperationException 을 던진다.
  • Task.WhenAll(t1,t2) 의 결과 Task 는 두 예외를 모두 담은 AggregateExceptionall.Exception 에 갖는다(순서는 입력 Task 순서 = A, B).
  • 그러나 await all; 은 편의상 AggregateException 의 첫 InnerException 하나만 다시 던진다 → catch (Exception ex)InvalidOperationException, 메시지 "A". → 줄1 = caught InvalidOperationException: A.
  • Task 객체는 여전히 faulted 이고 두 예외를 보존 → all.IsFaulted=True, all.Exception.InnerExceptions.Count=2. → 줄2.
  • 둘 다 처리하려면: await 로 잡지 말고 all.Exception 을 직접 순회하거나, try { await all; } catch { foreach (var e in all.Exception.InnerExceptions) ... } 로 집계 예외를 열어본다.

줄3: 협조적 취소 → Canceled 상태

  • Task.Run(..., cts.Token) 에 토큰을 넘겼고, 본문이 cts.Token.ThrowIfCancellationRequested()같은 토큰OperationCanceledException 을 던진다.
  • TPL 규칙: 던져진 OCE 의 토큰이 Task 에 연결된 토큰과 일치하면 그 Task 는 Canceled(예외적 종료가 아님)로 전이. → work.IsCanceled=True. → 줄3.
  • 만약 토큰이 불일치하거나 아무 토큰도 안 넘겼다면 같은 OCE 라도 Faulted 로 처리됐을 것(이 구분이 자주 나오는 함정).
  • await work 는 취소 시 TaskCanceledException(OCE 파생)을 던지므로 catch (OperationCanceledException) 로 잡힌다.

흔한 오해·함정

  • await 는 첫 예외만 던진다: 병렬 작업의 "모든 실패"를 봐야 하는데 await 만 하면 나머지를 놓친다. Task.Exception(AggregateException) 을 봐야 한다.
  • WhenAny 의 미관측 예외: await Task.WhenAny(a, b)먼저 끝난 하나만 돌려준다. 진 쪽이 나중에 예외로 끝나면 아무도 그 Task 를 await/관측하지 않아 UnobservedTaskException 이 된다(예전엔 파이널라이저에서 프로세스 종료까지). → 진 쪽도 나중에 반드시 await(또는 .Exception 관측)해야 한다.
  • async void: 반환 Task 가 없어 호출자가 await/try-catch 로 예외를 잡을 수 없다. 예외는 캡처된 SynchronizationContext(또는 스레드풀)로 올라가 앱을 죽인다. 이벤트 핸들러 외엔 async void 금지 — async Task 를 쓴다(csharp_internals/problem1 상태머신과 연결).
  • 취소는 협조적: 토큰을 넘겨도 본문이 ThrowIfCancellationRequested/토큰 인지 API 를 호출해야 실제로 멈춘다. Task.Delay(ms)(토큰 없이)나 CPU 루프는 취소를 인지하지 못한다 → Task.Delay(ms, token) 처럼 토큰 전파 필요.
  • fire-and-forget 금지: _ = SomethingAsync(); 는 예외를 삼킨다(session_network/problem29 아웃바운드 연결의 미관측 예외와 같은 뿌리).

면접 포인트

  • WhenAll 의 예외 집계와 await rethrow 규칙(첫 예외 하나) — 모든 실패를 보려면?
  • 토큰 일치 OCE 가 Faulted 가 아니라 Canceled 가 되는 조건, 협조적 취소의 의미.
  • WhenAny 미관측 예외·async void 예외 전파 — 왜 위험하고 어떻게 피하는가.