async, await and Tasks

Article · 15:00

A Task is work that may not have finished; await is where a method suspends and hands its thread back to the pool. What that actually frees, running work together with WhenAll, cancellation tokens, and why async void swallows the exception.

async, await and Tasks

A Task is work that may not have finished. async marks a method that can suspend; await is where it suspends. The thread is not blocked while it waits - it is returned to the pool and picks up other work, which is the whole point.

Console.WriteLine("before");
string name = await FetchNameAsync();
Console.WriteLine($"got {name}");
Console.WriteLine("after");

static async Task<string> FetchNameAsync()
{
    await Task.Delay(50);          // not Thread.Sleep: this does not hold a thread
    return "Ada";
}

await at the top level works because the compiler wraps the file in an async entry point. In a library the rule is: a method that awaits must be async, and an async method should return Task or Task<T> - never void, except for an event handler, because nothing can await it and nothing can catch what it throws.

Sequential await costs the sum; WhenAll costs the slowest

var clock = System.Diagnostics.Stopwatch.StartNew();

await LoadAsync("a", 60);
await LoadAsync("b", 60);
await LoadAsync("c", 60);
Console.WriteLine($"sequential took at least 180ms: {clock.ElapsedMilliseconds >= 180}");

clock.Restart();

// Start all three, THEN await. The tasks are already running by the time
// WhenAll is reached - which is the part people get wrong.
Task<string>[] running = [LoadAsync("a", 60), LoadAsync("b", 60), LoadAsync("c", 60)];
string[] results = await Task.WhenAll(running);
Console.WriteLine($"concurrent: {string.Join(",", results)} in about 60ms: "
                  + $"{clock.ElapsedMilliseconds < 180}");

static async Task<string> LoadAsync(string name, int ms)
{
    await Task.Delay(ms);
    return name;
}

Cancellation is a parameter, not an exception you invent

Every async API in .NET takes a CancellationToken. Pass it down; do not swallow the OperationCanceledException it produces.

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

try
{
    await SlowAsync(cts.Token);
}
catch (OperationCanceledException)
{
    Console.WriteLine("cancelled after 100ms, as asked");
}

// A timeout is the same mechanism with a clock attached.
using var timeout = new CancellationTokenSource(TimeSpan.FromMilliseconds(50));
string value = await FallbackAsync(timeout.Token);
Console.WriteLine(value);

static async Task SlowAsync(CancellationToken token)
{
    await Task.Delay(TimeSpan.FromSeconds(5), token);
}

static async Task<string> FallbackAsync(CancellationToken token)
{
    try
    {
        await Task.Delay(TimeSpan.FromSeconds(5), token);
        return "eventually";
    }
    catch (OperationCanceledException)
    {
        return "default, because it took too long";
    }
}

Errors travel in the Task

An exception inside an async method is captured in the returned task and rethrown at the await. That is what makes try/catch around an await work the way it looks like it should.

try
{
    await RiskyAsync(-1);
}
catch (ArgumentOutOfRangeException e)
{
    Console.WriteLine($"caught at the await: {e.ParamName}");
}

// WhenAll reports the FIRST exception at the await, but the task holds them all.
Task all = Task.WhenAll(RiskyAsync(-1), RiskyAsync(-2));
try
{
    await all;
}
catch (ArgumentOutOfRangeException)
{
    Console.WriteLine($"WhenAll collected {all.Exception?.InnerExceptions.Count} failures");
}

static async Task<int> RiskyAsync(int n)
{
    await Task.Yield();
    return n < 0 ? throw new ArgumentOutOfRangeException(nameof(n)) : n * 2;
}

The two rules that prevent most async bugs

Never block on async code. .Result and .Wait() deadlock in any context with a synchronisation context (classic ASP.NET, WinForms, WPF) and waste a thread everywhere else. Await it, all the way up.

Do not async void. Nothing can await it, so nothing can observe the failure: an exception from an async void method is thrown on the thread pool and takes the process down.

// Right: async all the way, returning Task.
await GoodAsync();

// A fire-and-forget that at least observes its own failure.
_ = Task.Run(async () =>
{
    try
    {
        await BackgroundAsync();
    }
    catch (Exception e)
    {
        Console.WriteLine($"background failed, and we know: {e.Message}");
    }
});

await Task.Delay(100);

static async Task GoodAsync()
{
    await Task.Delay(10);
    Console.WriteLine("finished properly");
}

static async Task BackgroundAsync()
{
    await Task.Delay(10);
    throw new InvalidOperationException("something went wrong out of sight");
}

IAsyncEnumerable: results that arrive over time

await foreach (int value in CountToAsync(3))
{
    Console.WriteLine($"got {value}");
}
Console.WriteLine("stream finished");

static async IAsyncEnumerable<int> CountToAsync(int n)
{
    for (int i = 1; i <= n; i++)
    {
        await Task.Delay(10);
        yield return i;               // one value at a time, as it is ready
    }
}

What to take away

  • await frees the thread; Thread.Sleep and .Result hold it.
  • An async method returns Task/Task<T> - async void only for event handlers.
  • Start tasks, then await Task.WhenAll - awaiting one at a time costs the sum.
  • Pass the CancellationToken down; a timeout is a token with a clock.
  • Exceptions are captured in the task and rethrown at the await.
  • await foreach over an IAsyncEnumerable<T> for results that arrive over time.