C# (.NET 8 LTS) track

Lesson 19 · Part 5: Modern C#

async/await

Run I/O-bound work asynchronously with Task, await, cancellation and ConfigureAwait.

Part 5: Modern C#5 min readFree lesson

Asynchronous programming keeps threads from being occupied while waiting for I/O. C# async/await lets you write non-blocking code in a style that reads like synchronous code.

Learning Objectives

  • Understand the relationship between Task/Task<T> and async/await
  • Wait for all tasks with Task.WhenAll and react to the first with Task.WhenAny
  • Safely cancel asynchronous operations with CancellationToken
  • Exceptions from async methods surface at the await.
  • Understand the purpose of ConfigureAwait(false).

Key concepts

A Task represents work in progress, and Task<T> represents work that returns T when complete. await waits for the result and then retrieves it.

async Task<int> GetAsync()
{
    await Task.Delay(1000);   //Releases the thread for 1 second
    return 42;
}

int n = await GetAsync();   //Looks asynchronous, but reads as synchronous
  • The return type is Task, Task<T>, or ValueTask/ValueTask<T>
  • async void as an event handler-specific exception — Do not use in regular code (exception cannot be caught)
  • By convention, Async is added to the end of the method name (SaveAsync, FetchAsync)

If you use await in succession, it will be a sequential wait, but if you collect Task and use await at once, it will proceed simultaneously.

Task<int> a = FetchAsync("A");
Task<int> b = FetchAsync("B");
int[] all = await Task.WhenAll(a, b);   //are finished, only one
Task<int> first = await Task.WhenAny(a, b);   //needs to be finished.

This is a cooperative cancellation model. The token must be confirmed on the receiving end for it to actually stop.

using CancellationTokenSource cts = new();
cts.CancelAfter(2000);
await Task.Delay(5000, cts.Token);   //OperationCanceledException at 2 seconds

Most BCL methods, such as Task.Delay, HttpClient.GetAsync, and Stream.ReadAsync, take the CancellationToken parameter.

Exceptions from async methods are stored in Task and are rethrown at await. Therefore, try/catch can be placed on the calling side.

try { await DoAsync(); }
catch (InvalidOperationException ex) { /* ... */ }

If multiple exceptions occur in Task.WhenAll, only the first one is thrown, and the rest are collected in Task.Exception (AggregateException).

Use this when you want to avoid the cost of returning to the same context after awaiting in an environment with a **UI/Request context, such as WinForms/WPF/ASP.NET Framework. It's almost pointless in console apps or ASP.NET Core.

await stream.ReadAsync(buf).ConfigureAwait(false);

If you are a library writer, it is safe to add it repeatedly, and it is OK to not worry about the app code.

See as example

Example 1 — AsyncBasics: Basic flow

Console.WriteLine("Start task");
int result = await DoWorkAsync();
Console.WriteLine($"Result: {result}");

static async Task<int> DoWorkAsync()
{
    await Task.Delay(1000);
    await Task.Delay(1000);
    return 42;
}

Execution result

Start task
...Wait 1 second
...Wait 1 second more
Result: 42
Operation terminated

Note: During await Task.Delay, the thread can do other things. This is the core reason why the UI doesn't freeze.

Task<int> t1 = FetchAsync("A", 1000);
Task<int> t2 = FetchAsync("B", 1500);
Task<int> t3 = FetchAsync("C", 800);
int[] results = await Task.WhenAll(t1, t2, t3);

Execution result

[A] Start (1000ms)
[B] Start (1500ms)
[C] Start (800ms)
[C] Complete
[A] Complete
[B] Complete
Total of results: 3300
Total time: 1500 ms (about 3300ms if sequential)

Note: It will only take as long as the longest operation. The best pattern for grouping multiple I/O calls.

Task<string> winner = await Task.WhenAny(mirrorA, mirrorB, mirrorC);
string result = await winner;

Execution result

First to respond: Tokyo (800ms)

Note: WhenAny returns the first Task itself, so you need to await one more time to get the value.

using CancellationTokenSource cts = new();
cts.CancelAfter(TimeSpan.FromMilliseconds(1500));

try { await LongJobAsync(cts.Token); }
catch (OperationCanceledException) { Console.WriteLine("Operation canceled"); }

Execution result

Phase 1 completed
Phase 2 completed
Operation canceled

Memo: Inside the method that receives the token, you must also pass the token like Task.Delay(ms, token). If you ignore the token, it will never stop.

try { await FailingAsync(); }
catch (InvalidOperationException ex) { Console.WriteLine($"Caught: {ex.Message}"); }

Execution result

Caught: Intended Failed
Caught in WhenAll: Intended Failed
t1 state: Faulted, t2 state: Faulted

Memo: Exceptions thrown within an async method are contained in a Task object and are thrown as is when awaiting. Just like usual synchronous code, you can catch it with try/catch.

