Task as a promise of completion, delegate tasks versus promise tasks, TaskStatus, and the core combinators in .NET 10.
A Task is an object that represents an operation that will complete in the future, with a status, an optional exception and the ability to attach continuations. It is not a thread. Some tasks run code on a pool thread (Task.Run, called delegate tasks); many represent work with no thread at all, such as a pending network read, a timer (Task.Delay) or a TaskCompletionSource (promise tasks). You compose tasks with await, Task.WhenAll, Task.WhenAny and WaitAsync, and avoid blocking on them.
"A
Taskis an object representing an operation that will complete in the future. It carries a status, a result forTask<T>, any exception or cancellation, and a list of continuations to run when it completes. It is not a thread. Some tasks do run code on a pool thread:Task.Runcreates a delegate task. But most tasks in a server are promise tasks: a pending socket read, aTask.Delaytimer, anasyncmethod waiting on I/O, or aTaskCompletionSource. While those are pending, no thread is involved at all. That separation of what is happening from which thread runs it is what lets a few dozen threads serve thousands of concurrent requests. I compose tasks withawait,Task.WhenAll,WhenAny,WhenEachandWaitAsync, and I don't block on them with.Resultor.Wait()."
Thread |
Task |
|
|---|---|---|
| What it is | An OS thread with a stack | A representation of a future completion |
| Uses a thread? | Is one | Only delegate tasks, and only while running |
| Result / exception | Manual plumbing | Built in (Result, Exception, await) |
| Composition | Join, events |
await, WhenAll, WhenAny, WhenEach, ContinueWith |
| Cancellation | Manual | CancellationToken integration, Canceled state |
| Cost | ~MB stack reservation, kernel object | A small heap object |
| Kind | Created by | Thread while pending? | Example |
|---|---|---|---|
| Delegate task | Task.Run, Task.Factory.StartNew |
Yes, while the delegate runs | Task.Run(() => ResizeImage(bytes)) |
| Promise task | async I/O, Task.Delay, async methods, TaskCompletionSource<T> |
No | await httpClient.GetAsync(url) |
TaskStatus |
Meaning |
|---|---|
Created |
Cold task from new Task(...), not started (avoid) |
WaitingForActivation |
Promise task / async method not finished |
WaitingToRun → Running |
Delegate task queued, then executing |
WaitingForChildrenToComplete |
Has attached children (rare; Task.Run denies attachment) |
RanToCompletion |
Success |
Faulted |
Exception(s) in Task.Exception (an AggregateException) |
Canceled |
Cancelled via token / OperationCanceledException |
IsCompleted is true for the last three; IsCompletedSuccessfully only for RanToCompletion.
| Need | API |
|---|---|
| Wait for one asynchronously | await task |
| Run independent work concurrently | Start all, then await Task.WhenAll(...) |
| First to finish | Task.WhenAny(...) |
| Process results in completion order | await foreach (var t in Task.WhenEach(tasks)) (.NET 9+) |
| Stop waiting after a timeout / on cancellation | task.WaitAsync(timeout, token) (.NET 6+) |
| Already-completed tasks | Task.CompletedTask, Task.FromResult, Task.FromException, Task.FromCanceled |
| Bridge callbacks/events | TaskCompletionSource<T> with RunContinuationsAsynchronously |
| Avoid | Because | Use |
|---|---|---|
new Task(...).Start() |
Cold tasks are easy to forget; uses TaskScheduler.Current |
Task.Run |
.Result / .Wait() on incomplete tasks |
Blocks a thread; can deadlock under a SynchronizationContext |
await |
Task.Factory.StartNew(async () => ...) |
Returns Task<Task>; outer completes at the first await |
Task.Run(async () => ...) (unwraps) |
| Awaiting independent calls one by one | Latency = sum | WhenAll |
Our [dashboard endpoint] aggregated data from three internal services and had a p95 of [900 ms]. The distributed trace showed the three HTTP spans strictly back to back, with no data dependency between them: the code awaited each call before starting the next. I started all three tasks first, then awaited Task.WhenAll(...) with a shared WaitAsync(TimeSpan.FromSeconds(2), ct) timeout. p95 dropped to [about 400 ms], the slowest dependency, with no extra threads, because the tasks were I/O promises. We added a review checklist item for sequential independent awaits.
Checks the core mental model of tasks.
Green flags
WhenAll for independent work.IsCompletedSuccessfully.Red flags
TaskConceptually:
Task
├─ m_stateFlags (status bits: started, completed, faulted, canceled, ...)
├─ m_continuationObject (null | single continuation | list of continuations)
├─ m_action / m_stateObject (delegate tasks only)
├─ m_contingentProperties (lazily: exception holder, cancellation registration, parent, ...)
└─ (Task<T>) m_result
The common case is lean: a completed async method with no exception doesn't allocate the contingent properties. Completing a task atomically sets the state and then runs or schedules continuations.
var tcs = new TaskCompletionSource<string>(TaskCreationOptions.RunContinuationsAsynchronously);
socket.OnMessage += msg => tcs.TrySetResult(msg); // completed by an event, no thread waiting
string first = await tcs.Task;
RunContinuationsAsynchronously makes the awaiting code run on the pool instead of synchronously inside the TrySetResult call, which avoids surprising re-entrancy and deadlocks in the completing code.
Task<int> a = GetAAsync(); // starts now
Task<int> b = GetBAsync(); // starts now
int[] r = await Task.WhenAll(a, b); // observes both
await never starts anything; it only observes. Tasks returned by methods are already running. That's why the order of starting vs awaiting decides whether work is sequential or concurrent.
WhenAll exception semanticsAggregateException.await Task.WhenAll(...) rethrows only the first; inspect the WhenAll task's Exception.InnerExceptions (or each task) to see all.WhenEach/WhenAny if you want to stop early.WhenEach (.NET 9+)Task<Quote>[] quotes = providers.Select(p => p.GetQuoteAsync(ct)).ToArray();
await foreach (Task<Quote> done in Task.WhenEach(quotes))
{
if (done.IsCompletedSuccessfully) Show(done.Result); // safe: it's complete
}
This replaces the old while (tasks.Count > 0) { var t = await Task.WhenAny(tasks); tasks.Remove(t); } pattern, which was O(n²).
WaitAsync vs cancellationtask.WaitAsync(TimeSpan.FromSeconds(2)) stops waiting; the underlying operation keeps running. To actually stop the work, pass a CancellationToken into the operation itself.
| Version | Change |
|---|---|
| .NET Framework 4.0 | TPL: Task, Task<T> |
| C# 5 / .NET 4.5 | async/await, Task.Run, Task.Delay, WhenAll, WhenAny |
| .NET Core 2.0 | IsCompletedSuccessfully |
| .NET 6 | WaitAsync |
| .NET 9 | Task.WhenEach; params ReadOnlySpan<Task> overloads for WhenAll/WhenAny |
| .NET 10 | Same model |
ThreadPool.ThreadCount during 1,000 concurrent Task.Delay(1000) calls: it stays small.await vs the WhenAll task's Exception.InnerExceptions.WhenAny loop with Task.WhenEach and print results in completion order.Task.Factory.StartNew(async () => { await Task.Delay(1000); }) and observe it "completes" immediately; fix it with Task.Run or .Unwrap().TaskCompletionSource without RunContinuationsAsynchronously, complete it while holding a lock, and observe the continuation running inside the lock.using System.Diagnostics;
var sw = Stopwatch.StartNew();
int a = await FetchAsync("orders", 300);
int b = await FetchAsync("stock", 400);
int c = await FetchAsync("prices", 200);
Console.WriteLine($"Sequential: {sw.ElapsedMilliseconds} ms"); // about 900
sw.Restart();
Task<int> orders = FetchAsync("orders", 300); // hot: already running
Task<int> stock = FetchAsync("stock", 400);
Task<int> prices = FetchAsync("prices", 200);
int[] all = await Task.WhenAll(orders, stock, prices);
Console.WriteLine($"Concurrent: {sw.ElapsedMilliseconds} ms, total {all.Sum()}"); // about 400
// A promise task with no thread behind it.
var tcs = new TaskCompletionSource<string>(TaskCreationOptions.RunContinuationsAsynchronously);
_ = Task.Delay(100).ContinueWith(_ => tcs.SetResult("signalled"), TaskScheduler.Default);
Console.WriteLine(await tcs.Task);
static async Task<int> FetchAsync(string name, int ms)
{
await Task.Delay(ms); // stands in for an HTTP call
return ms;
}
What happens: "each task runs on its own green thread". Why it is wrong: most tasks have no thread; they're promises completed by I/O or timers. Do instead: describe a task as a representation of a future result.
What happens: await A(); await B(); await C(); with no dependencies.
Why it is wrong: latency becomes the sum.
Do instead: start all, then await Task.WhenAll(...).
new Task(...).Start()What happens: cold tasks created and started manually.
Why it is wrong: easy to forget Start; uses TaskScheduler.Current, which may not be the pool.
Do instead: Task.Run for CPU work, async methods for I/O.
.Result or .Wait()What happens: sync code calls into async code and blocks.
Why it is wrong: holds a pool thread, risks starvation and deadlocks.
Do instead: await all the way up.
Task.Factory.StartNew with an async lambdaWhat happens: await Task.Factory.StartNew(async () => ...) "finishes" instantly.
Why it is wrong: it returns Task<Task>; you awaited only the outer task.
Do instead: Task.Run(async () => ...) or .Unwrap().
WaitAsync cancels the workWhat happens: a timeout is added with WaitAsync and the operation is assumed stopped.
Why it is wrong: only the wait stops; the work continues and may still have side effects.
Do instead: pass a CancellationToken into the operation.
WhenAllWhat happens: catch (Exception ex) after await Task.WhenAll logs only one failure.
Why it is wrong: await rethrows the first exception only.
Do instead: inspect the WhenAll task's Exception.InnerExceptions or each task.
Task.RunWhat happens: await Task.Run(() => httpClient.GetAsync(url)).
Why it is wrong: an extra pool hop for nothing.
Do instead: await httpClient.GetAsync(url).
What happens: Task.WhenAll(items.Select(CallApiAsync)) over 50,000 items.
Why it is wrong: unbounded concurrency overwhelms downstream services and sockets.
Do instead: Parallel.ForEachAsync with MaxDegreeOfParallelism, or a SemaphoreSlim.
What happens: task.Dispose() calls sprinkled "for cleanliness".
Why it is wrong: unnecessary in almost all code and throws if the task isn't complete.
Do instead: don't dispose tasks.
The key insight is that tasks separate "what is happening" from "which thread does it". That is what lets a server run thousands of operations on a few threads, and it is why blocking on a task throws that advantage away.
| Need | API |
|---|---|
| CPU work off the current thread | Task.Run |
| Wait for several | Task.WhenAll |
| First to finish or timeout | Task.WhenAny or WaitAsync |
| Process results as they finish | Task.WhenEach (.NET 9+) |
| Bridge a callback API | TaskCompletionSource<T> with RunContinuationsAsynchronously |
async tells the compiler to rewrite the method into a state machine and lets you use await inside it; it does not create a thread. At await x, the code calls x.GetAwaiter() and checks IsCompleted. If it is already complete, execution continues synchronously. If not, the method registers the rest of itself as a continuation, captures the current SynchronizationContext (or non-default TaskScheduler), and returns an incomplete task to its caller, releasing the thread. When the awaited work finishes, the continuation resumes, on the captured context if there was one, otherwise on the ThreadPool. Exceptions and results flow through the returned task.
Task<T> derives from Task and adds a Result of type T. The safe way to get it is await, which unwraps the value and rethrows the original exception. .Result blocks the calling thread and wraps failures in AggregateException; GetAwaiter().GetResult() also blocks but throws the original exception. Because Task<T> is a class, it is not covariant: a Task<string> is not a Task<object>. Return Task.FromResult for synchronous results and use TaskCompletionSource<T> to turn callbacks into a Task<T>.
A System.Threading.Thread is a managed wrapper over one dedicated operating-system thread with its own stack and scheduling. Creating one is expensive (a stack reservation, a kernel object, scheduler work), so normal application code uses the ThreadPool through Task.Run, async/await or framework hosts. I only create a Thread directly for long-lived, dedicated work: a message pump, a blocking legacy loop, a thread that needs a specific apartment state, name, priority or stack size, or work that must never compete with pool threads.
How to tell it (STAR)
Our [dashboard] was slow and the trace showed three independent calls running back to back. I started all three tasks and awaited them together, which brought p95 from [900 ms] to about [400 ms], the slowest dependency. It needed no extra threads because the tasks were I/O promises.
Last updated 31 Oct 2026.