Steven's Knowledge

Async & Concurrency

async/await done right -- Task.WhenAll, the ConfigureAwait and sync-over-async pitfalls that deadlock production, ValueTask, IAsyncEnumerable, and channels

Async & Concurrency

C# pioneered async/await back in 2012, and the head start shows. The feature is woven into the runtime and the standard library so deeply that asynchronous I/O is the default path, not a bolted-on afterthought. Where other ecosystems retrofitted async onto a synchronous core and ended up with two parallel worlds, .NET's Task-based model is the native one: the framework methods you reach for -- HttpClient, DbCommand, file streams -- expose ...Async variants first, and the synchronous versions are increasingly the legacy path. The result is that idiomatic C# is async by default, and senior engineers are expected to understand not just the syntax but the threading model underneath it.

Async/Await

The mental model is simple: await yields control back to the caller while the I/O is in flight, and resumes on a thread when the result is ready. No thread is blocked waiting. The compiler rewrites your method into a state machine, but you write it as if it were straight-line synchronous code -- that readability is the whole point.

public async Task<Order> ProcessOrderAsync(string orderId, CancellationToken ct)
{
    var order = await _orders.GetAsync(orderId, ct);
    if (order is null)
        throw new OrderNotFoundException(orderId);

    await _payments.ChargeAsync(order, ct);
    await _notifications.SendAsync(order.UserId, "Order confirmed", ct);

    return order;
}

Each await here is sequential and intentional -- you cannot charge a payment before you have the order, and you should not notify before the charge succeeds. The CancellationToken threads through every call, which is the discipline that lets a request be torn down cleanly when the client disconnects or a timeout fires. Note that exceptions propagate naturally: if ChargeAsync throws, it surfaces at the await exactly as a synchronous throw would, and the try/catch/finally you already know works unchanged.

Concurrent Work with Task.WhenAll

When operations are genuinely independent, running them sequentially is wasted latency. Start all the tasks first -- which kicks off the I/O immediately -- then await Task.WhenAll to join. The total wall-clock time becomes the slowest call rather than the sum of all of them.

public async Task<Dashboard> BuildDashboardAsync(string userId, CancellationToken ct)
{
    var ordersTask = _orders.GetRecentAsync(userId, ct);
    var profileTask = _users.GetProfileAsync(userId, ct);
    var statsTask = _analytics.GetStatsAsync(userId, ct);

    await Task.WhenAll(ordersTask, profileTask, statsTask);

    return new Dashboard(
        Orders: await ordersTask,
        Profile: await profileTask,
        Stats: await statsTask);
}

The ordering matters: you assign the tasks to variables without awaiting, so all three are in flight before the first await. Awaiting each task again after WhenAll is free -- the result is already there -- and it gives you the typed return values. Be wary of the obvious failure mode: if these three calls all hit the same downstream database, fanning them out concurrently can multiply your connection pressure rather than help. Concurrency is a tool for independent work, not a default to sprinkle everywhere.

The one trap to internalize early is async void. It exists solely for event handlers, because the event signature predates Task. An async void method cannot be awaited, so the caller has no way to know when it finishes or whether it failed -- and an unhandled exception inside it does not bubble to a catch, it tears down the process. Always return Task or Task<T>. The only legitimate async void is a UI event handler, and even there you keep the body trivial.

The Sync-Over-Async Deadlock

This is the single most common way async code kills a production app, and it is worth understanding the mechanism rather than just memorizing the rule. The classic shape is calling .Result or .Wait() on an async method from synchronous code:

// DANGER: deadlocks under a captured SynchronizationContext
public Order GetOrder(string id)
{
    return ProcessOrderAsync(id, CancellationToken.None).Result; // blocks
}

Under a framework with a SynchronizationContext -- legacy ASP.NET, WPF, WinForms -- here is what happens. When ProcessOrderAsync hits its first await, the runtime captures the current context so the continuation (the code after the await) can resume on it. Meanwhile, .Result blocks the calling thread. But the calling thread is the context: the UI thread, or the single request thread. So when the I/O completes and the continuation tries to marshal back onto that context, it finds the thread blocked, waiting on the very task whose continuation it is refusing to run. Neither side can proceed. Deadlock.

The fix is not a clever trick; it is to stop blocking. Make the call chain async all the way down:

// Fix: async all the way, no blocking
public async Task<Order> GetOrderAsync(string id, CancellationToken ct)
{
    return await ProcessOrderAsync(id, ct);
}

ASP.NET Core deliberately ships with no SynchronizationContext, so this particular deadlock is much rarer there -- the continuation just runs on any thread-pool thread. But do not read that as "blocking is fine now." Every thread you block on .Result is a thread-pool thread sitting idle, and under load that path leads straight to thread-pool starvation: requests queue, the pool grows slowly, latency spikes, and the app appears hung even though no single line is deadlocked. The rule survives the platform change. Do not block on async.

ConfigureAwait(false)

ConfigureAwait(false) tells the runtime not to capture the synchronization context for the continuation -- resume on any available thread-pool thread instead of marshalling back. In library code this matters for two reasons: it avoids the deadlock above if a caller does block (you cannot control your callers), and it shaves the small cost of the context switch on every await.

public async Task<byte[]> DownloadAsync(Uri uri, CancellationToken ct)
{
    using var response = await _http.GetAsync(uri, ct).ConfigureAwait(false);
    return await response.Content.ReadAsByteArrayAsync(ct).ConfigureAwait(false);
}

