Skip to main content
C# · lesson 16 of 26
C# intermediate Lesson 16 of 26

Async/Await in C#

C# async/await by example: what await really does, Task.WhenAll vs sequential awaits, throttling, cancellation, ValueTask, async streams, async void.

By SkillByExample · Updated

Why Async: Waiting Without a Thread

A synchronous wait holds a thread hostage for the whole duration. An await on I/O (or a timer) releases the thread, so a handful of threads can serve thousands of waits.

using System.Diagnostics;

ThreadPool.SetMinThreads(4, 4);
const int N = 200;

var sw = Stopwatch.StartNew();
await Task.WhenAll(Enumerable.Range(0, N).Select(_ => Task.Run(() => Thread.Sleep(200))));
Console.WriteLine($"blocking waits: {sw.ElapsedMilliseconds,5} ms, pool threads {ThreadPool.ThreadCount}");

sw.Restart();
await Task.WhenAll(Enumerable.Range(0, N).Select(_ => Task.Delay(200)));
Console.WriteLine($"async waits:    {sw.ElapsedMilliseconds,5} ms");
$ dotnet run
blocking waits:  8827 ms, pool threads 5
async waits:      202 ms

Both lines wait 200 ms two hundred times. The blocking version needs a thread per sleeping task, and the thread pool only adds threads gradually, so the waits queue up and the whole run takes several times longer. The async version finishes in about 200 ms, because a pending Task.Delay occupies no thread at all. A web server calling a database is the same situation with real I/O instead of a timer. Exact timings vary by machine and core count.

async and await: Execution Order

An async method runs synchronously until its first await of an unfinished task, then returns a Task to its caller. The rest of the method runs later.

Console.WriteLine($"1. Main starts on thread {Environment.CurrentManagedThreadId}");
Task<string> pending = FetchUserAsync(42);
Console.WriteLine($"3. Main got a Task back: IsCompleted={pending.IsCompleted}");

string user = await pending;
Console.WriteLine($"5. Main resumes with '{user}' on thread {Environment.CurrentManagedThreadId}");

static async Task<string> FetchUserAsync(int id)
{
    Console.WriteLine($"2. FetchUserAsync runs synchronously until its await");
    await Task.Delay(100);
    Console.WriteLine($"4. FetchUserAsync resumes on thread {Environment.CurrentManagedThreadId}");
    return $"user-{id}";
}
$ dotnet run
1. Main starts on thread 1
2. FetchUserAsync runs synchronously until its await
3. Main got a Task back: IsCompleted=False
4. FetchUserAsync resumes on thread 5
5. Main resumes with 'user-42' on thread 5

Step 3 printing before step 4 is the whole model: at await Task.Delay, FetchUserAsync handed back an incomplete Task and Main kept going. When the delay finished, the continuation ran — on a thread-pool thread, which is why the thread ID changed. Console apps and ASP.NET Core have no SynchronizationContext, so continuations resume on whatever pool thread is free.

Task.WhenAll and Task.WhenAny

Start several tasks first, then await them together. WhenAll waits for every task; WhenAny returns the first to finish.

using System.Diagnostics;

var sw = Stopwatch.StartNew();
string a = await GetAsync("a", 300);
string b = await GetAsync("b", 200);
Console.WriteLine($"sequential: {a}{b} in ~{Round(sw)} ms");

sw.Restart();
Task<string> ta = GetAsync("a", 300), tb = GetAsync("b", 200);
string[] both = await Task.WhenAll(ta, tb);
Console.WriteLine($"WhenAll:    {string.Concat(both)} in ~{Round(sw)} ms");

sw.Restart();
Task<string> winner = await Task.WhenAny(GetAsync("slow", 500), GetAsync("fast", 100));
Console.WriteLine($"WhenAny:    {await winner} in ~{Round(sw)} ms");

Console.WriteLine(await Task.FromResult(42));

static async Task<string> GetAsync(string name, int ms) { await Task.Delay(ms); return name; }
static long Round(Stopwatch sw) => sw.ElapsedMilliseconds / 50 * 50;
$ dotnet run
sequential: ab in ~500 ms
WhenAll:    ab in ~300 ms
WhenAny:    fast in ~100 ms
42

Two sequential awaits cost the sum (300 + 200); starting both before awaiting costs the maximum. WhenAny returns the finished Task, not its value, hence the second await — and the slow task keeps running in the background unless you cancel it. Task.FromResult wraps a value you already have. (Times are rounded down to 50 ms.)

Awaiting in a Loop: Sequential, Concurrent, Throttled

