async/await State Machine
What the C# compiler actually generates when you write async/await β it's not magic, it's a state machine class.
Every async method you write becomes a compiler-generated state machine. The C# compiler transforms your linear async code into a class that implements IAsyncStateMachine, converting each await point into a state transition. Your local variables become fields, your await points become state numbers, and your method body becomes a MoveNext() method full of goto labels. Understanding this transformation demystifies ConfigureAwait, async void, and ValueTask behavior.
When you write 'async Task<T>', the C# compiler replaces your method body with a state machine struct/class. Your method becomes a wrapper that creates the state machine, initializes it, and calls MoveNext() once to start it. Every local variable, captured 'this', and awaiter becomes a field on the state machine.
The state field (named '<>1__state' in generated code) tells MoveNext() where to resume. -1 = initial call, 0 = at first await, 1 = at second await, -2 = completed. It's literally a goto-based state machine. The compiler emits goto labels at each await resume point.
Each awaited expression must provide an INotifyCompletion awaiter. When awaiter.IsCompleted is false, MoveNext() saves the awaiter, schedules itself as the continuation via builder.AwaitUnsafeOnCompleted(), and returns β yielding the thread back. The awaiter calls MoveNext() again when the operation completes.
By default, the continuation after an await is scheduled on the captured SynchronizationContext (e.g., the UI thread in WinForms, or the ASP.NET Classic request context). ConfigureAwait(false) opts out β the continuation runs on a thread pool thread. Missing this in library code deadlocks single-threaded SCs.
Task always allocates. ValueTask<T> avoids allocation when the result is already available synchronously (e.g., cache hit). The state machine is identical β ValueTask just uses a different builder. Use ValueTask for hot paths that frequently complete synchronously; don't use it everywhere as it adds complexity for little gain.
Key Concepts
The compiler-generated interface with a single MoveNext() method. Each async method becomes a class implementing this. The builder (AsyncTaskMethodBuilder<T>) manages the Task lifecycle and calls MoveNext() via continuations.
The heart of the state machine. Called once to start, then called again by each awaiter when its operation completes. Uses goto-based dispatch to jump to the correct resume point based on the state field.
Tells the awaiter to not capture the current SynchronizationContext for the continuation. Required in library code. Without it, async library code deadlocks when called from a single-threaded SC (WinForms, old ASP.NET) using .Result or .Wait().
Abstraction for marshaling work to a specific thread or context. WinForms has a UI-thread SC. ASP.NET Classic had a request SC. ASP.NET Core intentionally has no SC β ConfigureAwait(false) is less critical but still a good habit.
An async method returning void. Exceptions thrown inside it cannot be observed β they go to the unhandled exception handler and crash the process in .NET Core. Only acceptable for event handlers. Never use for library code.
A struct-based alternative to Task<T>. Avoids heap allocation when the result is synchronously available. Use for methods with frequent cache-hit paths. Don't await the same ValueTask twice β undefined behavior.
1// What you write:2public async Task<Order> GetOrderAsync(3 int orderId,4 CancellationToken cancellationToken)5{6 var user = await _userRepository // await #1 β state 0β17 .GetCurrentUserAsync(cancellationToken)8 .ConfigureAwait(false); // β missing this in libraries deadlocks910 var order = await _dbContext.Orders // await #2 β state 1β211 .Include(o => o.LineItems)12 .FirstOrDefaultAsync(13 o => o.Id == orderId && o.UserId == user.Id,14 cancellationToken)15 .ConfigureAwait(false);1617 return order ?? throw new KeyNotFoundException(18 $"Order {orderId} not found for user {user.Id}");19}2021// What the C# compiler ACTUALLY generates (~simplified):22[CompilerGenerated]23private sealed class GetOrderAsync_StateMachine24 : IAsyncStateMachine25{26 // State: -1 = not started, 0 = at first await,27 // 1 = at second await, -2 = done28 public int <>1__state;29 public AsyncTaskMethodBuilder<Order> <>t__builder;3031 // Captured locals (all method locals become fields)32 private int orderId;33 private CancellationToken cancellationToken;34 private OrderService <>4__this; // captured 'this'35 private User <user>5__1; // local 'user'36 private Order <order>5__2; // local 'order'3738 // Awaiters for each await point39 private TaskAwaiter<User> <>u__1;40 private TaskAwaiter<Order?> <>u__2;4142 void IAsyncStateMachine.MoveNext()43 {44 int state = this.<>1__state;45 try46 {47 if (state == 0) goto state0_resume;48 if (state == 1) goto state1_resume;4950 // State -1: initial call β start the first awaitable51 var awaiter1 = <>4__this._userRepository52 .GetCurrentUserAsync(cancellationToken)53 .ConfigureAwait(false)54 .GetAwaiter();5556 if (!awaiter1.IsCompleted)57 {58 <>1__state = 0; // remember we're at await #159 <>u__1 = awaiter1; // save awaiter60 <>t__builder.AwaitUnsafeOnCompleted(61 ref awaiter1, ref this); // schedule MoveNext as continuation62 return; // yield back to caller63 }6465 state0_resume: // continuation resumes here66 var user = <>u__1.GetResult();67 <user>5__1 = user;6869 var awaiter2 = <>4__this._dbContext.Orders70 .Include(o => o.LineItems)71 .FirstOrDefaultAsync(/* ... */, cancellationToken)72 .ConfigureAwait(false)73 .GetAwaiter();7475 if (!awaiter2.IsCompleted)76 {77 <>1__state = 1; // remember we're at await #278 <>u__2 = awaiter2;79 <>t__builder.AwaitUnsafeOnCompleted(80 ref awaiter2, ref this);81 return;82 }8384 state1_resume:85 var order = <>u__2.GetResult();86 <order>5__2 = order;8788 // Return value β sets Task result and transitions to -2 (done)89 var result = order ?? throw new KeyNotFoundException(/* ... */);90 <>t__builder.SetResult(result);91 }92 catch (Exception ex)93 {94 <>1__state = -2; // terminal state95 <>t__builder.SetException(ex); // faults the Task96 }97 }98}
The async/await transformation is one of the most impactful compiler features in C# history. It enables writing scalable I/O-bound code without manual callback spaghetti, while generating essentially the same state machine you'd write by hand. Understanding the generated code explains why async void is dangerous, why ConfigureAwait matters, why you can't use Span<T> across await points, and why awaiting the same ValueTask twice is undefined behavior.
Common Pitfalls
1The ConfigureAwait Deadlock That Took Down Checkout
We were migrating our checkout service from ASP.NET Framework to ASP.NET Core incrementally. During the migration window, our PaymentGatewayClient library (still targeting netstandard2.0) was called from classic ASP.NET controller actions. Every Friday evening checkout stalled β 100% CPU, requests hanging at payment step, then IIS recycled the app pool.
Our PaymentGatewayClient.ChargeAsync() method awaited multiple HttpClient calls without ConfigureAwait(false). ASP.NET Framework's SynchronizationContext is single-threaded per request. The controller called .Result on the task (blocking), which held the request SC thread. The continuation tried to resume on the same SC thread β deadlock. Only manifested under load when requests piled up.
Added ConfigureAwait(false) to every await in the library. Added a Roslyn analyzer rule (ConfigureAwaitChecker) to the library project to catch future violations in CI. Moved the library to async-all-the-way-down in the ASP.NET Classic layer, eliminating .Result calls. Deadlock never recurred.
Takeaway: Library code must use ConfigureAwait(false) on every await. Application code (controllers, handlers) doesn't need it in ASP.NET Core because there's no SynchronizationContext. But netstandard libraries run in both worlds β always use it. The deadlock only appears under load, making it a time-bomb in production.
2async void Swallowed Exceptions in the Email Worker
Our notification service had an email worker that processed a queue. Someone refactored the message handler from a void callback to async void to avoid blocking. For three months, occasional emails silently disappeared with no errors in logs. The monitoring team noticed a 0.3% delivery rate drop β just enough to be suspicious but below alert thresholds.
The async void handler threw SmtpException on certain malformed addresses. In async void methods, exceptions go directly to the thread's unhandled exception handler β not to any Task or caller. Our global UnhandledExceptionHandler suppressed these (it was set up for UI crash reporting, not server exceptions). The exception was lost, the message was acked off the queue, and delivery silently failed.
Changed async void to async Task everywhere in the worker. The queue framework was updated to catch Task exceptions and dead-letter the message. Added a policy: async void is banned in server code via a Roslyn analyzer (AsyncFixer). Fixed the SmtpException handling to retry with exponential backoff.
Takeaway: async void exceptions are not observable. They do not surface to the calling code. They do not fail the enclosing Task. In .NET Core they terminate the process unless there's an AppDomain.CurrentDomain.UnhandledException handler that swallows them. Never use async void outside of UI event handlers.