Advanced C# A 05 13 topics

Async and Await

await does not start a thread. It splits your method into pieces and runs the next piece when a task finishes. Once you see the pieces, deadlocks, ConfigureAwait and async void all make sense.

This page assumes you have written async Task methods and awaited HttpClient calls. It goes under the hood first, then covers what interviews ask about most. That means deadlocks, cancellation, combinators, async streams, channels and exception rules. Every sample is a static method that returns a value. Demos block with .GetAwaiter().GetResult() only so a plain test can check them. Real code should await all the way up.

Contents

  1. Task vs ValueTask
  2. What the compiler generates
  3. SynchronizationContext and ConfigureAwait(false)
  4. Sync-over-async deadlocks
  5. async void
  6. Cancellation, linked tokens and timeouts
  7. Task.WhenAll, WhenAny and WhenEach
  8. TaskCompletionSource
  9. IAsyncEnumerable and await foreach
  10. IAsyncDisposable and await using
  11. Channels: producer and consumer
  12. Parallel.ForEachAsync
  13. Exceptions in async code
  14. Recap

Several samples need a thread that behaves like a UI thread. It has one queue of work and runs it one item at a time. The class below is that helper. It is a cut-down version of what WinForms, WPF and old ASP.NET install. It is defined once here and reused later.

/// <summary>
/// A SynchronizationContext with one work queue, pumped by whoever calls RunPending.
/// It mimics a UI thread: continuations posted here wait until the pump runs them.
/// </summary>
/// <example>SingleThreadContext.Run(() => Task.FromResult(7)) returns 7</example>
public sealed class SingleThreadContext : SynchronizationContext
{
    private readonly Queue<(SendOrPostCallback Work, object? State)> _queue = new();
    private readonly object _gate = new();

    /// <summary>Queues work. Await continuations arrive here when they capture us.</summary>
    public override void Post(SendOrPostCallback d, object? state)
    {
        lock (_gate) { _queue.Enqueue((d, state)); }
    }

    /// <summary>How many work items are waiting for the pump.</summary>
    public int Pending { get { lock (_gate) { return _queue.Count; } } }

    /// <summary>Runs queued work on the calling thread until the queue is empty.</summary>
    /// <returns>How many items ran.</returns>
    /// <example>After one Post, RunPending() returns 1</example>
    public int RunPending()
    {
        // ran counts finished items. It starts at 0 because nothing ran yet.
        int ran = 0;
        // Each pass takes one item. The loop ends when the queue is empty.
        while (true)
        {
            (SendOrPostCallback Work, object? State) item;
            lock (_gate)
            {
                if (_queue.Count == 0) return ran;
                item = _queue.Dequeue();
            }
            item.Work(item.State);
            ran++;
        }
    }

    /// <summary>
    /// Installs a fresh context on this thread, runs body, and pumps until its task ends.
    /// This is the "UI message loop" for the demos.
    /// </summary>
    /// <param name="body">The async work to start on the context.</param>
    /// <returns>The task's result.</returns>
    /// <example>SingleThreadContext.Run(async () => { await Task.Yield(); return 1; }) returns 1</example>
    public static TResult Run<TResult>(Func<Task<TResult>> body)
    {
        SynchronizationContext? previous = Current;
        var ctx = new SingleThreadContext();
        SetSynchronizationContext(ctx);
        try
        {
            Task<TResult> task = body();
            // Pump until the task is done. Each pass runs what is queued, then naps.
            while (!task.IsCompleted)
            {
                if (ctx.RunPending() == 0) Thread.Sleep(1);   // 1 ms nap: avoid a hot spin
            }
            // Drain once more. An async void method may have posted its exception.
            ctx.RunPending();
            return task.GetAwaiter().GetResult();
        }
        finally
        {
            SetSynchronizationContext(previous);
        }
    }
}

1. Task vs ValueTask

Task: .NET 4, await: C# 5ValueTask<T>: .NET Core 2.0ValueTask: .NET Core 2.1

What it is

Task<T> is a class. Every call that returns one allocates it, unless the runtime has a cached instance. ValueTask<T> is a struct. It holds either a ready result or a Task<T>. It can also wrap a pooled IValueTaskSource<T>. When the result is already known, it costs no heap allocation.

Why and when

Sample

/// <summary>A cache whose hits finish synchronously, so they return ValueTask.</summary>
/// <example>new SquareCache().GetAsync(3) completes with 9</example>
public sealed class SquareCache
{
    private readonly Dictionary<int, int> _cache = new();

    /// <summary>How many times the slow path ran.</summary>
    public int Loads { get; private set; }

    /// <summary>Returns key squared. Hits allocate nothing. Misses go async.</summary>
    /// <param name="key">The number to square.</param>
    /// <returns>A ValueTask that is already complete on a hit.</returns>
    /// <example>await cache.GetAsync(4) returns 16</example>
    public ValueTask<int> GetAsync(int key)
    {
        // Hit: wrap the value directly. No Task object is created.
        if (_cache.TryGetValue(key, out int hit)) return new ValueTask<int>(hit);
        // Miss: fall back to a real async method and wrap its Task.
        return new ValueTask<int>(LoadAsync(key));
    }

    private async Task<int> LoadAsync(int key)
    {
        Loads++;
        // Task.Yield forces a real async hop, like an I/O call would.
        await Task.Yield();
        int value = key * key;
        _cache[key] = value;
        return value;
    }

    /// <summary>Reads the same key three times and reports hits and loads.</summary>
    /// <returns>Sum of the three reads, how many finished synchronously, and loads.</returns>
    /// <example>SquareCache.Demo() returns "sum=27 sync=2 loads=1"</example>
    public static string Demo()
    {
        var cache = new SquareCache();
        // sync counts reads that were complete the moment they returned. Starts at 0.
        int sum = 0, sync = 0;
        // 3 reads of key 3: the first misses, the other two hit.
        for (int i = 0; i < 3; i++)
        {
            ValueTask<int> vt = cache.GetAsync(3);
            if (vt.IsCompletedSuccessfully) sync++;
            // Consume each ValueTask exactly once. Here, block for the demo.
            sum += vt.AsTask().GetAwaiter().GetResult();
        }
        return $"sum={sum} sync={sync} loads={cache.Loads}";
    }
}