await inside a foreach runs the calls one at a time. Projecting to tasks and using WhenAll runs them all at once; a SemaphoreSlim caps how many run at a time.

using System.Diagnostics;

int[] ids = Enumerable.Range(1, 10).ToArray();
int active = 0, peak = 0;

async Task<int> GetValueAsync(int id)
{
    int now = Interlocked.Increment(ref active);
    InterlockedMax(ref peak, now);
    await Task.Delay(100);
    Interlocked.Decrement(ref active);
    return id * id;
}

var sw = Stopwatch.StartNew();
var seq = new List<int>();
foreach (int id in ids) seq.Add(await GetValueAsync(id));
Report("sequential", seq.Sum());

var all = await Task.WhenAll(ids.Select(GetValueAsync));
Report("WhenAll", all.Sum());

var gate = new SemaphoreSlim(3);
var throttled = await Task.WhenAll(ids.Select(async id =>
{
    await gate.WaitAsync();
    try { return await GetValueAsync(id); }
    finally { gate.Release(); }
}));
Report("throttled(3)", throttled.Sum());

void Report(string label, int sum)
{
    Console.WriteLine($"{label,-13} sum={sum} peak={peak,2} ~{sw.ElapsedMilliseconds / 100 * 100} ms");
    peak = 0; sw.Restart();
}

static void InterlockedMax(ref int target, int value)
{
    int cur;
    while (value > (cur = target) && Interlocked.CompareExchange(ref target, value, cur) != cur) { }
}
$ dotnet run
sequential    sum=385 peak= 1 ~1000 ms
WhenAll       sum=385 peak=10 ~100 ms
throttled(3)  sum=385 peak= 3 ~400 ms

Same results, three shapes. Sequential: never more than one call in flight, ten times the latency. WhenAll: all ten at once, one round-trip of latency — and ten simultaneous hits on whatever service is behind GetValueAsync. Throttled: at most three in flight, four “waves” of 100 ms. Throttling is what you want when the downstream service has a rate limit or a connection pool. (.NET 6+ also offers Parallel.ForEachAsync with MaxDegreeOfParallelism for the same job.)

CancellationToken

Pass a CancellationToken down through async calls so a caller can abandon work that is no longer needed. CancellationTokenSource can cancel after a timeout.

using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(350));

try
{
    await ProcessItemsAsync(Enumerable.Range(1, 10), cts.Token);
}
catch (OperationCanceledException ex)
{
    Console.WriteLine($"cancelled: {ex.GetType().Name}, token cancelled={cts.IsCancellationRequested}");
}

var done = await Task.WhenAny(Task.Delay(5000, CancellationToken.None), Task.Delay(50));
Console.WriteLine("WhenAny returned without waiting for the 5 s delay");

static async Task ProcessItemsAsync(IEnumerable<int> items, CancellationToken ct)
{
    foreach (var item in items)
    {
        ct.ThrowIfCancellationRequested();
        await Task.Delay(100, ct);
        Console.WriteLine($"processed {item}");
    }
}
$ dotnet run
processed 1
processed 2
processed 3
cancelled: TaskCanceledException, token cancelled=True
WhenAny returned without waiting for the 5 s delay

After about 350 ms the token fired in the middle of item 4’s Task.Delay, which throws immediately rather than waiting out its 100 ms. The exception is TaskCanceledException, a subclass of OperationCanceledException, so catch the base type. The last lines show the flip side: without a token, the “losing” 5-second delay in WhenAny is simply abandoned, still running, with nothing to stop it.

ConfigureAwait(false)

By default await resumes on the captured SynchronizationContext, if there is one. ConfigureAwait(false) says “any thread will do”.

Console.WriteLine($"SynchronizationContext here: {SynchronizationContext.Current?.GetType().Name ?? "none"}");

byte[] data = await ReadAsync();
Console.WriteLine($"read {data.Length} bytes");

static async Task<byte[]> ReadAsync()
{
    await File.WriteAllBytesAsync("blob.bin", new byte[1024]).ConfigureAwait(false);
    return await File.ReadAllBytesAsync("blob.bin").ConfigureAwait(false);
}
$ dotnet run
SynchronizationContext here: none
read 1024 bytes

In a console app or ASP.NET Core there is no context, so ConfigureAwait(false) changes nothing observable — as the first line shows. It matters in UI apps (WinForms, WPF, MAUI) and old ASP.NET, which do have a single-threaded context: there, library code that blocks with .Result while its continuation waits for that same thread deadlocks. Library authors add ConfigureAwait(false) so their code is safe wherever it is called from; application code in ASP.NET Core can leave it out.

