Asynchrony in C#
Asynchrony in C# exists for the sake of one resource: the thread. While a synchronous call waits for the network or the disk, the thread stands still doing nothing, yet it still holds a stack and a slot in the pool. await breaks that coupling: it registers a continuation, returns the thread to the pool, and the pool serves other requests while the operation proceeds on its own. Hence the claim interviewers probe for: asynchrony buys throughput and responsiveness, never speed — the operation itself does not get any faster.
What is specific to C# is that all of this machinery is the compiler's work, not the runtime's. The compiler rewrites an async method into a state machine: locals become its fields, every await becomes a resumption point, and a builder ties the machine to the returned task. That is where all of the topic's traps come from, so let us name them upfront: await neither creates a thread nor blocks — but .Result does block and deadlocks a UI app; ValueTask saves an allocation, yet you must never await it twice; cancellation is cooperative — a CancellationToken stops nothing until the callee checks it itself; async void returns no task, so the caller cannot catch its exception, and the process dies. The layer-by-layer breakdown is below.
Topic map
- async and await — what the compiler generates for an
asyncmethod, howawaitsuspends and resumes the state machine, and why no thread is created along the way. - Task and ValueTask —
Taskas a handle to an in-flight operation, its three terminal states, and when theValueTaskstruct saves an allocation. - CPU-bound versus I/O-bound — why there is no thread behind I/O, what
Task.Runactually does, and how blocking starves the thread pool. - Task combinators —
Task.WhenAllandTask.WhenAny, how they differ from the blockingWaitAll/WaitAny, and what happens to the losing tasks. - Cancellation with CancellationToken —
CancellationTokenSourceas the owner of the signal, the cooperative token check, and theCanceledstate. - Async streams —
IAsyncEnumerable<T>,await foreach, and why an iterator needs the[EnumeratorCancellation]attribute. - async void — why a method with no task cannot be awaited, where the process crash comes from, and the single legitimate scenario.
- Exceptions in async code — where a thrown exception lives, why
awaitthrows one exception while.Resultthrows anAggregateExceptionwrapper, and how failures of unawaited tasks get lost.
Common Mistakes and Traps
| Mistake | Consequence |
|---|---|
Believing await starts the work on a new thread | await creates no threads — it merely hooks up a continuation and releases the current one |
Expecting await to speed up the operation itself | The database query gets no faster — only throughput and responsiveness grow |
Blocking on .Result or .Wait() inside async code | A pool thread is stuck waiting; in a UI app the continuation has nowhere to return — deadlock |
Wrapping an I/O-bound call in Task.Run | An extra thread hop and an extra pool thread bought for nothing |
Making ValueTask<T> your default return type | The gain exists only on a hot, usually-synchronous path; elsewhere you buy the constraints for free |
Awaiting the same ValueTask<T> twice | The backing source object may already have been recycled into a pool — the result is unpredictable |
Accepting a CancellationToken and not passing it down | Cancellation never fires: the token cancels nothing by itself, it only reports the request |
Swallowing OperationCanceledException in a broad catch | The task reports success instead of Canceled — the cancellation goes invisible |
Assuming Task.WhenAny stops the losing tasks | They keep running; you must cancel them via a CancellationTokenSource and observe them |
Catching AggregateException around an await | await already unwrapped it and threw the first inner exception — the catch never fires |
Calling an async method without await | The task is dropped along with its exception: the failure surfaces nowhere and is silently lost |
Writing async void for fire-and-forget | The caller can neither await nor catch the failure — the exception takes the process down |
Materializing an IAsyncEnumerable<T> with ToList | The whole point of streaming is gone — the full result is buffered again |
Forgetting [EnumeratorCancellation] in an async iterator | The token from WithCancellation silently never arrives: the iterator sees default |
Interview relevance
Asynchrony is the topic that filters candidates at the middle and senior bar. What is probed is not the words async and await but your execution model: what happens to the thread at the moment of suspension, where the locals live afterwards, and who invokes the continuation. A candidate who says "await registers a continuation and returns the thread to the pool, and the state machine moves to the heap only on a real suspension" is immediately apart from the one who answers "await waits for the task".
Typical checks:
- What the compiler does to an
asyncmethod and howawaitresumes execution. - How a
Taskdiffers from a thread and which states it completes in. - When
ValueTask<T>is appropriate and why it must not be awaited twice. - Why
awaitsaves a thread on I/O and does not save one on computation. - The difference between
WhenAllandWhenAny, and whatawaitthrows on multiple failures. - The cooperative nature of cancellation and the role of
CancellationTokenSource. - Why
async voidis a trap and where it is nevertheless unavoidable. - How an exception travels from an async method into the caller's
catch.
Common wrong answer: "await starts the operation on a background thread and waits for it." That opens the discussion that there is no thread behind asynchronous I/O at all: the request goes to the device and the completion arrives as a callback from the OS — which is exactly why a thousand concurrent requests on a server do not cost a thousand threads. The second classic failure is catch (AggregateException) around an await: it never fires, because the awaiter itself unwraps the wrapper.