Pitfalls

Interview questions

Q. When would you return ValueTask<T> instead of Task<T>?
A. When the method is hot and usually completes synchronously, like a cache hit. It saves an allocation per call. Otherwise Task is simpler and safer, because it can be awaited many times.
Q. What can you do with a Task that you must not do with a ValueTask?
A. Await it more than once, await it from two places, store it and await it later, or block on it before it finishes.

2. What the compiler generates for async/await

C# 5.NET 4.5

What it is

An async method compiles to a stub plus a state machine struct. The stub creates the struct, starts it, and returns the builder's Task. The struct's MoveNext() runs your code up to the first await on an unfinished task. There it saves its state and asks the awaiter to call MoveNext() again when done. Then it returns. This is the same shape as the iterator state machine, but driven by task completion instead of a foreach.

int x = await a; int y = await b; return x + y; caller AddLaterAsync(a, b) MoveNext, state -1 runs until await a a not done: state = 0 register, then return caller gets Task not finished yet a completes: MoveNext jump to state 0, x = result b not done: state = 1 register, then return b completes: MoveNext jump to state 1, sum Builder.SetResult caller's Task completes Dashed: the awaiter calls MoveNext later, maybe on another thread.
Figure A5.1 — An async method runs in slices, and each await on an unfinished task ends one slice.

Reading the figure. Amber boxes are slices of your code running inside MoveNext(). Blue boxes are suspensions: the state field is saved and MoveNext() returns. Dashed arrows are the continuation firing later. Green is the end, where the builder completes the Task the caller has held since the first slice. If a were already done, the first blue box is skipped and the code runs straight on.

Why and when

Knowing this answers several interview questions at once. No thread waits during an await. Locals live in the struct, which moves to the heap only on the first real suspension. An exception thrown in your code is caught by the machine and stored in the Task. A method that finishes without ever suspending runs fully synchronously.

Sample

A hand-written state machine for AddLaterAsync. The compiler's real output uses the same builder calls and the same state numbers.

using System.Runtime.CompilerServices;

public static class HandAsync
{
    /// <summary>
    /// Hand-written equivalent of:
    /// async Task<int> AddLaterAsync(Task<int> a, Task<int> b)
    ///     { int x = await a; int y = await b; return x + y; }
    /// </summary>
    /// <param name="a">First value, maybe not ready yet.</param>
    /// <param name="b">Second value, maybe not ready yet.</param>
    /// <returns>A task for a + b.</returns>
    /// <example>await AddLaterAsync(Task.FromResult(2), Task.FromResult(3)) returns 5</example>
    public static Task<int> AddLaterAsync(Task<int> a, Task<int> b)
    {
        // The stub: build the machine, start it, hand back the builder's Task.
        var sm = new AddMachine
        {
            A = a,
            B = b,
            Builder = AsyncTaskMethodBuilder<int>.Create(),
            State = -1,          // -1 = running, the compiler's code for "not suspended"
        };
        sm.Builder.Start(ref sm);
        return sm.Builder.Task;
    }

    private struct AddMachine : IAsyncStateMachine
    {
        // -1 running, 0 suspended at await a, 1 suspended at await b, -2 finished.
        public int State;
        public AsyncTaskMethodBuilder<int> Builder;
        public Task<int> A, B;
        private int _x;                       // the local x, hoisted to a field
        private TaskAwaiter<int> _awaiter;    // the awaiter kept across a suspension

        public void MoveNext()
        {
            int result;
            try
            {
                TaskAwaiter<int> aw;
                // Resume where we left off. 0 and 1 are the two await points.
                switch (State)
                {
                    case 0: aw = _awaiter; _awaiter = default; State = -1; goto ResumeA;
                    case 1: aw = _awaiter; _awaiter = default; State = -1; goto ResumeB;
                }
                aw = A.GetAwaiter();
                if (!aw.IsCompleted)
                {
                    State = 0;                // 0 = "come back to ResumeA"
                    _awaiter = aw;
                    Builder.AwaitUnsafeOnCompleted(ref aw, ref this);
                    return;                   // the thread is free now
                }
            ResumeA:
                _x = aw.GetResult();          // rethrows if a faulted
                aw = B.GetAwaiter();
                if (!aw.IsCompleted)
                {
                    State = 1;                // 1 = "come back to ResumeB"
                    _awaiter = aw;
                    Builder.AwaitUnsafeOnCompleted(ref aw, ref this);
                    return;
                }
            ResumeB:
                result = _x + aw.GetResult();
            }
            catch (Exception ex)
            {
                State = -2;                   // -2 = finished
                Builder.SetException(ex);     // the exception goes into the Task
                return;
            }
            State = -2;
            Builder.SetResult(result);
        }

        public void SetStateMachine(IAsyncStateMachine machine) =>
            Builder.SetStateMachine(machine);
    }

    /// <summary>Runs the hand-written machine with one value that arrives late.</summary>
    /// <returns>Whether the task was done before b arrived, then the sum.</returns>
    /// <example>HandAsync.Demo() returns "doneEarly=False sum=5"</example>
    public static string Demo()
    {
        // 2 is ready now. 3 arrives later through the completion source.
        var late = new TaskCompletionSource<int>();
        Task<int> sum = AddLaterAsync(Task.FromResult(2), late.Task);
        bool doneEarly = sum.IsCompleted;
        late.SetResult(3);
        return $"doneEarly={doneEarly} sum={sum.GetAwaiter().GetResult()}";
    }
}

Pitfalls

Interview questions

Q. Does await create a thread?
A. No. It registers a continuation and returns. The continuation runs when the task completes, on the captured context or on a thread pool thread. True I/O waits use no thread at all.
Q. Why is the generated state machine a struct?
A. If the method finishes without suspending, nothing needs the heap. The struct is boxed only on the first real suspension. That makes the fast synchronous path allocation-free.