ValueTask for Hot, Often-Synchronous Paths

Task<T> is an object, so a method that usually returns a cached value still allocates. ValueTask<T> avoids that when the result is already available.

var pricer = new CachedPricer();
Console.WriteLine(await pricer.GetPriceTask("W-1"));
Console.WriteLine(await pricer.GetPriceValueTask("W-1"));

long before = GC.GetAllocatedBytesForCurrentThread();
for (int i = 0; i < 10_000; i++) await pricer.GetPriceTask("W-1");
Console.WriteLine($"Task<decimal>:      {GC.GetAllocatedBytesForCurrentThread() - before:N0} bytes");

before = GC.GetAllocatedBytesForCurrentThread();
for (int i = 0; i < 10_000; i++) await pricer.GetPriceValueTask("W-1");
Console.WriteLine($"ValueTask<decimal>: {GC.GetAllocatedBytesForCurrentThread() - before:N0} bytes");

public class CachedPricer
{
    private readonly Dictionary<string, decimal> _cache = new();

    public Task<decimal> GetPriceTask(string id) =>
        _cache.TryGetValue(id, out var p) ? Task.FromResult(p) : FetchAsync(id);

    public ValueTask<decimal> GetPriceValueTask(string id) =>
        _cache.TryGetValue(id, out var p) ? new ValueTask<decimal>(p) : new ValueTask<decimal>(FetchAsync(id));

    private async Task<decimal> FetchAsync(string id)
    {
        await Task.Delay(50);
        return _cache[id] = 9.99m;
    }
}
$ dotnet run
9.99
9.99
Task<decimal>:      801,856 bytes
ValueTask<decimal>: 0 bytes

After the first call fills the cache, 10,000 Task<decimal> hits allocate a new Task object each time (roughly 80 bytes apiece); the ValueTask version allocates nothing. The rule that comes with it: await a ValueTask exactly once — never store it and await it twice, or call .Result on it before it completes. Use it only where profiling shows the allocation matters.

Async Streams with IAsyncEnumerable

IAsyncEnumerable<T> produces items asynchronously, one at a time; await foreach consumes them as they arrive.

using System.Diagnostics;
using System.Runtime.CompilerServices;

var sw = Stopwatch.StartNew();
await foreach (var page in FetchPagesAsync(3))
    Console.WriteLine($"~{sw.ElapsedMilliseconds / 100 * 100,3} ms: got {page}");

using var cts = new CancellationTokenSource();
await foreach (var page in FetchPagesAsync(100).WithCancellation(cts.Token))
{
    Console.WriteLine($"processing {page}");
    if (page == "page 2") cts.Cancel();
}

static async IAsyncEnumerable<string> FetchPagesAsync(int count, [EnumeratorCancellation] CancellationToken ct = default)
{
    for (int i = 1; i <= count && !ct.IsCancellationRequested; i++)
    {
        await Task.Delay(100, CancellationToken.None);
        yield return $"page {i}";
    }
}
$ dotnet run
~100 ms: got page 1
~200 ms: got page 2
~300 ms: got page 3
processing page 1
processing page 2

Each page is printed about 100 ms after the previous one — the consumer works on page 1 while page 2 has not been fetched yet, instead of waiting 300 ms for a full list. WithCancellation flows the token into the [EnumeratorCancellation] parameter; this producer checks it between pages and stops cleanly after page 2 instead of fetching 100.

The Classic Mistakes

async void cannot be awaited, so its exceptions escape every try/catch. That is the most damaging async mistake.

try
{
    await FailingTask();
}
catch (Exception ex)
{
    Console.WriteLine($"async Task: caught '{ex.Message}'");
}

try
{
    FailingVoid();
    await Task.Delay(200);
    Console.WriteLine("never printed");
}
catch (Exception ex)
{
    Console.WriteLine($"async void: caught '{ex.Message}'");
}

static async Task FailingTask() { await Task.Yield(); throw new InvalidOperationException("from Task"); }
static async void FailingVoid() { await Task.Yield(); throw new InvalidOperationException("from void"); }
$ dotnet run
async Task: caught 'from Task'
Unhandled exception. System.InvalidOperationException: from void
   at Program.<<Main>$>g__FailingVoid|0_1()
   at System.Threading.Tasks.Task.<>c.<ThrowAsync>b__128_1(Object state)
   at System.Threading.ThreadPoolWorkQueue.Dispatch()
   at System.Threading.PortableThreadPool.WorkerThread.WorkerThreadStart()

