How await works under the hood
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.
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;}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).
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).