3. SynchronizationContext and ConfigureAwait(false)

C# 5ConfigureAwaitOptions: .NET 8

What it is

By default await captures SynchronizationContext.Current. If there is none, it captures the current TaskScheduler. The continuation is then posted back there. On a UI thread that means “resume on the UI thread”, so you can touch controls after the await. ConfigureAwait(false) says “do not capture”. The continuation runs wherever the task completed, usually a thread pool thread.

Why and when

Sample

public static class ContextDemo
{
    /// <summary>Awaits once and reports whether it resumed on the captured context.</summary>
    /// <param name="capture">False means ConfigureAwait(false).</param>
    /// <returns>True if the continuation ran on the original context.</returns>
    /// <example>await ResumesOnContext(true) is true inside a SingleThreadContext</example>
    public static async Task<bool> ResumesOnContext(bool capture)
    {
        SynchronizationContext? before = SynchronizationContext.Current;
        // 5 ms makes sure the delay is unfinished at the await, so we really suspend.
        await Task.Delay(5).ConfigureAwait(capture);
        return before is not null && SynchronizationContext.Current == before;
    }

    /// <summary>Runs both variants on a UI-like context.</summary>
    /// <returns>Where each variant resumed.</returns>
    /// <example>ContextDemo.Demo() returns "default=True false=False"</example>
    public static string Demo()
    {
        bool captured = SingleThreadContext.Run(() => ResumesOnContext(capture: true));
        bool notCaptured = SingleThreadContext.Run(() => ResumesOnContext(capture: false));
        return $"default={captured} false={notCaptured}";
    }

    /// <summary>Awaits a faulted task without throwing, using .NET 8 options.</summary>
    /// <returns>"swallowed" plus whether the task is faulted.</returns>
    /// <example>ContextDemo.SuppressDemo() returns "swallowed faulted=True"</example>
    public static string SuppressDemo()
    {
        Task failing = Task.FromException(new InvalidOperationException("boom"));
        async Task<string> Inner()
        {
            // SuppressThrowing waits for completion but does not rethrow the fault.
            await failing.ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing);
            return $"swallowed faulted={failing.IsFaulted}";
        }
        return Inner().GetAwaiter().GetResult();
    }
}

Pitfalls

Interview questions

Q. What does ConfigureAwait(false) do, and do you need it in ASP.NET Core?
A. It tells that await not to resume on the captured SynchronizationContext. ASP.NET Core has no such context, so app code there does not need it. Libraries should still use it, since they may run under WPF or WinForms.

4. Sync-over-async deadlocks

C# 5any single-threaded context

What it is

The classic deadlock needs three things. A thread with a single-threaded context, such as a UI thread, blocks on a task with .Result or .Wait(). The task's async method awaited something without ConfigureAwait(false). So its continuation is queued to that same blocked thread. The thread waits for the task. The task waits for the thread. Neither moves.

UI thread GetDataAsync().Result blocked, waiting for the Task GetDataAsync await Task.Delay(5) delay done, wants to resume UI thread queue [ rest of GetDataAsync ] waits for the Task to finish posts continuation only the UI thread can run this queue A cycle: each side waits for the other
Figure A5.2 — Blocking a single-threaded context on a task that needs that same context is a circular wait.

Reading the figure. Red is the blocked thread. Amber is the async method, ready to finish. Blue is the context's queue, holding the continuation. The dashed arrow is the step that never happens: the blocked thread would have to pump its own queue. Break any arrow and the cycle ends. ConfigureAwait(false) removes the “posts” arrow. await instead of .Result removes the “waits” arrow.

Why and when

It shows up in WinForms, WPF, MAUI, Blazor WebAssembly and classic ASP.NET. Console apps and ASP.NET Core do not deadlock this way, but blocking there still wastes thread pool threads and can starve the pool under load. The real fix is “async all the way”: make callers async too.

Sample

The demo reproduces the deadlock on SingleThreadContext and detects it with a 50 ms timeout instead of hanging. Then it pumps the queue to release the stuck task.

public static class DeadlockDemo
{
    private static async Task<int> GetDataAsync(bool capture)
    {
        // 5 ms keeps the delay unfinished at the await, so a continuation is needed.
        await Task.Delay(5).ConfigureAwait(capture);
        return 42;   // 42 is an arbitrary sample payload
    }

    /// <summary>Blocks a UI-like thread on an async method and reports if it finished.</summary>
    /// <param name="capture">True reproduces the bug. False applies ConfigureAwait(false).</param>
    /// <returns>"finished" or "deadlocked", and the value once released.</returns>
    /// <example>DeadlockDemo.BlockOn(true) returns "deadlocked then 42"</example>
    public static string BlockOn(bool capture)
    {
        SynchronizationContext? previous = SynchronizationContext.Current;
        var ui = new SingleThreadContext();
        SynchronizationContext.SetSynchronizationContext(ui);
        try
        {
            Task<int> task = GetDataAsync(capture);
            // Block like .Result would, but give up after 50 ms instead of forever.
            bool finished = task.Wait(TimeSpan.FromMilliseconds(50));
            if (finished) return $"finished {task.Result}";
            // Rescue: pump the queue so the posted continuation can run.
            while (!task.IsCompleted) ui.RunPending();
            return $"deadlocked then {task.Result}";
        }
        finally
        {
            SynchronizationContext.SetSynchronizationContext(previous);
        }
    }
}

Pitfalls

Interview questions

Q. Why does .Result hang a WPF app but not a console app?
A. WPF installs a single-threaded SynchronizationContext. The awaited continuation is posted back to the UI thread, which is blocked on .Result. A console app has no context, so the continuation runs on the thread pool and completes the task.
Q. How do you fix it?
A. Await instead of blocking, all the way up to an async event handler. In library code, also add ConfigureAwait(false) to every await.

5. async void

C# 5

What it is

