Async & await
The async/await state machine, Task versus ValueTask, cancellation with CancellationToken, IAsyncEnumerable, async void, and exception propagation.
15 questions
JuniorTheoryVery commonWhat do async and await actually buy you compared to blocking on a call?
What do async and await actually buy you compared to blocking on a call?
Blocking holds a thread hostage until the call finishes. await instead registers a continuation and hands the thread back to the pool, so it serves other work while the operation is in flight. It buys throughput and responsiveness, never raw speed.
Common mistakes
- ✗Thinking
awaitmakes the awaited operation itself run faster - ✗Believing
awaitalways starts the work on a separate thread - ✗Blocking with
.Resultor.Wait()inside async code, giving up the whole benefit
Follow-up questions
- →What happens to the calling thread between the
awaitand the completion of the call? - →Why can blocking with
.Resulton an async call deadlock in a UI application?
MiddleTheoryVery commonWhy is async void discouraged, and where is it still unavoidable?
Why is async void discouraged, and where is it still unavoidable?
async void returns no Task, so the caller cannot await it, cannot see when it ends, and cannot catch its exception — the failure is rethrown on the captured context and tears the process down. Use async Task everywhere except event handlers, whose signature forces void.
Common mistakes
- ✗Using
async voidfor ordinary fire-and-forget helpers instead ofasync Task - ✗Assuming a
try/catcharound anasync voidcall will catch its exception - ✗Thinking the caller can await an
async voidmethod to learn when it finished
Follow-up questions
- →Where does an exception thrown inside an
async voidmethod actually surface? - →How do you write an event handler so that its failures stay observable?
JuniorTheoryCommonWhat is a CancellationToken, and who creates it and signals it?
What is a CancellationToken, and who creates it and signals it?
A CancellationToken is a read-only signal the caller passes down so callees can see a cancellation request. Only its owner, a CancellationTokenSource, can trip it, by calling Cancel(). The token stops nothing itself — it merely reports IsCancellationRequested.
Common mistakes
- ✗Thinking the token itself can cancel — only its
CancellationTokenSourcecan - ✗Expecting cancellation to abort a running method without any cooperation from it
- ✗Not passing the token down the call chain, so inner calls never see the request
Follow-up questions
- →What does a method have to do to actually honour a token that has been signalled?
- →How do you combine two tokens so that either one of them can cancel the operation?
JuniorTheoryCommonWhat does a Task object represent, and what states can it end up in?
What does a Task object represent, and what states can it end up in?
A Task is a handle to an operation that is already under way — a promise of a future result, not a thread and not the work itself. It ends up RanToCompletion, Faulted with an exception, or Canceled; Task<T> also carries the produced value.
Common mistakes
- ✗Equating a
Taskwith a thread rather than a handle to an operation - ✗Thinking a
Taskis cold and only starts running once it is awaited - ✗Missing that
Canceledis a state of its own, distinct fromFaulted
Follow-up questions
- →How does the
Canceledstate differ fromFaultedwhen youawaitthe task? - →What is the difference between a
Taskanasyncmethod returns and one fromTask.Run?
JuniorTheoryCommonHow does Task.WhenAll differ from Task.WhenAny, and what does each return?
How does Task.WhenAll differ from Task.WhenAny, and what does each return?
Task.WhenAll returns a task that completes once every input task has finished, yielding all the results together. Task.WhenAny completes as soon as the first one finishes and hands you that task; the rest keep running. Both are awaited, not blocked on.
Common mistakes
- ✗Thinking
Task.WhenAllruns the tasks one after another instead of concurrently - ✗Believing
Task.WhenAnycancels or stops the tasks that did not win - ✗Confusing
WhenAll/WhenAnywith the blockingWaitAll/WaitAny
Follow-up questions
- →If several tasks inside a
Task.WhenAllfail, which exception doesawaitthrow? - →How do you cancel the losing tasks after
Task.WhenAnyhas returned?
MiddleTheoryCommonWhy does await save a thread on I/O-bound work but not on CPU-bound work?
Why does await save a thread on I/O-bound work but not on CPU-bound work?
I/O has no thread behind it — the device works while the OS holds a completion callback, so await can release the thread outright. CPU work needs instructions executing the whole time, so some thread must sit on it; Task.Run frees the caller but still burns a pool thread.
Common mistakes
- ✗Wrapping CPU-bound work in
asyncand expecting it to scale the way I/O does - ✗Thinking an I/O
awaitkeeps a thread parked on the device handle - ✗Believing
Task.Runreduces the total thread cost of CPU-bound work
Follow-up questions
- →Why does offloading CPU work with
Task.Runstill help a UI thread or a request thread? - →What happens to server throughput when every request blocks a thread-pool thread?
MiddleTheoryCommonHow does Task.Run(() => FooAsync()) differ from writing await FooAsync()?
How does Task.Run(() => FooAsync()) differ from writing await FooAsync()?
await FooAsync() starts the method on the current thread and runs it inline up to its first real await. Task.Run first hands the delegate to a thread-pool thread, so that synchronous prefix runs there instead. For I/O-bound work the extra hop buys nothing and just burns a thread.
Common mistakes
- ✗Wrapping an I/O-bound async call in
Task.Run, adding a thread hop for nothing - ✗Thinking an
asyncmethod does not start running until it is awaited - ✗Believing
Task.Runmakes the I/O operation itself complete faster
Follow-up questions
- →When is offloading to
Task.Rungenuinely the right call inside a server request? - →Where does code after an
awaitresume when no scheduling contextSynchronizationContextis captured?
MiddleTheoryOccasionalWhen does the wrapper exception AggregateException surface, and why does await usually not throw it?
When does the wrapper exception AggregateException surface, and why does await usually not throw it?
Blocking APIs — .Wait(), .Result, Task.WaitAll — wrap failures in an AggregateException, since several tasks may have failed at once. await unwraps it and rethrows only the first inner exception, so a failed Task.WhenAll throws one; the rest stay in the task's Exception.InnerExceptions.
Common mistakes
- ✗Catching
AggregateExceptionaround anawait, where the exception arrives unwrapped - ✗Assuming a failed
Task.WhenAllreports every failure through the thrown exception - ✗Mixing blocking
.Resultinto async code and being surprised by the wrapper type
Follow-up questions
- →How do you recover every failure from a
Task.WhenAllin which several tasks faulted? - →What does
AggregateException.Flatten()do to nested wrappers?
MiddleTheoryOccasionalHow does an async method actually honour a request from the cancellation handle CancellationToken?
How does an async method actually honour a request from the cancellation handle CancellationToken?
Cancellation is cooperative — nothing aborts the method for you. It must flow the token into every awaitable call it makes and call ThrowIfCancellationRequested() between chunks of work. That throws OperationCanceledException, putting the task in Canceled, not Faulted.
Common mistakes
- ✗Accepting a token but never passing it into the inner awaited calls
- ✗Expecting the runtime to abort the method automatically once the token is signalled
- ✗Swallowing
OperationCanceledException, so the task reports success instead ofCanceled
Follow-up questions
- →Why does
awaiton a cancelled task throw rather than return a default value? - →How do you put a timeout on an operation using
CancellationTokenSource?
MiddleTheoryOccasionalWhat does IAsyncEnumerable<T> give you that Task<List<T>> does not?
What does IAsyncEnumerable<T> give you that Task<List<T>> does not?
Task<List<T>> is awaited once and materialises the whole batch in memory. IAsyncEnumerable<T> yields items one at a time and may await between them, so await foreach processes the first item while later ones are still being fetched — streaming, without buffering everything.
Common mistakes
- ✗Materialising an async stream with
ToListAsync()and losing the streaming benefit - ✗Thinking
await foreachfetches the whole sequence before the first iteration - ✗Forgetting to flow a
CancellationTokenin viaWithCancellation
Follow-up questions
- →What does the compiler generate for an
async IAsyncEnumerable<T>iterator method? - →How do you flow a
CancellationTokeninto anawait foreachloop?
MiddleTheoryOccasionalWhen do you return ValueTask<T> instead of Task<T>, and what is the catch?
When do you return ValueTask<T> instead of Task<T>, and what is the catch?
Task<T> is a class, so every call allocates one even when the result is already cached. ValueTask<T> is a struct wrapping either the value or a Task<T>, so a synchronously-completed hot path allocates nothing. The catch: await it exactly once, never twice.
Common mistakes
- ✗Awaiting the same
ValueTask<T>twice, or reading.Resultoff it - ✗Using
ValueTask<T>as a default everywhere instead of on hot, usually-synchronous paths - ✗Thinking
ValueTask<T>removes the allocation even when the path genuinely goes async
Follow-up questions
- →What exactly goes wrong if you await the same
ValueTask<T>twice? - →How does the pooling interface
IValueTaskSourcelet aValueTaskavoid allocation on async paths?
MiddleDebuggingOccasionalAn exception from an async call never reaches the catch
An exception from an async call never reaches the catch
The call is missing its await, so the returned Task is discarded. An async method does not throw into the caller's frame — it captures the failure onto its Task and rethrows only when that task is awaited. Fix it by writing await _repository.SaveAsync(order);.
Common mistakes
- ✗Calling an async method without
awaitand assuming failures still propagate - ✗Believing an
asyncmethod throws synchronously into the caller'stry/catch - ✗Treating the returned
Taskas optional and discarding it
Follow-up questions
- →What happens to an exception sitting on a
Taskthat nobody ever awaits? - →How would a compiler warning or an analyzer have caught this discarded task?
SeniorTheoryRareHow does an exception cross an await, and why can an async void failure not be caught?
How does an exception cross an await, and why can an async void failure not be caught?
A failing async Task method catches the exception inside its state machine and stores it on the task via SetException; the awaiter later rethrows it through ExceptionDispatchInfo, which preserves the original stack trace instead of resetting it. async void has no task to store it on, so its builder rethrows the exception on the captured SynchronizationContext — or on the thread pool — where nobody is awaiting, and the process dies.
Common mistakes
- ✗Expecting an
async voidfailure to be catchable by the caller'stry/catch - ✗Thinking a rethrow across an
awaitresets the exception's original stack trace - ✗Confusing an
async voidcrash with an unobserved-task exception, which no longer crashes
Follow-up questions
- →Why did unobserved
Taskexceptions stop killing the process after .NET 4.5? - →How does the builder of an
async voidmethod differ from the one forasync Task?
SeniorTheoryRareWhat does the C# compiler generate for an async method, and how does await resume it?
What does the C# compiler generate for an async method, and how does await resume it?
The compiler rewrites the method into a state machine whose MoveNext() resumes at each await; the locals become its fields. At an await it asks the awaiter whether the operation already completed — if so it continues inline; if not, the builder boxes the machine onto the heap, hooks MoveNext on as the continuation, and the thread leaves. Completion re-enters MoveNext at the saved state.
Common mistakes
- ✗Thinking
awaityields to the scheduler even when the awaited task is already complete - ✗Believing the state machine is always heap-allocated rather than only once it suspends
- ✗Assuming the method's original stack frame is kept alive across the suspension
Follow-up questions
- →When does the async state machine escape to the heap, and when does it stay on the stack?
- →How does
ConfigureAwait(false)change whereMoveNextis invoked after completion?
SeniorTheoryRareWhy does an async IAsyncEnumerable<T> iterator need the parameter attribute [EnumeratorCancellation]?
Why does an async IAsyncEnumerable<T> iterator need the parameter attribute [EnumeratorCancellation]?
A consumer cancels a stream through await foreach (... .WithCancellation(ct)), which passes the token to GetAsyncEnumerator(ct) — not to the iterator's own parameters, which were bound back when the method was first called. [EnumeratorCancellation] tells the compiler to route that enumerator-level token into the marked parameter, linking it with whatever token was passed at call time.
Common mistakes
- ✗Passing a token only at call time and expecting
WithCancellationto reach the iterator - ✗Omitting
[EnumeratorCancellation]and silently getting adefaulttoken inside the iterator - ✗Thinking
await foreachstops the producer without any token flowing into it
Follow-up questions
- →What does the compiler generate for
GetAsyncEnumerator(CancellationToken)on such an iterator? - →How are the call-time token and the
WithCancellationtoken combined at runtime?