Async Internals

By the end of this lesson you'll understand what really happens when you write async / await : how the compiler rewrites your method into a resumable state machine , what the awaiter pattern does under the hood, why ConfigureAwait(false) matters, when ValueTask beats Task , and exactly where threads do — and don't — get used.

Learn Python, JavaScript, Java and more with free interactive lessons, real projects and AI-powered help. Beginner-friendly.

Part of the free C# course at LearnCodingFast — hands-on lessons with examples you run in your browser, plus practice exercises and a quick quiz.

The compiler rewrites an async method into a state machine — like a recipe broken into resumable steps . Imagine a recipe card where each step ends with "…then wait for the oven timer". You don't stand and stare at the oven; you put the card down with a sticky note on the current step ("State 1"), go do something else, and when the timer dings you pick the card back up and continue from the exact step you marked. Each await is one of those "wait for the timer" lines, the sticky note is the saved state , and "pick the card back up" is the continuation . The method's local variables are the ingredients already measured out, kept on the card so they survive the pause.

The single idea that unlocks this whole topic: everything after an await is a separate piece of code called a continuation . Your one tidy async method is, at compile time, chopped into segments at each await . The runtime runs the first segment now, and schedules the rest to run later — once the awaited task signals it's done.

None of this requires a new thread. For real I/O — a network or disk call — there is usually no thread occupied at all during the wait; the OS calls you back when the data arrives. async ≠ multithreading.

You rarely type any of the left column by hand — the compiler does. But knowing it exists is what turns "async magic" into "async I can debug".

1. Code After await Is a Continuation

Start with something you can see . An async method runs straight through until its first real await ; at that point it pauses and returns control to whoever called it. The lines after the await are a continuation — a separate chunk the runtime runs later , when the awaited task finishes. The worked example below prints numbered messages so the resume order is visible. Read it, run it, and follow the numbers.

Your turn. This tiny program just needs the async pause and the continuation line. Fill in the two ___ blanks, run it, and confirm the lines print A, then B, then C — proving the line after await only runs once the delay completes.

2. How async Compiles to a State Machine

So how does the method "remember where it was"? The compiler rewrites your async method into a state machine — a generated struct that implements IAsyncStateMachine and exposes a single method, MoveNext() . Your method body is split into segments at each await ; an int state field records which segment to run next, and your local variables become fields on the struct so they survive each pause. When an awaited task completes, the runtime calls MoveNext() again and a switch on state jumps straight to the right segment. The worked example shows the three segments running in order, with the compiler's pseudo-code in comments so you can map your code onto the generated machine.

3. The Awaiter Pattern Behind await

await isn't tied to Task at all — it works on anything that follows the awaiter pattern . When you write await x , the compiler calls x.GetAwaiter() to get an awaiter, checks awaiter.IsCompleted (if it's already done, it skips suspension — the fast path), otherwise calls awaiter.OnCompleted(...) to register the continuation, and finally calls awaiter.GetResult() to read the value or re-throw any exception. The worked example performs those steps by hand so you can see that await is plain method calls, not magic.

4. SynchronizationContext, ConfigureAwait & Threads

When a continuation is ready to run, where should it run? That's decided by the SynchronizationContext — "the place a continuation should resume". In a WPF or WinForms app it's the UI thread (so you can safely touch controls); in a console app there's no context, so continuations run on a thread-pool thread. By default await captures that context and marshals the continuation back to it. ConfigureAwait(false) opts out: "I don't need the original context — resume on any free thread." Library code should almost always use it (it's faster and avoids the classic deadlock in the next section). The worked example also marks out exactly where threads are and aren't used.

5. ValueTask<T> vs Task<T>

A Task T is a heap object — it's allocated even when the result is already sitting in a cache and there was nothing to wait for. For hot paths that usually finish synchronously (cache hits, buffered stream reads), that allocation adds up. ValueTask T fixes this: it can wrap a value directly with no heap allocation on the synchronous fast path, and fall back to wrapping a real Task only when it genuinely has to wait. The trade-off: a ValueTask must be awaited at most once — to use the result twice or store it, call .AsTask() first.

The biggest misconception about async is "every await spins up a thread to wait." It doesn't. For I/O-bound work — a web request, a database query, a file read — the wait is handled by the operating system and hardware. No thread sits blocked; when the bytes arrive, the OS triggers your continuation. That's why a server can handle thousands of concurrent requests on a handful of threads.

A thread only gets involved when there's actual CPU work to do. Task.Run(...) explicitly borrows a thread-pool thread to run a chunk of computation; the continuation after an await also needs a thread to execute on (briefly) once it's scheduled. The distinction:

So: await by itself means "don't block while waiting" — not "run on another thread". Reach for Task.Run only when you have real computation to push off the current thread.

6. Resume After await and Return a Value

Now tie the state machine back to ordinary code. An async Task int method pauses at its await , resumes on the next line (the continuation), and the value you return there becomes the awaited result the caller receives. Fill in the three ___ blanks: mark the method async , return the sum after the delay, and await it from Main .