An async void method returns nothing the caller can await. The caller cannot know when it finishes. Worse, an exception cannot reach the caller. It is raised on the captured SynchronizationContext, or on the thread pool if there is none. On the thread pool, an unhandled exception crashes the process.

Why and when

Use it only for event handlers, where the signature must be void. Put a try/catch around the whole body. Everywhere else return Task. Watch for lambdas too. A lambda passed to an Action parameter becomes async void silently.

Sample

public static class AsyncVoidDemo
{
    private static async void FireAndForget(List<string> log)
    {
        await Task.Yield();   // the rest runs later, after the caller moved on
        log.Add("void body ended");
    }

    private static async void Explode()
    {
        await Task.Yield();
        throw new InvalidOperationException("from async void");
    }

    /// <summary>Shows that the caller of async void cannot wait for it.</summary>
    /// <returns>The log, in the order things really happened.</returns>
    /// <example>AsyncVoidDemo.CannotAwait() returns [caller done, void body ended]</example>
    public static List<string> CannotAwait()
    {
        var log = new List<string>();
        SingleThreadContext.Run(() =>
        {
            FireAndForget(log);
            log.Add("caller done");   // runs first: nothing to await
            return Task.FromResult(0);
        });
        return log;
    }

    /// <summary>Shows that the caller's try/catch never sees an async void exception.</summary>
    /// <returns>What the caller caught, and what surfaced on the context.</returns>
    /// <example>AsyncVoidDemo.CannotCatch() returns "caller=nothing context=from async void"</example>
    public static string CannotCatch()
    {
        string caller = "nothing", context = "nothing";
        try
        {
            SingleThreadContext.Run(() =>
            {
                try { Explode(); }
                catch (InvalidOperationException) { caller = "caught"; }   // never runs
                return Task.FromResult(0);
            });
        }
        catch (InvalidOperationException ex)
        {
            // The exception was posted to the context and thrown by its pump.
            context = ex.Message;
        }
        return $"caller={caller} context={context}";
    }
}

Pitfalls

Interview questions

Q. Why is async void dangerous?
A. The caller cannot await it or catch its exceptions. An exception goes to the SynchronizationContext or crashes the process on the thread pool. Use it only for event handlers, wrapped in try/catch.

6. Cancellation, linked tokens and timeouts

CancellationToken: .NET 4WaitAsync: .NET 6TryReset: .NET 6

What it is

Cancellation in .NET is cooperative. A CancellationTokenSource owns the switch. A CancellationToken is the read-only view you pass down. Code checks the token, or passes it to APIs that check it. On cancel, they throw OperationCanceledException. A linked source cancels when any of its parents cancel. CancelAfter turns a source into a timeout.

Why and when

Sample

public static class CancelDemo
{
    /// <summary>Sums items, checking the token before each one.</summary>
    /// <param name="items">Numbers to add.</param>
    /// <param name="ct">Stops the work when cancelled.</param>
    /// <returns>The sum.</returns>
    /// <example>await SumAsync([1, 2, 3], default) returns 6</example>
    public static async Task<int> SumAsync(IEnumerable<int> items, CancellationToken ct = default)
    {
        // sum of the items seen so far. 0 is the empty sum.
        int sum = 0;
        foreach (int x in items)
        {
            // Throws OperationCanceledException as soon as cancel was requested.
            ct.ThrowIfCancellationRequested();
            sum += x;
            await Task.Yield();   // a stand-in for real async work per item
        }
        return sum;
    }

    /// <summary>Cancels in the middle of a sum from a token callback.</summary>
    /// <returns>The exception type that ended the work.</returns>
    /// <example>CancelDemo.CancelMidway() returns "OperationCanceledException"</example>
    public static string CancelMidway()
    {
        using var cts = new CancellationTokenSource();
        // Cancel when the sequence reaches item 3. 1..10 is a sample range.
        IEnumerable<int> Items()
        {
            for (int i = 1; i <= 10; i++)
            {
                if (i == 3) cts.Cancel();
                yield return i;
            }
        }
        try { SumAsync(Items(), cts.Token).GetAwaiter().GetResult(); return "finished"; }
        catch (OperationCanceledException ex) { return ex.GetType().Name; }
    }

    /// <summary>Links a caller token with a 20 ms timeout and reports which one fired.</summary>
    /// <param name="callerCancelsFirst">True cancels the caller token at once.</param>
    /// <returns>"caller" or "timeout".</returns>
    /// <example>CancelDemo.WhoCancelled(false) returns "timeout"</example>
    public static string WhoCancelled(bool callerCancelsFirst)
    {
        using var caller = new CancellationTokenSource();
        using var timeout = new CancellationTokenSource(TimeSpan.FromMilliseconds(20));
        // linked fires when either parent fires.
        using var linked = CancellationTokenSource.CreateLinkedTokenSource(
            caller.Token, timeout.Token);
        if (callerCancelsFirst) caller.Cancel();
        try
        {
            // Infinite delay: only cancellation can end it.
            Task.Delay(Timeout.Infinite, linked.Token).GetAwaiter().GetResult();
            return "impossible";
        }
        catch (OperationCanceledException)
        {
            // Ask the parents, not the linked source, to learn the cause.
            return caller.IsCancellationRequested ? "caller" : "timeout";
        }
    }

    /// <summary>Stops waiting for a task that ignores cancellation, after 20 ms.</summary>
    /// <returns>The exception type, and whether the task is still running.</returns>
    /// <example>CancelDemo.WaitAsyncTimeout() returns "TimeoutException stillRunning=True"</example>
    public static string WaitAsyncTimeout()
    {
        // A task that never completes on its own and takes no token.
        var never = new TaskCompletionSource<int>();
        try
        {
            never.Task.WaitAsync(TimeSpan.FromMilliseconds(20)).GetAwaiter().GetResult();
            return "finished";
        }
        catch (TimeoutException ex)
        {
            return $"{ex.GetType().Name} stillRunning={!never.Task.IsCompleted}";
        }
    }
}

Pitfalls

Interview questions

