Menu

C# Task: Task.Run, WhenAll, WhenAny, Cancellation and Threads

Task is the unit of asynchronous and parallel work in .NET. Learn Task.Run for CPU-bound work, reading results, combining tasks with WhenAll and WhenAny, continuations, cancelling with CancellationToken, how a Task differs from a Thread, and when Parallel.For fits better.

This page includes runnable editors - edit, run, and see output instantly.

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
ThreadTask
Runs onA new dedicated OS threadA pool thread (or no thread, for I/O)
Cost to startHighLow
Result valueNo, share a variableTask<T>.Result / await
ExceptionsUnhandled ones crash the processStored in the task, rethrown on await
CancellationManual flagsCancellationToken built in
Wait for itJoin()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

  • .Result or .Wait() inside async code. Blocks a thread and can deadlock in UI apps; await instead.
  • Catching the original exception type around .Result. Failures arrive wrapped in AggregateException; await unwraps them.
  • Fire and forget. Task.Run(...) without awaiting or storing the task loses its exceptions.
  • Task.Run around I/O. Around an async method it only adds a thread pool hop; around a blocking method such as File.ReadAllText it parks a pool thread for the whole wait. Await the async I/O method directly.
  • Ignoring the token. Passing a CancellationToken does 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.

Coddy programming languages illustration

Learn to code with Coddy

GET STARTED