How await works under the hood

1 min read

The compiler rewrites an async method into a state machine: a generated type with an int _state and a MoveNext() method that acts like a switch.

C#
async Task<string> GetDataAsync(){    Console.WriteLine("before");                          // state 0    var result = await httpClient.GetStringAsync(url);    // suspend point    Console.WriteLine("after");                           // state 1, resumes here    return result;}
States of an async method: run the code before the await; if the task is already done, continue at once; if not, save the state and return, then MoveNext runs when the task completes. State 0 code before await task done? yes State 1 code after await no _state = 1 register, return later task completes: MoveNext() runs
If the task is already complete at the await, the method continues without suspending.

At an await

If the awaited task is not complete, the state is saved, a continuation is registered, and the method returns to its caller. The thread is not blocked. When the task completes, MoveNext() runs again, possibly on a different pool thread (see the thread poolThe thread poolA set of reusable worker threads that take jobs from a queue.Opens in a new tab).

C#
switch (_state){    case 0:        Console.WriteLine("before");        var awaiter = httpClient.GetStringAsync(url).GetAwaiter();        if (!awaiter.IsCompleted)        {            _state = 1;            awaiter.OnCompleted(MoveNext); // register the continuation, then RETURN: the thread is freed            return;        }        goto case 1;    case 1:        var result = awaiter.GetResult();        Console.WriteLine("after");        _result = result;        break;}

I/O

Network, file and database calls use OS async I/O (IOCP on Windows, epoll on Linux), so no thread waits. The OS signals completion and a pool thread runs MoveNext(). One thread can have thousands of operations in flight: concurrency without parallelism. Task.Run is different (see Task.RunTask.Run: when it helps and when it doesn'tTask.Run moves CPU work to a pool thread. It pays off when the current thread must stay free.Opens in a new tab).

Context

await captures the current SynchronizationContext and resumes on it, which in a UI app is the UI thread. ConfigureAwait(false) skips that, and libraries use it (see the UI threadThe UI thread and SynchronizationContextOne thread owns the controls, and other threads post work to it.Opens in a new tab).