Q. How do you add a timeout to an operation that already takes a token?
A. Create a linked source from the caller's token, call CancelAfter(timeout) on it, and pass the linked token down. In the catch, check the caller's token to tell a user cancel from a timeout.
Q. Does cancelling a token stop a running thread?
A. No. Cancellation is cooperative. The code must check the token or pass it to APIs that do. Nothing is aborted.

7. Task.WhenAll, WhenAny and WhenEach

WhenAll and WhenAny: .NET 4.5WhenEach: .NET 9

What it is

Why and when

Start independent work first, then await them together. Three calls of 100 ms each take about 100 ms with WhenAll and 300 ms awaited one by one. Use WhenAny for first-wins races and hand-made timeouts. Use WhenEach to process results as they arrive.

Sample

The completion sources let the test choose the finish order, so the results never depend on timing.

public static class CombinatorDemo
{
    /// <summary>Finishes three tasks out of order and collects WhenAll results.</summary>
    /// <returns>Results in input order, regardless of finish order.</returns>
    /// <example>CombinatorDemo.AllInInputOrder() returns [1, 2, 3]</example>
    public static int[] AllInInputOrder()
    {
        var a = new TaskCompletionSource<int>();
        var b = new TaskCompletionSource<int>();
        var c = new TaskCompletionSource<int>();
        Task<int[]> all = Task.WhenAll(a.Task, b.Task, c.Task);
        // Finish order 3, 1, 2. The values are just labels.
        c.SetResult(3);
        a.SetResult(1);
        b.SetResult(2);
        return all.GetAwaiter().GetResult();
    }

    /// <summary>A first-wins race with a fallback.</summary>
    /// <returns>The winner's value.</returns>
    /// <example>CombinatorDemo.FirstWins() returns "mirror"</example>
    public static string FirstWins()
    {
        var primary = new TaskCompletionSource<string>();
        var mirror = new TaskCompletionSource<string>();
        Task<Task<string>> race = Task.WhenAny(primary.Task, mirror.Task);
        mirror.SetResult("mirror");
        // WhenAny returns the winning task. Await it again to get its value or exception.
        Task<string> winner = race.GetAwaiter().GetResult();
        primary.SetResult("primary");   // the loser still runs to the end
        return winner.GetAwaiter().GetResult();
    }

    /// <summary>Processes results in completion order with Task.WhenEach (.NET 9).</summary>
    /// <returns>Values in the order the tasks finished.</returns>
    /// <example>CombinatorDemo.EachAsItFinishes() returns [20, 30, 10]</example>
    public static List<int> EachAsItFinishes()
    {
        // 10, 20 and 30 are labels for three requests.
        var r10 = new TaskCompletionSource<int>();
        var r20 = new TaskCompletionSource<int>();
        var r30 = new TaskCompletionSource<int>();
        var order = new List<int>();
        async Task Consume()
        {
            // Each pass gets the next task to finish. It is already complete.
            await foreach (Task<int> done in Task.WhenEach(r10.Task, r20.Task, r30.Task))
                order.Add(await done);
        }
        Task loop = Consume();
        r20.SetResult(20);
        r30.SetResult(30);
        r10.SetResult(10);
        loop.GetAwaiter().GetResult();
        return order;
    }

    /// <summary>Runs n fake calls together and reports total work started.</summary>
    /// <param name="n">How many calls.</param>
    /// <returns>The sum of the call results.</returns>
    /// <example>CombinatorDemo.StartThenAwait(4) returns 10</example>
    public static int StartThenAwait(int n)
    {
        static async Task<int> FakeCall(int id)
        {
            await Task.Delay(10);   // 10 ms: each call alone is short
            return id;
        }
        // Start all calls first. Ids run 1..n so the sum is n * (n + 1) / 2.
        Task<int>[] calls = Enumerable.Range(1, n).Select(FakeCall).ToArray();
        return Task.WhenAll(calls).GetAwaiter().GetResult().Sum();
    }
}

Pitfalls

Interview questions

Q. What does Task.WhenEach give you over a WhenAny loop?
A. The loop rebuilds the list and calls WhenAny again after each finish, so it costs O(n²). WhenEach registers once per task and yields tasks as they complete. It is O(n) and reads as a plain await foreach.

8. TaskCompletionSource

TaskCompletionSource<T>: .NET 4non-generic: .NET 5

What it is

TaskCompletionSource<T> is a task you complete by hand. You hand out tcs.Task. Later you call SetResult, SetException or SetCanceled, or the Try versions. It is the bridge from callbacks and events into await.

Why and when

Sample

/// <summary>An old-style API that reports results through events.</summary>
/// <example>var d = new LegacyDownloader(); d.Start("a"); d.Finish(); // raises Completed</example>
public sealed class LegacyDownloader
{
    private string _url = "";
    /// <summary>Raised with the content when a download finishes.</summary>
    public event Action<string>? Completed;
    /// <summary>Raised when a download fails.</summary>
    public event Action<Exception>? Failed;

    /// <summary>Begins a download. The result arrives later via an event.</summary>
    public void Start(string url) => _url = url;
    /// <summary>Simulates the download succeeding.</summary>
    public void Finish() => Completed?.Invoke($"content of {_url}");
    /// <summary>Simulates the download failing.</summary>
    public void Fail() => Failed?.Invoke(new IOException($"cannot reach {_url}"));
}

public static class TcsDemo
{
    /// <summary>Wraps the event API in a Task.</summary>
    /// <param name="d">The downloader.</param>
    /// <param name="url">What to fetch.</param>
    /// <returns>A task that completes when an event fires.</returns>
    /// <example>await DownloadAsync(d, "x") returns "content of x" once d finishes</example>
    public static Task<string> DownloadAsync(LegacyDownloader d, string url)
    {
        // RunContinuationsAsynchronously stops awaiters from running inside our event call.
        var tcs = new TaskCompletionSource<string>(
            TaskCreationOptions.RunContinuationsAsynchronously);
        Action<string>? onDone = null;
        Action<Exception>? onFail = null;
        // Unhook both handlers when either fires, so nothing leaks.
        onDone = s => { Unhook(); tcs.TrySetResult(s); };
        onFail = e => { Unhook(); tcs.TrySetException(e); };
        void Unhook() { d.Completed -= onDone; d.Failed -= onFail; }
        d.Completed += onDone;
        d.Failed += onFail;
        d.Start(url);
        return tcs.Task;
    }

