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().- 차이의 세 축:
- 언래핑(Unwrap):
Task.Run은Func<Task>오버로드에서 자동 언래핑.StartNew는 안 함 →Task<Task>. - 스케줄러:
Task.Run=Default(스레드풀 고정).StartNew=Current(문맥 의존, 함정). - 옵션:
Task.Run=DenyChildAttach(부모-자식 첨부 차단).StartNew기본은 첨부 허용이라AttachedToParent자식과 얽힐 수 있음.
- 언래핑(Unwrap):
단계별 내부 동작
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기본이 위험한 이유.