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 5Work 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.
Check yourself
3 questions. Get them all right to finish this topic.
0 of 3 right so far. Get all three to finish this topic.