Common Mistakes

  1. Using async void — The caller cannot catch the exception, and the app dies. Do not use anything other than an event handler.
  2. task.Result / task.Wait() calls — the main culprit of deadlocks in the UI context. Always await.
  3. Wrap simple I/O with Task.Run(async () => ...) — I/O is already asynchronous, so there is no need to throw it into the thread pool.
  4. After collecting the results of WhenAll, only look at the first exception and finish — Exceptions from other tasks must also be checked to ensure they are not missed.
  5. Just receive CancellationToken and not pass it to the next call — the cancellation is not propagated.

Cleanup

  • Methods with async return Task/Task<T>, and the caller retrieves the result as await.
  • Task.WhenAll when combining multiple tasks, Task.WhenAny when using only the fastest one.
  • External cancellation is CancellationToken, which propagates to the end along the call chain.
  • The exception is thrown at await, so you can handle it as try/catch as usual.
  • You don't need to worry about Console/Server app, you need to take care of the UI library.

Try it yourself

cd src/AsyncBasics && dotnet run
cd ../WhenAll && dotnet run
cd ../WhenAny && dotnet run
cd ../Cancellation && dotnet run
cd ../AsyncException && dotnet run

Example files

The runnable files used in this lesson. Copy them into your own project and experiment.

src/AsyncBasics/AsyncBasics.csprojText
<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net8.0</TargetFramework>
    <RootNamespace>CodingNow.Lecture.Modern19</RootNamespace>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>

</Project>
src/AsyncBasics/Program.csC#
//The top-level statement of .NET 8 can use await as is.
Console.WriteLine("Start task");
int result = await DoWorkAsync();
Console.WriteLine($"Result: {result}");
Console.WriteLine("Operation terminated");

//Async method returns Task / Task<T>.
//Returns to the caller when await is encountered, and continues execution when the task is completed.
static async Task<int> DoWorkAsync()
{
    Console.WriteLine("...Wait 1 second");
    await Task.Delay(1000);
    Console.WriteLine("...Wait 1 second more");
    await Task.Delay(1000);
    return 42;
}
src/AsyncException/AsyncException.csprojText
<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net8.0</TargetFramework>
    <RootNamespace>CodingNow.Lecture.Modern19</RootNamespace>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>

</Project>
src/AsyncException/Program.csC#
//Exceptions thrown by async methods are "wrapped" within the Task and re-thrown at the await point.
try
{
    await FailingAsync();
}
catch (InvalidOperationException ex)
{
    Console.WriteLine($"Caught: {ex.Message}");
}

//In the case of Task.WhenAll, only the first exception is thrown, and the remaining exceptions are collected within Task.Exception.
Task t1 = FailingAsync();
Task t2 = FailingAsync();
try
{
    await Task.WhenAll(t1, t2);
}
catch (InvalidOperationException ex)
{
    Console.WriteLine($"Noise in WhenAll: {ex.Message}");
    Console.WriteLine($"t1 status: {t1.Status}, t2 status: {t2.Status}");
}

static async Task FailingAsync()
{
    await Task.Delay(200);
    throw new InvalidOperationException("Intentional Failure");
}
src/Cancellation/Cancellation.csprojText
<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net8.0</TargetFramework>
    <RootNamespace>CodingNow.Lecture.Modern19</RootNamespace>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>

</Project>
src/Cancellation/Program.csC#
//CancellationToken: A signal that allows an asynchronous task to be stopped externally.
using CancellationTokenSource cts = new();

//A cancellation signal is sent after 1.5 seconds.
cts.CancelAfter(TimeSpan.FromMilliseconds(1500));

try
{
    await LongJobAsync(cts.Token);
    Console.WriteLine("Normal completion");
}
catch (OperationCanceledException)
{
    Console.WriteLine("Operation canceled");
}

//The token is checked at every step, or passed as is to Task.Delay to automatically cancel.
static async Task LongJobAsync(CancellationToken token)
{
    for (int i = 1; i <= 5; i++)
    {
        await Task.Delay(500, token);   //If canceled, an OperationCanceledException is thrown.
        Console.WriteLine($"Step {i} completed.");
    }
}
src/WhenAll/Program.csC#
using System.Diagnostics;

//Start three tasks simultaneously and wait for them all to finish.
Stopwatch sw = Stopwatch.StartNew();

Task<int> t1 = FetchAsync("A", 1000);
Task<int> t2 = FetchAsync("B", 1500);
Task<int> t3 = FetchAsync("C", 800);

int[] results = await Task.WhenAll(t1, t2, t3);

sw.Stop();
Console.WriteLine($"Sum of results: {results.Sum()}");
Console.WriteLine($"Total time: {sw.ElapsedMilliseconds} ms (about 3300ms if sequential)");

//Asynchronous methods that mimic network calls — they actually await HttpClient, etc.
static async Task<int> FetchAsync(string name, int delayMs)
{
    Console.WriteLine($"[{name}] start ({delayMs}ms)");
    await Task.Delay(delayMs);
    Console.WriteLine($"[{name}] done");
    return delayMs;
}
src/WhenAll/WhenAll.csprojText
<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net8.0</TargetFramework>
    <RootNamespace>CodingNow.Lecture.Modern19</RootNamespace>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>

