Task는 진행 중인 작업을 나타내는 객체입니다. 스레드 풀 스레드에서 실행 중일 수도, 타이머를 기다리는 중일 수도, 네트워크 응답을 기다리는 중일 수도 있습니다. 어느 쪽이든 끝났는지 묻고, 결과를 얻고, 기다리고, 작업을 더 붙일 수 있습니다. async와 await는 태스크 위의 언어 문법입니다. 이 페이지는 Task API 자체와 스레드를 다룹니다.
Task.Run: 스레드 풀에서의 작업
Task.Run은 람다를 받아 스레드 풀에 큐로 넣습니다. 스레드 풀은 .NET이 작업마다 새 스레드를 만들지 않도록 유지하는 스레드들의 집합입니다. Task를, 람다가 값을 반환하면 Task<T>를 반환합니다.
출력:
Started, completed yet: False
Primes up to 200,000: 17984
Status: RanToCompletion
Result는 태스크가 끝날 때까지 호출하는 스레드를 막습니다. 콘솔 프로그램의 Main에서는 괜찮지만, 비동기 코드 안에서는 막지 않고 기다리는 int primes = await task;라고 씁니다. 결과가 없는 Task의 task.Wait()도 마찬가지입니다.
Task.Run은 코어를 계속 바쁘게 하는 계산 같은 CPU 중심 작업을 위한 것입니다. 파일 읽기나 웹 API 호출 같은 I/O에는 라이브러리가 이미 제공하는 비동기 메서드(File.ReadAllTextAsync, HttpClient.GetStringAsync)를 쓰고 바로 await하세요. I/O가 끝나기를 기다리는 동안 await된 비동기 호출은 스레드를 하나도 붙잡지 않으므로, 이를 Task.Run으로 감싸도 얻는 것이 없고 스레드 풀을 한 번 더 거칠 뿐입니다. 풀 스레드를 실제로 차지하는 것은 File.ReadAllText 같은 동기 블로킹 호출을 Task.Run으로 감쌀 때입니다. 그 스레드는 디스크나 네트워크가 응답할 때까지 아무 일도 하지 않고 기다립니다.
여러 태스크를 한 번에: WhenAll
독립적인 작업을 태스크로 나누고, 모두 시작한 뒤, Task.WhenAll로 결과를 결합하세요. 코어가 여러 개인 컴퓨터에서는 조각들이 병렬로 실행됩니다.
출력:
Parts: 14999995, 14999999, 15000003, 15000000
Total: 59999997
각 조각은 자기 범위에서 일하고 자기 합계를 반환하므로, 태스크들은 아무것도 공유하지 않고 잠금도 필요 없습니다. WhenAll은 스레드가 어떤 순서로 끝났든 태스크 순서대로 결과를 반환합니다. 태스크가 공유 상태를 갱신해야 한다면 lock을 참고하세요.
WhenAny와 시간 제한
Task.WhenAny는 태스크 중 첫 번째가 완료되면 완료되고 그 태스크를 반환합니다. 실제 작업을 Task.Delay와 경주시키면 시간 제한이 됩니다:
출력:
fast mirror answered after 100 ms
Timed out after 300 ms
WhenAny는 결과가 아니라 이긴 태스크 자체를 반환하므로, 값(또는 실패했다면 예외)을 얻으려면 다시 await합니다. 진 태스크는 계속 실행된다는 점에 주의하세요. WhenAny는 이들을 멈추지 않습니다. 느린 작업을 실제로 멈추려면 아래처럼 토큰으로 취소하세요. .NET 6 이상에서는 await work.WaitAsync(TimeSpan.FromMilliseconds(300))이 같은 시간 제한을 호출 한 번으로 표현합니다.
ContinueWith를 이용한 연속 작업
await 이전에는 태스크가 끝난 뒤 코드를 실행하는 방법이 ContinueWith였습니다. 예전 코드에서 여전히 보게 됩니다:
출력:
Report total: 42
각 연속 작업은 앞의 태스크를 받아 그것이 완료되면 실행됩니다. await는 훨씬 적은 형식으로 같은 일을 하고, 예외를 자연스럽게 처리하며, 올바른 컨텍스트로 돌아오므로 새 코드에서는 이를 우선 쓰세요. ContinueWith는 앞의 태스크가 실패하거나 취소되었을 때도 실행되므로, 예전 코드는 실패한 태스크에서 .Result를 호출하지 않도록 t.IsFaulted를 확인하거나 TaskContinuationOptions를 넘겨야 합니다.
CancellationToken을 이용한 취소
태스크는 협력적으로 취소됩니다. CancellationTokenSource가 토큰을 발급하고, 작업을 하는 코드가 토큰을 확인해 스스로 멈춥니다. 강제로 중단되는 것은 없습니다.
출력:
Cancelled before finishing all 10 orders
Processed 3
토큰은 모든 계층을 거쳐 아래로 전달되고, Task.Delay, HttpClient, 데이터베이스 호출 같은 비동기 API도 토큰을 받으므로 취소가 기다리는 도중의 이들을 멈춥니다. OperationCanceledException을 잡으세요. 일부 API가 던지는 TaskCanceledException은 이것에서 파생됩니다. CancellationTokenSource는 IDisposable이므로 using을 씁니다.
Thread와 Task
Thread는 직접 만드는 운영체제 스레드입니다. 더 무겁습니다. 스레드마다 자기 스택(Windows에서 기본 1MB)을 갖고, 시작하는 데 측정 가능한 시간이 걸리며, 결과나 예외 처리를 주지 않습니다.
출력:
Thread started
Result: 42
Thread | Task | |
|---|---|---|
| 실행 위치 | 새 전용 OS 스레드 | 풀 스레드(I/O라면 스레드 없음) |
| 시작 비용 | 높음 | 낮음 |
| 결과 값 | 없음, 변수를 공유 | Task<T>.Result / await |
| 예외 | 처리되지 않으면 프로세스가 죽음 | 태스크에 저장되어 await에서 다시 던져짐 |
| 취소 | 수동 플래그 | CancellationToken 내장 |
| 기다리기 | Join() | await, Wait(), WhenAll |
풀이 주지 않는 것이 필요할 때만 Thread를 쓰세요. 풀 스레드를 빌리면 안 되는 오래 도는 루프, 특정 우선순위, COM을 위한 단일 스레드 아파트 같은 것입니다. 오래 실행되는 태스크에는 Task.Factory.StartNew(work, TaskCreationOptions.LongRunning)이 태스크 API를 유지하면서 전용 스레드를 얻습니다.
Parallel.For와 Parallel.ForEach
"모든 코어를 써서 모든 항목에 이것을 하라"에는 직접 태스크를 만드는 것보다 System.Threading.Tasks.Parallel이 간단합니다. 범위를 스레드들에 나누고 모든 반복이 끝나면 반환합니다:
출력 예:
image 4 on thread 7
image 3 on thread 6
image 5 on thread 8
image 0 on thread 1
image 1 on thread 4
image 2 on thread 5
Total pixels: 385425
이미지별 줄은 스레드가 실행한 순서대로 나오고 스레드 ID는 실행할 때마다 다르며, 총합만 고정됩니다. 이것이 병렬 루프의 본성입니다. 반복은 서로의 순서에 의존하면 안 되고, 공유 상태에는 동기화(여기서는 Interlocked.Add)가 필요합니다. Parallel.ForEach는 어떤 컬렉션에 대해서든 같은 일을 하고, .NET 6은 비동기 본문을 위한 Parallel.ForEachAsync를 추가했습니다.
흔한 실수
- 비동기 코드 안의
.Result나.Wait(). 스레드를 막고 UI 앱에서는 교착 상태가 될 수 있습니다. 대신await하세요. .Result주위에서 원래 예외 타입을 잡기. 실패는AggregateException으로 감싸져 도착합니다.await가 이를 풀어 줍니다.- 실행하고 잊기. await하거나 저장하지 않은
Task.Run(...)은 예외를 잃습니다. - I/O를
Task.Run으로 감싸기. 비동기 메서드를 감싸면 스레드 풀을 한 번 더 거칠 뿐이고,File.ReadAllText같은 블로킹 메서드를 감싸면 기다리는 내내 풀 스레드 하나를 차지합니다. 비동기 I/O 메서드를 바로 await하세요. - 토큰 무시하기. 작업이 확인하지 않는 한
CancellationToken을 넘겨도 아무 일도 일어나지 않습니다. - 병렬 코드에서 동기화되지 않은 공유 상태.
Interlocked,lock을 쓰거나 태스크마다 자기 데이터를 주세요.
자주 묻는 질문
C#에서 Task란 무엇인가요?
Task는 미래에 끝날 수 있는 작업을 나타냅니다. 스레드 풀에서 코드를 실행하는 것, 타이머, I/O 요청 같은 것입니다. await할 수 있고, 완료되었는지, 실패했는지, 취소되었는지 알려 주며, Task<T>는 결과도 담습니다. 태스크는 async/await 밑에 있는 구성 요소입니다.
C#에서 Task와 Thread의 차이는 무엇인가요?
Thread는 직접 만들고 관리하는 운영체제 스레드로, 자기 스택을 가지며 시작 비용이 크고 값을 반환하지 않습니다. Task는 작업을 설명하며, Task.Run은 이를 재사용 가능한 스레드의 공유 풀에 예약합니다. 결과를 반환하고, 예외를 전파하고, 취소되고, await될 수 있습니다. 특별한 설정을 가진 전용의 오래 사는 스레드가 필요한 게 아니라면 태스크를 쓰세요.
Task.Run은 언제 써야 하나요?
현재 스레드에서 빼내고 싶은 CPU 중심 작업에 씁니다. 이미지 처리, 큰 계산, 메모리 안의 큰 파일 파싱 같은 것이며, 특히 UI의 반응성을 유지할 때 그렇습니다. I/O는 Task.Run으로 감싸지 마세요. ReadAllTextAsync 같은 비동기 I/O 메서드는 이미 기다리는 동안 스레드를 풀어 줍니다.
C#에서 Task는 어떻게 취소하나요?
CancellationTokenSource를 만들고, 그 Token을 작업에 넘기고, 소스에서 Cancel()(또는 CancelAfter(timeout))을 호출합니다. 작업이 협력해야 합니다. 루프에서 token.IsCancellationRequested를 확인하거나 token.ThrowIfCancellationRequested()를 호출하고, Task.Delay 같은 비동기 API에 토큰을 넘기세요. 취소된 태스크를 await하면 OperationCanceledException이 발생합니다.
Task.WhenAll과 Task.WhenAny의 차이는 무엇인가요?
Task.WhenAll은 모든 태스크가 완료되면 완료되고 모든 결과를 줍니다. Task.WhenAny는 첫 태스크가 완료되자마자 완료되고 그 태스크를 반환합니다. WhenAny는 시간 제한을 추가하는 흔한 방법으로, 작업을 Task.Delay와 경주시킵니다.
Task.Result가 AggregateException을 던지는 이유는 무엇인가요?
태스크는 일반적으로 예외를 여러 개 담을 수 있으므로 .Result와 .Wait()는 모든 실패를 AggregateException으로 감쌉니다. await는 이를 풀어 첫 내부 예외를 바로 다시 던지며, await를 선호하는 이유 중 하나입니다. 꼭 막고 기다려야 한다면 .GetAwaiter().GetResult()도 원래 예외를 던집니다.