Every async method you've ever written became a hidden class too — the third member of a family you already know.
You've used Task and Task<T> since Intermediate lesson 151. You know a Task is a "promise" — an object standing in for work that hasn't finished yet. You know await suspends a method without blocking a thread (lesson 152). What none of those lessons showed you is what async actually compiles into — and the answer will feel very familiar. You've already opened up two other compiler-generated machines this book: lesson 189 showed you the hidden "display class" behind a capturing lambda, and lesson 196 opened up the hidden state-machine class behind yield return. An async method compiles into a third machine from the exact same family — a state machine with a numbered state field and resume logic — except this one doesn't produce an IEnumerable<T>. It builds and returns a Task.
In this lesson, you'll see the states a Task actually moves through, how continuations attach to a completing Task, and — the centerpiece — the literal shape of the state machine the C# compiler generates for an async method: the IAsyncStateMachine struct, the AsyncTaskMethodBuilder/AsyncTaskMethodBuilder<T> that actually constructs and completes the Task object you get back, and exactly how it's the third recognizable member of the compiler-generated-state-machine family you now know on sight.
A Task object is not the work itself. It's a ticket — an object that tracks a piece of work: whether it's finished, what it produced (for Task<T>), whether it failed, and who's waiting to be told when it's done. You've held that mental model since lesson 151. This lesson goes one level deeper: what actually flips that ticket from "not done" to "done," and what the compiler builds to make that happen automatically every time you write async.
System.Threading.Tasks.Task is a class representing an asynchronous operation's eventual completion, result, or failure — a future in the general computer-science sense (other ecosystems call the same idea a "promise"). Internally, a Task carries a status field, a place to store either a result (Task<TResult>) or a captured exception, and a list of registered continuations — delegates to invoke once the task transitions to a completed state. Completing a Task is the trigger event that runs every continuation registered on it.
Two things live inside every Task: a place to record how the operation ended (succeeded with a value, succeeded with nothing, failed with an exception, or was canceled), and a list of "call me when you know the answer" registrations. Everything else — await, ContinueWith, WhenAll — is built on exactly those two things.
An ordinary method call is a stack frame: its local variables live on the call stack, and the instant the method returns, that frame is gone. But an async method needs to do something a normal method can't — pause partway through at an await, hand control back to the caller, and later resume from that exact point, with every local variable still holding the value it had when execution paused. The stack frame that existed at the moment of the pause is long gone by the time the resume happens; something else has to remember where you were and what you were holding.
You already know this solution's shape from lessons 189 and 196: when something needs to survive past where the stack would normally erase it, promote it to a field on a heap-allocated object. That's exactly what the compiler does for async methods — it builds a class (or struct) with a field for every local variable and parameter that needs to survive an await, plus a numeric field recording exactly where execution paused. And because the caller also needs a way to know when that paused work eventually finishes, the compiler wires that same state machine to an AsyncTaskMethodBuilder, whose entire job is to create the Task object the caller gets back, and complete it — with a result, or with a captured exception — the moment the state machine reaches its end.
yield returnIEnumerable<T>/IEnumerator<T>asyncIAsyncStateMachine, driven by an AsyncTaskMethodBuilderIEnumerable<T>, or — here — a Task.
Every Task has a Status that moves forward through a fixed set of values (exposed as the TaskStatus enum). You'll rarely inspect this directly in application code, but it's exactly what await, .IsCompleted, and .IsFaulted are reading under the hood:
Task object exists, but hasn't necessarily been handed to the scheduler yet (rare in everyday code — mostly relevant to manually constructed Task objects created but not yet started).async-method-produced Tasks and Task.Run work, the task is either waiting for its turn on the thread pool or is actively executing right now.Task<T>, .Result is now safe to read without blocking.CancellationToken (lesson 153) and stopped cooperatively via OperationCanceledException.These last three are the terminal states — once a Task reaches one of them, it never changes again, and that's the exact moment its registered continuations run.
await is, underneath, a request to attach a continuation. When you write await someTask;, the compiler-generated code checks whether someTask is already complete. If it is, execution just carries on synchronously — no continuation needed. If it isn't, the rest of your method is packaged up as a callback and registered on the task, and control returns immediately to your caller. Later, when someTask reaches a terminal state, it walks its list of registered continuations and invokes each one — which, for an await, means "resume the state machine that was waiting on this task," typically by posting that resumption onto the thread pool (more on exactly where continuations run in the next two lessons, on SynchronizationContext and the ThreadPool).
Take a small async method with one await in the middle and a local variable used on both sides of it:
public async Task<int> GetTotalAsync(int orderId)
{
int baseAmount = 100; // local BEFORE the await
int fetched = await FetchDiscountAsync(orderId); // suspend here
return baseAmount - fetched; // local used AFTER the await
}
This is not compiled as an ordinary method. The moment the compiler sees async, it generates a struct — conventionally named something like <GetTotalAsync>d__0 — implementing IAsyncStateMachine, plus a rewritten version of GetTotalAsync that constructs one, initializes an AsyncTaskMethodBuilder<int>, and kicks it off. Written out by hand — simplified, but faithful to the real shape:
// Conceptually equivalent to what the compiler generates:
private struct <GetTotalAsync>d__0 : IAsyncStateMachine
{
public int <>1__state; // WHERE execution paused (-1 = not started / running, 0 = resuming after the await)
public int orderId; // captured parameter → field
public int <baseAmount>5__1; // local BEFORE the await → field
public AsyncTaskMethodBuilder<int> <>t__builder; // builds and completes the returned Task<int>
private TaskAwaiter<int> <>u__1; // holds the awaiter for the in-flight FetchDiscountAsync call
public void MoveNext()
{
int result;
try
{
if (<>1__state != 0)
{
<baseAmount>5__1 = 100; // int baseAmount = 100;
var awaiter = FetchDiscountAsync(orderId).GetAwaiter();
if (!awaiter.IsCompleted)
{
<>1__state = 0;
<>u__1 = awaiter;
<>t__builder.AwaitUnsafeOnCompleted(ref awaiter, ref this); // schedule the resume; RETURN to the caller now
return; // ← the state machine "pauses" here
}
}
else
{
awaiter = <>u__1; // resuming: recover the completed awaiter
}
int fetched = awaiter.GetResult(); // int fetched = await FetchDiscountAsync(...);
result = <baseAmount>5__1 - fetched; // return baseAmount - fetched;
}
catch (Exception ex)
{
<>1__state = -2;
<>t__builder.SetException(ex); // faults the returned Task
return;
}
<>1__state = -2;
<>t__builder.SetResult(result); // completes the returned Task with its result
}
public void SetStateMachine(IAsyncStateMachine sm) { /* rarely needed for the struct form */ }
}
// Your original method becomes essentially:
public Task<int> GetTotalAsync(int orderId)
{
var machine = new <GetTotalAsync>d__0
{
orderId = orderId,
<>t__builder = AsyncTaskMethodBuilder<int>.Create(),
<>1__state = -1
};
machine.<>t__builder.Start(ref machine); // runs MoveNext() once, synchronously, up to the first incomplete await
return machine.<>t__builder.Task; // the caller gets THIS immediately — long before the method's logic finishes
}
Code → Meaning → Result: Calling GetTotalAsync(42) doesn't run your method to completion. It builds the state machine, calls Start — which runs MoveNext() once — and hands back whatever Task<int> the builder has created, possibly before FetchDiscountAsync has even returned. If FetchDiscountAsync's task isn't finished yet, MoveNext() registers itself as that task's continuation (via AwaitUnsafeOnCompleted) and returns — control genuinely returns to whoever called GetTotalAsync, exactly as lesson 152 described. Later, when the discount task completes, MoveNext() runs a second time, this time entering the else branch, recovering the saved awaiter, computing the subtraction, and calling SetResult on the builder — which is the exact moment the Task<int> your original caller is holding actually completes and any of its own continuations fire.
Consider an ASP.NET Core action method that fetches an order, applies a discount lookup, and saves the result:
[HttpPost("checkout")]
public async Task<IActionResult> CheckoutAsync(int orderId)
{
var order = await _db.Orders.FindAsync(orderId); // await #1 — suspend point
var discount = await _pricing.GetDiscountAsync(order); // await #2 — suspend point
order.Total -= discount;
await _db.SaveChangesAsync(); // await #3 — suspend point
return Ok(order);
}
This one method compiles into a state machine with three possible resume points, one field per await's awaiter, and fields for orderId, order, and discount — every local that needs to survive across a suspend. Every one of those three awaits is a real place where the request-handling thread is released back to the ASP.NET Core thread pool to go handle other incoming requests, and where — once the awaited work finishes — the state machine's MoveNext() is scheduled again to pick up exactly where it left off. This is precisely how a modern web server handles thousands of concurrent, in-flight requests with a modest number of threads: nobody is dedicating a thread to sit and wait through three separate I/O operations per request.
Drop off a shirt at the dry cleaner and you get a claim ticket, not the shirt. The ticket doesn't do the cleaning — it's just something you (or anyone else holding it) can use to ask "is it ready?" or to be told the moment it is. A Task is that ticket. The state machine behind an async method is the counter clerk: they take your order, hand you the ticket immediately, then quietly do the actual work — pausing between steps as needed — and only mark the ticket "ready" (complete the Task, via the builder's SetResult/SetException) once the shirt genuinely is. Everyone holding a copy of that ticket number gets notified the same way, whether they've been waiting five seconds or five minutes.
yield return machine from lesson 196 (always a class), the compiler generates the async state machine as a struct when it safely can, specifically to avoid a heap allocation in the common case where the method completes synchronously without ever truly suspending (its first incomplete await never happens). The moment it actually needs to suspend — the awaited task isn't finished yet — the runtime boxes that struct onto the heap so its address stays stable across the pause, because a resuming continuation absolutely cannot be resuming into a stack frame that's since been reused for something else. This is the exact same "does it actually need heap storage" judgment call lesson 189 showed for capture-free vs. capturing lambdas — applied here to a different kind of state machine.AsyncTaskMethodBuilder (for async Task methods) and AsyncTaskMethodBuilder<TResult> (for async Task<T> methods) are the pieces that construct the actual Task/Task<T> object handed back to your caller, and later call SetResult or SetException on it once the state machine reaches its end. Your async method's return statement never directly touches a Task at all — it's entirely mediated through the builder. This is also the exact extension point ValueTask<T> (lesson 210) hooks into via a different builder, to skip allocating a Task object at all when possible.await hits an incomplete task, the generated code calls the builder's AwaitUnsafeOnCompleted(ref awaiter, ref stateMachine). This is what actually registers "call MoveNext() on this exact state machine instance again" as the continuation on the awaited task — and it's also the exact point where SynchronizationContext capture happens (the subject of the next lesson): by default, the awaiter captures the current context so the continuation can be marshaled back to the right place to resume on.MoveNext() body sits inside one big try/catch. Any exception your method throws — including one that bubbles up from an awaited task via awaiter.GetResult() re-throwing a captured exception — lands in that catch and gets handed to the builder's SetException, which stores it on the Task rather than crashing the process immediately. It only re-surfaces when your caller awaits that Task (or touches .Result) — precisely the mechanism the Intermediate exception-handling lesson described from the outside; this is what produces it.Nothing about the generated state machine inherently means work is happening on a background thread. MoveNext() runs synchronously on whatever thread invokes it — which, for the very first call (via Start), is the calling thread itself, right up until the first genuinely incomplete await. Only when the awaited operation is something that itself uses another thread (like CPU-bound work wrapped in Task.Run) does a different thread actually get involved. async/await is about suspending and resuming a method, not about scheduling it onto a specific thread.
Both are compiler-generated types with a numbered state field and fields standing in for locals that must survive a pause — the same underlying trick. But they implement completely different interfaces and drive completely different outcomes: the yield return machine implements IEnumerable<T>/IEnumerator<T> and is pulled forward one item at a time by MoveNext() calls from a foreach; the async machine implements IAsyncStateMachine and is pushed forward by completions of the tasks it's awaiting, via a builder that owns a Task. Seeing the family resemblance ("compiler promotes locals to fields, tracks a resume point") doesn't mean they're interchangeable machinery.
Believing that calling an async method just queues it for some future point, doing nothing immediately. As the Simple Example showed, Start(ref machine) runs MoveNext() synchronously, right now, all the way up to the first genuinely incomplete await — real work happens before your caller gets the returned Task back. Understand that everything up to the first suspend point runs immediately, on the calling thread, exactly like ordinary code.
Writing reflection code, or reasoning about behavior, based on names like <>1__state or <GetTotalAsync>d__0 appearing exactly that way across builds or compiler versions. These are Roslyn implementation details — genuinely useful for building the correct mental model (as this lesson does), but never something to depend on programmatically. They can and do shift between compiler versions.
Assuming an async method with no real work has zero cost. Calling it still constructs a state machine value (possibly boxed if it must suspend), sets up an AsyncTaskMethodBuilder, and allocates a Task object for the builder to hand back — real, if usually small, overhead on every single call. In an extremely hot, high-call-frequency path where synchronous completion is common, this per-call cost is exactly the motivating problem lesson 210 (ValueTask) exists to address. For ordinary application code this overhead is negligible and not worth avoiding — reach for ValueTask deliberately, only where profiling shows it matters.
You've been writing async methods since Intermediate Part VII — this lesson isn't a new tool, it's the mental model underneath the tool you already reach for constantly.
Task is merely returned.await point rather than at the point they were thrown.<CheckoutAsync>d__4.MoveNext() — you now know exactly what that frame is.await at all runs entirely synchronously (no suspend point ever gets reached).async/await remains the correct way to write asynchronous code in everyday C# — nothing here suggests writing IAsyncStateMachine implementations by hand.Task objects (via TaskCompletionSource) is reserved for bridging non-Task-based async APIs — not a substitute for async/await in ordinary code.Task is a completion signal plus a list of continuations to run when it fires.async methods compile into a state machine implementing IAsyncStateMachine — the third member of the compiler-generated state-machine family, after lambda closures (189) and yield return iterators (196).AwaitUnsafeOnCompleted is where a continuation actually registers itself on the awaited task.Task is a completion tracker — a status, a result-or-exception slot, and a list of continuations to invoke on completion.Task moves through states (Created → Running → RanToCompletion/Faulted/Canceled) — the terminal states are exactly when its continuations fire.async method compiles into a compiler-generated struct (or class, once it must be boxed) implementing IAsyncStateMachine, with a numeric state field and one field per surviving local — the same pattern as lessons 189 and 196, applied to a third kind of machine.AsyncTaskMethodBuilder/AsyncTaskMethodBuilder<T> is what actually creates and completes the real Task/Task<T> object your caller receives.await runs synchronously, immediately, on the calling thread — the returned Task only represents whatever's left after that.You've opened up the third member of the compiler-generated state-machine family. Let's check your understanding.
1. What does a Task object primarily represent?
Correct: B
Why B is correct: A Task is a tracking object — a status, a slot for a result or captured exception, and a list of continuations to invoke once it completes. It's a promise/future, not the work itself.
Why A is incorrect: A Task doesn't inherently mean a dedicated thread is running — plenty of Tasks represent I/O-bound work with no thread actively blocked on it at all, as the ThreadPool lesson covers next.
Why C is incorrect: The result isn't computed at creation time — for an async-method-produced Task, the method may not even have started its real work when the Task is returned.
Why D is incorrect: A Task is a single operation's tracker, not a queue — the thread pool's work queue is a separate concept, covered in lesson 209.
Reinforcement: Task = completion tracker + continuation list. That's the whole idea underneath async/await.
2. When you call an async method, what actually runs immediately, on the calling thread?
Correct: B
Why B is correct: As the Simple Example's builder.Start(ref machine) call showed, MoveNext() runs immediately and synchronously, executing everything up through the first await whose task isn't already complete — only then does control actually return to the caller.
Why A is incorrect: This is the exact misconception Common Mistakes calls out — real work happens before the Task is even returned, not only once someone later awaits it.
Why C is incorrect: If that were true, await would be pointless — the entire reason a Task can be "not yet complete" is that execution genuinely paused partway through.
Why D is incorrect: async does not inherently spawn a thread — MoveNext() runs on whatever thread called the method, at least up to the first suspend point.
Reinforcement: "Async" means "can pause," not "runs later" or "runs elsewhere" — everything up to the first real pause is ordinary, immediate, same-thread execution.
3. What is the role of AsyncTaskMethodBuilder/AsyncTaskMethodBuilder<T> in the compiler-generated machinery?
Correct: B
Why B is correct: As Under the Hood point 2 explained, the builder owns the Task's lifecycle — it constructs the Task the caller receives and calls SetResult or SetException on it once MoveNext() reaches the state machine's end.
Why A is incorrect: The state field lives on the state machine struct itself (<>1__state), not on the builder — they're separate pieces working together.
Why C is incorrect: The builder is compiler-generated infrastructure, invoked by the rewritten method body — application code writes async/await and never touches an AsyncTaskMethodBuilder directly.
Why D is incorrect: CancellationToken is an entirely separate, orthogonal mechanism (lesson 211) — the builder has nothing to do with cancellation signaling.
Reinforcement: Your return statement in an async method never touches a Task directly — the builder mediates the entire relationship.
4. How does the async-method state machine relate to the yield-return state machine from lesson 196?
Correct: B
Why B is correct: As Common Confusion #2 explained, both promote surviving locals to fields and track a resume point with a state field — the same family trait — but one implements IEnumerable<T>/IEnumerator<T> and is pulled by foreach, while the other implements IAsyncStateMachine and is pushed forward by task completions through a builder that owns a Task.
Why A is incorrect: They're genuinely different generated types serving different interfaces — recognizing the shared pattern doesn't make them interchangeable.
Why C is incorrect: This entire lesson demonstrates the opposite — async methods absolutely compile to a real state machine, just as documented and shown in the Simple Example.
Why D is incorrect: async and yield return are independent language features — a method can use one, the other, or (in the case of async iterators, covered later) both together, but neither requires the other.
Reinforcement: Same family trick — promote locals to fields, track a resume point — applied to two different jobs.
5. An async method throws an exception partway through its body, after an await. What happens to that exception?
Correct: C
Why C is correct: As Under the Hood point 4 showed, the entire rewritten method body sits inside one try/catch inside MoveNext(); any exception lands there and is handed to SetException, which stores it on the Task rather than throwing it right away — it only re-throws at the point a caller awaits that Task.
Why A is incorrect: This is exactly what the Task-based capture mechanism exists to avoid — an async Task method's exceptions are captured, not thrown immediately on some arbitrary thread.
Why B is incorrect: The exception is very much preserved, not discarded — it's stored on the Task precisely so a caller can observe it later.
Why D is incorrect: Nothing about the Task/async machinery does automatic retries — that would need to be application-level logic (e.g. a resiliency library), not something the compiler-generated state machine provides.
Reinforcement: Exceptions in async methods are captured onto the Task, not thrown immediately — this is the mechanism, not just a fact to memorize.
You've opened the hood on Task and async itself. Next: where the resumed continuation actually runs — SynchronizationContext.
dotnetmadeeasy.com — Learn C# and .NET, the right way.