    /// <summary>Shows a success and a failure through the wrapper.</summary>
    /// <returns>The content, then the failure type.</returns>
    /// <example>TcsDemo.Demo() returns "content of a.txt | IOException"</example>
    public static string Demo()
    {
        var ok = new LegacyDownloader();
        Task<string> good = DownloadAsync(ok, "a.txt");
        ok.Finish();
        var bad = new LegacyDownloader();
        Task<string> broken = DownloadAsync(bad, "b.txt");
        bad.Fail();
        string failure;
        try { broken.GetAwaiter().GetResult(); failure = "none"; }
        catch (IOException ex) { failure = ex.GetType().Name; }
        return $"{good.GetAwaiter().GetResult()} | {failure}";
    }

    /// <summary>Shows that SetResult twice throws, while TrySetResult returns false.</summary>
    /// <returns>The result of the second TrySetResult.</returns>
    /// <example>TcsDemo.SecondTry() returns false</example>
    public static bool SecondTry()
    {
        var tcs = new TaskCompletionSource();
        tcs.SetResult();
        return tcs.TrySetResult();
    }
}

Pitfalls

Interview questions

Q. How do you turn an event-based API into an awaitable method?
A. Create a TaskCompletionSource<T>. Subscribe handlers that call TrySetResult or TrySetException and unsubscribe. Start the operation and return tcs.Task. Pass RunContinuationsAsynchronously.

9. IAsyncEnumerable and await foreach

C# 8.NET Core 3.0LINQ for async streams: .NET 10

What it is

An async iterator is a method with async, yield return and a return type of IAsyncEnumerable<T>. It can await between items. Consumers read it with await foreach. Each step calls MoveNextAsync(), which returns a ValueTask<bool>. .NET 10 ships LINQ operators for it in the box: Where, Select, ToListAsync and more.

Why and when

Use it for data that arrives over time: paged APIs, database rows, lines from a socket. The consumer starts on page one while page two is still loading. Memory stays flat, unlike Task<List<T>>, which waits for everything.

Sample

public static class AsyncStreams
{
    /// <summary>Yields items page by page, awaiting a fake fetch for each page.</summary>
    /// <param name="pages">How many pages.</param>
    /// <param name="pageSize">Items per page.</param>
    /// <param name="ct">Set by WithCancellation thanks to [EnumeratorCancellation].</param>
    /// <returns>Item numbers 0 .. pages * pageSize - 1.</returns>
    /// <example>FetchAllAsync(2, 2) yields 0, 1, 2, 3</example>
    public static async IAsyncEnumerable<int> FetchAllAsync(
        int pages, int pageSize,
        [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct = default)
    {
        // page is the page being fetched. Pages are numbered from 0.
        for (int page = 0; page < pages; page++)
        {
            await Task.Yield();                // stands in for one network call per page
            ct.ThrowIfCancellationRequested();
            // i is the slot within the page, 0 .. pageSize - 1.
            for (int i = 0; i < pageSize; i++)
                yield return page * pageSize + i;
        }
    }

    /// <summary>Reads a whole async stream with await foreach.</summary>
    /// <returns>All items from 3 pages of 2.</returns>
    /// <example>AsyncStreams.ReadAll() returns [0, 1, 2, 3, 4, 5]</example>
    public static List<int> ReadAll()
    {
        async Task<List<int>> Go()
        {
            var all = new List<int>();
            // 3 pages of 2 items is a small sample.
            await foreach (int x in FetchAllAsync(3, 2)) all.Add(x);
            return all;
        }
        return Go().GetAwaiter().GetResult();
    }

    /// <summary>Uses the built-in async LINQ in .NET 10 to filter a stream.</summary>
    /// <returns>Even items from 3 pages of 3.</returns>
    /// <example>AsyncStreams.EvensWithLinq() returns [0, 2, 4, 6, 8]</example>
    public static List<int> EvensWithLinq() =>
        FetchAllAsync(3, 3)                    // 3 pages of 3: items 0..8
            .Where(x => x % 2 == 0)            // % 2 == 0 keeps even items
            .ToListAsync()
            .AsTask().GetAwaiter().GetResult();

    /// <summary>Cancels a stream after the first page using WithCancellation.</summary>
    /// <returns>Items read before the cancel, then the exception type.</returns>
    /// <example>AsyncStreams.CancelAfterFirstPage() returns "read=2 OperationCanceledException"</example>
    public static string CancelAfterFirstPage()
    {
        async Task<string> Go()
        {
            using var cts = new CancellationTokenSource();
            int read = 0;
            try
            {
                // 5 pages of 2. The token reaches the iterator through WithCancellation.
                await foreach (int x in FetchAllAsync(5, 2).WithCancellation(cts.Token))
                {
                    read++;
                    if (read == 2) cts.Cancel();   // 2 = the end of the first page
                }
                return "finished";
            }
            catch (OperationCanceledException ex) { return $"read={read} {ex.GetType().Name}"; }
        }
        return Go().GetAwaiter().GetResult();
    }
}

Pitfalls

Interview questions

Q. Task<List<T>> or IAsyncEnumerable<T>?
A. Task<List<T>> when the set is small and you need it all at once. IAsyncEnumerable<T> when data arrives in pieces or is large. The consumer can start early and stop early, and memory stays bounded.

10. IAsyncDisposable and await using

C# 8.NET Core 3.0

What it is

IAsyncDisposable has one method, ValueTask DisposeAsync(). await using calls it at the end of the scope, even after an exception. It exists because some cleanup must do I/O. A stream may need to flush a buffer, or a connection may need to send a goodbye message. Blocking on that inside Dispose() would be sync-over-async.

Why and when

Implement it on types whose cleanup is naturally async. Also keep IDisposable if sync callers exist. Use await using on FileStream, DbConnection, Utf8JsonWriter and similar types.

Sample

/// <summary>A writer that buffers lines and flushes them asynchronously on dispose.</summary>
/// <example>await using (var w = new BufferedSink("a", log)) w.Write("x");</example>
public sealed class BufferedSink(string name, List<string> log) : IAsyncDisposable
{
    private readonly List<string> _buffer = new();

