Task is a handle to an operation that may finish later. It records completion, cancellation, or failure and gives other code something to await or combine. The abstraction says nothing about whether a thread is currently executing the operation.
A CPU-bound task may run on a ThreadPool worker. A task representing asynchronous I/O normally spends most of its lifetime waiting for the operating system, with a thread needed only to initiate work and process completion. Keeping that distinction visible prevents needless Task.Run calls and misleading thread-count assumptions.
How It Works
Task is the read side of a future/promise pair. TaskCompletionSource<T> exposes the write side for adapting callbacks or events to a task-based contract. A task eventually reaches RanToCompletion, Faulted, or Canceled. Its earlier status depends on how it was created and scheduled.
await first asks whether the awaiter has completed. If so, execution may continue synchronously. Otherwise, the async state machine registers its continuation and returns control to the caller. The continuation later runs through the captured scheduling context when one applies, or through the default task infrastructure.
Core forms:
Task— represents an operation with no return value.Task<T>— represents an operation that produces a value of typeT.ValueTask/ValueTask<T>— value-type awaitables that can represent an immediate result, aTask, or anIValueTaskSource. They can avoid aTaskallocation in carefully measured APIs, but their consumption rules are stricter.
Creation and scheduling. Tasks returned by async methods and most framework APIs already represent work in progress. A task created with the Task constructor remains in Created until scheduled with Start, which is rarely useful in application code. Task.Factory.StartNew is lower-level and easy to misuse with asynchronous delegates:
- An
asynclambda can produce a nested task that requiresUnwrap. - The default scheduler is
TaskScheduler.Current, so behavior can depend on the caller’s context.
Task.Run is the normal way to queue CPU-bound work to the default scheduler. TaskCreationOptions.LongRunning is a scheduler hint for work that would otherwise monopolize a worker. The default scheduler typically responds with a non-pool thread, but custom schedulers may behave differently.
Already-completed tasks. Task.CompletedTask, Task.FromResult, Task.FromException, and Task.FromCanceled express a known outcome without scheduling work. The runtime may cache some completed instances, but that is an implementation optimization rather than a contract to build allocation claims around.
Example — Parallel Fan-Out
public async Task<IReadOnlyList<UserDto>> LoadUsersAsync(
IEnumerable<int> ids,
CancellationToken cancellationToken)
{
var tasks = ids.Select(id => _client.GetUserAsync(id, cancellationToken));
var users = await Task.WhenAll(tasks);
return users;
}Enumerating ids.Select(...) creates the request tasks, and Task.WhenAll returns a task that completes after all of them finish. WhenAll does not start dormant tasks itself. When the operations are independent and their work overlaps, elapsed time approaches the slowest operation rather than the sum. Downstream limits can change that result.
Failure Aggregation
The task returned by WhenAll waits for every input. If any input faults, the combined task is faulted and retains the collection of unwrapped input failures in its Exception. Awaiting it propagates one exception, so code that needs the full set must inspect the combined task or the individual tasks after completion.
The example below rebuilds an aggregate from the individual tasks. Its catch executes only after WhenAll has reached a terminal state, so every input can be inspected.
public async Task SyncAllAsync(CancellationToken cancellationToken)
{
Task a = _catalog.SyncAsync(cancellationToken);
Task b = _pricing.SyncAsync(cancellationToken);
Task c = _inventory.SyncAsync(cancellationToken);
try
{
await Task.WhenAll(a, b, c);
}
catch when (a.IsFaulted || b.IsFaulted || c.IsFaulted)
{
// Inspect all faults, not only the first observed one.
var failures = new[] { a, b, c }
.Where(t => t.IsFaulted)
.SelectMany(t => t.Exception!.Flatten().InnerExceptions)
.ToArray();
throw new AggregateException("Batch sync failed", failures);
}
}Composition Patterns
| Pattern | Use case |
|---|---|
Task.WhenAll(tasks) | Complete after every input. Preserve failures on the combined task |
Task.WhenAny(tasks) | Return the first completed task, whether successful, faulted, or canceled |
TaskCompletionSource<T> | Bridge callback/event APIs into task-based APIs |
Task.Run(action) | Offload CPU-bound work to a pool thread |
ValueTask<T> | Measured optimization when synchronous completion is common |
Processing Tasks as They Finish
Repeatedly calling WhenAny over a shrinking list adds coordination work and makes early-exit cleanup easy to forget. On .NET 9+, Task.WhenEach yields each task after it completes. If several tasks finish together, their exact enumeration order is unspecified:
await foreach (var finished in Task.WhenEach(tasks))
{
// handle each result/fault once available; ties have no defined order
Use(await finished);
}TaskScheduler decides where scheduled task work runs. Async continuations also interact with SynchronizationContext, so continuation placement is broader than TaskScheduler alone. For bounded concurrent fan-out over a collection, Parallel.ForEachAsync is often clearer than creating every task up front and gating each one with SemaphoreSlim.
Pitfalls
Unobserved task exceptions. A fault stays on its task until observed through await, Wait, Result, or Exception. If the task becomes collectible first, the runtime can raise TaskScheduler.UnobservedTaskException. Modern .NET does not terminate the process by default after that event. Relying on finalization for reporting is still nondeterministic.
// Exception is lost — the task is never awaited or observed
_ = SendEmailAsync(user);The fault-only continuation below observes the fault, but it is not a general fire-and-forget primitive. Application work should normally be awaited or handed to an owned background queue whose worker logs failures and participates in shutdown.
_ = SendEmailAsync(user).ContinueWith(
t => _logger.LogError(t.Exception, "Email send failed"),
TaskContinuationOptions.OnlyOnFaulted);ValueTask consumed more than once. A ValueTask backed by IValueTaskSource may reuse pooled state, so multiple awaits, concurrent awaits, or mixing Result with awaiting can break its contract. A value backed by a Task or immediate result is more permissive, but callers should not depend on knowing that representation.
var vt = GetCachedValueAsync();
var r1 = await vt; // OK
var r2 = await vt; // WRONG — may read from a recycled objectCall AsTask() once when the result must be cached, shared, or awaited more than once.
Task.Run for asynchronous I/O. Wrapping an already asynchronous API adds a ThreadPool scheduling hop. The worker normally returns after the I/O has been initiated, so it is inaccurate to say that it stays occupied for the whole wait, but the wrapper still adds no useful asynchrony.
// Pointless — GetStringAsync is already async
var result = await Task.Run(() => _http.GetStringAsync(url));Fix: await the async method directly.
Unbounded fan-out. Creating thousands of operations can exhaust connection limits, rate limits, memory, or downstream capacity. Pure asynchronous waiting does not consume one ThreadPool worker per task, but every completion and CPU segment still needs processing.
// Dangerous with large collections — no concurrency limit
var results = await Task.WhenAll(items.Select(i => ProcessAsync(i)));Bound concurrency according to the scarce resource. Parallel.ForEachAsync, a channel-backed worker set, or SemaphoreSlim can limit work before it reaches the dependency or ThreadPool.
Questions
Why is
Tasknot equivalent to a thread?A
Taskrepresents an operation that may finish later and records whether it completed, failed, or was canceled. A thread is a resource that executes code. A task for asynchronous I/O can remain incomplete while no managed thread is assigned to it; a thread is needed only when code starts the operation or resumes after completion.
When is
Task.Runappropriate in ASP.NET Core?
Task.Runcan move CPU-bound work to a thread-pool thread, but it does not add CPU capacity. Under load, those extra work items compete with request processing and can reduce throughput. It should not wrap I/O APIs that are already asynchronous. Long or expensive work usually belongs behind a bounded background queue or in a separate service instead of running inside the request.
Why can
Task.WhenAllreduce the elapsed time for independent async calls?If the operations are started before they are awaited, their waiting time can overlap.
Task.WhenAllthen completes when all of those tasks finish, so the elapsed time is closer to the slowest call than to the sum of every call. It only coordinates tasks that have already started; it does not make synchronous work parallel, and the fan-out must still respect connection and downstream limits.
When is
ValueTaskworth using instead ofTask?
ValueTaskis useful in a measured hot path where operations often complete synchronously and allocating a completedTaskis a real cost. Its consumption rules are stricter: depending on what backs it, the value may be safe to await only once and should not be cached or shared directly.Taskremains the simpler choice when the result needs to be stored, shared, or awaited more than once.