Q: Is the generated state machine a class or a struct?

In Release builds it's a struct — that avoids a heap allocation when the method completes synchronously. It only gets "boxed" onto the heap if the method actually suspends at an await. (In Debug builds it's a class to make debugging easier.)

Q: What exactly does ConfigureAwait(false) change?

It tells the await not to capture the current SynchronizationContext , so the continuation resumes on any available thread-pool thread instead of marshalling back to the original context (e.g. the UI thread). It's faster and prevents the classic .Result / .Wait() deadlock — which is why library code uses it.

Q: When should I prefer ValueTask over Task ?

When a hot-path method often completes synchronously (a cache hit, a buffered read) and the extra allocation of a Task shows up in profiling. For ordinary code, stick with Task — it's simpler and can be awaited multiple times.

No. If the awaited task is already complete ( IsCompleted is true), await continues synchronously on the same thread — no switch at all. If it does suspend, the continuation runs wherever the context (or lack of one) directs it.

No blanks this time — just a brief and an outline. Write an async method that prints step numbers around an await , call it from Main without awaiting straight away, and arrange the prints so the output order itself proves that the code after await is a continuation scheduled to run later. Run it and check your output matches the expected five lines exactly.

Practice quiz

What is a 'continuation' in async/await?

  • The first line of an async method
  • A separate thread that always runs the method
  • The code after an await, scheduled to run later when the awaited task completes
  • The exception handler for a Task

Answer: The code after an await, scheduled to run later when the awaited task completes. Everything after an await is a continuation — a separate chunk the runtime resumes once the awaited task finishes.

Up to what point does an async method run synchronously?

  • Until its first real await (suspension point)
  • It never runs synchronously
  • Until the very end, always
  • Until the second await only

Answer: Until its first real await (suspension point). An async method runs straight through until its first real await; at that point it can pause and return control to its caller.

How does the compiler implement an async method?

  • By spawning a thread per await
  • By converting it to a synchronous method
  • By inlining it at every call site
  • By rewriting it into a state machine that implements IAsyncStateMachine with a MoveNext() method

Answer: By rewriting it into a state machine that implements IAsyncStateMachine with a MoveNext() method. The compiler rewrites the method into a generated state machine struct whose MoveNext() advances one segment per state.

What does the int 'state' field in the generated state machine represent?

  • The number of threads in use
  • A saved bookmark recording which segment to resume at
  • The Task's result value
  • The exception code

Answer: A saved bookmark recording which segment to resume at. The state field is a saved bookmark; a switch on it jumps MoveNext() straight to the segment to resume.

Which sequence of calls does 'await x' compile down to on the awaiter?

  • GetAwaiter(), IsCompleted, OnCompleted(...), GetResult()
  • Start(), Stop(), Result()
  • Begin(), End(), Dispose()
  • Wait(), Then(), Catch()

Answer: GetAwaiter(), IsCompleted, OnCompleted(...), GetResult(). await calls GetAwaiter(), checks IsCompleted (fast path), otherwise registers OnCompleted(...), and finally calls GetResult().

What does awaiter.GetResult() do once the awaited work is done?

  • Always returns null
  • Cancels the operation
  • Returns the value, or re-throws any exception that occurred
  • Starts the task running

Answer: Returns the value, or re-throws any exception that occurred. GetResult() reads the value or re-throws a stored exception — the same observable behaviour as awaiting the task.

What does ConfigureAwait(false) change?

  • It cancels the awaited task
  • It tells the await not to capture the current SynchronizationContext, so the continuation can resume on any thread-pool thread
  • It forces the continuation onto the UI thread
  • It makes the method synchronous

Answer: It tells the await not to capture the current SynchronizationContext, so the continuation can resume on any thread-pool thread. ConfigureAwait(false) opts out of capturing the context, so the continuation resumes on any free thread — the library default.

Where should ConfigureAwait(false) typically be used?

  • In UI event handlers that update controls
  • Never — it is deprecated
  • Only in Main()
  • In reusable library code that doesn't need the original context

Answer: In reusable library code that doesn't need the original context. Library code should not assume a context exists; ConfigureAwait(false) is faster there and avoids the classic blocking deadlock.

What is the key rule when consuming a ValueTask<T>?

  • It can be awaited unlimited times
  • It must be awaited at most once
  • It must always be converted to void
  • It cannot be awaited at all

Answer: It must be awaited at most once. A ValueTask must be awaited at most once. To use the result twice or store it, call .AsTask() first.

What advantage does ValueTask<T> have over Task<T> on the synchronous fast path?

  • It runs on a separate thread automatically
  • It retries automatically on failure
  • It can wrap a value directly with no heap allocation when the method completes synchronously
  • It supports cancellation tokens, unlike Task

Answer: It can wrap a value directly with no heap allocation when the method completes synchronously. Task<T> is a heap object allocated even on a cache hit; ValueTask<T> avoids that allocation when it completes synchronously.