C# and .NET path · Asynchronous code · Topic 10 of 14

async/await: Task and the state machine

Not started

async and await let you write code that waits for I/O as if it were sequential, without holding a thread while it waits. The compiler does the heavy lifting: it turns an async method into a state machine that can stop at each await, hand its thread back, and continue later on whatever thread completes the awaited work. A Task is the object that connects the two halves.

Why it matters: A server that blocks a thread per request runs out of threads; async I/O lets a handful of threads serve thousands of requests, as long as nothing blocks them.

  1. 1A Task is a promise

    Task and Task<T> represent work that will finish later, with a result or an exception, and let you register what to run next.

  2. 2Synchronous until the first real wait

    An async method runs on the caller's thread until it awaits something that hasn't finished. Then it returns an incomplete Task to its caller.

  3. 3The compiler builds a state machine

    Locals become fields, each await becomes a numbered state, and the continuation calls MoveNext to resume where it left off.

  4. 4Don't block on async

    .Result and .Wait() tie up a thread waiting for work that may need a thread to finish. Await all the way up.

1

How it works

The explanation, with examples you can run.

What runs where

Console.WriteLine($"main thread {Environment.CurrentManagedThreadId}");var t = Work();Console.WriteLine("Work returned a Task; IsCompleted=" + t.IsCompleted);await t;Console.WriteLine($"after await, thread {Environment.CurrentManagedThreadId}"); static async Task Work(){    Console.WriteLine($"Work starts synchronously on thread {Environment.CurrentManagedThreadId}");    await Task.Delay(50);    Console.WriteLine($"Work resumes on thread {Environment.CurrentManagedThreadId}");}

In the .NET 10 console run this printed:

main thread 1Work starts synchronously on thread 1Work returned a Task; IsCompleted=FalseWork resumes on thread 5after await, thread 5

Work started on the caller's thread. At await Task.Delay(50) the delay wasn't finished, so Work registered a continuation and returned an incomplete Task. When the timer fired, a thread-pool thread ran the rest. A console app has no synchronization context, so nothing brings the code back to thread 1; the next topic covers when that differs. Your numbers will vary.

The state machine

The compiler rewrites Work into roughly this:

struct WorkStateMachine : IAsyncStateMachine{    public int state;                       // which await we are at    public AsyncTaskMethodBuilder builder;  // creates and completes the Task    TaskAwaiter awaiter;     public void MoveNext()    {        if (state == 0) goto resume0;        Console.WriteLine("Work starts ...");        awaiter = Task.Delay(50).GetAwaiter();        if (!awaiter.IsCompleted)        {            state = 0;            builder.AwaitUnsafeOnCompleted(ref awaiter, ref this);  // box, register MoveNext, return            return;        }    resume0:        awaiter.GetResult();                // rethrows if the delay faulted        Console.WriteLine("Work resumes ...");        builder.SetResult();                // completes the Task, runs its continuations    }}

(Simplified: the real code wraps the body in a try/catch that calls builder.SetException.) Locals that live across an await become fields. If every awaited task is already complete, the method runs straight through and nothing is allocated except, possibly, the Task itself. Only when it genuinely suspends is the state machine boxed onto the heap.

Why it scales

While Work waits, no thread is blocked. A socket read in ASP.NET Core is registered with the operating system's I/O notification mechanism (epoll on Linux, I/O completion ports on Windows), and a thread-pool thread runs the continuation when data arrives. A few dozen threads can serve thousands of concurrent requests.

That advantage disappears if code blocks. task.Result, task.Wait() and Thread.Sleep inside request handling hold a pool thread hostage. Under load the pool has to inject new threads slowly, requests queue up, and latency climbs: the problem known as thread-pool starvation. Keep the chain async from the controller down to the I/O call, use Task.WhenAll to wait for several operations at once, and pass a CancellationToken all the way down so abandoned requests stop their work.

Avoid async void except for event handlers: there is no Task for the caller to await, so an exception can't be observed and typically crashes the process.

2

Check yourself

3 questions. Get them all right to finish this topic.

  1. 1.In a console app, what runs on the calling thread when you call `var t = Work();`?
    static async Task Work(){    Console.WriteLine("A");    await Task.Delay(50);    Console.WriteLine("B");}
  2. 2.Where do an async method's local variables live while it is waiting?
  3. 3.What happens to an exception thrown inside an `async Task` method?

0 of 3 right so far. Get all three to finish this topic.