The honest tradeoff: in ASP.NET Core application code, ConfigureAwait(false) is largely pointless because there is no context to capture in the first place, and littering it everywhere is just noise. But in a shared library -- a NuGet package, an internal SDK -- you have no idea whether it will be consumed by a WPF app or a legacy ASP.NET service that still has a context. There it remains mandatory on every await. The discipline is contextual: skip it in your app, apply it religiously in code meant to be reused.

ValueTask

Task<T> is a reference type, so every async call that returns one allocates an object on the heap. For the vast majority of code that is irrelevant. But on a genuinely hot path that usually completes synchronously -- a cache lookup that hits 95% of the time -- those allocations add up to real GC pressure. ValueTask<T> exists for exactly this case: when the result is already available, it wraps the value with no allocation; only on the slow asynchronous path does it fall back to a backing Task.

public ValueTask<User> GetUserAsync(string id, CancellationToken ct)
{
    if (_cache.TryGetValue(id, out var user))
        return new ValueTask<User>(user); // synchronous, zero allocation

    return new ValueTask<User>(LoadAsync(id, ct)); // slow path falls back to Task

    async Task<User> LoadAsync(string userId, CancellationToken token)
    {
        var loaded = await _db.GetUserAsync(userId, token);
        _cache.Set(userId, loaded);
        return loaded;
    }
}

The convenience comes with rules, and breaking them causes subtle corruption rather than a clean error. Await a ValueTask exactly once -- it may be backed by a pooled, reusable object whose state is invalid after the first await. Never await it twice, never store it and await later, and never block on it with .Result. If you need any of those, call .AsTask() first to get a normal Task you can treat freely. The plain advice for everyone else: default to Task<T>, and reach for ValueTask<T> only when a profiler tells you a synchronously-completing hot path is allocating too much.

Async Streams: IAsyncEnumerable

Sometimes you are not awaiting one result but a sequence that arrives over time -- pages from an API, rows streamed from a query, events off a queue. IAsyncEnumerable<T> is the async counterpart to IEnumerable<T>: you await foreach over it, and you produce it with yield return inside an async iterator. The win is that you never buffer the whole sequence in memory; each item flows through as it arrives.

public async IAsyncEnumerable<Order> StreamOrdersAsync(
    [EnumeratorCancellation] CancellationToken ct = default)
{
    string? cursor = null;
    do
    {
        var page = await _api.GetPageAsync(cursor, ct);
        foreach (var order in page.Items)
            yield return order;
        cursor = page.NextCursor;
    } while (cursor is not null);
}

The [EnumeratorCancellation] attribute is the part people miss. When a consumer writes await foreach (var o in StreamOrdersAsync().WithCancellation(token)), the token is supplied at enumeration time, and that attribute is what wires it into your ct parameter so cancellation actually reaches the GetPageAsync call. Without it, the token passed via WithCancellation is silently dropped and your stream cannot be cancelled. The consuming side reads cleanly:

await foreach (var order in StreamOrdersAsync().WithCancellation(ct))
    Process(order);

Channels for Producer/Consumer

When you have producers generating work and consumers processing it -- and especially when their rates differ -- System.Threading.Channels is the modern, async-native answer. A channel is essentially a thread-safe, awaitable queue. A bounded channel is the important variant: it caps the buffer, so when it fills, writers asynchronously wait rather than allocating unboundedly. That is backpressure, built in, and it is the property that keeps a fast producer from running you out of memory when the consumer falls behind.

var channel = Channel.CreateBounded<Order>(new BoundedChannelOptions(100)
{
    FullMode = BoundedChannelFullMode.Wait
});

// Producer
_ = Task.Run(async () =>
{
    await foreach (var order in StreamOrdersAsync(ct))
        await channel.Writer.WriteAsync(order, ct); // awaits when full
    channel.Writer.Complete();
});

// Consumer
await foreach (var order in channel.Reader.ReadAllAsync(ct))
    await ProcessAsync(order, ct);

Contrast this with the old approach: a BlockingCollection<T> or a hand-rolled queue guarded by locks and Monitor.Pulse. Those block threads to coordinate, which is exactly the thread-pool-starvation hazard async exists to avoid, and the locking is easy to get wrong. Channels coordinate by awaiting instead of blocking, decouple producers from consumers completely, and give you backpressure and completion signalling for free. For anything resembling a pipeline, reach for a channel before you reach for a lock.

Sharp Edges

A few failure modes recur often enough to call out. Forgetting to await a task is the worst: the call becomes fire-and-forget, the method returns immediately, and any exception it throws is swallowed into an ignored Task that no one observes -- a silent failure that is brutal to diagnose. Task.WhenAll only surfaces the first exception when you await it; if three tasks failed, you see one, and the rest are sitting on the returned task's .Exception as an AggregateException -- inspect it if completeness matters. Watch what your async lambdas capture in closures: holding a reference to a large object or a DbContext for the lifetime of a long-running task quietly extends its lifetime and can pin far more memory than you intended. And finally, a CancellationToken does nothing on its own -- it is just a signal. It only cancels work if something actually observes it, by passing it down to async calls or checking ThrowIfCancellationRequested(). A token threaded through your signatures but never inspected is a comforting lie.

On this page