A Task is an object that stands for work in progress. It might be running on a thread pool thread, waiting on a timer, or waiting for a network reply; either way you can ask whether it is done, get its result, wait for it, or attach more work to it. async and await are the language syntax over tasks. This page covers the Task API itself, and threads.
Task.Run: work on the thread pool
Task.Run takes a lambda and queues it on the thread pool, a set of threads .NET keeps around so it does not create a new one for every job. It returns a Task, or a Task<T> if the lambda returns a value.
Output:
Started, completed yet: False
Primes up to 200,000: 17984
Status: RanToCompletion
Result blocks the calling thread until the task is done. That is acceptable in a console program's Main, but inside async code you would write int primes = await task;, which waits without blocking. The same applies to task.Wait() for a Task with no result.
Task.Run is for CPU-bound work: calculations that keep a core busy. For I/O, such as reading a file or calling a web API, use the async method the library already provides (File.ReadAllTextAsync, HttpClient.GetStringAsync) and await it directly. While the I/O is pending, an awaited async call holds no thread at all, so wrapping it in Task.Run gains nothing and only adds a hop through the thread pool. The case that does tie up a pool thread is Task.Run around a synchronous blocking call such as File.ReadAllText: that thread sits idle until the disk or network answers.
Several tasks at once: WhenAll
Split independent work into tasks, start them all, and combine the results with Task.WhenAll. On a machine with several cores the pieces run in parallel.
Output:
Parts: 14999995, 14999999, 15000003, 15000000
Total: 59999997
Each chunk works on its own range and returns its own sum, so the tasks share nothing and need no locking. WhenAll returns the results in the order of the tasks, however the threads happened to finish. When tasks do need to update shared state, see lock.
WhenAny and timeouts
Task.WhenAny completes when the first of its tasks does and returns that task. Racing the real work against Task.Delay gives a timeout:
Output:
fast mirror answered after 100 ms
Timed out after 300 ms
WhenAny returns the winning task itself, not its result, so you await it again to get the value (or the exception, if it failed). Note that the losing tasks keep running; WhenAny does not stop them. To actually stop the slow work, cancel it with a token as shown below. In .NET 6 and later, await work.WaitAsync(TimeSpan.FromMilliseconds(300)) expresses the same timeout in one call.
Continuations with ContinueWith
Before await, the way to run code after a task finished was ContinueWith. You will still see it in older code:
Output:
Report total: 42
Each continuation receives the previous task and runs when it completes. await does the same thing with far less ceremony, handles exceptions naturally, and returns to the right context, so prefer it in new code. ContinueWith runs even when the previous task failed or was cancelled, so older code has to inspect t.IsFaulted or pass TaskContinuationOptions to avoid calling .Result on a failed task.
Cancellation with CancellationToken
Tasks are cancelled cooperatively. A CancellationTokenSource issues a token; the code doing the work checks the token and stops itself. Nothing is forcibly aborted.
Output:
Cancelled before finishing all 10 orders
Processed 3
The token is passed down through every layer, and async APIs such as Task.Delay, HttpClient and database calls accept it too, so a cancellation stops them mid-wait. Catch OperationCanceledException; the TaskCanceledException that some APIs throw derives from it. CancellationTokenSource is IDisposable, hence the using.
Thread vs Task
A Thread is an operating system thread you create yourself. It is heavier: each has its own stack (1 MB by default on Windows), starting one takes measurable time, and it gives you no result or exception handling.
Output:
Thread started
Result: 42
Thread | Task | |
|---|---|---|
| Runs on | A new dedicated OS thread | A pool thread (or no thread, for I/O) |
| Cost to start | High | Low |
| Result value | No, share a variable | Task<T>.Result / await |
| Exceptions | Unhandled ones crash the process | Stored in the task, rethrown on await |
| Cancellation | Manual flags | CancellationToken built in |
| Wait for it | Join() | await, Wait(), WhenAll |
Reach for Thread only when you need something the pool does not give you: a long-running loop that should never borrow a pool thread, a specific priority, or a single-threaded apartment for COM. For long-running tasks, Task.Factory.StartNew(work, TaskCreationOptions.LongRunning) gets a dedicated thread while keeping the task API.
Parallel.For and Parallel.ForEach
For "do this to every item, using all cores", System.Threading.Tasks.Parallel is simpler than creating tasks by hand. It partitions the range across threads and returns when all iterations are done:
Example output:
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
The per-image lines come out in whatever order the threads ran them, and the thread IDs vary from run to run; only the total is fixed. That is the nature of parallel loops: iterations must not depend on each other's order, and any shared state needs synchronization (Interlocked.Add here). Parallel.ForEach does the same over any collection, and .NET 6 adds Parallel.ForEachAsync for async bodies.
Common mistakes
.Resultor.Wait()inside async code. Blocks a thread and can deadlock in UI apps;awaitinstead.- Catching the original exception type around
.Result. Failures arrive wrapped inAggregateException;awaitunwraps them. - Fire and forget.
Task.Run(...)without awaiting or storing the task loses its exceptions. Task.Runaround I/O. Around an async method it only adds a thread pool hop; around a blocking method such asFile.ReadAllTextit parks a pool thread for the whole wait. Await the async I/O method directly.- Ignoring the token. Passing a
CancellationTokendoes nothing unless the work checks it. - Unsynchronized shared state in parallel code. Use
Interlocked,lock, or give each task its own data.
Frequently Asked Questions
What is a Task in C#?
A Task represents an operation that may finish in the future: running code on the thread pool, a timer, an I/O request. It can be awaited, reports whether it completed, failed or was cancelled, and Task<T> also carries a result. Tasks are the building block under async/await.
What is the difference between a Task and a Thread in C#?
A Thread is an operating system thread you create and manage yourself, with its own stack; it is expensive to start and returns no value. A Task describes work, and Task.Run schedules it on a shared pool of reusable threads; it can return a result, propagate exceptions, be cancelled and be awaited. Use tasks unless you need a dedicated long-lived thread with special settings.
When should I use Task.Run?
For CPU-bound work you want off the current thread: image processing, a big calculation, parsing a large file in memory, especially to keep a UI responsive. Do not wrap I/O in Task.Run: async I/O methods such as ReadAllTextAsync already free the thread while waiting.
How do I cancel a Task in C#?
Create a CancellationTokenSource, pass its Token to the work, and call Cancel() (or CancelAfter(timeout)) on the source. The work must cooperate: check token.IsCancellationRequested or call token.ThrowIfCancellationRequested() in its loop, and pass the token on to async APIs like Task.Delay. Awaiting a cancelled task throws OperationCanceledException.
What is the difference between Task.WhenAll and Task.WhenAny?
Task.WhenAll completes when every task has completed and gives you all the results. Task.WhenAny completes as soon as the first task completes and returns that task. WhenAny is the usual way to add a timeout: race the work against Task.Delay.
Why does Task.Result throw AggregateException?
.Result and .Wait() wrap any failure in an AggregateException, because a task can in general hold several exceptions. await unwraps it and rethrows the first inner exception directly, which is one reason to prefer await. If you must block, .GetAwaiter().GetResult() also throws the original exception.