C# & .NET (C# 14, .NET 10 LTS) Module 15 – Asynchronous programming
async and await from first principles
What await does to a C# method and its thread, async methods that return Task and Task<T>, waits that overlap, and the exceptions awaited tasks carry.
What you will learn
- Predict which part of an async method runs before its first await returns, and where the rest resumes
- Write async methods that return Task and Task<T>, and await their results
- Start independent waits before awaiting them, and move CPU-bound work off the caller with Task.Run
- Catch the exception an awaited task carries, and explain why an async void handler ends the process instead
What you’ll learn
What you will learn
- Predict which part of an async method runs before its first await returns, and where the rest resumes
- Write async methods that return Task and Task<T>, and await their results
- Start independent waits before awaiting them, and move CPU-bound work off the caller with Task.Run
- Catch the exception an awaited task carries, and explain why an async void handler ends the process instead
On this page
A price check asks three vendors for a quote, and their answers take 300, 200 and 250 ms to arrive. Awaited one
after another, the three take 750 ms; started together, about 300 ms. Either way the waiting needs no thread: await
lets the method step aside while the answer travels, so a server keeps its threads for other requests.
You will see what await does to a method and to the thread that runs it, write async methods that return Task and
Task<T>, start waits so that they overlap, give CPU-bound work a thread of its own, and catch the exceptions that
awaited tasks carry.
await hands control back to the caller and resumes the method later
order.cs calls an async method that waits 500 ms for a quote, standing in for a network call, and prints where each
part of the run happens:
// What await does to a method. The part before the first await runs at once, on the caller's thread; the caller
// gets an unfinished task back; the rest of the method runs later, when the awaited task completes.
Console.WriteLine($"Main calls LoadQuoteAsync (thread-pool thread: {OnPool()})");
Task<int> loading = LoadQuoteAsync();
Console.WriteLine($"Main has a task back: completed {loading.IsCompleted}, status {loading.Status}");
int quote = await loading;
Console.WriteLine($"Main resumes with {quote} (thread-pool thread: {OnPool()})");
static async Task<int> LoadQuoteAsync()
{
Console.WriteLine($" LoadQuoteAsync runs up to its await (thread-pool thread: {OnPool()})");
// Task.Delay stands in for a network call that answers after 500 ms.
await Task.Delay(500);
Console.WriteLine($" LoadQuoteAsync resumes after the wait (thread-pool thread: {OnPool()})");
return 412;
}
static bool OnPool() => Thread.CurrentThread.IsThreadPoolThread; Output
Main calls LoadQuoteAsync (thread-pool thread: False) LoadQuoteAsync runs up to its await (thread-pool thread: False) Main has a task back: completed False, status WaitingForActivation LoadQuoteAsync resumes after the wait (thread-pool thread: True) Main resumes with 412 (thread-pool thread: True)
Recorded with .NET SDK 10.0.401 (runtime 10.0.12) on macOS 26 arm64. To run it yourself: dotnet run order.cs
Read the five lines in order. LoadQuoteAsync ran on the caller’s thread up to its await, as an ordinary method
would. At the await the delay was not finished, so the method stopped there and gave its caller a
task: An object that stands for work in progress, such as a Task or a Task<T>. It ends once, with a result, an exception or a cancel; code awaits it to get the outcome, or reads its Status. that was not complete yet. WaitingForActivation is the status .NET gives an async
method’s task while the method is still in progress. Main went on to its own await, and the program waited. When
the delay ended, the rest of LoadQuoteAsync ran, its continuation: The code that runs when an awaited task completes: the rest of an async method after the await. Without a synchronization context, as in a console app, it runs on a thread-pool thread., then the rest of
Main, both on a thread pool: The set of worker threads that .NET keeps and reuses for short pieces of work: the work given to Task.Run, timer callbacks and the continuations of awaited tasks. thread. Step through the same run:
The drawing shows the whole scene; its 7 steps are listed below.
Every step as text
- The call. Main calls LoadQuoteAsync on the main thread, which is not a thread-pool thread. The body starts at once, as the body of any method does.
- Up to the await. LoadQuoteAsync prints its first line, still on the main thread, and calls Task.Delay(500). The delay starts a timer and returns a task that is not complete.
- The await returns. The delay has not finished, so the await keeps the rest of LoadQuoteAsync as a continuation and returns. Main gets a task with status WaitingForActivation.
- Main awaits it. Main prints that it has a task back, then awaits that task. For the next 500 ms no code of either method runs, and no thread waits inside LoadQuoteAsync.
- The timer fires. After 500 ms the timer completes the delay task on a thread-pool thread, and the continuation of LoadQuoteAsync runs there.
- The rest of the method. On a thread-pool thread, LoadQuoteAsync prints its second line and returns 412, which completes its task with that result.
- Main resumes. The continuation of Main runs, also on a thread-pool thread, and prints "Main resumes with 412". The program ends.
For those 500 ms no thread was inside LoadQuoteAsync. The compiler turns every async method into a
The object the compiler writes for an async method. It keeps the method's local variables and the place where it stopped, so the method can go on from that await when the awaited task completes.
that remembers its local variables and where it stopped, and the timer, not a waiting thread, resumed it. In a console
app the main thread does wait at the end, because the runtime waits for Main’s task before the process exits; in a
web server the thread goes back to serve another request.
Reading await as starting a thread
await starts nothing. The call LoadQuoteAsync() started the work, and await only says what to run when that
work ends. Microsoft’s guide to the task model says the keywords create no threads of their own, and the run above
shows it: the first half ran on the main thread, the second on the thread pool.
Checkpoint
Check: What await does to a method and its thread
An async method returns a task that promises its result
An async method declares what its task will carry: Task<int> for a method that ends with return 412;, and a plain
Task for one that returns nothing. The return statement gives the value, the compiler stores it in the task, and
await takes it out again. tasks.cs shows both kinds, and what happens to a call that nobody awaits:
// Task<int> promises an int, Task promises only that the work ends. await unwraps the int, and waits for the Task.
Task<int> pending = QuoteAsync("north");
Console.WriteLine($"QuoteAsync gave back a task with status {pending.Status}");
int price = await pending;
Console.WriteLine($"await gave the int inside it: {price}; status now {pending.Status}");
await SaveReceiptAsync("order 7");
Console.WriteLine("order 7: Main went on after the receipt was saved");
// The mistake: the call is not awaited (warning CS4014), so Main goes on at once and the program ends
// before the receipt is saved. Nothing reports it.
SaveReceiptAsync("order 8");
Console.WriteLine("order 8: Main went on without waiting, and the program ends here");
static async Task<int> QuoteAsync(string vendor)
{
await Task.Delay(200);
return vendor == "north" ? 412 : 398;
}
static async Task SaveReceiptAsync(string order)
{
await Task.Delay(1000);
Console.WriteLine($" receipt saved for {order}");
} Output
QuoteAsync gave back a task with status WaitingForActivation await gave the int inside it: 412; status now RanToCompletion receipt saved for order 7 order 7: Main went on after the receipt was saved order 8: Main went on without waiting, and the program ends here
Recorded with .NET SDK 10.0.401 (runtime 10.0.12) on macOS 26 arm64. To run it yourself: dotnet run tasks.cs
await pending gave the int 412 and left the task RanToCompletion. SaveReceiptAsync returns a plain Task:
awaiting it gives no value, but Main waited until the receipt for order 7 was saved before it printed its next line.
The call for order 8 was not awaited, so Main went straight on, the program ended, and that receipt was never saved.
Nothing reported it at run time. The compiler does warn about the call:
SaveReceiptAsync("order 8");
Console.WriteLine($"Main went on; the receipt has {await CountLinesAsync("total 412\npaid")} lines");
static async Task SaveReceiptAsync(string order)
{
await Task.Delay(1000);
Console.WriteLine($"receipt saved for {order}");
}
static async Task<int> CountLinesAsync(string text) => text.Split('\n').Length; from session import run
# warnings/Program.cs makes the two mistakes of this lesson: a call whose task is not awaited, and an async method
# with no await in it. Both compile; this SDK warns about the first one only.
run("""
cd warnings
dotnet build -v q
""") Output
$ cd warnings
$ dotnet build -v q
warnings/Program.cs(1,1): warning CS4014: Because this call is not awaited, execution of the current method continues before the call is completed. Consider applying the 'await' operator to the result of the call. [warnings/App.csproj]
Build succeeded.
warnings/Program.cs(1,1): warning CS4014: Because this call is not awaited, execution of the current method continues before the call is completed. Consider applying the 'await' operator to the result of the call. [warnings/App.csproj]
1 Warning(s)
0 Error(s)
Time Elapsed …
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 warnings_session.py
CS4014 marks the call whose task nobody awaits. The second mistake in the file, CountLinesAsync, an async method
with no await in it, compiled without a word from this SDK, though Microsoft’s list of compiler messages still
describes a warning, CS1998, for it; the section after next shows what such a method does. The return types an async
method can have:
| The method | Its return type | What await gives |
|---|---|---|
returns a value, return 412; |
Task<int> |
the int |
| returns nothing | Task |
no value; it waits for the end |
| often has its value at once | ValueTask<int> |
the int, as Task<int> does (a later lesson) |
| is an event handler | void |
nothing to await (the last section) |
| hands out items over time | IAsyncEnumerable<T> |
one item per pass of await foreach |
By convention the name ends in Async. Main may be async Task Main() since C# 7.1, and top-level statements may
use await directly, as every example here does.
Checkpoint
Check: Task, Task<T> and a task nobody awaits
Waits overlap when you start them before you await them
waits.cs asks three vendors for a quote, with waits of 300, 200 and 250 ms. First it awaits each call before it
makes the next; then it starts all three calls and awaits them after:
using System.Diagnostics;
// Three price quotes, each a wait of a few hundred milliseconds (Task.Delay stands in for the network).
// First each call is awaited before the next one starts; then all three start first and are awaited after.
var watch = Stopwatch.StartNew();
int north = await QuoteAsync("north", 300);
int south = await QuoteAsync("south", 200);
int east = await QuoteAsync("east", 250);
Console.WriteLine($"awaited one by one: cheapest {Math.Min(north, Math.Min(south, east))}, about {Rounded(watch)} ms");
watch.Restart();
Task<int> northTask = QuoteAsync("north", 300);
Task<int> southTask = QuoteAsync("south", 200);
Task<int> eastTask = QuoteAsync("east", 250);
int cheapest = Math.Min(await northTask, Math.Min(await southTask, await eastTask));
Console.WriteLine($"started together: cheapest {cheapest}, about {Rounded(watch)} ms");
static async Task<int> QuoteAsync(string vendor, int milliseconds)
{
await Task.Delay(milliseconds);
return vendor switch
{
"north" => 412,
"south" => 398,
_ => 405,
};
}
// The elapsed time, to the nearest 50 ms: the exact figure changes a little from run to run.
static long Rounded(Stopwatch watch) => (long)Math.Round(watch.Elapsed.TotalMilliseconds / 50) * 50; Output
awaited one by one: cheapest 398, about 750 ms started together: cheapest 398, about 300 ms
This output changes from run to run: the elapsed times change a little from run to run, with what else the computer is doing
Recorded with .NET SDK 10.0.401 (runtime 10.0.12) on macOS 26 arm64. To run it yourself: dotnet run waits.cs
Awaited one by one, the waits added up: 300 + 200 + 250 = 750 ms. Started together, they overlapped, and the whole
check took as long as the slowest vendor, about 300 ms. The second version calls QuoteAsync three times before its
first await. Each call runs up to its own await, starts its delay and returns a task at once, so all three delays
are running before Main waits for any of them:
| Vendor | Its wait | One by one, it answers at | Started together, it answers at |
|---|---|---|---|
| north | 300 ms | 300 ms | 300 ms |
| south | 200 ms | 500 ms | 200 ms |
| east | 250 ms | 750 ms | 250 ms |
Awaiting inside the loop that makes the calls
foreach (var v in vendors) prices.Add(await QuoteAsync(v)); is the one-by-one version: each pass waits before the
next call starts. Make every call first and keep the tasks in a list, then await them; the next lesson combines such
a list with Task.WhenAll.
None of the three waits used a thread. A delay is a timer, and a network reply is a notice from the operating system, so I/O-bound: Work that spends its time waiting for something outside the processor, such as a disk, a database or a network reply. While it waits, it needs no thread. work needs a thread only for the moments its own code runs. That is why async code helps most with I/O: a thread that would sit blocked for 300 ms can serve other work instead.
Key idea
Start every independent operation first and await them afterwards: each call runs up to its own await and returns, so the waits overlap and the total is the longest wait, not the sum.
Checkpoint
Check: Starting waits before awaiting them
CPU-bound work needs a thread, so it goes to Task.Run
A wait needs no thread, but a computation does. Counting the primes below 10 million keeps a processor busy for the
whole count, so it is CPU-bound: Work that keeps a processor busy computing, such as hashing a file or resizing images. It needs a thread for its whole run, which Task.Run can take from the thread pool., and marking the method async changes nothing about where
it runs. cpu.cs counts twice: once in an async method with no await, once through Task.Run:
// CPU-bound work. An async method with no await in it runs to its end before it returns, so the caller waits for
// the whole count. Task.Run puts the count on a thread-pool thread and returns an unfinished task at once.
Task<(int Count, bool OnPool)> direct = CountPrimesAsync(10_000_000);
Console.WriteLine($"async method without await: task completed on return: {direct.IsCompleted}");
Task<(int Count, bool OnPool)> offloaded = Task.Run(() => CountPrimes(10_000_000));
Console.WriteLine($"Task.Run: task completed on return: {offloaded.IsCompleted}");
var (count, onPool) = await direct;
Console.WriteLine($"without await: {count} primes, counted on a thread-pool thread: {onPool}");
(count, onPool) = await offloaded;
Console.WriteLine($"with Task.Run: {count} primes, counted on a thread-pool thread: {onPool}");
// The mistake this example shows: async on a method that never awaits.
static async Task<(int Count, bool OnPool)> CountPrimesAsync(int limit) => CountPrimes(limit);
// The sieve of Eratosthenes: the primes below limit.
static (int Count, bool OnPool) CountPrimes(int limit)
{
var composite = new bool[limit];
int count = 0;
for (int n = 2; n < limit; n++)
{
if (composite[n])
{
continue;
}
count++;
for (long m = (long)n * n; m < limit; m += n)
{
composite[m] = true;
}
}
return (count, Thread.CurrentThread.IsThreadPoolThread);
} Output
async method without await: task completed on return: True Task.Run: task completed on return: False without await: 664579 primes, counted on a thread-pool thread: False with Task.Run: 664579 primes, counted on a thread-pool thread: True
Recorded with .NET SDK 10.0.401 (runtime 10.0.12) on macOS 26 arm64. To run it yourself: dotnet run cpu.cs
The async method had finished the whole count before it returned: its task was already complete, and the count ran on
the main thread, so the caller could do nothing in the meantime. Task.Run queued the same count to a thread-pool
thread and returned a task that was not complete yet, which the caller awaited while the count went on elsewhere. Both
found 664579 primes. Microsoft’s guide to async scenarios sorts work with two questions: does the code wait for a
result, or does it run an expensive computation? The two kinds of work side by side:
| I/O-bound | CPU-bound | |
|---|---|---|
| Spends its time | waiting for a disk, a database or the network | computing |
| Needs a thread | only while its own code runs | for the whole computation |
| In an async method | await the I/O method’s task |
await Task.Run(...) |
Task.Run belongs where one particular thread must stay free, such as the UI thread of a desktop app, or where the
caller has other work to do while the count runs. A web request is different: ASP.NET Core already runs it on a
thread-pool thread, and Microsoft’s performance guidance says not to call Task.Run and await it at once there, since
that only adds a hop to another pool thread. Wrapping a database call in Task.Run gains nothing either: the call
already returns a task, and the extra thread would only wait for it.
Coming from Java
A chain of CompletableFuture stages, thenApply and thenCompose, becomes plain statements after await. Java 21’s
virtual threads (JEP 444) make threads cheap enough to block; .NET keeps async and await, and .NET 11’s Runtime
Async moves async methods into the runtime itself for cleaner stack traces and lower overhead, with the same async
and await in the code.
Checkpoint
Check: CPU-bound work and Task.Run
A failed task keeps its exception until you await it
faults.cs calls a quote method that throws after its wait, then blocks on two more calls instead of awaiting them:
// An exception inside an async method does not fly out of the call. It is stored in the task the call returned,
// and await throws it again, as the same exception.
Task<int> quote = QuoteAsync("west");
Console.WriteLine($"the call returned without an exception; status {quote.Status}");
try
{
int price = await quote;
Console.WriteLine($"price {price}");
}
catch (InvalidOperationException e)
{
Console.WriteLine($"await threw {e.GetType().Name}: {e.Message}");
}
Console.WriteLine($"the task is {quote.Status}, its Exception holds {quote.Exception!.InnerExceptions.Count} exception");
// Even a throw before the first await is stored, not thrown at the call.
Task<int> empty = QuoteAsync("");
Console.WriteLine($"empty vendor name: the call returned too; status {empty.Status}");
// Blocking on the task instead of awaiting it: .Result wraps the exception, GetAwaiter().GetResult() does not.
try
{
_ = QuoteAsync("west").Result;
}
catch (AggregateException e)
{
Console.WriteLine($".Result threw {e.GetType().Name} around {e.InnerException!.GetType().Name}");
}
try
{
_ = QuoteAsync("west").GetAwaiter().GetResult();
}
catch (InvalidOperationException e)
{
Console.WriteLine($"GetAwaiter().GetResult() threw {e.GetType().Name}");
}
static async Task<int> QuoteAsync(string vendor)
{
ArgumentException.ThrowIfNullOrEmpty(vendor);
await Task.Delay(100);
throw new InvalidOperationException($"{vendor} sent no price");
} Output
the call returned without an exception; status WaitingForActivation await threw InvalidOperationException: west sent no price the task is Faulted, its Exception holds 1 exception empty vendor name: the call returned too; status Faulted .Result threw AggregateException around InvalidOperationException GetAwaiter().GetResult() threw InvalidOperationException
Recorded with .NET SDK 10.0.401 (runtime 10.0.12) on macOS 26 arm64. To run it yourself: dotnet run faults.cs
The call itself returned normally, with a task still in progress. The exception came out of await quote, as the
same InvalidOperationException that was thrown, so a plain try and catch around the await caught it. In
between it was stored in the task, which ended Faulted: a faulted task: A task that ended with an unhandled exception. Its Status is Faulted, its Exception property holds the exception inside an AggregateException, and await throws the exception again., whose
Exception property is an AggregateException: An exception that holds a list of other exceptions in its InnerExceptions property. Task.Exception is one, because a task can fail with more than one exception, and .Result and Wait() throw it too. holding 1 exception. Even the throw
at the top of the method, for an empty vendor name, before any await, was stored the same way: that call returned a
task that was already Faulted. Follow the exception of the first call:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- The call returns. Main calls QuoteAsync("west"). The method runs up to its await and returns a task in progress, so the call itself throws nothing.
- The throw. After the 100 ms delay the method throws InvalidOperationException. Nothing in QuoteAsync catches it.
- Stored in the task. The exception leaves the async method and is stored in its task, which ends Faulted. Its Exception property is an AggregateException with this one exception inside.
- Rethrown at the await. await quote finds the task Faulted and throws the stored exception again: the same InvalidOperationException, not the AggregateException around it.
- Caught by the caller. The try around the await catches it, and Main prints "await threw InvalidOperationException: west sent no price".
- Blocking wraps it. Reading .Result instead of awaiting blocks the thread until the task ends and throws the AggregateException itself, with the InvalidOperationException inside it.
Blocking on the task changes the shape of the error. .Result threw the AggregateException with the real exception
inside it, while GetAwaiter().GetResult() threw the InvalidOperationException itself. Both hold the thread until
the task ends, so async code uses await; Microsoft’s guide to task exceptions keeps the blocking forms for
synchronous entry points that cannot await.
A try around the call instead of the await
try { task = QuoteAsync("west"); } catch (InvalidOperationException) { … } catches nothing: the call returns a
task, and the exception waits in it. Put the await inside the try, as faults.cs does.
Checkpoint
Check: Exceptions stored in a faulted task
async void hands its exception to no one, and the process ends
Event handlers must return void, so C# allows async void methods for them. Such a method gives its caller nothing to
await, which changes where its exception goes. handlers.cs fails the same way in two click handlers:
// Two click handlers fail the same way. The async Task one returns a task, so its caller awaits it and catches the
// error. The async void one returns nothing to await: its exception goes to the thread pool and ends the process.
AppDomain.CurrentDomain.UnhandledException += (_, e) =>
Console.WriteLine($"unhandled: {((Exception)e.ExceptionObject).Message}; the process ends: {e.IsTerminating}");
try
{
await OnPrintClickedAsync(null, EventArgs.Empty);
}
catch (InvalidOperationException e)
{
Console.WriteLine($"async Task handler: the caller caught '{e.Message}'");
}
try
{
OnPrintClicked(null, EventArgs.Empty);
Console.WriteLine("async void handler: the call returned, and no exception reached this try");
}
catch (InvalidOperationException)
{
Console.WriteLine("never printed: this catch block cannot see the exception");
}
// The async void handler fails while Main waits here.
await Task.Delay(1000);
Console.WriteLine("never printed: the process has already ended");
static async Task OnPrintClickedAsync(object? sender, EventArgs e)
{
await Task.Delay(100);
throw new InvalidOperationException("printer out of paper");
}
static async void OnPrintClicked(object? sender, EventArgs e)
{
await Task.Delay(100);
throw new InvalidOperationException("printer out of paper");
} Output (exit status 134)
async Task handler: the caller caught 'printer out of paper' async void handler: the call returned, and no exception reached this try unhandled: printer out of paper; the process ends: True
Printed as an error (standard error)
Unhandled exception. System.InvalidOperationException: printer out of paper at Program.<<Main>$>g__OnPrintClicked|0_2(Object sender, EventArgs e) in ./handlers.cs:line 38 at System.Threading.Tasks.Task.<>c.<ThrowAsync>b__124_1(Object state) at System.Threading.ThreadPoolWorkQueue.Dispatch() at System.Threading.PortableThreadPool.WorkerThread.WorkerThreadStart() at System.Threading.Thread.StartCallback()
Recorded with .NET SDK 10.0.401 (runtime 10.0.12) on macOS 26 arm64. To run it yourself: dotnet run handlers.cs
The async Task handler’s exception reached its caller’s catch block. The async void handler’s call returned at
its first await, and the try around it caught nothing. When that handler threw, 100 ms later, there was no task to
store the exception in. A console app has no
An object that decides where the continuations of awaits run. UI frameworks install one that sends them back to the UI thread; console apps and ASP.NET Core have none, so continuations run on the thread pool.
to post it to, so .NET threw it on a thread-pool thread, where an unhandled exception ends the process. The
UnhandledException event reported it with IsTerminating true, the runtime printed the stack trace, and the run
ended with exit status 134, before Main’s last line. Step through both paths:
The drawing shows the whole scene; its 8 steps are listed below.
Every step as text
- Two handlers, one failure. Both handlers wait 100 ms and then throw InvalidOperationException. The only difference is the return type: Task on the left, void on the right.
- async Task throws. The async Task handler throws after its delay. Its exception has a home: the task the caller is awaiting.
- Stored, then rethrown. The exception is stored in the task, which ends Faulted, and the caller's await throws it again.
- Caught. The caller's catch block gets it and prints "async Task handler: the caller caught 'printer out of paper'". The program goes on.
- async void returns at once. The call to the async void handler returns as soon as it reaches its await, with nothing to await. The try around the call has already ended when the exception comes.
- async void throws. After 100 ms the handler throws. There is no task to store the exception in.
- On the thread pool. With no synchronization context to post it to, as in a console app, .NET throws it on a thread-pool thread, where nothing can catch it.
- The process ends. An unhandled exception on a thread-pool thread ends the process: UnhandledException fired with IsTerminating True, and the run ended with exit status 134 before Main's last line.
Microsoft’s guidance gives async void to event handlers only: the caller cannot catch their exceptions, they are
hard to test, and callers do not expect them to still be running. Every other async method returns a task. An event
handler that must be async void catches and handles its own exceptions inside.
Key idea
An exception in an async method waits in its task until someone awaits it; an async void method has no task, so its exception reaches no caller and ends the process.
Checkpoint
Check: Why async void ends the process
Practise: the cheapest of three quotes
prices.cs checks CheapestQuoteAsync, which asks several vendors for a price. The rules are at the top of the file,
and all five checks fail on the starter:
// Practice: write CheapestQuoteAsync, then run this file until all five checks pass.
// The rules: ask every vendor at once (start every call before awaiting any), skip a vendor whose task fails,
// return the cheapest price that arrived, and throw InvalidOperationException when no vendor sent a price.
// As written, CheapestQuoteAsync throws NotImplementedException.
(string Check, Func<Task<string>> Run, string Expected)[] checks =
[
("three prices", () => Cheapest(new Vendor(412, 300), new Vendor(398, 200), new Vendor(405, 250)), "398"),
("one vendor fails", () => Cheapest(new Vendor(412, 300), new Vendor(0, 100, Fails: true), new Vendor(405, 250)), "405"),
("the cheapest vendor fails", () => Cheapest(new Vendor(399, 100, Fails: true), new Vendor(420, 200)), "420"),
("every vendor fails", () => Cheapest(new Vendor(0, 100, Fails: true), new Vendor(0, 50, Fails: true)), "throws InvalidOperationException"),
("all calls start before any finishes", StartedTogether, "3 started when the first finished"),
];
int passed = 0;
foreach (var (check, run, expected) in checks)
{
string got = await run();
bool ok = got == expected;
if (ok)
{
passed++;
}
Console.WriteLine(ok ? $"{check}: ok" : $"{check}: FAIL, {got} instead of {expected}");
}
Console.WriteLine($"{passed} of {checks.Length} checks pass");
return passed == checks.Length ? 0 : 1;
static async Task<int> CheapestQuoteAsync(params Func<Task<int>>[] vendors)
{
throw new NotImplementedException();
}
static async Task<string> Cheapest(params Vendor[] vendors)
{
try
{
return (await CheapestQuoteAsync([.. vendors.Select(v => (Func<Task<int>>)v.QuoteAsync)])).ToString();
}
catch (Exception e)
{
return $"throws {e.GetType().Name}";
}
}
// Each vendor counts the calls started so far; the first one to finish notes how many there were.
static async Task<string> StartedTogether()
{
var log = new StartLog();
Vendor[] vendors = [new(412, 300, Log: log), new(398, 200, Log: log), new(405, 250, Log: log)];
await Cheapest(vendors);
return log.SeenByFirst is int n ? $"{n} started when the first finished" : "no vendor finished";
}
sealed class StartLog
{
public int Started;
public int? SeenByFirst;
}
sealed record Vendor(int Price, int Milliseconds, bool Fails = false, StartLog? Log = null)
{
public async Task<int> QuoteAsync()
{
if (Log is not null)
{
Log.Started++;
}
await Task.Delay(Milliseconds);
if (Log is not null)
{
Log.SeenByFirst ??= Log.Started;
}
return Fails ? throw new InvalidOperationException("the vendor sent no price") : Price;
}
} Output (exit status 1)
three prices: FAIL, throws NotImplementedException instead of 398 one vendor fails: FAIL, throws NotImplementedException instead of 405 the cheapest vendor fails: FAIL, throws NotImplementedException instead of 420 every vendor fails: FAIL, throws NotImplementedException instead of throws InvalidOperationException all calls start before any finishes: FAIL, no vendor finished instead of 3 started when the first finished 0 of 5 checks pass
Recorded with .NET SDK 10.0.401 (runtime 10.0.12) on macOS 26 arm64. To run it yourself: dotnet run prices.cs
Write CheapestQuoteAsync
Copy prices.cs. Call every vendor first and keep the tasks in an array, then await each task inside its own try,
skipping a vendor whose task throws InvalidOperationException. Keep the lowest price, and throw
InvalidOperationException when none arrived. Run dotnet run prices.cs until it prints 5 of 5 checks pass.
Last, a self-check on all six sections. It begins where your answers so far are weakest, and it gives the reason behind each choice, right or wrong:
Practice that adapts to you
Practise this lesson
8 questions about 6 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Fresher · What does await do to the thread that is running the method?
It lets the thread go. When await meets a task that has not completed, it stores the rest of the method as a
continuation and returns to the caller, often with a task of its own; no thread stays blocked inside the method while
the operation runs. When the awaited task completes, the continuation is scheduled: on the thread pool in a console app
or ASP.NET Core, back on the UI thread in a desktop app. The result, or the stored exception, comes out of the await
expression. If the task had already completed, await goes straight on without returning at all. await itself
starts no work and creates no thread: the call before it started the operation.
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Fresher to mid
A dashboard endpoint calls four independent services, each answering in about 120 ms, and responds in about 500 ms. Each call is awaited on its own line before the next one is made. Why is it slow, how do you fix it, and what changes when one of the four calls fails?
-
Mid to senior
A background service crashes a few times a day with an unhandled InvalidOperationException from a method OnMessageReceived, declared async void. The team wrapped every call to it in try and catch, and nothing changed. Explain why, and fix it.
Key takeaways
- An async method runs on the caller’s thread up to the first
awaitof an unfinished task, then returns a task; its continuation runs when that task completes, on the thread pool in a console app. Task<T>carries the result thatreturngives andawaittakes out;Taskcarries only the end of the work, and a call whose task nobody awaits runs on unchecked, which CS4014 warns about.- Independent waits overlap when every call starts before the first
await, so the total is the longest wait rather than the sum; I/O-bound waits hold no thread. - An async method with no
awaitruns to its end before it returns, so CPU-bound work that must not block the caller goes toTask.Run. - A failed task stores its exception, and
awaitrethrows it as it was thrown, while.Resultwraps it in anAggregateException. - An
async voidmethod has no task to hold its exception, so the exception ends the process; keepasync voidfor event handlers that catch their own errors.
Terms in this lesson
- AggregateException
- An exception that holds a list of other exceptions in its InnerExceptions property. Task.Exception is one, because a task can fail with more than one exception, and .Result and Wait() throw it too.
- continuation
- The code that runs when an awaited task completes: the rest of an async method after the await. Without a synchronization context, as in a console app, it runs on a thread-pool thread.
- CPU-bound
- Work that keeps a processor busy computing, such as hashing a file or resizing images. It needs a thread for its whole run, which Task.Run can take from the thread pool.
- faulted task
- A task that ended with an unhandled exception. Its Status is Faulted, its Exception property holds the exception inside an AggregateException, and await throws the exception again.
- I/O-bound
- Work that spends its time waiting for something outside the processor, such as a disk, a database or a network reply. While it waits, it needs no thread.
- task
- An object that stands for work in progress, such as a Task or a Task<T>. It ends once, with a result, an exception or a cancel; code awaits it to get the outcome, or reads its Status.
- thread pool
- The set of worker threads that .NET keeps and reuses for short pieces of work: the work given to Task.Run, timer callbacks and the continuations of awaited tasks.
References
- Asynchronous programming with async and await (Microsoft Learn)
- Asynchronous programming scenarios (Microsoft Learn)
- The Task asynchronous programming (TAP) model with async and await (Microsoft Learn)
- Async return types (Microsoft Learn)
- await operator (C# reference) (Microsoft Learn)
- Errors and warnings that involve async, await and the Task-asynchronous protocol (Microsoft Learn)
- TaskStatus enum (Microsoft Learn)
- Task exception handling (Microsoft Learn)
- Task-based Asynchronous Pattern (TAP): Introduction and overview (Microsoft Learn)
- ASP.NET Core Best Practices (Microsoft Learn)
- The managed thread pool (Microsoft Learn)
- AppDomain.UnhandledException event (Microsoft Learn)
- Main() and command-line arguments (Microsoft Learn)
- The history of C# (Microsoft Learn)
- What's new in .NET 11 (Microsoft Learn)
- JEP 444: Virtual Threads (OpenJDK)
- CompletableFuture (Java SE 21 API) (Oracle)
Report a problem with this lesson
Kept only in this browser. Your Learn progress