The async Task failure was caught normally. The async void failure was not: the exception was rethrown on a thread-pool thread, nothing could catch it, and the whole process died before “never printed” — or the second catch — could run. Three more rules round this out: don’t block on async code with .Result or .Wait() (deadlock risk under a synchronization context, wasted threads everywhere else); don’t wrap a method in async/await just to return a task you already have — Task<int> Get() => Task.FromResult(42); needs no state machine; and don’t create a new HttpClient per call — reuse one, or use IHttpClientFactory.

Practice

  1. Timeout wrapper. Write Task<T> WithTimeout<T>(Task<T> task, TimeSpan timeout) that throws TimeoutException if the task has not finished in time. Test with a 50 ms task and a 500 ms task against a 200 ms limit.
Solution
Console.WriteLine(await WithTimeout(Work(50), TimeSpan.FromMilliseconds(200)));
try { await WithTimeout(Work(500), TimeSpan.FromMilliseconds(200)); }
catch (TimeoutException ex) { Console.WriteLine(ex.Message); }

static async Task<string> Work(int ms) { await Task.Delay(ms); return $"done in {ms} ms"; }

static async Task<T> WithTimeout<T>(Task<T> task, TimeSpan timeout)
{
    var winner = await Task.WhenAny(task, Task.Delay(timeout));
    if (winner != task) throw new TimeoutException($"gave up after {timeout.TotalMilliseconds} ms");
    return await task;
}
$ dotnet run
done in 50 ms
gave up after 200 ms

.NET 6+ has this built in: await task.WaitAsync(timeout).

  1. Parallel.ForEachAsync. Rewrite the throttled example with Parallel.ForEachAsync and MaxDegreeOfParallelism = 3, summing the squares of 1-10.
Solution
int total = 0;
await Parallel.ForEachAsync(Enumerable.Range(1, 10),
    new ParallelOptions { MaxDegreeOfParallelism = 3 },
    async (id, ct) =>
    {
        await Task.Delay(100, ct);
        Interlocked.Add(ref total, id * id);
    });
Console.WriteLine(total);
$ dotnet run
385

Interlocked.Add is needed because up to three bodies update total at once.

  1. First successful mirror. Given three simulated downloads (fail after 50 ms, succeed after 150 ms, succeed after 300 ms), return the first successful result, ignoring failures.
Solution
var tasks = new List<Task<string>> { Mirror("A", 50, fail: true), Mirror("B", 150), Mirror("C", 300) };

while (tasks.Count > 0)
{
    var done = await Task.WhenAny(tasks);
    tasks.Remove(done);
    if (done.IsCompletedSuccessfully) { Console.WriteLine($"winner: {done.Result}"); break; }
    Console.WriteLine($"failed: {done.Exception!.InnerException!.Message}");
}

static async Task<string> Mirror(string name, int ms, bool fail = false)
{
    await Task.Delay(ms);
    return fail ? throw new HttpRequestException($"mirror {name} down") : $"mirror {name}";
}
$ dotnet run
failed: mirror A down
winner: mirror B

Reading done.Result is safe here because the task is already complete — the “don’t use .Result” rule is about blocking on unfinished tasks.

Frequently Asked Questions

What is the difference between Task.Run and async/await?
Task.Run offloads CPU-bound work to a thread pool thread. async/await is for I/O-bound work — it releases the thread while waiting for a network call, file read, or database query, then resumes when the result is ready. Don't use Task.Run for I/O-bound work.
When should I use ConfigureAwait(false)?
Use ConfigureAwait(false) in library code that doesn't need to resume on the original synchronization context. This avoids deadlocks in legacy frameworks (WinForms, ASP.NET classic) and is slightly more efficient. In ASP.NET Core there is no SynchronizationContext, so it makes no functional difference but is still good practice in libraries.
What is ValueTask and when should I use it?
ValueTask<T> avoids the heap allocation that Task<T> requires when the result is available synchronously (from a cache, for example). Use it in high-throughput code where the fast path often completes synchronously. For most application code, Task<T> is simpler and fine.
Does async make my code run on another thread?
Not by itself. An async method runs synchronously on the caller's thread until its first await of something that has not finished yet. While it waits for I/O, no thread is used at all; when the I/O completes, the rest of the method is scheduled, usually on a thread-pool thread in console and ASP.NET Core apps. Use Task.Run only for CPU-bound work you want off the current thread.
Why is async void dangerous?
An async void method returns nothing the caller can await, so an exception inside it cannot be caught by the caller. It is rethrown on the thread pool, where it crashes the process. Use async Task everywhere except event handlers, which must be void because of their delegate signature.