Async
Lookup sheet for stage 4: predicting what an asynchronous C# program does.
A task is not a thread
A Task represents an asynchronous operation "at a higher level of abstraction" than a thread: tasks are queued to the thread pool, and a task can exist with no delegate at all (TaskCompletionSource<TResult> wraps an external component's completion). A task is a handle on a completion, not a piece of running code.
| Created with | When |
|---|---|
Task.Run(...) | The ordinary case |
Task.Factory.StartNew(...) | Extra creation options, a specific scheduler, or state via AsyncState |
TaskCompletionSource<TResult> | No delegate: wrap an operation an external component performs |
TaskFactory.FromAsync | Wrapping an older Begin/End API |
| Observing it | How |
|---|---|
| Blocking | Wait, WaitAll, WaitAny, .Result: stops the calling thread |
| Continuations | ContinueWith: starts a new task when the antecedent finishes (callback composition, replaced by await) |
| Async composition | Task.WhenAll, Task.WhenAny: wait without blocking |
The unobserved-fault hazard: a faulted task's exception is wrapped in AggregateException and delivered to whatever thread joins it. If nothing ever joins, the exception is unhandled at finalization and terminates the process, unless something reads the task's Exception property first. Fire-and-forget is not free.
async/await
await suspends the enclosing async method until its operand completes, does not block the thread, and returns control to the method's caller when it suspends. Not every await suspends: an already-completed operand returns immediately, so async marks a method that may yield, not one that does.
await t where t is | Evaluates to | On failure |
|---|---|---|
Task<TResult> / ValueTask<TResult> | TResult | Rethrows t's exception directly |
Task / ValueTask | void | Rethrows t's exception directly |
Contrast with joining via Wait/Result: that delivers AggregateException. The same fault arrives in two different shapes depending on how it was observed; await's catch clause matches the original exception type.
await is forbidden: in a synchronous local function's body, inside a lock block (you cannot hold a monitor across a suspension), and in an unsafe context.
async void is a trap (fine only for event handlers, whose signature is fixed by their delegate type): a caller has nothing to await, so a fault inside has nowhere to go but the unhandled-exception path.
Composing tasks: overlap, don't accidentally serialize
Calling an async method starts the work immediately; awaiting only observes it. await A(); await B(); serializes (B doesn't even start until A finishes). Starting both, then awaiting, overlaps them:
serial: A(0-1s) --await--> B(1-2s) total ~2s
concurrent: A(0-1s) started; B(0-1s) started; await both total ~1s
The LINQ-deferred-execution trap: a task is hot (work is running the moment you hold it); a LINQ query is cold (nothing runs until enumerated). Projecting a sequence into tasks with .Select(id => GetAsync(id)) produces no tasks at all until enumerated:
var tasks = userIds.Select(id => GetUserAsync(id)).ToArray(); // ToArray forces the calls now
return await Task.WhenAll(tasks);
Without .ToArray()/.ToList(), Task.WhenAll(tasks) sees zero tasks at the call, and re-enumerating a deferred sequence of async calls starts every call again.
Task.WhenAny needs two awaits: the first yields the completed task, not its value or exception; the second awaits that task to get the value (and to surface its exception, per await's rethrow rule). Remove the completed task from the set, or the loop returns it forever.
| Need | Reach for |
|---|---|
| Every result, nothing to do until all arrive | WhenAll |
| First answer, cancel the rest | WhenAny, redundant operations |
| Every result, processed as each arrives | WhenAny in a loop, removing each completed task |
| A concurrency cap | WhenAny, throttled: start a new one as each finishes |
| Work or a timeout, whichever first | WhenAny against a delay task |
Not every await in a loop is a bug: sequential is correct when steps depend on each other, a service imposes ordering/rate limits, or unlimited concurrency would exhaust something scarce.
Cancellation tokens: cooperative, one requester, many listeners
Only the object holding a CancellationTokenSource can request cancellation (Cancel()); every listener holding a copy of its Token is responsible for noticing and responding. A token is one-way: once IsCancellationRequested is true it can never reset, so a source is scoped to one cancelable operation, not reused.
How a delegate stops determines what the task reports:
| Delegate | Task status |
|---|---|
| Returns normally | RanToCompletion (success, indistinguishable from actually finishing) |
Throws OperationCanceledException carrying the token (ThrowIfCancellationRequested()) | Canceled |
The token must be passed twice: once into the delegate body (so it can notice and throw) and once to Task.Run/StartNew (so the task compares the thrown exception's token against its own and recognizes it as cancellation rather than a fault). Omit the second and a correctly cancelled operation reports Faulted.
Joining a cancelled task via Wait/WaitAll delivers TaskCanceledException inside AggregateException; this indicates successful cancellation, not a fault, so logging every AggregateException as an error reports a clean shutdown as a failure.
For a blocking call that can't poll the token, register a callback with CancellationToken.Register to unblock it.
Async streams
Replace Task<IEnumerable<T>> (accumulate everything, then hand it over) with async IAsyncEnumerable<T> plus yield return, consumed with await foreach, each iteration of which can suspend while the next element is fetched.
| Async | Sync counterpart |
|---|---|
IAsyncEnumerable<T> | IEnumerable<T> |
IAsyncEnumerator<T> | IEnumerator<T> |
IAsyncDisposable | IDisposable |
await foreach is duck-typed like foreach: any type with a public parameterless GetAsyncEnumerator (possibly an extension member) returning something with Current and MoveNextAsync works, no interface required.
What moving to a stream buys: the first element is available as soon as it arrives (not after the last one); no accumulating collection needs allocating; progress needs no separate callback, since receiving an element is the progress; the consumer decides when to stop, so no try/catch is needed just to handle early cancellation. It doesn't automatically fix whether per-element work inside the loop is concurrent, only when elements become visible.
[EnumeratorCancellation] bridges two separate token entry points: the token passed when the method is called (producing the IAsyncEnumerable<T>) and the token passed later to GetAsyncEnumerator when something actually enumerates it (via .WithCancellation(ct)). Without the attribute on the token parameter, the iterator body never sees the consumer's cancellation token, even though it compiles and appears to check it correctly.
Stage 4's four diagnostic questions
- Lesson 17: what is this task, and who started it?
- Lesson 18: where does this method suspend, and where does control go?
- Lesson 19: which operations overlap, and which were serialized by accident?
- Lesson 20: what happens when someone stops caring about the result?