Tasks

Intro

Task is the core .NET abstraction for asynchronous work. It models eventual completion, result/error propagation, and composition (WhenAll, WhenAny) without forcing you to manage raw threads. For production systems, understanding Task semantics is critical for avoiding deadlocks, thread starvation, and unbounded fan-out.

Task represents an operation, not a thread. A task might run on a pooled worker, or it might represent asynchronous I/O that completes later without occupying a worker while waiting.

How It Works

A Task is a promise: it starts in one of three terminal states — RanToCompletion, Faulted, or Canceled. The runtime tracks the state and stores the result or exception. When you await a task, the compiler generates a continuation that runs when the task reaches a terminal state.

Key types:

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;
}

Task.WhenAll starts all requests concurrently and waits for all to complete. Total latency is the slowest individual request, not the sum.

Failure Aggregation

Task.WhenAll throws the first exception when awaited, but all tasks run to completion. To inspect all failures:

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
    {
        // 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) Wait for all; aggregate failures
Task.WhenAny(tasks) Race multiple operations; first-success or timeout fallback
TaskCompletionSource<T> Bridge callback/event APIs into task-based APIs
Task.Run(action) Offload CPU-bound work to a pool thread
ValueTask<T> Hot-path optimization when result is often synchronously available

Pitfalls

Unobserved task exceptions
If a Task faults and nothing observes its exception (no await, no .Exception check), the exception is silently swallowed. In .NET 4.5+, unobserved exceptions no longer crash the process by default, but they are still lost.

// Exception is lost — the task is never awaited or observed
_ = SendEmailAsync(user);

Fix: await the task, or attach a continuation to handle the exception:

_ = SendEmailAsync(user).ContinueWith(
    t => _logger.LogError(t.Exception, "Email send failed"),
    TaskContinuationOptions.OnlyOnFaulted);

ValueTask consumed more than once
ValueTask may be backed by a pooled object. Awaiting it twice, calling .Result after awaiting, or storing it for later use violates its contract and causes undefined behavior.

var vt = GetCachedValueAsync();
var r1 = await vt; // OK
var r2 = await vt; // WRONG — may read from a recycled object

Fix: convert to Task with .AsTask() if you need to consume the result multiple times.

Task.Run for I/O
Wrapping async I/O in Task.Run wastes a pool thread for the entire I/O duration.

// Pointless — GetStringAsync is already async
var result = await Task.Run(() => _http.GetStringAsync(url));

Fix: await the async method directly.

Unbounded Task.WhenAll
Calling Task.WhenAll on thousands of tasks simultaneously can overwhelm the ThreadPool and downstream services.

// Dangerous with large collections — no concurrency limit
var results = await Task.WhenAll(items.Select(i => ProcessAsync(i)));

Fix: use SemaphoreSlim to bound concurrency (see ThreadPool).

Questions

References


Whats next