← 문제로

53. Task.Run vs Task.Factory.StartNew (언래핑·스케줄러·async 델리게이트 함정)

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

해설 — Task.Run vs Task.Factory.StartNew (언래핑·스케줄러·async 델리게이트 함정)

난이도: 상

요약

Task.Factory.StartNew(async () => ...)에 async 람다를 넘기면 반환 타입이 **Task<Task>**가 된다. 바깥 작업(outer)은 "async 람다를 시작"하기만 하면 완료로 간주되므로, await DoWorkAsync()의 내부 비동기 작업이 끝나기 전에 outer가 완료된다. 그래서 RunViaStartNew가 돌려주는 Task를 기다려도 실제 작업 완료를 기다리지 못한다(불의 후속 실행/조기 완료). 반대로 Task.Run(async () => ...)은 내부적으로 Unwrap을 수행해 내부 Task가 끝나야 완료되는 올바른 Task를 돌려준다. 또한 StartNew의 기본 스케줄러는 TaskScheduler.Current(호출 문맥의 스케줄러)라, UI/특정 스케줄러 문맥에서는 스레드풀이 아닌 곳에서 실행될 수 있다. Task.Run은 항상 **TaskScheduler.Default(스레드풀)**를 쓴다.

핵심 개념

  • Task.Run(Func<Task>) ≡ 대략 Task.Factory.StartNew(func, CancellationToken.None, TaskCreationOptions.DenyChildAttach, TaskScheduler.Default).Unwrap().
  • 차이의 세 축:
    1. 언래핑(Unwrap): Task.Run은 Func<Task> 오버로드에서 자동 언래핑. StartNew는 안 함 → Task<Task>.
    2. 스케줄러: Task.Run = Default(스레드풀 고정). StartNew = Current(문맥 의존, 함정).
    3. 옵션: Task.Run = DenyChildAttach(부모-자식 첨부 차단). StartNew 기본은 첨부 허용이라 AttachedToParent 자식과 얽힐 수 있음.

단계별 내부 동작

  • RunViaRun():
    • Task.Run이 Func<Task> 오버로드로 바인딩 → 스레드풀에서 람다 실행 → 람다가 DoWorkAsync() Task 반환 → Unwrap이 그 내부 Task 완료를 outer에 전파.
    • 반환 Task를 await 하면 "work finished" 이후에 완료. 올바름.
  • RunViaStartNew():
    • StartNew가 Func<Task>를 그냥 Func<TResult>(TResult=Task)로 취급 → 반환 Task<Task>.
    • outer는 람다 본문의 동기 부분(첫 await까지)만 실행하면 완료로 표시. 내부 DoWorkAsync는 아직 진행 중.
    • RunViaStartNew()의 정적 반환 타입이 Task라 Task<Task>가 Task로 업캐스트되어, 호출자는 outer만 기다리고 내부 작업 완료를 놓친다. WaitAll 후에도 "work finished"가 아직 안 찍혔을 수 있다.
    • 고치려면 .Unwrap() 필요: return Task.Factory.StartNew(...).Unwrap();

흔한 오해·함정

  • "StartNew가 Task.Run의 원조/동급" — async 델리게이트에선 다르다. Task<Task> 조기 완료가 대표적 버그(백그라운드 저장/브로드캐스트가 "끝났다"고 오인).
  • 스케줄러 함정: 라이브러리 코드가 StartNew를 쓰면 호출자의 TaskScheduler.Current에서 돌아 예기치 않은 스레드(또는 직렬 스케줄러)에서 실행. CPU 바운드 오프로드 의도가 깨진다.
  • 취소/옵션: StartNew로 LongRunning(전용 스레드) 같은 옵션이 필요할 때만 명시적으로 쓰고, 그 외엔 Task.Run 권장.

안전한 대안

  • CPU 바운드 오프로드/async 델리게이트: Task.Run 사용.
  • StartNew가 꼭 필요하면(예: TaskCreationOptions.LongRunning) 반드시 .Unwrap() 하고 스케줄러를 명시(TaskScheduler.Default).
  • 분석기(CA2008 등)로 스케줄러 미지정 StartNew를 잡는다.

면접 포인트

  • Task.Run이 내부적으로 하는 일(Unwrap + Default 스케줄러 + DenyChildAttach).
  • StartNew(async...)가 Task<Task>를 만들어 조기 완료되는 메커니즘과 .Unwrap().
  • 라이브러리에서 StartNew의 TaskScheduler.Current 기본이 위험한 이유.