</Project>
src/WhenAny/Program.csC#
//Only process tasks that are completed first — the "which server responds first" pattern.
Task<string> mirrorA = MirrorAsync("Seoul", 1500);
Task<string> mirrorB = MirrorAsync("Tokyo", 800);
Task<string> mirrorC = MirrorAsync("Singapore", 1200);

Task<string> winner = await Task.WhenAny(mirrorA, mirrorB, mirrorC);
string result = await winner;   //Await one more time to retrieve the value.
Console.WriteLine($"The first to respond: {result}");

//The rest can be left to finish in the background or canceled (see the next example).
static async Task<string> MirrorAsync(string region, int delayMs)
{
    await Task.Delay(delayMs);
    return $"{region}({delayMs}ms)";
}
src/WhenAny/WhenAny.csprojText
<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net8.0</TargetFramework>
    <RootNamespace>CodingNow.Lecture.Modern19</RootNamespace>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>

</Project>

Assignment

  • Reference solution: shown below
  • Core concepts: async Task, Task.Delay, CancellationTokenSource

Requirements

  • Create the CountdownAsync(int seconds, CancellationToken token) method.
  • Outputs the remaining seconds at 1-second intervals ("5 seconds remaining", "4 seconds remaining", ...).
  • When it reaches 0, “Fire!” outputs.
  • Create CancellationTokenSource in the main and send a cancellation signal 2.5 seconds later to stop the countdown midway.
  • If canceled, “Cancelled” is output.

Expected output

5 seconds left
4 seconds left
3 seconds left
Canceled

Hint

  • If you pass along a token like await Task.Delay(1000, token);, OperationCanceledException is automatically thrown.

  • Cancel after a specified time with cts.CancelAfter(2500);.

  • Countdown is in the order Console.WriteLine($"{i} seconds left"); → await Task.Delay(1000, token);.

  • Reference solution: shown below

  • Core concepts: Task.WhenAll, async Task<T>

Requirements

  • Creates 3 asynchronous tasks LoadAsync(string name, int delayMs) — After waiting for the specified time, delayMs is returned as is.
  • Start 3 tasks simultaneously in main (e.g. 700ms / 1200ms / 900ms).
  • After waiting for all Task.WhenAll, print the sum of the returned values.
  • The total time required is also measured and output as Stopwatch (it should be similar to the longest task).
[A] Start
[B] Start
[C] Start
[A] Completed (700ms)
[C] Completed (900ms)
[B] Completed (1200ms)
Total: 2800
Takes: Near 1200ms

Hint

  • Using System.Diagnostics.Stopwatch.
  • int[] results = await Task.WhenAll(t1, t2, t3); → results.Sum().
  • The output order is in the order of task completion (starting from the shortest).

Check the correct answer

After solving it yourself, compare it with the reference solution below.

Show the reference solution

Try the assignment yourself first — comparing your attempt with a working solution is where most of the learning happens.

Homework01/Homework01.csprojText
<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net8.0</TargetFramework>
    <RootNamespace>CodingNow.Lecture.Modern19</RootNamespace>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>

</Project>
Homework01/Program.csC#
using CancellationTokenSource cts = new();
cts.CancelAfter(TimeSpan.FromMilliseconds(2500));   //Automatic cancellation after 2.5 seconds

try
{
    await CountdownAsync(5, cts.Token);
}
catch (OperationCanceledException)
{
    Console.WriteLine("Canceled");
}

static async Task CountdownAsync(int seconds, CancellationToken token)
{
    for (int i = seconds; i > 0; i--)
    {
        Console.WriteLine($"{i} seconds left");
        await Task.Delay(1000, token);   //Pass token — if canceled, throw here
    }
    Console.WriteLine("Fire!");
}
Homework02/Homework02.csprojText
<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net8.0</TargetFramework>
    <RootNamespace>CodingNow.Lecture.Modern19</RootNamespace>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>

</Project>
Homework02/Program.csC#
using System.Diagnostics;

Stopwatch sw = Stopwatch.StartNew();

//starts at the same time — takes only as long as the longest operation (1200ms).
Task<int> t1 = LoadAsync("A", 700);
Task<int> t2 = LoadAsync("B", 1200);
Task<int> t3 = LoadAsync("C", 900);

int[] results = await Task.WhenAll(t1, t2, t3);
sw.Stop();

Console.WriteLine($"Sum: {results.Sum()}");
Console.WriteLine($"Takes: {sw.ElapsedMilliseconds}ms Near");

static async Task<int> LoadAsync(string name, int delayMs)
{
    Console.WriteLine($"Starts[{name}]");
    await Task.Delay(delayMs);
    Console.WriteLine($"[{name}] Completes ({delayMs}ms)");
    return delayMs;
}

Learn this lesson with a mentor

Work through the C# (.NET 8 LTS) track on your own, or join a guided cohort at our Pitampura campus and online.

Call NowEnroll Now