Module 7 · 7. Async, Concurrency, Cancellation, and Performance · Lesson 19 of 24
async and await: Task Lifecycles Without Blocking Threads
What you will learn
Trace an asynchronous call from entry to completion, combine two independent results, and account for failures from the whole operation. You will run three short experiments in one console project. Every dependency is in memory, so the expected output does not depend on a website, machine speed, or a lucky thread schedule.
1. Separate the operation from the waiting
An async method does not automatically create a thread. It starts executing when called. For I/O-bound work, prefer the asynchronous API; Task.Run is useful for moving CPU work to the thread pool when that suits the application. Making a call asynchronous is not a promise that it is faster.
At an incomplete await, the method can suspend and return control to its caller without blocking that thread. An already completed awaitable needs no suspension. Awaiting a successful Task<T> produces T; awaiting a faulted task propagates an exception. Code before the first incomplete await still executes as part of the call.
Return Task for completion or Task<T> for a result so the caller can await and observe the operation. Use async void only when an event-handler signature requires void; its caller cannot await it or observe its failures through a returned task.
A precise analogy
Think of a service counter giving you a numbered collection ticket. The ticket represents one unfinished order; it is not the person preparing it. Our signal is the counter marking that ticket ready. Await is the point at which our recipe cannot continue without that result. The analogy says nothing about how many cooks exist or whether two cooks are working simultaneously. In particular, two outstanding tickets do not prove parallel execution.
2. Run the lifecycle experiment
Create a folder named AsyncWalkthrough containing the project file and the four C# files below. Use a .NET 10 SDK. From that folder, run dotnet run --configuration Release. No packages are needed.
AsyncWalkthrough.csproj
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
</Project>A TaskCompletionSource lets this example control a task's completion explicitly. We pass its Task to the consumer and later supply its result. It represents an asynchronous boundary without performing real I/O.
RunContinuationsAsynchronously prevents completing this signal from invoking registered continuations inline. It does not promise a dedicated thread.
Lifecycle.cs
internal static class Lifecycle
{
public static TaskCompletionSource<int> NewSignal() =>
new(TaskCreationOptions.RunContinuationsAsynchronously);
public static async Task<int> DoubleAsync(Task<int> input, TextWriter output)
{
output.WriteLine("worker: entered");
int value = await input;
output.WriteLine("worker: resumed");
return value * 2;
}
public static async Task ShowAsync(TextWriter output)
{
var signal = NewSignal();
output.WriteLine("caller: before call");
Task<int> pending = DoubleAsync(signal.Task, output);
output.WriteLine($"caller: pending {!pending.IsCompleted}");
signal.SetResult(21);
output.WriteLine($"caller: result {await pending}");
Task<int> immediate = DoubleAsync(Task.FromResult(5), output);
output.WriteLine($"caller: completed {immediate.IsCompleted}");
output.WriteLine($"caller: result {await immediate}");
}
}Work through the first call
- The caller prints its first line, then calls DoubleAsync. The worker prints entered before returning an incomplete task.
- The only code that supplies 21 occurs after the pending check. Therefore the pending check must print True; no delay is needed to create this state.
- SetResult supplies 21. The worker can finish its multiplication. The caller awaits that result before printing 42, so its result line cannot overtake the worker's resumed line.
- The second input already contains 5. Both worker lines appear before the caller checks immediate.IsCompleted, and the result is 10.
The word resumed is our trace label for the statement after await. In the second call there was no suspension to resume from. Do not infer a thread switch from that label, or from the async keyword.
3. Start independent work, then join it
Task.WhenAll joins supplied tasks; it does not start them or limit concurrency. It finishes after every input finishes. Successful generic results retain input order. Any fault makes the group faulted; otherwise any cancellation makes it canceled. With no inputs it succeeds immediately. A faulted group's Exception retains its collected failures; catching the exception from await alone is not an inventory of every failure.
Composition.cs
internal sealed class ManualLookup
{
private readonly TaskCompletionSource<string> _reply =
new(TaskCreationOptions.RunContinuationsAsynchronously);
public int Calls { get; private set; }
public Task<string> ReadAsync()
{
Calls++;
return _reply.Task;
}
public void Complete(string value) => _reply.SetResult(value);
}
internal static class Composition
{
public static async Task<string> SummaryAsync(ManualLookup profile, ManualLookup orders)
{
Task<string> profileTask = profile.ReadAsync();
Task<string> ordersTask = orders.ReadAsync();
string[] values = await Task.WhenAll(profileTask, ordersTask);
return string.Join(" | ", values);
}
public static async Task ShowAsync(TextWriter output)
{
var profile = new ManualLookup();
var orders = new ManualLookup();
Task<string> summary = SummaryAsync(profile, orders);
output.WriteLine($"lookups started: {profile.Calls + orders.Calls}");
orders.Complete("2 orders");
output.WriteLine($"summary pending: {!summary.IsCompleted}");
profile.Complete("Mira");
output.WriteLine($"summary: {await summary}");
}
}Why this composition is safe to reason about
ManualLookup is a one-reply classroom dependency. ReadAsync records that a request was made and returns its unfinished reply. SummaryAsync calls both lookups before reaching its join. Completing orders first cannot complete summary because profile still has no reply. Once profile supplies Mira, the output follows the supplied profile/orders order, giving Mira | 2 orders.
The two lookups are independent: neither needs the other's value to be requested. If orders required a customer identifier obtained from profile, start orders only after that identifier is available. Independence is a data-dependency decision, not a naming convention.
Do not move the first await in front of the second ReadAsync merely to make the code look simpler: that would prevent our second request from being issued while the first is pending. Conversely, issuing millions of requests together would be a resource-policy mistake. The concurrency lesson implements a bounded gate; this lesson keeps just two owned requests.
4. Observe the whole failure
Failures.cs
internal static class Failures
{
public static async Task ShowAsync(TextWriter output)
{
Task all = Task.WhenAll(
Task.FromException(new InvalidOperationException("profile unavailable")),
Task.FromException(new ArgumentException("orders malformed")));
try
{
await all;
}
catch (Exception) when (all.IsFaulted)
{
// The whole group owns both failures; do not assume which await throws.
AggregateException aggregate = all.Exception
?? throw new InvalidOperationException("Missing failure details.");
string[] messages = aggregate.Flatten().InnerExceptions
.Select(error => error.Message)
.OrderBy(message => message, StringComparer.Ordinal)
.ToArray();
output.WriteLine($"failures observed: {messages.Length}");
foreach (string message in messages)
output.WriteLine(message);
}
}
}Both failures are deliberately supplied in memory. The group is retained in all so the handler can inspect it. The handler is limited to the faulted-group path; it is not a general instruction to hide every exception. Sorting the two fixed messages gives reproducible presentation without claiming which failure await chooses first. No result summary is produced when this group fails.
In a real application, an owner must decide whether to return a failure, report it, retry safely, or use a permitted fallback. Starting a task and discarding it supplies none of those decisions. This experiment displays synthetic messages only; production exception text may contain private data.
Program.cs
internal static class Program
{
public static async Task Main()
{
await Lifecycle.ShowAsync(Console.Out);
await Composition.ShowAsync(Console.Out);
await Failures.ShowAsync(Console.Out);
}
}Expected output
caller: before call worker: entered caller: pending True worker: resumed caller: result 42 worker: entered worker: resumed caller: completed True caller: result 10 lookups started: 2 summary pending: True summary: Mira | 2 orders failures observed: 2 orders malformed profile unavailable
5. Worked exercises
Exercise A: predict a boundary
Remove signal.SetResult(21) from the first experiment. Will caller: result 42 appear?
Solution: No. The only producer for that input is now absent. The pending task never obtains its value, and the caller remains asynchronously waiting. This is an unfinished-operation bug, not evidence that await blocked a dedicated worker thread. Do not make this edit in the normal runnable project: its purpose is to expose who owns completion.
Exercise B: preserve ownership after a failure
Suppose one input has already faulted and another input remains unfinished. May you declare the whole join finished? What must your code retain?
Solution: Retain the group and await it. The separate semantic test deliberately creates this exact state, verifies the group is still incomplete, and then completes the second signal. A failure in one input does not establish that the other input stopped. If a sibling should be canceled, that requires an explicit cancellation policy and support from that operation; joining alone does not request it.
Exercise C: add a third independent result
Add a one-reply points lookup using the same ManualLookup class. Request its task beside profileTask and ordersTask, include it as the third WhenAll input, and complete it with 50 points before supplying profile. What final summary should you expect?
Solution: Mira | 2 orders | 50 points. Its position comes from the third input, regardless of when that reply was supplied. All three reply producers must be completed or otherwise terminated. Keeping a task variable for each operation makes the ownership visible. Bounding large fan-out and forwarding cancellation are developed in the next two lessons.
6. Common mistakes and interview reasoning
- Blocking an asynchronous path: .Result and .Wait() occupy the waiting thread. In an environment where completion requires that blocked context, this can deadlock; blocking can also reduce capacity without a deadlock. Await the operation through its calling chain.
- Assuming async means parallel: our unfinished signals demonstrate overlapping operation lifetimes. They do not measure simultaneous CPU execution.
- Abandoning an operation: store or return its task, observe its final outcome, and give background work a deliberate lifetime owner.
- Using one catch as a failure list: the two-error experiment inspects the group rather than guessing that the one propagated exception describes every failed input.
Interview answer: Explain which operation produces the task, what state makes the next await suspend, who completes or cancels the operation, and who observes its final outcome. Then explain whether the operations are independent and what limits their fan-out. Those answers are more useful than saying async makes code run in the background.