Skip to main content
ANVISoftware Solutions
Lesson 53 of 62Advanced20 min

async and await

By the end of this lesson

Write asynchronous methods that read like ordinary sequential code.

The previous lesson left you holding tasks and collecting results by hand. Two keywords turn that into code that reads top to bottom like any other method.

await means "I need this result before I continue". If the work is not finished, your method stops there and gives the thread back. When the result arrives, your method picks up on the next line with all its local variables intact.

async marks a method as one that contains awaits. It permits the keyword and lets the compiler rewrite the method into something that can pause and resume. It does not, by itself, make anything asynchronous.

An order confirmation, written sequentially
C#
public class CheckoutService
{
    private readonly IOrderStore _orders;
    private readonly IStockService _stock;
    private readonly IEmailSender _email;

    public CheckoutService(IOrderStore orders, IStockService stock, IEmailSender email)
    {
        _orders = orders;
        _stock = stock;
        _email = email;
    }

    public async Task<string> ConfirmAsync(int orderId)
    {
        Order? order = await _orders.FindAsync(orderId);

        if (order is null)
        {
            return "No such order.";
        }

        bool inStock = await _stock.IsAvailableAsync(order.Sku, order.Quantity);

        if (!inStock)
        {
            return $"Order {order.Reference} is waiting for stock.";
        }

        order.Confirm();
        await _orders.SaveAsync(order);
        await _email.SendConfirmationAsync(order);

        return $"Order {order.Reference} confirmed.";
    }
}
  • Read it without the awaits and it is an ordinary method: find, check, confirm, save, notify. That readability is the whole point of the feature.
  • Each await is a place the method may pause. At each one, if the work is not already finished, the thread is released and the rest of the method is scheduled to run when the result arrives.
  • Local variables — order, inStock — survive across those pauses. The compiler moves them somewhere that outlives a single stretch of execution.
  • The if statements, the early return and the string interpolation all behave exactly as they would in a synchronous method. Ordinary control flow works.
  • The signature is async Task<string>. The caller receives a task immediately, and the string appears in it later.

What the runtime does at an await, once, so the rest of this lesson makes sense:

  1. Check whether the work is already done

    Awaiting a task that has already completed does not pause anything. Execution continues straight to the next line on the same thread. This is common with cached values and fast local reads.

  2. If not done, record where to resume

    The compiler has rewritten your method into a small object that remembers which stretch of code comes next and holds your local variables. That object is attached to the task as a continuation.

  3. Return to the caller

    Your method returns an incomplete task to whoever called it. If that caller awaits too, the same thing happens one level up. The thread ends up free.

  4. Resume when the result arrives

    The awaited work completes and the continuation is scheduled. A thread — often a different one from before — picks your method up on the following line. In a web app the request context travels with it, so things like the current user still work.

Async all the way up

Once a method is asynchronous, its callers should be too, right up to the point where something outside your code is in charge. That point is a controller action, a hosted service, a message handler, or Main.

Those entry points are all able to be asynchronous. ASP.NET Core awaits your controller action. A modern Main may be declared async Task. There is nearly always somewhere sensible to hand the task to.

The alternative — an asynchronous method called from a synchronous one that blocks for the answer — reintroduces exactly the cost you were removing, and adds a failure mode.

The same call, three ways
C#
// Wrong. Blocks the calling thread and risks a deadlock.
public string Confirm(int orderId)
{
    return ConfirmAsync(orderId).Result;
}

// Also wrong. Wait() has the same problems as Result.
public void Notify(Order order)
{
    _email.SendConfirmationAsync(order).Wait();
}

// Right. The asynchrony is passed up to the caller.
[HttpPost("orders/{orderId}/confirm")]
public async Task<IActionResult> Confirm(int orderId)
{
    string outcome = await _checkout.ConfirmAsync(orderId);

    return Ok(outcome);
}
  • The first method turns an asynchronous call back into a blocking one. The thread is held for the whole operation, so nothing was saved — and in some hosts it never finishes at all.
  • Wait() is the same mistake without a return value. So is GetAwaiter().GetResult(), which differs only in how it reports exceptions.
  • The third version changes the return type to Task<IActionResult> and awaits. The framework is happy to be handed a task; that is what it expects.
  • Notice that fixing this is usually a matter of propagating the signature change, not restructuring logic. It is tedious in a large codebase, which is why starting asynchronous is easier than converting later.

