The async modifier, the awaitable pattern, the IsCompleted fast path, continuation registration, context capture and valid async return types.
async tells the compiler to rewrite the method into a state machine and lets you use await inside it; it does not create a thread. At await x, the code calls x.GetAwaiter() and checks IsCompleted. If it is already complete, execution continues synchronously. If not, the method registers the rest of itself as a continuation, captures the current SynchronizationContext (or non-default TaskScheduler), and returns an incomplete task to its caller, releasing the thread. When the awaited work finishes, the continuation resumes, on the captured context if there was one, otherwise on the ThreadPool. Exceptions and results flow through the returned task.
"
asyncdoes two things: it lets you useawaitin the method, and it tells the compiler to rewrite the method into a state machine whose builder produces the returned task. It does not create a thread or run anything in the background. Atawait x, the generated code callsx.GetAwaiter()and checksIsCompleted. If the operation has already finished, it carries on synchronously: no yield, no thread switch. If not, it saves the method's state, registers 'the rest of the method' as a continuation, captures the currentSynchronizationContext(unlessConfigureAwait(false)), and returns an incomplete task to the caller, so the thread is free to do other work. When the awaited operation completes, the continuation runs, on the captured context if there was one, otherwise on the ThreadPool.GetResult()then returns the value or rethrows the exception. Results and exceptions, even ones thrown before the firstawait, travel through the returned task."
async does and doesn't do| Does | Doesn't |
|---|---|
Enables await in the body |
Create a thread |
| Generates a state machine + method builder | Make code parallel |
| Stores exceptions in the returned task | Throw exceptions at the call site (for Task-returning methods) |
| Lets the method return before its work is done | Move CPU work off the current thread |
await expr, step by step| Step | What happens |
|---|---|
| 1 | Evaluate expr, call GetAwaiter() (any type following the awaitable pattern works) |
| 2 | if (awaiter.IsCompleted) → call GetResult() and continue synchronously |
| 3 | Otherwise save locals and the current position (state) into the state machine |
| 4 | First real yield only: box the state machine onto the heap |
| 5 | Register the continuation via UnsafeOnCompleted / OnCompleted; capture SynchronizationContext / non-default TaskScheduler if present |
| 6 | Return to the caller (the first time, the caller gets the incomplete task) |
| 7 | Operation completes → continuation scheduled → MoveNext() resumes at the saved position |
| 8 | GetResult() returns the value or rethrows the original exception |
| Environment | Default resume location |
|---|---|
| ASP.NET Core, console, worker services | ThreadPool (no SynchronizationContext) |
| WPF / WinForms / MAUI UI thread | The UI thread (captured context) |
| Legacy ASP.NET (System.Web) | The request context (one thread at a time) |
Code using ConfigureAwait(false) |
ThreadPool, regardless of context |
Custom TaskScheduler current |
That scheduler |
ExecutionContext (with AsyncLocal<T> values, culture, activity) flows across await regardless of ConfigureAwait.
| Return type | Use |
|---|---|
Task / Task<T> |
Default |
ValueTask / ValueTask<T> |
Hot paths, often synchronous |
IAsyncEnumerable<T> / IAsyncEnumerator<T> |
Async streams (yield return) |
Custom task-like with [AsyncMethodBuilder] |
Advanced libraries |
void |
Event handlers only: can't be awaited; exceptions crash the process |
await is allowed in catch and finally (C# 6+), not inside lock (use SemaphoreSlim).async Main is allowed (C# 7.1+).async without any await runs synchronously (warning CS1998).async (returning the inner task directly) changes exception timing and breaks using/try semantics.In our [document export endpoint], a developer marked a CPU-heavy PDF generation method async Task<byte[]> expecting it to "run in the background" and free the request thread. Request threads still stayed busy for [3–5 s] per export, and the build log had warning CS1998. I showed the team that the method contained no await on incomplete work, so it executed entirely on the request thread; async never moves work. We moved PDF generation behind a background queue with a status endpoint (the request now returns 202 Accepted), removed the misleading async, and turned CS1998 into a build error so the pattern couldn't return.
Checks a correct, mechanical understanding of async/await.
Green flags
GetAwaiter, IsCompleted, continuation registration.ConfigureAwait(false).async void is dangerous.Red flags
The compiler only needs:
TAwaiter GetAwaiter(); // instance or extension method
// TAwaiter must have:
bool IsCompleted { get; }
void OnCompleted(Action continuation); // INotifyCompletion (and optionally ICriticalNotifyCompletion.UnsafeOnCompleted)
T GetResult(); // or void
That's why you can await a Task, a ValueTask, Task.Yield(), ConfiguredTaskAwaitable, a TimeSpan (with an extension), or your own types.
async Task<int> GetAsync()
{
var a = await LoadAsync();
return a + 1;
}
becomes roughly:
void MoveNext()
{
int result;
try
{
TaskAwaiter<int> aw;
if (state == 0) { aw = savedAwaiter; state = -1; goto Resume; }
aw = LoadAsync().GetAwaiter();
if (!aw.IsCompleted)
{
state = 0; savedAwaiter = aw;
builder.AwaitUnsafeOnCompleted(ref aw, ref this); // box (first time) + register
return; // yield to caller
}
Resume:
int a = aw.GetResult();
result = a + 1;
}
catch (Exception ex) { state = -2; builder.SetException(ex); return; }
state = -2;
builder.SetResult(result);
}
The next question goes deeper into the state machine itself.
awaitTask t = ValidateAsync(null); // no exception here
await t; // ArgumentNullException surfaces here
async Task ValidateAsync(string? s)
{
ArgumentNullException.ThrowIfNull(s); // stored in the task
await Task.Yield();
}
If you need argument validation to throw eagerly, split into a non-async public method that validates and an async private implementation (or use a local function).
Task.Yield() and forcing asynchronyawait Task.Yield() always reports IsCompleted == false, so the method always yields and the rest runs as a scheduled continuation. Useful in tests, in UI code to let the message loop run, or to avoid deep synchronous recursion. It's not a way to "run in the background" in ASP.NET Core; it just requeues to the pool.
await SomethingAsync(); // captures SynchronizationContext.Current (if any)
await SomethingAsync().ConfigureAwait(false); // doesn't
Capture happens at the await, only if the awaitable is incomplete. If SynchronizationContext.Current is null and TaskScheduler.Current is the default, there's nothing to capture, so the continuation can run directly on the completing thread or be queued to the pool.
async: when it's safe| Method body | Safe to return the task directly? |
|---|---|
=> _inner.GetAsync(id); (pure pass-through) |
Usually yes |
Has using / try / finally |
No: disposal/finally runs before the task completes |
| Validates arguments that should throw from the task | No: exceptions become synchronous |
Uses AsyncLocal values set in the method |
No: subtle context differences |
| Version | Change |
|---|---|
| C# 5 | async/await |
| C# 6 | await in catch/finally |
| C# 7 | Generalised async return types (ValueTask, [AsyncMethodBuilder]) |
| C# 7.1 | async Main |
| C# 8 | Async streams (IAsyncEnumerable<T>, await foreach) |
| C# 13 | ref locals and ref struct locals allowed in async methods (not across await) |
| .NET 8 | ConfigureAwaitOptions |
await, call without awaiting, check IsFaulted, then await.GetAwaiter extension on TimeSpan) and await TimeSpan.FromSeconds(1).await with and without ConfigureAwait(false).async in a method with using var conn = ... and observe ObjectDisposedException.IsCompleted check.Console.WriteLine($"Main start on thread {Environment.CurrentManagedThreadId}");
Task<int> pending = ComputeAsync(fast: false); // returns at the first incomplete await
Console.WriteLine("Caller continues while ComputeAsync waits");
Console.WriteLine($"Result {await pending}");
Console.WriteLine($"Fast path result {await ComputeAsync(fast: true)}");
Task faulty = ThrowsEarlyAsync(); // no exception here
Console.WriteLine($"Faulted before await? {faulty.IsFaulted}");
try { await faulty; }
catch (InvalidOperationException ex) { Console.WriteLine($"Observed: {ex.Message}"); }
static async Task<int> ComputeAsync(bool fast)
{
Console.WriteLine($" before await on {Environment.CurrentManagedThreadId}");
if (fast)
await Task.CompletedTask; // IsCompleted is true: continues synchronously
else
await Task.Delay(100); // incomplete: method yields
Console.WriteLine($" after await on {Environment.CurrentManagedThreadId}");
return 42;
}
static async Task ThrowsEarlyAsync()
{
throw new InvalidOperationException("stored in the task");
#pragma warning disable CS0162
await Task.CompletedTask;
#pragma warning restore CS0162
}
async runs the method on a background threadWhat happens: CPU-heavy code is marked async to "offload" it.
Why it is wrong: code runs on the caller's thread until the first incomplete await.
Do instead: Task.Run for CPU work you need off the current thread (in UI apps), a queue for long server work.
async with no awaitWhat happens: CS1998 is ignored.
Why it is wrong: it's synchronous with extra overhead.
Do instead: remove async and return Task.FromResult, or add real async work.
async in methods with using or tryWhat happens: Task GetAsync() { using var c = Open(); return c.QueryAsync(); }.
Why it is wrong: c is disposed before the task completes; the try doesn't catch async failures.
Do instead: keep async/await unless the method is a trivial pass-through.
async void outside event handlersWhat happens: public async void SaveAsync().
Why it is wrong: not awaitable; exceptions crash the process; tests can't wait for it.
Do instead: return Task.
What happens: try { var t = DoAsync(); } catch {...} without awaiting.
Why it is wrong: exceptions are stored in the task and surface only when awaited.
Do instead: await inside the try.
await is on the same threadWhat happens: thread-affine code (thread-static state, Monitor held across await) breaks.
Why it is wrong: in server code the continuation can run on any pool thread.
Do instead: don't rely on thread identity across awaits; use SemaphoreSlim for async locking.
lockWhat happens: trying to await within lock (x) { ... }.
Why it is wrong: the compiler forbids it because the lock is thread-affine.
Do instead: await _semaphore.WaitAsync() with try/finally Release().
What happens: await A(); await B(); where B doesn't need A.
Why it is wrong: latency adds up.
Do instead: start both, then await Task.WhenAll(a, b).
Task.Yield() to make work "background"What happens: await Task.Yield(); at the top of a CPU-heavy method in ASP.NET Core.
Why it is wrong: the work still runs on a pool thread, just a different one, after a requeue.
Do instead: design CPU-heavy work explicitly (queue, Task.Run in UI apps).
ConfigureAwait(false) stops AsyncLocal flowWhat happens: it's used to "drop" logging scopes or activity.
Why it is wrong: ExecutionContext always flows; only SynchronizationContext capture changes.
Do instead: use ExecutionContext.SuppressFlow() when you truly need to stop flow.
I explain await as "pause this method, give the thread back, resume later". The senior points are the fast path (no yield if already complete), where continuations resume, and that exceptions travel in the task. Those three explain most async bugs I see in reviews.
| Question | Short answer |
|---|---|
| Does async create a thread? | No |
| When does a method yield? | At the first await on an incomplete awaitable |
| Where does it resume? | Captured context, else ThreadPool |
| Where do exceptions go? | Into the returned task |
| When is async void OK? | Event handlers only |
The compiler turns an async method into a small stub plus a generated type implementing IAsyncStateMachine. The type holds a state integer, a method builder (such as AsyncTaskMethodBuilder<T>), awaiter fields and any locals that live across an await. The stub initialises it and calls builder.Start, which runs MoveNext synchronously. MoveNext is a switch on state: it runs until an incomplete await, stores the awaiter, sets state, and asks the builder to call MoveNext again on completion. In Release builds the machine is a struct that is only boxed to the heap the first time the method actually suspends, so synchronously completing async methods are cheap.
A Task is an object that represents an operation that will complete in the future, with a status, an optional exception and the ability to attach continuations. It is not a thread. Some tasks run code on a pool thread (Task.Run, called delegate tasks); many represent work with no thread at all, such as a pending network read, a timer (Task.Delay) or a TaskCompletionSource (promise tasks). You compose tasks with await, Task.WhenAll, Task.WhenAny and WaitAsync, and avoid blocking on them.
Roslyn parses C# into syntax trees, binds names and types into a semantic model, then lowers high-level constructs into simpler ones before emitting IL. foreach over an array becomes an index loop; over `List<T>` it uses the struct enumerator in a try/finally; using becomes try/finally with Dispose; lock becomes Monitor.Enter(obj, ref lockTaken) / Monitor.Exit (or Lock.EnterScope() for System.Threading.Lock); a + b + c on strings becomes one string.Concat; a switch on strings may become a length or hash-based jump. async methods and iterators become state machines. Roslyn does little optimisation; the JIT does the heavy work.
How to tell it (STAR)
Our [export endpoint] held request threads for seconds even though the method was `async`. I showed the team that `async` does not move work, and the CS1998 warning proved there was no real await. I moved the CPU work to a background queue and made CS1998 a build error.
Last updated 31 Oct 2026.