    /// <summary>Buffers a line. Nothing is written yet.</summary>
    public void Write(string line) => _buffer.Add(line);

    /// <summary>Flushes the buffer with an async call, then records the disposal.</summary>
    public async ValueTask DisposeAsync()
    {
        await Task.Yield();   // stands in for an async flush to disk or network
        log.Add($"{name} flushed {_buffer.Count}");
    }
}

public static class AsyncDisposeDemo
{
    /// <summary>Two await using declarations dispose in reverse order, even on error.</summary>
    /// <param name="fail">Throw inside the scope.</param>
    /// <returns>The disposal log.</returns>
    /// <example>AsyncDisposeDemo.Run(false) returns [second flushed 1, first flushed 2]</example>
    public static List<string> Run(bool fail)
    {
        var log = new List<string>();
        async Task Body()
        {
            await using var first = new BufferedSink("first", log);
            await using var second = new BufferedSink("second", log);
            first.Write("a");
            first.Write("b");
            second.Write("c");
            if (fail) throw new InvalidOperationException("mid-scope failure");
        }
        try { Body().GetAwaiter().GetResult(); }
        catch (InvalidOperationException) { log.Add("caught"); }
        return log;
    }
}

Pitfalls

Interview questions

Q. Why does IAsyncDisposable exist when we already have IDisposable?
A. Some cleanup needs I/O, like flushing a buffer. Doing it in Dispose() means blocking on async work. DisposeAsync lets await using release the thread during cleanup.

11. Channels: producer and consumer

System.Threading.Channels: .NET Core 3.0

What it is

A Channel<T> is an async queue with a writer end and a reader end. Channel.CreateBounded<T>(capacity) limits its size. When it is full, WriteAsync waits, which is back-pressure. Readers await foreach over reader.ReadAllAsync(). The loop ends after the writer calls Complete() and the queue drains.

producer await WriteAsync 4 5 full bounded channel, capacity 2 consumer A consumer B writer waits here while the channel is full Complete() on the writer ends every ReadAllAsync loop once the queue drains.
Figure A5.3 — A bounded channel decouples a producer from its consumers and slows the producer when they fall behind.

Reading the figure. Amber is the producer, blue is the channel, green are consumers. The two slots are full, so the producer's next WriteAsync waits without blocking a thread. Each item goes to exactly one consumer.

Why and when

Use a channel for pipelines: read, then parse, then save, with each stage at its own speed. Use it for background work queues in a service, and for fan-out to N workers. It replaces BlockingCollection<T>, which blocks threads.

Sample

using System.Threading.Channels;

public static class ChannelDemo
{
    /// <summary>One producer writes 1..n into a bounded channel. Two consumers sum it.</summary>
    /// <param name="n">How many items to produce.</param>
    /// <returns>Total of both consumers, and how many items each one handled together.</returns>
    /// <example>ChannelDemo.Pipeline(100) returns "sum=5050 items=100"</example>
    public static string Pipeline(int n)
    {
        // Capacity 2 keeps the queue tiny, so back-pressure really happens.
        var channel = Channel.CreateBounded<int>(new BoundedChannelOptions(2)
        {
            FullMode = BoundedChannelFullMode.Wait,   // writers wait when full
        });

        async Task Produce()
        {
            // i is the next item. 1..n so the expected sum is n * (n + 1) / 2.
            for (int i = 1; i <= n; i++)
                await channel.Writer.WriteAsync(i);
            // Tell readers no more items are coming.
            channel.Writer.Complete();
        }

        async Task<(int Sum, int Count)> Consume()
        {
            int sum = 0, count = 0;   // 0: this consumer has seen nothing yet
            await foreach (int x in channel.Reader.ReadAllAsync())
            {
                sum += x;
                count++;
            }
            return (sum, count);
        }

        // Start both consumers on the pool, then produce.
        Task<(int Sum, int Count)> a = Task.Run(Consume);
        Task<(int Sum, int Count)> b = Task.Run(Consume);
        Task.WhenAll(Produce(), a, b).GetAwaiter().GetResult();
        return $"sum={a.Result.Sum + b.Result.Sum} items={a.Result.Count + b.Result.Count}";
    }