async void, and why it is almost always wrong

You can write async void. The compiler allows it. It exists for one reason: event handlers have a signature that returns void, and you sometimes need to await inside one.

Everywhere else it is a trap, because an async void method returns nothing that can be observed. There is no task, so there is nothing to await, nothing to know it finished, and nowhere for an exception to go.

An exception thrown after the first await in an async void method cannot be caught by the caller. It surfaces as an unhandled exception on whatever thread happened to be running the continuation, which in many hosts brings the process down.

The difference a return type makes
C#
// Dangerous. If archiving throws, the caller cannot catch it.
public async void ArchiveOrder(Order order)
{
    await _archive.StoreAsync(order);
}

// Correct. The caller can await it, and can catch failures.
public async Task ArchiveOrderAsync(Order order)
{
    await _archive.StoreAsync(order);
}

// The caller's view of each:
try
{
    ArchiveOrder(order);            // returns instantly; exceptions escape entirely
    await ArchiveOrderAsync(order); // waits, and a failure lands in the catch below
}
catch (Exception ex)
{
    _logger.LogError(ex, "Archiving order {Reference} failed.", order.Reference);
}
  • The only difference in the declarations is void versus Task. That one word decides whether failure is reportable.
  • The call to ArchiveOrder returns as soon as the first await is reached. The try block is finished long before the archiving is. Its catch can never see the failure.
  • The call to ArchiveOrderAsync is awaited, so the catch is still active when the failure surfaces. The exception is delivered at the await.
  • The structured logging call passes order.Reference as a parameter rather than building the message by hand, which keeps the reference searchable in the logs.

Summary

  • await means "I need this result now": if the work is unfinished, the method pauses and the thread is released
  • async gives the compiler permission to rewrite a method so it can pause and resume
  • Propagate asynchrony up to the framework entry point rather than blocking somewhere in the middle
  • Blocking with .Result or .Wait() holds the thread you were freeing and can deadlock in some hosts
  • async void produces no task, so its failures cannot be caught — use it only for event handlers

Practice

Attempt each one before opening the solution. Getting it wrong first is how the idea sticks.

Try it yourself

Try it yourself

You have a synchronous method that reads a CSV of supplier prices from disk, parses it, and returns a list. It is called from a controller action, which is also synchronous.

Sketch the conversion: which signatures change, in what order, and where does the chain stop?

Show solution

Start at the bottom, with the file read, and work upwards. The read becomes File.ReadAllTextAsync — or better, a stream read — and the method that contains it becomes async Task<List<SupplierPrice>> with an Async suffix.

Every caller then has to change, because it now receives a task. Each one becomes async Task or async Task<T> and awaits. The chain stops at the controller action, which becomes async Task<IActionResult>. ASP.NET Core awaits it for you, so nothing blocks.

The parsing itself stays synchronous. It is your own code doing work, so there is nothing to await, and wrapping it in Task.Run would add a thread hop for no benefit.

Why bottom-up rather than top-down? Because the compiler guides you. Change the lowest method and every caller becomes a compile error you can work through, which is far more reliable than searching for call sites.

Think about it

Think about it

A colleague says: "The deadlock warning about .Result is out of date. ASP.NET Core does not have a synchronisation context, so blocking is safe there."

The technical claim is accurate. Why is the advice still wrong?

Show solution

The deadlock is only one of the two problems, and it is the less common one. Blocking still holds a thread pool thread for the whole operation, which is precisely the cost async existed to remove. Under load, blocking calls exhaust the pool and response times climb.

There is a worse version of this. A blocked thread waiting on work that itself needs a pool thread can starve the very thing it is waiting for. As concurrency rises, that behaves like a deadlock even without a synchronisation context.

And code moves. A helper written for ASP.NET Core gets reused in a desktop tool or a library, where the context does exist. Correctness that depends on the host is not correctness.

There is one narrow exception worth knowing: a console application's entry point, before anything is running concurrently, is a place where blocking is harmless. Declaring Main as async Task is still clearer.

Knowledge check

Nothing is recorded and there is no score. The explanation appears either way.

What does await do when the task it is given has already completed?
Why can an exception from an async void method not be caught by its caller?

Saved in this browser only.