    /// <summary>Shows TryWrite failing on a full bounded channel.</summary>
    /// <returns>The result of three TryWrite calls on a channel of capacity 2.</returns>
    /// <example>ChannelDemo.TryWriteWhenFull() returns [true, true, false]</example>
    public static List<bool> TryWriteWhenFull()
    {
        var ch = Channel.CreateBounded<int>(2);   // 2 slots
        // Items 1, 2, 3: the third has no room.
        return [ch.Writer.TryWrite(1), ch.Writer.TryWrite(2), ch.Writer.TryWrite(3)];
    }
}

Pitfalls

Interview questions

Q. How do you build a background work queue in a .NET service?
A. A bounded Channel<T>. Request handlers call WriteAsync. A hosted service loops with await foreach over ReadAllAsync(stoppingToken). The bound gives back-pressure, and nothing blocks a thread.

12. Parallel.ForEachAsync

.NET 6

What it is

Parallel.ForEachAsync runs an async body for each item, with at most MaxDegreeOfParallelism bodies in flight. It accepts both IEnumerable<T> and IAsyncEnumerable<T>. It passes a token to each body and stops starting new items after the first failure or a cancel.

Why and when

Use it for throttled I/O fan-out, such as “call this API for 10,000 ids, at most 8 at a time”. Before .NET 6 that took a SemaphoreSlim plus WhenAll. Do not use Parallel.ForEach with an async lambda. That creates async void bodies that the loop does not wait for.

Sample

public static class ParallelDemo
{
    /// <summary>Processes n items with a cap of 3 in flight and records the peak.</summary>
    /// <param name="n">How many items.</param>
    /// <returns>The sum of item squares, and whether the peak stayed within the cap.</returns>
    /// <example>ParallelDemo.Throttled(10) returns "sum=385 peakOk=True"</example>
    public static string Throttled(int n)
    {
        // 3 is the cap: at most three bodies run at the same moment.
        const int Cap = 3;
        int inFlight = 0, peak = 0;
        long sum = 0;
        var options = new ParallelOptions { MaxDegreeOfParallelism = Cap };
        Parallel.ForEachAsync(Enumerable.Range(1, n), options, async (item, ct) =>
        {
            // Track the live count with Interlocked, since bodies run on many threads.
            int now = Interlocked.Increment(ref inFlight);
            // Raise peak to now, retrying if another thread changed it in between.
            int seen;
            while ((seen = Volatile.Read(ref peak)) < now &&
                   Interlocked.CompareExchange(ref peak, now, seen) != seen) { }
            await Task.Delay(2, ct);   // 2 ms of fake I/O
            Interlocked.Add(ref sum, (long)item * item);
            Interlocked.Decrement(ref inFlight);
        }).GetAwaiter().GetResult();
        return $"sum={sum} peakOk={peak <= Cap}";
    }
}

Pitfalls

Interview questions

Q. How do you call an API for 10,000 ids with at most 10 calls at once?
A. Parallel.ForEachAsync(ids, new ParallelOptions { MaxDegreeOfParallelism = 10 }, async (id, ct) => ...). On older .NET, use a SemaphoreSlim(10) with WaitAsync and Release around each call, plus Task.WhenAll.

13. Exceptions in async code

C# 5await unwraps since .NET 4.5

What it is

A faulted Task stores an AggregateException in task.Exception. How you observe it decides what you see.

Why and when

Interviewers ask “three tasks fail in a WhenAll, what do you catch?”. Keep the WhenAll task in a variable. Then inspect its Exception in the catch block to log every failure.

Sample

public static class AsyncErrors
{
    private static async Task FailAsync(string message)
    {
        await Task.Yield();
        throw new InvalidOperationException(message);
    }

    /// <summary>Compares the exception type seen by await, GetResult and Wait.</summary>
    /// <returns>The type each style throws.</returns>
    /// <example>AsyncErrors.WhatYouCatch() returns "await=InvalidOperationException ..."</example>
    public static string WhatYouCatch()
    {
        string viaAwait = "", viaGetResult = "", viaWait = "";
        async Task Awaiter()
        {
            try { await FailAsync("x"); }
            catch (Exception ex) { viaAwait = ex.GetType().Name; }
        }
        Awaiter().GetAwaiter().GetResult();
        try { FailAsync("x").GetAwaiter().GetResult(); }
        catch (Exception ex) { viaGetResult = ex.GetType().Name; }
        try { FailAsync("x").Wait(); }
        catch (Exception ex) { viaWait = ex.GetType().Name; }
        return $"await={viaAwait} getresult={viaGetResult} wait={viaWait}";
    }

    /// <summary>Three tasks fail in WhenAll. Await sees one. The task holds all three.</summary>
    /// <returns>The caught message, then every message from the WhenAll task.</returns>
    /// <example>AsyncErrors.AllFailures() returns "caught=a all=a,b,c"</example>
    public static string AllFailures()
    {
        async Task<string> Go()
        {
            // Already-faulted tasks keep the order fixed: a, b, c.
            // With real async work, the order follows completion instead.
            static Task Failed(string m) => Task.FromException(new InvalidOperationException(m));
            // Keep the WhenAll task so its full exception list stays reachable.
            Task all = Task.WhenAll(Failed("a"), Failed("b"), Failed("c"));
            try
            {
                await all;
                return "no error";
            }
            catch (InvalidOperationException first)
            {
                IEnumerable<string> every =
                    all.Exception!.InnerExceptions.Select(e => e.Message);
                return $"caught={first.Message} all={string.Join(",", every)}";
            }
        }
        return Go().GetAwaiter().GetResult();
    }

    /// <summary>Shows that an exception thrown before the first await still lands in the Task.</summary>
    /// <returns>Whether calling threw, and whether the returned task is faulted.</returns>
    /// <example>AsyncErrors.ThrowBeforeAwait() returns "callThrew=False faulted=True"</example>
    public static string ThrowBeforeAwait()
    {
        static async Task Validate(int x)
        {
            // Below 0 is invalid. This throw happens before any await runs.
            if (x < 0) throw new ArgumentOutOfRangeException(nameof(x));
            await Task.Yield();
        }
        bool callThrew = false;
        Task t = Task.CompletedTask;
        // -1 is a sample invalid input.
        try { t = Validate(-1); }
        catch (ArgumentOutOfRangeException) { callThrew = true; }
        return $"callThrew={callThrew} faulted={t.IsFaulted}";
    }
}

Pitfalls

Interview questions

Q. Three tasks in Task.WhenAll throw. What does await throw, and how do you see all three?
A. await throws the first exception, unwrapped. Keep the WhenAll task in a variable and read task.Exception.InnerExceptions in the catch block.
Q. What is the difference between .Result and .GetAwaiter().GetResult()?
A. Both block. .Result wraps failures in AggregateException. GetResult() rethrows the original exception, like await. Neither avoids the deadlock.
Say this out loud: “Await all the way up. Libraries use ConfigureAwait(false). No async void outside event handlers. Every long call takes a CancellationToken. If I start several tasks, I keep the WhenAll task so I can log every failure.”

Recap

The things to carry forward

Where this goes next

Async code passes results through many layers, and those results often have a shape worth matching on. A 06, Pattern Matching and Records, covers the C# features for taking data apart and building immutable values.


← A 04 — LINQ and Iterators A 06 — Pattern Matching and Records →