Skip to content

9. Asynchronous Programming

The previous chapter introduced threads: we can execute multiple pieces of code “at the same time,” synchronize them, and assign tasks to the thread pool. This chapter, which was not included in the 2008 course, addresses a related but distinct issue: how to write a program that waits a long time (for the disk, the network, or a database) without blocking threads during these waits. The answer in .NET lies in two keywords of the C# language, [async] and [await], and in one type, [Task]. They are everywhere today: the following chapters will use them to access databases ([OpenAsync], [ExecuteReaderAsync], [ToListAsync], [SaveChangesAsync]), to write TCP servers ([AcceptTcpClientAsync]), web clients ([HttpClient]), and web services using ASP.NET Core.

All examples in this chapter are single-file applications ([async-01.cs], [async-02.cs]…) that are run using [dotnet run async-NN.cs] in the [09-async] folder. Many of them measure durations using a stopwatch ([Stopwatch]): the milliseconds displayed vary from one run to another and from one machine to another (the examples were run on a dual-processor machine). It’s the orders of magnitude that matter: 1 second or 3 seconds, 300 ms or 0 ms.

9.1. Why Asynchrony

9.1.1. Operations that wait

A program performs two types of work:

  • computational tasks, which occupy the processor: sorting a list, calculating a tax, analyzing a text;
  • input/output (I/O), which essentially consists of waiting for a peripheral device: reading a file from disk, sending a request to a web server and waiting for its response, executing a SQL query on a database server.

The orders of magnitude are very different. A processor executes a few billion instructions per second. A read operation on a disk (SSD) takes on the order of one-tenth of a millisecond; a round trip over a local network takes a fraction of a millisecond; a round trip to a server located on the other side of the country takes a few tens of milliseconds; and a somewhat complex SQL request can sometimes take several seconds. During these waits, the processor could have executed millions of instructions.

When a program reads a line over a network connection using a synchronous (blocking) method, the thread performing this read is blocked: it does nothing, but it still exists. It occupies memory (its stack, on the order of a megabyte), it is recognized by the operating system, and, most importantly, it is no longer available to do anything else. This isn’t a problem in a small console program that does one thing at a time. It’s much more of an issue in two situations:

  • on a server: each client served by a blocked thread ties up that thread. The 2008 course already explained this in the conclusion of its discussion of the asynchronous TCP client: if the thread pool has N threads and all N threads are performing a blocking operation, there are no threads left available for a new client. A modern web server handles thousands of simultaneous requests with just a few dozen threads: this is possible because its threads never get blocked on I/O;
  • in a graphical user interface: a single thread—the UI thread—handles clicks and redraws windows. If it is blocked by a download, the window freezes.

Asynchrony involves initiating the I/O operation, freeing up the thread while it runs (the hardware and operating system do not need it to transfer data), and then resuming work once the operation is complete, possibly on a different thread:

Image

  • In [1], the thread T1 initiates the I/O and remains blocked until it finishes;
  • in [2], the thread T1 initiates the I/O and returns to the thread pool, where it can handle other requests. When the I/O operation completes, the system notifies .NET, which has a thread from the pool—T2 (or T1, if it is available)—execute the rest of the processing.

The [async-01.cs] program measures the difference over 100 operations, each of which waits for one second (the wait simulates I/O). It uses instructions that we will explain later in this chapter ([Task.Run], [Task.Delay], [await]); here, we are only interested in the results.

// why asynchrony: 100 operations that each wait for one second
// (a wait simulates an I/O: disk read, network request, SQL query…)
using System.Diagnostics;

const int nbOperations = 100;
Console.WriteLine($"{nbOperations} operations of one second, {Environment.ProcessorCount} processors");

// 1 - one thread per operation, each thread is blocked during the wait
var stopwatch = Stopwatch.StartNew();
var threads = new Thread[nbOperations];
for (int i = 0; i < nbOperations; i++)
{
    threads[i] = new Thread(() => Thread.Sleep(1000));
    threads[i].Start();
}
Display("1 - one thread per operation", ProcessThreads());
foreach (var thread in threads)
{
    thread.Join();
}
Display("    end");

// 2 - the operations are handed to the thread pool, but they block it
stopwatch.Restart();
var tasks = new Task[nbOperations];
for (int i = 0; i < nbOperations; i++)
{
    tasks[i] = Task.Run(() => Thread.Sleep(1000));
}
Display("2 - thread pool, blocking waits", ProcessThreads());
Task.WaitAll(tasks);
Display("    end");

// 3 - asynchronous waits: no thread is blocked during the wait
stopwatch.Restart();
for (int i = 0; i < nbOperations; i++)
{
    tasks[i] = Task.Delay(1000);
}
Display("3 - asynchronous waits", ProcessThreads());
await Task.WhenAll(tasks);
Display("    end");

// number of threads of the process, half a second after the operations are launched
int ProcessThreads()
{
    if (stopwatch.ElapsedMilliseconds < 500)
    {
        Thread.Sleep(500 - (int)stopwatch.ElapsedMilliseconds);
    }
    using var process = Process.GetCurrentProcess();
    return process.Threads.Count;
}

// display of the elapsed time and, if provided, of the number of threads
void Display(string message, int? nbThreads = null)
{
    string threadsInfo = nbThreads is null ? "" : $", {nbThreads,3} threads in the process";
    Console.WriteLine($"{message,-42} : {stopwatch.ElapsedMilliseconds,5} ms{threadsInfo}");
}

Results:

1
2
3
4
5
6
7
8
> dotnet run async-01.cs
100 operations of one second, 2 processors
1 - one thread per operation               :   526 ms, 110 threads in the process
    end                                    :  1012 ms
2 - thread pool, blocking waits            :   501 ms,  13 threads in the process
    end                                    : 24007 ms
3 - asynchronous waits                     :   501 ms,  17 threads in the process
    end                                    :  1002 ms

Comments:

  • Lines 8–21: The solution from the 2008 course, one thread per operation. Each thread executes [Thread.Sleep(1000)]: it is blocked for one second. The 100 waits occur simultaneously, and the entire process takes exactly one second (line 4), but at the cost of 100 additional threads: the process now has more than 100 threads (line 3). With 10,000 operations, 10,000 threads would be required;
  • lines 23–31: the operations are delegated to the thread pool ([Task.Run]), which was introduced in the previous chapter. The pool starts with as many threads as there are processors (2 in this case) and adds more only gradually when they are all busy, since it assumes that its threads are performing computations and that there is no need to have many more than there are processors. But here, its threads are not computing: they are idle. The operations wait for a free thread, and the whole process takes… more than twenty seconds (line 6). This is known as “thread pool starvation,” the most common flaw in servers that combine thread pools with blocking I/O;
  • lines 34–41: the waits are asynchronous ([Task.Delay] instead of [Thread.Sleep]). No thread is blocked during the wait: a system timer will wake up .NET after one second. The entire process lasts one second, and the number of threads does not increase (the threads displayed on line 7 are those that the pool created during step 2; they are idle);
  • line 51: [Process.GetCurrentProcess()] provides access to the currently running process; its property [Threads] is the collection of its threads (those of .NET and those of the system).

Let’s remember the lesson: to wait, you must not block a thread. Asynchrony does not make I/O faster; it allows you to handle many I/O operations simultaneously with few threads, and to keep the threads the application needs available (the GUI thread, the threads in a server’s pool).

9.1.2. Three Successive Models

.NET proposed three ways to write asynchronous code. The first two are still encountered in legacy code and older articles, so it’s useful to recognize them.

Model
Origin
Principle
APM (“Asynchronous Programming Model”)
.NET 1.0 (2002)
a pair of methods: [BeginXxx] and [EndXxx]. [BeginXxx] initiates the operation and receives a callback method ([AsyncCallback]); the callback method, executed by a thread from the pool at the end of the operation, calls [EndXxx] to retrieve the result or exception. Example: [Socket.BeginConnect] / [EndConnect], used by the asynchronous TCP client from the 2008 course.
EAP (“Event-based Asynchronous Pattern”)
.NET 2.0 (2005)
an [XxxAsync] method that initiates the operation and a [XxxCompleted] event triggered upon its completion. Examples: [WebClient.DownloadStringAsync] and [DownloadStringCompleted], the [BackgroundWorker] class (events [DoWork], [ProgressChanged], [RunWorkerCompleted]) studied in the 2008 course.
TAP (“Task-based Asynchronous Pattern”)
.NET 4.0 (2010), C# 5 (2012)
a method [XxxAsync] that returns a task ([Task] or [Task<T>]) representing the current operation. The calling code waits for the task using the keyword [await]. This is the current model, the only one used in the rest of the course.

The APM and EAP patterns share the same flaw: the code is broken up into pieces. What naturally reads as “connect, then read, then display” becomes a method that initiates the connection, a callback method that starts reading, and another callback method that displays the data; variables must be shared among these methods, exceptions are handled in different places, and a simple loop (“read as long as there is data”) becomes a callback method that recursively calls itself, like the read method ([lecture]) from the 2008 course. The TAP pattern, along with [async] and [await], allows you to write asynchronous code that takes the form of synchronous code.

The program [async-02.cs] writes the same TCP client according to the two extreme models, APM and TAP. It also contains a small server, run by a thread, that responds to two clients (network programming will be covered in detail in Chapter 12; for now, it suffices to know that a client connects to the server and reads the message the server sends it).

// the same TCP client written with the 2008 APM model then with the current TAP model
using System.Net;
using System.Net.Sockets;
using System.Text;

const int port = 27500;
// a minimal server, executed by a thread: it serves two clients then stops
var listener = new TcpListener(IPAddress.Loopback, port);
listener.Start();
var server = new Thread(() =>
{
    for (int i = 1; i <= 2; i++)
    {
        using Socket service = listener.AcceptSocket();
        // the server takes its time to answer
        Thread.Sleep(500);
        service.Send(Encoding.UTF8.GetBytes($"Hello client no. {i}"));
    }
    listener.Stop();
});
server.Start();
Display("start of the program");

// 1 - the APM model (Asynchronous Programming Model): BeginXxx / EndXxx and callback methods
var end = new ManualResetEventSlim(false);
byte[] buffer = new byte[1024];
var client = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
// we launch the connection, giving the method to call back when it is done
client.BeginConnect(IPAddress.Loopback, port, Connected, client);
Display("APM: connection launched, the program goes on");
// the main thread has nothing left to do: it waits for the signal of the last callback method
end.Wait();

// callback method no. 1: the connection is made
void Connected(IAsyncResult result)
{
    // we retrieve the object passed as the last parameter of BeginConnect
    var socket = (Socket)result.AsyncState!;
    // we end the operation (it is EndConnect that would throw the exception on failure)
    socket.EndConnect(result);
    Display("APM: connected, read launched");
    // we launch the reading of the server's answer with another callback method
    socket.BeginReceive(buffer, 0, buffer.Length, SocketFlags.None, Received, socket);
}

// callback method no. 2: data has arrived
void Received(IAsyncResult result)
{
    var socket = (Socket)result.AsyncState!;
    int nbBytes = socket.EndReceive(result);
    Display($"APM: received [{Encoding.UTF8.GetString(buffer, 0, nbBytes)}]");
    socket.Close();
    // we notify the main thread
    end.Set();
}

// 2 - the TAP model (Task-based Asynchronous Pattern): XxxAsync and await
using (var client2 = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp))
{
    await client2.ConnectAsync(IPAddress.Loopback, port);
    Display("TAP: connected, read launched");
    int nbBytes = await client2.ReceiveAsync(buffer);
    Display($"TAP: received [{Encoding.UTF8.GetString(buffer, 0, nbBytes)}]");
}
server.Join();

// display prefixed by the number of the thread that executes it
static void Display(string message)
{
    Console.WriteLine($"[thread {Environment.CurrentManagedThreadId,2}] {message}");
}

Results:

1
2
3
4
5
6
7
> dotnet run async-02.cs
[thread  1] start of the program
[thread  1] APM: connection launched, the program goes on
[thread  6] APM: connected, read launched
[thread  6] APM: received [Hello client no. 1]
[thread  6] TAP: connected, read launched
[thread  6] TAP: received [Hello client no. 2]

Comments:

  • lines 8–21: the server. It listens on port 27500 of the local machine ([IPAddress.Loopback], i.e., 127.0.0.1), accepts two connections one after the other, and sends each client a welcome message after half a second;
  • lines 24–54: The client written according to the APM pattern, as in the 2008 course:
    • line 29: [BeginConnect] initiates the connection and immediately yields control (line 3 of the results). Its last two parameters are the callback method to be invoked at the end of the connection, [Connected], and an arbitrary object that the callback method will find in the [AsyncState] property of its [IAsyncResult] parameter: in this case, the socket itself;
    • line 32: the main thread has nothing left to do. To prevent the program from terminating before the exchange is complete, it waits for a signal that will be sent by the last callback method: a [ManualResetEventSlim] object, whose [Wait] method blocks the thread that calls it until another thread calls its [Set] method (this is a lightweight version of the [AutoResetEvent] barriers seen with threads);
    • lines 35–43: the connection callback method. It must call [EndConnect] with the received parameter: this call terminates the operation and would throw an exception if the connection had failed. It then initiates the read operation with [BeginReceive], specifying a second callback method, [Received];
    • lines 47–54: the read callback retrieves the number of bytes received using [EndReceive], displays the message, closes the socket, and releases the main thread;
    • the results show that the callback methods are executed by a thread other than the main thread (a thread from the pool, not thread 1);
  • lines 57–65: the same client using the TAP model. [ConnectAsync] and [ReceiveAsync] return tasks, and [await] waits for them to finish without blocking the thread: The code reads from top to bottom like synchronous code; the variables are ordinary local variables; there are no callback methods or signals to manage. The thread executing the rest of the code is generally no longer Thread 1: we’ll explain why in the section “Which thread executes the rest?”;
  • Line 68: Each display is prefixed with the number of the thread executing it, [Environment.CurrentManagedThreadId].

It is not necessary to remember the APM pattern. If you need to use an older library that only provides this pattern, the method [Task.Factory.FromAsync] converts a pair [BeginXxx] / [EndXxx] into a method that returns a [Task]; and we will see in the section on [TaskCompletionSource] how to convert an event-based API (EAP model) into a task.

9.2. Tasks: Task and Task<T>

A task is an object that represents an ongoing or completed operation:

  • [Task] represents an operation that does not return a result;
  • [Task<T>] represents an operation that will return a result of type [T]. [Task<T>] derives from [Task].

A task is not a thread. A task can be executed by a thread from the pool (as is the case with tasks created by [Task.Run]), but many tasks are not executed by any thread: the task returned by [Task.Delay(1000)] is simply “completed” by a timer after one second; the task returned by [ReceiveAsync] is completed when the system signals that data has arrived. A task is a promise of a result, which can be queried (is it finished? was it successful?) and waited for.

The program [async-03.cs] demonstrates the main ways to create and wait for a task:

// tasks: creation, wait, status, result, continuation

// 1 - Task.Run hands a processing to the thread pool and returns a Task<T> task
Task<long> task1 = Task.Run(() =>
{
    // a computation of a few tenths of a second
    long sum = 0;
    for (int i = 1; i <= 200_000_000; i++)
    {
        sum += i % 7;
    }
    return sum;
});
Console.WriteLine($"task1 launched, status: {task1.Status}");
// await suspends the program until the end of the task and returns its result
long result = await task1;
Console.WriteLine($"task1 finished, status: {task1.Status}, result: {result}");

// 2 - Task.Delay returns a task that ends after a delay, without occupying a thread
Task task2 = Task.Delay(300);
Console.WriteLine($"task2 launched, status: {task2.Status}");
await task2;
Console.WriteLine($"task2 finished, status: {task2.Status}");

// 3 - tasks that are already finished
Task<int> task3 = Task.FromResult(42);
Task task4 = Task.CompletedTask;
Console.WriteLine($"task3: status {task3.Status}, result {await task3} ; task4: status {task4.Status}");

// 4 - a task that fails: await rethrows its exception
Task<int> task5 = Task.Run(() => int.Parse("abc"));
try
{
    await task5;
}
catch (FormatException e)
{
    Console.WriteLine($"task5: status {task5.Status}, {e.GetType().Name}: {e.Message}");
}

// 5 - the blocking forms Wait() and Result: the current thread is blocked during the wait
Task<string> task6 = Task.Run(() =>
{
    Thread.Sleep(300);
    return "done";
});
task6.Wait();
Console.WriteLine($"task6: Wait() then Result = {task6.Result}");

// 6 - a continuation: what to do when the task is finished
Task<int> task7 = Task.Delay(300).ContinueWith(t => 2 * 21);
Console.WriteLine($"task7: result of the continuation = {await task7}");

// 7 - IsCompleted, IsCompletedSuccessfully, IsFaulted, IsCanceled
Console.WriteLine($"task1: IsCompletedSuccessfully={task1.IsCompletedSuccessfully}, task5: IsCompleted={task5.IsCompleted}, IsFaulted={task5.IsFaulted}");
Console.WriteLine($"task5.Exception: {task5.Exception?.GetType().Name}, {task5.Exception?.InnerException?.GetType().Name}");

Results:

> dotnet run async-03.cs
task1 launched, status: WaitingToRun
task1 finished, status: RanToCompletion, result: 599999998
task2 launched, status: WaitingForActivation
task2 finished, status: RanToCompletion
task3: status RanToCompletion, result 42 ; task4: status RanToCompletion
task5: status Faulted, FormatException: The input string 'abc' was not in a correct format.
task6: Wait() then Result = done
task7: result of the continuation = 42
task1: IsCompletedSuccessfully=True, task5: IsCompleted=True, IsFaulted=True
task5.Exception: AggregateException, FormatException

Comments:

  • Lines 4–12: [Task.Run] assigns a function to the thread pool and immediately returns a task representing its execution. The function returns a [long], so the task is of type [Task<long>]. [Task.Run] is the tool for the calculations to be executed in the background; it must not be used to “make asynchronous” a blocking I/O operation (which is what step 2 of [async-01.cs] did);
  • Line 14: The [Status] property returns the task’s status. Immediately after it is launched, the task waits here for a thread from the pool to take it on ([WaitingToRun]); depending on the machine’s load, you might also see [Running] (running);
  • Line 16: [await task1] waits for the task to finish without blocking the thread and returns its result, which in this case is of type [long]. The expression [await t] is of type [T] if [t] is of type [Task<T>], and has no value if [t] is of type [Task];
  • Line 20: [Task.Delay(300)] returns a task that will complete in 300 ms. No thread is occupied during this time. This is the asynchronous equivalent of [Thread.Sleep(300)]. Its status, [WaitingForActivation], means “waiting for an external event”;
  • lines 26–27: [Task.FromResult(42)] returns a task that has already completed with a result of 42, while [Task.CompletedTask] returns a task that has already completed with no result. These are used when a method needs to return a task but already has the result (a cached value, for example);
  • lines 31–36: A task whose function throws an exception ends with the status [Faulted]. The exception is retained within the task, and [await] rethrows it; it is caught using a standard [try / catch];
  • lines 42–48: [Wait()] waits for the task to finish by blocking the current thread, and the [Result] property returns the result of a [Task<T>] while also blocking the thread if the task has not finished. These are the thread-waiting forms from the previous chapter ([Join]). They should be avoided: they have all the drawbacks of blocking I/O, wrap exceptions in a [AggregateException] (line 56), and can cause a deadlock in a graphical application. They should be reserved for the rare cases where you know the task has already completed;
  • Line 51: [ContinueWith] attaches a continuation—a function that will be executed when the task is complete—to a task. The continuation receives the completed task and returns an integer here: this results in a new task, [Task<int>]. This is how asynchronous code was written with .NET 4.0, prior to [await]; [await] is based on a similar mechanism (registering a sequence to be executed at the end of the task), implemented by the compiler in a safer way. [ContinueWith] is hardly ever used directly anymore;
  • lines 54–56: the Boolean properties [IsCompleted] (terminated, regardless of how), [IsCompletedSuccessfully] (terminated successfully), [IsFaulted] (terminated on an exception), [IsCanceled] (canceled), and the property [Exception], of type [AggregateException], which contains the task’s exception(s).

The possible values for the [Status] property (enumeration [TaskStatus]) are as follows:

Status
Meaning
[Created]
The task was created by a constructor and has not yet been started (tasks are rarely created via [new Task(...)])
[WaitingForActivation]
The task is waiting for an external event: the end of a timeout, an I/O operation, or another task
[WaitingToRun]
The task is waiting for a thread from the pool to become available
[Running]
The task is currently being executed by a thread
[WaitingForChildrenToComplete]
The task is complete but is waiting for attached "child" tasks (rare case)
[RanToCompletion]
The task has completed successfully
[Faulted]
The task terminated due to an uncaught exception
[Canceled]
The task was canceled (see the section on cancellation)

The last three statuses are final states: a completed task no longer changes state.

9.3. Asynchronous methods: async and await

9.3.1. Writing an asynchronous method

A method that contains [await] must be declared with the [async] modifier. It returns:

  • [Task] if it does not return a result (the equivalent of [void]);
  • [Task<T>] if it returns a result of type [T]: within the method, you write [return value;] with a value of type [T], and the compiler wraps it in the task.

By convention, the name of a method that returns a task ends with [Async]: [ReadAllTextAsync], [ConnectAsync], [MakeCoffeeAsync].

The program [async-04.cs] makes coffee and counts sugar cubes:

// asynchronous methods: async Task, async Task<T> and execution order
using System.Diagnostics;

var stopwatch = Stopwatch.StartNew();
Display("call of MakeCoffeeAsync");
Task making = MakeCoffeeAsync();
Display("MakeCoffeeAsync has returned control: the program goes on");
Display("call of CountSugarsAsync");
Task<int> counting = CountSugarsAsync(3);
Display("CountSugarsAsync has returned control");
// we now wait for the two results
await making;
int nbSugars = await counting;
Display($"coffee ready, with {nbSugars} sugar(s)");

// an asynchronous method that returns no result: it returns a Task
async Task MakeCoffeeAsync()
{
    Display("  coffee: the machine is heating");
    // at the first wait (await) of an unfinished task, the method returns control to its caller
    await Task.Delay(1000);
    // the rest will be executed when the delay has elapsed
    Display("  coffee: the machine is pouring the coffee");
    await Task.Delay(500);
    Display("  coffee: done");
}

// an asynchronous method that returns an integer: it returns a Task<int>
async Task<int> CountSugarsAsync(int count)
{
    for (int i = 1; i <= count; i++)
    {
        await Task.Delay(200);
        Display($"  sugar no. {i}");
    }
    // we return an int, the compiler wraps it in the Task<int>
    return count;
}

// display with the time elapsed since the start
void Display(string message)
{
    Console.WriteLine($"{stopwatch.ElapsedMilliseconds,5} ms : {message}");
}

Results:

> dotnet run async-04.cs
    1 ms : call of MakeCoffeeAsync
   29 ms :   coffee: the machine is heating
   33 ms : MakeCoffeeAsync has returned control: the program goes on
   33 ms : call of CountSugarsAsync
   36 ms : CountSugarsAsync has returned control
  237 ms :   sugar no. 1
  436 ms :   sugar no. 2
  636 ms :   sugar no. 3
 1031 ms :   coffee: the machine is pouring the coffee
 1532 ms :   coffee: done
 1532 ms : coffee ready, with 3 sugar(s)

Comments:

  • Lines 17–25: an asynchronous method with no return value. It executes normally, synchronously, until it encounters its first [await] on an unfinished task (line 21). At that point, it returns control to its caller, passing back an unfinished task that represents it; this is why line 4 of the results appears immediately after line 3, rather than a second later;
  • line 23: when the one-second timeout has elapsed, the method resumes execution immediately after the [await], with its local variables intact, until the next [await] or until it terminates. When it ends, the task returned to the caller transitions to the [RanToCompletion] state;
  • lines 29–37: an asynchronous method that returns an integer: its return type is [Task<int>], but the [return] instruction returns an [int];
  • lines 6–9: The main program calls both methods without waiting for them. It retrieves two tasks and continues execution. The two methods then proceed “simultaneously”: the sugar is counted (lines 7–9) while the machine heats up;
  • lines 12–13: The main program waits for both tasks. The coffee is ready after 1.5 seconds—the duration of the longer of the two operations—and not after 2.1 seconds (1.5 + 3 × 0.2).

It is important to note that [async] does not make a method asynchronous: it merely allows the use of [await] within the method. An [async] method that does not wait for any unfinished tasks executes entirely synchronously (the compiler, in fact, issues a warning for an [async] method that contains no [await]). It is the awaited operations ([Task.Delay], asynchronous I/O) that are actually asynchronous.

9.3.2. What the compiler does

How can a method be interrupted in the middle of a loop, yield control, and then resume later at the same point with the same local variable values? The compiler transforms the [async] method into a state machine: a class (a structure in the optimized version of the program, compiled in “Release” mode) whose fields are the method’s local variables and a state number that indicates where to resume. For the method [CountSugarsAsync], the generated code looks something like this:

// what the compiler generates (very simplified)
Task<int> CountSugarsAsync(int count)
{
    var machine = new CountSugarsMachine { count = count, state = 0 };
    machine.Advance();         // executes the method up to the first await
    return machine.ResultTask; // the task that the machine will complete later
}

class CountSugarsMachine
{
    public int count, i, state;               // the local variables become fields
    public TaskCompletionSource<int> Source = new();
    public Task<int> ResultTask => Source.Task;

    public void Advance()
    {
        if (state == 0) { i = 1; }             // first pass: start of the loop
        else { Console.WriteLine($"  sugar no. {i}"); i++; }   // resumption after the await
        if (i <= count)
        {
            state = 1;
            // we ask to be called back at the end of the delay, then we return control
            Task.Delay(200).GetAwaiter().OnCompleted(Advance);
            return;
        }
        Source.SetResult(count);               // end of the method: the task is completed
    }
}

This follows the same principle as the callback methods in the APM model ([OnCompleted(Advance)] requests a callback at the end of the operation), but it is the compiler that splits the method, saves the variables, and handles exceptions. The developer, on the other hand, writes a method that reads from top to bottom. You can view the actual generated code using a decompiler such as ILSpy or on the SharpLab website; it is significantly longer, particularly because it handles the case where the expected task has already completed (in which case execution continues without interruption) and the case of exceptions.

9.3.3. Which thread executes the rest of the code?

The program [async-05.cs] displays the thread number executing each piece of code:

// which thread executes the rest of a method after an await?
Display("start of the program");
Display($"synchronization context: {SynchronizationContext.Current?.GetType().Name ?? "none"}");

// 1 - await of an already finished task: no suspension, we stay on the same thread
await Task.CompletedTask;
Display("after await Task.CompletedTask");

// 2 - await of an unfinished task: the rest is executed by a pool thread
for (int i = 1; i <= 3; i++)
{
    await Task.Delay(100);
    Display($"after await Task.Delay(100) no. {i}");
}

// 3 - during the wait, the thread is free: it can execute something else
Task t1 = WorkAsync("A");
Task t2 = WorkAsync("B");
await Task.WhenAll(t1, t2);
Display($"end of the program, threads created by the pool: {ThreadPool.ThreadCount}");

async Task WorkAsync(string name)
{
    for (int i = 1; i <= 3; i++)
    {
        Display($"  {name}{i} : before await");
        await Task.Delay(100);
    }
}

// display prefixed by the number of the thread that executes it
static void Display(string message)
{
    Console.WriteLine($"[thread {Environment.CurrentManagedThreadId,2}] {message}");
}

Results:

> dotnet run async-05.cs
[thread  1] start of the program
[thread  1] synchronization context: none
[thread  1] after await Task.CompletedTask
[thread  5] after await Task.Delay(100) no. 1
[thread  5] after await Task.Delay(100) no. 2
[thread  5] after await Task.Delay(100) no. 3
[thread  5]   A1 : before await
[thread  5]   B1 : before await
[thread  6]   A2 : before await
[thread  5]   B2 : before await
[thread  6]   B3 : before await
[thread  5]   A3 : before await
[thread  6] end of the program, threads created by the pool: 2

Comments:

  • line 3: [SynchronizationContext.Current] is the synchronization context of the current thread. In a console program, there is none (value [null]); we will see in the section on [ConfigureAwait] the role it plays in graphical user interfaces;
  • line 6: waiting for a task that has already completed does not suspend the method: execution continues immediately on the same thread (thread 1, line 4);
  • Lines 10–13: After waiting for an unfinished task, and in the absence of a synchronization context, the remainder of the method is executed by any thread from the pool. Thread 1, the main program’s thread, is no longer used: during the wait, it had nothing to do. Therefore, in an asynchronous method, one should never assume that the code following a [await] is executed by the same thread as the code preceding it;
  • Lines 17–19: Two calls to the [WorkAsync] method proceed simultaneously. The six code blocks are executed by one or two threads from the pool (this varies from one execution to another), which switch between A and B as waiting occurs: while one waits, the other works. No thread is assigned to A or B, and none is blocked during waiting periods;
  • line 20: [ThreadPool.ThreadCount] returns the number of threads currently created by the pool: only a few.

This method of performing multiple operations simultaneously without increasing the number of threads is called concurrency. It differs from parallelism, which involves executing multiple computations at the same time across multiple processors (as discussed in the previous chapter: [Parallel.For], [Task.Run]). Asynchrony facilitates I/O concurrency; it does not make calculations faster.

9.3.4. The Asynchronous Main Program

The previous programs use [await] directly in high-level statements. This has been possible since C# 9: when high-level statements contain a [await], the compiler generates an asynchronous [Main] method with the signature [static async Task Main(string[] args)] (or [Task<int>] if the program uses [return integer;]).

You can also write this method explicitly, which has been possible since C# 7.1. This is what the program [async-06.cs] does:

// the explicit form of an asynchronous Main method
namespace Async06;

public static class Program
{
    // Main can return Task or Task<int> (the exit code of the program)
    public static async Task<int> Main(string[] args)
    {
        // the number of seconds is passed as an argument
        if (args.Length != 1 || !int.TryParse(args[0], out int nbSeconds) || nbSeconds < 0)
        {
            Console.WriteLine("syntax: dotnet run async-06.cs -- nbSeconds");
            return 1;
        }
        for (int i = nbSeconds; i > 0; i--)
        {
            Console.WriteLine($"{i} s left");
            await Task.Delay(1000);
        }
        Console.WriteLine("finished");
        return 0;
    }
}

Results, first with and then without an argument:

1
2
3
4
5
> dotnet run async-06.cs -- 3
3 s left
2 s left
1 s left
finished
> dotnet run async-06.cs
syntax: dotnet run async-06.cs -- nbSeconds

Comments:

  • Line 7: The [Main] method is asynchronous and returns a [Task<int>]. The integer is the program’s exit code (0 for success, any other value for an error), which can be checked by a script ([echo $?] on Linux, [echo %ERRORLEVEL%] on Windows);
  • line 10: the argument is the number of seconds for the countdown. It is passed after [--] in the [dotnet run async-06.cs -- 3] command;
  • line 18: a one-second wait, without blocking any threads.

When [Main] is asynchronous, the runtime calls it, retrieves the returned task, and waits for it to finish before terminating the program. This is the only place where a blocking wait is performed, by .NET itself.

9.4. Sequential Execution and Concurrent Execution

9.4.1. Task.WhenAll

The coffee example demonstrated this: the moment an asynchronous operation is initiated and the moment it is awaited can be different. Writing [await MethodAsync()] initiates the operation and awaits it immediately; writing [var t = MethodAsync();] and then, later, [await t;] allows other operations to be launched in between. The program [async-07.cs] compares the two approaches using three one-second operations:

// sequential execution and concurrent execution of three one-second operations
using System.Diagnostics;

var stopwatch = Stopwatch.StartNew();

// 1 - in sequence: each operation waits for the end of the previous one
int a = await ReadTemperatureAsync("Angers");
int b = await ReadTemperatureAsync("Brest");
int c = await ReadTemperatureAsync("Colmar");
Console.WriteLine($"in sequence: {a}, {b}, {c} in {stopwatch.ElapsedMilliseconds} ms");

// 2 - concurrently: we launch the three operations, then we wait for them
stopwatch.Restart();
Task<int> ta = ReadTemperatureAsync("Angers");
Task<int> tb = ReadTemperatureAsync("Brest");
Task<int> tc = ReadTemperatureAsync("Colmar");
await Task.WhenAll(ta, tb, tc);
Console.WriteLine($"concurrently: {ta.Result}, {tb.Result}, {tc.Result} in {stopwatch.ElapsedMilliseconds} ms");

// 3 - Task.WhenAll on a collection of Task<T> directly returns the array of results
stopwatch.Restart();
string[] cities = ["Angers", "Brest", "Colmar", "Dijon", "Évry", "Foix"];
int[] temperatures = await Task.WhenAll(cities.Select(ReadTemperatureAsync));
Console.WriteLine($"{cities.Length} cities: [{string.Join(", ", temperatures)}] in {stopwatch.ElapsedMilliseconds} ms");

// simulates querying a remote service that answers in one second
static async Task<int> ReadTemperatureAsync(string city)
{
    await Task.Delay(1000);
    // a "temperature" derived from the name of the city
    return 10 + city.Length;
}

Results:

1
2
3
4
> dotnet run async-07.cs
in sequence: 16, 15, 16 in 3007 ms
concurrently: 16, 15, 16 in 1001 ms
6 cities: [16, 15, 16, 15, 14, 14] in 1005 ms

Comments:

  • lines 7–9: Each operation is started and then waited for before starting the next one. The code does not block any threads, but the three operations are performed one after the other: 3 seconds (line 2). This is the correct approach when one operation requires the result of the previous one;
  • lines 14–16: The three operations are started without waiting for each other. They run simultaneously;
  • line 17: [Task.WhenAll] returns a task that completes when all tasks passed to it are finished. We wait for it using [await]: 1 second instead of 3 (line 3);
  • line 18: The three tasks are complete, so the [Result] property returns their result without blocking. We could also have written [await ta], which immediately returns the result of a completed task;
  • line 23: when passed a collection of [Task<T>], [Task.WhenAll] returns a [Task<T[]>] whose result is the array of results, in the order of the tasks (not in the order in which they completed). The expression [cities.Select(ReadTemperatureAsync)] from LINQ calls the method for each city, thereby producing six tasks launched simultaneously. The six operations together take one second.

[Task.WhenAll] is the asynchronous equivalent of [Task.WaitAll] (used in [async-01.cs]), which waits for all tasks by blocking the current thread.

9.4.2. Task.WhenAny

[Task.WhenAny] returns a task that terminates as soon as any of the tasks passed to it is completed; its result is that first completed task. The program [async-08.cs] uses it in two ways:

// Task.WhenAny: react to the first finished task
using System.Diagnostics;

var stopwatch = Stopwatch.StartNew();

// 1 - we query three mirror servers and keep the first answer
Task<string>[] requests =
[
    QueryAsync("mirror-paris", 800),
    QueryAsync("mirror-lyon", 300),
    QueryAsync("mirror-lille", 600),
];
// WhenAny returns the first finished task (a Task<string>)
Task<string> first = await Task.WhenAny(requests);
Console.WriteLine($"{stopwatch.ElapsedMilliseconds,5} ms : first answer: {await first}");
// the other requests go on: we wait for them here to see their answers
await Task.WhenAll(requests);
Console.WriteLine($"{stopwatch.ElapsedMilliseconds,5} ms : all the answers: {string.Join(", ", requests.Select(t => t.Result))}");

// 2 - a time-limited operation: we make it "race" against a delay
stopwatch.Restart();
Task<string> slow = QueryAsync("slow-server", 2000);
Task winner = await Task.WhenAny(slow, Task.Delay(500));
if (winner == slow)
{
    Console.WriteLine($"{stopwatch.ElapsedMilliseconds,5} ms : answer obtained in time: {await slow}");
}
else
{
    Console.WriteLine($"{stopwatch.ElapsedMilliseconds,5} ms : the slow server did not answer within the 500 ms delay");
}

// simulates a server that answers after [duration] milliseconds
static async Task<string> QueryAsync(string server, int duration)
{
    await Task.Delay(duration);
    return $"{server} ({duration} ms)";
}

Results:

1
2
3
4
> dotnet run async-08.cs
  309 ms : first answer: mirror-lyon (300 ms)
  803 ms : all the answers: mirror-paris (800 ms), mirror-lyon (300 ms), mirror-lille (600 ms)
  500 ms : the slow server did not answer within the 500 ms delay

Comments:

  • lines 7–11: Three mirror servers are queried; they all hold the same information but respond at varying speeds. The three tasks are stored in an array, initialized with a collection expression;
  • line 14: [await Task.WhenAny(requests)] returns the first completed task, of type [Task<string>]. To obtain its result, we wait for it in turn (line 15): it is complete, so [await] immediately returns the string. The first response arrives after 300 ms (line 2);
  • line 17: the other requests are not halted by [WhenAny]; they continue until they finish. To stop them, they would need to be canceled, which we’ll cover in the section on cancellation. Here, we wait for them to display all the responses;
  • lines 22–24: a classic use of [WhenAny]: limiting the duration of a wait by creating a “race” between the operation and a timeout. If the timeout wins, the operation took too long. .NET now offers a more direct way to handle this case: the [WaitAsync] method, which is discussed later.

9.4.3. Task.WhenEach

We often want to process the results of multiple operations as they finish, without waiting for the slowest one. Before .NET 9, we wrote a loop for this that called [Task.WhenAny], processed the resulting task, and removed it from the list. .NET 9 introduced the [Task.WhenEach] method, which returns tasks in the order they complete, in the form of an asynchronous stream that is iterated over using [await foreach] (asynchronous streams are discussed later in this chapter).

The program [async-09.cs] processes five “files,” each of which takes between 100 and 900 ms to process:

// Task.WhenEach (.NET 9): process the results in the order the tasks finish
using System.Diagnostics;

var stopwatch = Stopwatch.StartNew();
int[] durations = [900, 300, 700, 100, 500];
// we launch all the tasks
List<Task<string>> tasks = durations.Select((duration, i) => ProcessAsync($"file{i}.txt", duration)).ToList();

// await foreach: each task is delivered as soon as it is finished
await foreach (Task<string> task in Task.WhenEach(tasks))
{
    Console.WriteLine($"{stopwatch.ElapsedMilliseconds,5} ms : {await task}");
}
Console.WriteLine($"{stopwatch.ElapsedMilliseconds,5} ms : all the files have been processed");

// simulates the processing of a file that lasts [duration] milliseconds
static async Task<string> ProcessAsync(string file, int duration)
{
    await Task.Delay(duration);
    return $"{file} processed in {duration} ms";
}

Results:

1
2
3
4
5
6
7
> dotnet run async-09.cs
  111 ms : file3.txt processed in 100 ms
  312 ms : file1.txt processed in 300 ms
  509 ms : file4.txt processed in 500 ms
  710 ms : file2.txt processed in 700 ms
  906 ms : file0.txt processed in 900 ms
  906 ms : all the files have been processed

Comments:

  • line 7: the five processes are launched. The [Select] method of LINQ provides the function with the element and its index ([(duration, i)]);
  • Lines 10–12: [Task.WhenEach(tasks)] submits each task as soon as it is completed: file 3 (100 ms) first, file 0 (900 ms) last. The [await foreach] loop waits for the next task without blocking any threads. Each delivered task is complete: [await task] returns its result immediately;
  • the entire process takes 900 ms—the duration of the longest processing step—and each result is displayed as soon as possible.

9.5. Exceptions in Asynchronous Code

9.5.1. Propagation via [await]

An exception thrown in an asynchronous method is not propagated to the caller at the time of the call: it is stored in the task, which transitions to the [Faulted] state. It is the [await] instruction that rethrows it. The program [async-10.cs] examines the various cases:

// exceptions in asynchronous code

// 1 - await rethrows the exception of the awaited task
try
{
    int value = await ReadValueAsync("twelve");
    Console.WriteLine($"value = {value}");
}
catch (FormatException e)
{
    Console.WriteLine($"1 - exception caught: {e.GetType().Name} : {e.Message}");
}

// 2 - calling an async method does not throw an exception: it is stored in the task
// (here, the exception occurs even before the first await of the method)
Task<int> task = ReadValueAsync("");
Console.WriteLine($"2 - after the call: status={task.Status}");
try
{
    await task;
}
catch (ArgumentException e)
{
    Console.WriteLine($"2 - it is the await that throws the exception: {e.GetType().Name} : {e.Message}");
}

// 3 - Task.WhenAll with several failed tasks
Task<int>[] tasks = [ReadValueAsync("1"), ReadValueAsync("two"), ReadValueAsync("3"), ReadValueAsync("four")];
Task<int[]> all = Task.WhenAll(tasks);
try
{
    int[] values = await all;
}
catch (Exception e)
{
    // await only rethrows the first exception…
    Console.WriteLine($"3 - await rethrows: {e.GetType().Name} : {e.Message}");
    // … but all of them are in the Exception property of the task, of type AggregateException
    AggregateException ae = all.Exception!;
    Console.WriteLine($"3 - {ae.GetType().Name} with {ae.InnerExceptions.Count} exceptions:");
    foreach (Exception inner in ae.InnerExceptions)
    {
        Console.WriteLine($"      {inner.GetType().Name} : {inner.Message}");
    }
    // the successful tasks do have their result
    foreach (Task<int> t in tasks.Where(t => t.IsCompletedSuccessfully))
    {
        Console.WriteLine($"      successful task, result = {t.Result}");
    }
}

// 4 - the blocking forms wrap the exception in an AggregateException
try
{
    int value = ReadValueAsync("five").Result;
}
catch (AggregateException e)
{
    Console.WriteLine($"4 - Result throws: {e.GetType().Name} containing {e.InnerException?.GetType().Name}");
}

// an asynchronous method that fails if the string is not an integer
static async Task<int> ReadValueAsync(string text)
{
    // immediate check of the parameter, before any await
    ArgumentException.ThrowIfNullOrWhiteSpace(text);
    // the reading takes a little time
    await Task.Delay(100);
    return int.Parse(text);
}

Results:

> dotnet run async-10.cs
1 - exception caught: FormatException : The input string 'twelve' was not in a correct format.
2 - after the call: status=Faulted
2 - it is the await that throws the exception: ArgumentException : The value cannot be an empty string or composed entirely of whitespace. (Parameter 'text')
3 - await rethrows: FormatException : The input string 'two' was not in a correct format.
3 - AggregateException with 2 exceptions:
      FormatException : The input string 'two' was not in a correct format.
      FormatException : The input string 'four' was not in a correct format.
      successful task, result = 1
      successful task, result = 3
4 - Result throws: AggregateException containing FormatException

Comments:

  • Lines 63–69: the method being tested. It first checks its parameter ([ArgumentException.ThrowIfNullOrWhiteSpace] throws an [ArgumentException] if the string is [null], empty, or blank), waits 100 ms, then converts the entire string, which throws a [FormatException] if the string is not a number;
  • lines 3–11: the standard case. The exception thrown by [int.Parse] after the method’s [await] is rethrown by the caller’s [await]: a [try / catch] surrounding the [await] intercepts it, exactly as in a synchronous call. This is the major simplification introduced by [await]: with the APM model, the exception was rethrown by [EndXxx], within the callback method, far from the code that had initiated the operation;
  • lines 16–24: here, the exception is thrown even before the first [await] in the method. However, the call does not trigger anything: the [async] method returns a task that has already failed (status [Faulted], line 3), and it is the [await] that throws the exception. An [async] method never throws an exception when called;
  • lines 28–32: [Task.WhenAll] waits for four tasks, two of which fail. The task [all] then fails as well and contains both exceptions. But [await] rethrows only one of them, the first one (line 5): the language designers chose to retain the usual form of [catch] (intercepting a [FormatException], not a wrapper);
  • lines 39–43: to view all exceptions, consult the [Exception] property of the [all] task. It is of type [AggregateException], an exception that contains other exceptions, accessible via its property [InnerExceptions];
  • lines 46–48: The failure of [WhenAll] does not prevent the other tasks from succeeding; their results are available;
  • lines 52–59: the blocking forms ([Result], [Wait()]) do not rethrow the exception itself but rather an [AggregateException] that contains it. This is another reason to prefer [await] over them.

9.5.2. async void: to be avoided

An [async] method can also be declared as [void]. In that case, it does not return any task, which has two serious consequences: the caller cannot know when it finishes (it cannot wait for it), and it cannot catch its exceptions. An uncaught exception in an [async void] method is rethrown on a thread in the pool, where no one can catch it: it terminates the program. The program [async-11.cs] demonstrates this:

// why you must not write an async void method

// we display the message of an unhandled exception, then we stop the program
AppDomain.CurrentDomain.UnhandledException += (sender, e) =>
{
    var exception = (Exception)e.ExceptionObject;
    Console.WriteLine($"unhandled exception: {exception.GetType().Name} : {exception.Message}");
    Console.WriteLine("the process is going to be stopped");
    Environment.Exit(1);
};

// 1 - an async Task method: the caller can await it and catch its exception
try
{
    await SaveAsync("a");
}
catch (InvalidOperationException e)
{
    Console.WriteLine($"1 - async Task: exception caught: {e.Message}");
}

// 2 - an async void method: the caller can neither await it nor catch its exception
try
{
    Save("b");
    Console.WriteLine("2 - async void: the method has returned control, the try saw nothing");
}
catch (InvalidOperationException e)
{
    Console.WriteLine($"2 - async void: exception caught: {e.Message}");
}
// the program goes on with its work…
for (int i = 1; i <= 5; i++)
{
    await Task.Delay(100);
    Console.WriteLine($"the program is working ({i})");
}
Console.WriteLine("normal end of the program");

static async Task SaveAsync(string data)
{
    await Task.Delay(200);
    throw new InvalidOperationException($"failure saving [{data}]");
}

// DO NOT DO THIS: async void is reserved for event handlers
static async void Save(string data)
{
    await Task.Delay(200);
    throw new InvalidOperationException($"failure saving [{data}]");
}

Results:

1
2
3
4
5
6
7
> dotnet run async-11.cs
1 - async Task: exception caught: failure saving [a]
2 - async void: the method has returned control, the try saw nothing
the program is working (1)
the program is working (2)
unhandled exception: InvalidOperationException : failure saving [b]
the process is going to be stopped

Comments:

  • Lines 4–9: The [AppDomain.CurrentDomain.UnhandledException] event is triggered when an exception is not caught anywhere, just before the system terminates the process. Its handler cannot prevent this termination; we use it here to display a custom message (instead of the stack trace that .NET would display) and then terminate the process ourselves with [Environment.Exit(1)];
  • lines 15–19: The method [SaveAsync] returns a [Task]: its exception is intercepted normally;
  • lines 25–26: the method [Save], declared as [async void], returns control to its first [await]; [try] terminates without having encountered anything. 200 ms later, the method throws its exception on a thread in the pool: the program is halted in the middle of execution (lines 4–7), and line [normal end of the program] is never displayed.

The rule is therefore: an asynchronous method always returns [Task] or [Task<T>], never [void]. The only exception is a GUI event handler (such as a button click): the signature of event handlers requires [void], so we write [private async void CalculateButton_Click(object sender, EventArgs e)]. In this case, all exceptions are caught within the handler using [try / catch].

A related error involves calling a method that returns a task without waiting for it ([SaveAsync("c");] without [await]): the compiler flags this with a warning (CS4014) because any exception thrown by the task would be lost. If you really want to launch a task without waiting for it (“fire-and-forget”), you must explicitly specify the ignored element [_]: [_ = SaveAsync("c");], and the method must then handle its own exceptions.

9.6. Cancellation and Timeouts

9.6.1. CancellationTokenSource and CancellationToken

A long-running asynchronous operation must be cancellable: the user clicked on [Cancel], a web server client disconnected, the application shut down, or a timeout was exceeded. .NET uses a cooperative mechanism involving two objects for this purpose:

  • a [CancellationTokenSource] (cancel source), held by the code that decides to cancel. Its [Cancel()] method requests cancellation; [CancelAfter(delay)] automatically requests it after a timeout;
  • a [CancellationToken] (cancel token), obtained via the source’s [Token] property and passed as a parameter to cancelable operations. The operation checks the token ([IsCancellationRequested]) or requests that it throw an exception if cancellation has been requested ([ThrowIfCancellationRequested()]), and passes it on to the operations it calls.

The mechanism is cooperative: no one forcibly interrupts an operation (the [Thread.Abort] method from the 2008 course is obsolete in the current .NET: it simply throws a [PlatformNotSupportedException]); the operation stops on its own, at a point where it can do so cleanly. By convention, a canceled operation ends by throwing an [OperationCanceledException], and its task has the status [Canceled].

The program [async-12.cs] cancels a simulated download in three ways:

// cancel an asynchronous operation: CancellationTokenSource and CancellationToken
using System.Diagnostics;

var stopwatch = Stopwatch.StartNew();

// 1 - a cancellation source that cancels itself after 1.2 seconds
using (var source = new CancellationTokenSource())
{
    source.CancelAfter(TimeSpan.FromSeconds(1.2));
    try
    {
        // the token of the source is passed to the operation
        await DownloadAsync(10, source.Token);
    }
    catch (OperationCanceledException e)
    {
        Display($"1 - {e.GetType().Name} : {e.Message}");
    }
}

// 2 - a computation that itself checks, regularly, whether it must stop
stopwatch.Restart();
using (var source = new CancellationTokenSource(TimeSpan.FromMilliseconds(300)))
{
    Task<long> computation = Task.Run(() => Compute(source.Token), source.Token);
    try
    {
        long result = await computation;
        Display($"2 - result: {result}");
    }
    catch (OperationCanceledException e)
    {
        Display($"2 - {e.GetType().Name}, status of the task: {computation.Status}");
    }
}

// 3 - the cancellation is explicitly requested by the program, with Cancel()
stopwatch.Restart();
using (var source = new CancellationTokenSource())
{
    Task download = DownloadAsync(10, source.Token);
    await Task.Delay(700);
    Display("3 - the program requests the cancellation");
    source.Cancel();
    try
    {
        await download;
    }
    catch (OperationCanceledException)
    {
        Display($"3 - download cancelled, IsCanceled={download.IsCanceled}");
    }
}

// simulates the download of [nbBlocks] data blocks (one block every 300 ms)
async Task DownloadAsync(int nbBlocks, CancellationToken token)
{
    for (int i = 1; i <= nbBlocks; i++)
    {
        // Task.Delay accepts a token: the wait stops as soon as the cancellation is requested
        await Task.Delay(300, token);
        Display($"  block {i}/{nbBlocks} received");
    }
}

// a long synchronous computation that checks the token every 1,000,000 iterations
static long Compute(CancellationToken token)
{
    long sum = 0;
    for (long i = 0; i < 100_000_000_000; i++)
    {
        if (i % 1_000_000 == 0)
        {
            // throws OperationCanceledException if the cancellation has been requested
            token.ThrowIfCancellationRequested();
        }
        sum += i % 7;
    }
    return sum;
}

void Display(string message)
{
    Console.WriteLine($"{stopwatch.ElapsedMilliseconds,5} ms : {message}");
}

Results:

> dotnet run async-12.cs
  307 ms :   block 1/10 received
  620 ms :   block 2/10 received
  921 ms :   block 3/10 received
 1211 ms : 1 - TaskCanceledException : A task was canceled.
  304 ms : 2 - OperationCanceledException, status of the task: Canceled
  300 ms :   block 1/10 received
  600 ms :   block 2/10 received
  700 ms : 3 - the program requests the cancellation
  701 ms : 3 - download cancelled, IsCanceled=True

Comments:

  • Lines 56–62: The simulated download receives a block every 300 ms. It receives a [CancellationToken] as the last parameter (this is the convention) and passes it to [Task.Delay] (line 61): if cancellation is requested while waiting, the wait is immediately interrupted and a [TaskCanceledException]—a class derived from [OperationCanceledException]—is thrown. Almost all of the asynchronous methods of .NET accept a token: [ReadAllTextAsync], [ConnectAsync], [ToListAsync]…;
  • Lines 6–17: The source is created in a [using] because it uses a timer that must be released. [CancelAfter] requests cancellation after 1.2 seconds: the download receives three blocks and then stops at 1,200 ms (line 5). We intercept [OperationCanceledException], the base class, which handles all cancellations;
  • lines 21–33: a synchronous calculation (not a wait) that can be canceled. The method [Compute] (lines 67–79) checks the token every million iterations using [ThrowIfCancellationRequested()], which throws an [OperationCanceledException] if cancellation has been requested. The calculation is delegated to the pool by [Task.Run], to which the token is also passed: the task then ends with status [Canceled] rather than [Faulted] (line 6). Here, the source is created with a delay (constructor [CancellationTokenSource(TimeSpan)]), which is equivalent to [CancelAfter];
  • lines 37–51: The source is explicitly canceled by [Cancel()] after 700 ms. The cancellation is immediate: the wait for the third block is interrupted (lines 9–10).

9.6.2. The BackgroundWorker example rewritten

The 2008 course presented the class [BackgroundWorker] with an example of five processes lasting 1 to 5 seconds, of which No. 3 threw an exception and No. 4 was canceled. You had to subscribe methods to the events [DoWork] and [RunWorkerCompleted], enable cancellation ([WorkerSupportsCancellation]), test [CancellationPending] in the work loop, and populate the [Result] and [Cancel] properties of the event argument; and the course noted that in the event of an exception or cancellation, it was impossible to determine which process was affected. [BackgroundWorker] still exists within .NET, but it is designed for Windows Forms graphical user interfaces and no longer serves a purpose: tasks accomplish the same thing more simply. The program [async-13.cs] rewrites this example:

// the BackgroundWorker example of the 2008 course rewritten with tasks:
// five processings of 1 to 5 seconds, no. 3 throws an exception, no. 4 is cancelled
using System.Diagnostics;

var stopwatch = Stopwatch.StartNew();
using var cancellation = new CancellationTokenSource();
var tasks = new Task<Result>[5];
for (int i = 0; i < tasks.Length; i++)
{
    // only processing no. 4 receives the token of the cancellation source
    CancellationToken token = i == 4 ? cancellation.Token : CancellationToken.None;
    tasks[i] = SleepAsync(i, i + 1, token);
}
// we cancel processing no. 4 after 2.5 seconds
cancellation.CancelAfter(2500);
Console.WriteLine($"{stopwatch.Elapsed.TotalSeconds,4:F1} s : the {tasks.Length} processings are launched");

// we use each processing as soon as it is finished, whatever the way it finishes
await foreach (Task<Result> task in Task.WhenEach(tasks))
{
    int number = Array.IndexOf(tasks, task);
    string outcome = task.Status switch
    {
        TaskStatus.RanToCompletion => $"finished: scheduled duration {task.Result.Duration} s, actual duration {task.Result.ActualDuration:F1} s",
        TaskStatus.Faulted => $"encountered the following error: {task.Exception!.InnerException!.Message}",
        TaskStatus.Canceled => "cancelled",
        _ => $"unexpected status {task.Status}",
    };
    Console.WriteLine($"{stopwatch.Elapsed.TotalSeconds,4:F1} s : processing {number} {outcome}");
}

// the processing: sleep [duration] seconds, in one-second slices
static async Task<Result> SleepAsync(int number, int duration, CancellationToken token)
{
    var start = Stopwatch.GetTimestamp();
    // exception for processing no. 3
    if (number == 3)
    {
        throw new InvalidOperationException("test....");
    }
    for (int i = 1; i <= duration; i++)
    {
        await Task.Delay(1000, token);
    }
    // the result returned to the caller
    return new Result(duration, Stopwatch.GetElapsedTime(start).TotalSeconds);
}

// the result of a processing
record Result(int Duration, double ActualDuration);

Results:

1
2
3
4
5
6
7
> dotnet run async-13.cs
 0.0 s : the 5 processings are launched
 0.0 s : processing 3 encountered the following error: test....
 1.0 s : processing 0 finished: scheduled duration 1 s, actual duration 1.0 s
 2.0 s : processing 1 finished: scheduled duration 2 s, actual duration 2.0 s
 2.5 s : processing 4 cancelled
 3.0 s : processing 2 finished: scheduled duration 3 s, actual duration 3.0 s

Comments:

  • Lines 33–46: the processing. No. 3 throws an exception; the others “sleep” for [duration] seconds in one-second increments, with a cancelable [Task.Delay], and return a result—a [record]—that contains the scheduled duration and the actual duration. [Stopwatch.GetTimestamp()] and [Stopwatch.GetElapsedTime(start)] measure a duration without creating a [Stopwatch] object;
  • lines 8–15: The five processes are launched. Only process No. 4 receives the token from the cancellation source; the others receive [CancellationToken.None], a token that will never be canceled. Process No. 4 is canceled after 2.5 seconds, even though it still has 2.5 seconds left to sleep;
  • lines 19–29: With [Task.WhenEach], each process is processed as soon as it finishes, regardless of how it ended: we do not use [await task], which would trigger the exception, but instead check its status using a [switch] expression. The job number is retrieved based on its position in the task array ([Array.IndexOf]): the problem reported in the 2008 course is resolved;
  • the results show that No. 3 fails immediately, that Nos. 0, 1, and 2 complete after 1, 2, and 3 seconds, respectively, and that No. 4 is canceled at 2.5 seconds.

9.6.3. Limiting the duration of a wait

The program [async-14.cs] provides two complementary tools: the method [WaitAsync] and the associated cancellation sources.

// limit the duration of a wait: WaitAsync and linked tokens
using System.Diagnostics;

var stopwatch = Stopwatch.StartNew();

// 1 - WaitAsync(TimeSpan): we do not wait more than one second
Task<string> slow = QueryAsync("slow-server", 3000, CancellationToken.None);
try
{
    string answer = await slow.WaitAsync(TimeSpan.FromSeconds(1));
    Display($"1 - answer: {answer}");
}
catch (TimeoutException e)
{
    Display($"1 - {e.GetType().Name} : {e.Message}");
}
// beware: WaitAsync gives up the wait, not the operation, which goes on
Display($"1 - status of the operation: {slow.Status}");

// 2 - to really stop the operation, we must pass it a cancellation token
stopwatch.Restart();
using (var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(1)))
{
    Task<string> slow2 = QueryAsync("slow-server", 3000, timeout.Token);
    try
    {
        Display($"2 - answer: {await slow2}");
    }
    catch (OperationCanceledException)
    {
        Display($"2 - operation cancelled after one second, status: {slow2.Status}");
    }
}

// 3 - combine two causes of cancellation: a delay and a request from the caller
stopwatch.Restart();
using var generalStop = new CancellationTokenSource();
await Task.WhenAll(ProcessWithTimeoutAsync("fast", 500, 1000, generalStop.Token),
                   ProcessWithTimeoutAsync("slow", 3000, 1000, generalStop.Token),
                   ProcessWithTimeoutAsync("medium", 1500, 2000, generalStop.Token),
                   StopAfterAsync(1200));

// a processing limited to [timeout] milliseconds, which can also be stopped by the caller
async Task ProcessWithTimeoutAsync(string name, int duration, int timeout, CancellationToken stop)
{
    // the linked source is cancelled if either of the two causes occurs
    using var linked = CancellationTokenSource.CreateLinkedTokenSource(stop);
    linked.CancelAfter(timeout);
    try
    {
        Display($"3 - {await QueryAsync(name, duration, linked.Token)}");
    }
    catch (OperationCanceledException) when (stop.IsCancellationRequested)
    {
        Display($"3 - {name} : stopped at the caller's request");
    }
    catch (OperationCanceledException)
    {
        Display($"3 - {name} : timeout of {timeout} ms exceeded");
    }
}

// the caller requests the general stop after [duration] milliseconds
async Task StopAfterAsync(int duration)
{
    await Task.Delay(duration);
    Display("3 - general stop requested");
    generalStop.Cancel();
}

// simulates a server that answers after [duration] milliseconds
static async Task<string> QueryAsync(string server, int duration, CancellationToken token)
{
    await Task.Delay(duration, token);
    return $"{server} answered in {duration} ms";
}

void Display(string message)
{
    Console.WriteLine($"{stopwatch.ElapsedMilliseconds,5} ms : {message}");
}

Results:

1
2
3
4
5
6
7
8
> dotnet run async-14.cs
 1024 ms : 1 - TimeoutException : The operation has timed out.
 1037 ms : 1 - status of the operation: WaitingForActivation
 1001 ms : 2 - operation cancelled after one second, status: Canceled
  502 ms : 3 - fast answered in 500 ms
 1003 ms : 3 - slow : timeout of 1000 ms exceeded
 1204 ms : 3 - general stop requested
 1204 ms : 3 - medium : stopped at the caller's request

Comments:

  • Line 10: [t.WaitAsync(TimeSpan)] (.NET 6) returns a task that completes as [t] if [t] completes within the time limit, and fails with a [TimeoutException] otherwise. This is the direct implementation of the “race” with [Task.WhenAny] from the program [async-08.cs]. Another overload, [WaitAsync(CancellationToken)], allows you to stop waiting for a task that does not accept a token;
  • Line 18: [WaitAsync] aborts the wait, not the operation. The operation continues (status [WaitingForActivation]); it consumes resources, and its result will be lost;
  • Lines 20–31: To truly stop the operation, you must pass it a canceled token at the end of the timeout period. The operation ends after 1 second with status [Canceled]. This is the preferred solution whenever the operation accepts a token;
  • lines 44–59: A process may be canceled for several reasons; here, it is due to its own timeout and a general stop request from the caller. [CancellationTokenSource.CreateLinkedTokenSource(stop)] creates a linked source: it is canceled if the token [stop] is canceled, and it can also be canceled on its own, in this case using [CancelAfter(timeout)]. To determine which of the two causes occurred, an exception filter [when] (line 53) is used: the first [catch] is retained only if the caller requested the termination;
  • Lines 38–41: three processing steps and a general stop requested at 1,200 ms. The [fast] process completes normally; the [slow] process exceeds its one-second time limit; the [medium] process, which was allotted two seconds, is terminated by the general termination.

9.7. Reporting Progress: IProgress<T>

A long-running operation often needs to report its progress (a progress bar, a percentage). With [BackgroundWorker], the process called [ReportProgress], and the interface subscribed to the [ProgressChanged] event. With tasks, the [IProgress<T>] interface is used, which has only one method:

1
2
3
4
public interface IProgress<in T>
{
    void Report(T value);
}

The operation receives an [IProgress<T>] object as a parameter and passes its reports to it via [Report]; it is unaware of how they will be displayed. .NET provides the implementation [Progress<T>], intended for graphical user interfaces: it stores the synchronization context of the thread that creates it (the UI thread) and executes the report processing there, which allows the graphical components to be updated without having to worry about threads. The 2008 course had to call [Invoke] on the graphical components using a delegate to achieve the same result. In a console program, which has no synchronization context, [Progress<T>] has the reports processed by threads from the pool, without guaranteeing their order: the program [async-15.cs] therefore uses its own implementation of [IProgress<T>]. It simulates a file copy that the user can cancel by typing [Enter]:

// report the progress of an operation with IProgress<T>; the user can cancel it
using var cancellation = new CancellationTokenSource();
// a background task waits for the user to type [Enter] to request the cancellation
_ = Task.Run(() =>
{
    Console.ReadLine();
    cancellation.Cancel();
});
Console.WriteLine("Copy in progress, type [Enter] to cancel it");

// the object that receives the progress reports
IProgress<Advancement> progress = new ConsoleProgress();
try
{
    int nbFiles = await CopyAsync(8, progress, cancellation.Token);
    Console.WriteLine($"Copy finished: {nbFiles} files copied");
}
catch (OperationCanceledException)
{
    Console.WriteLine("Copy cancelled by the user");
}

// the operation does not know how its progress is displayed: it only reports it
static async Task<int> CopyAsync(int nbFiles, IProgress<Advancement>? progress, CancellationToken token)
{
    for (int i = 1; i <= nbFiles; i++)
    {
        // the copy of a file
        await Task.Delay(300, token);
        // the progress report (the caller may not want any: progress is then null)
        progress?.Report(new Advancement(100 * i / nbFiles, $"file{i}.dat"));
    }
    return nbFiles;
}

// a progress report
record Advancement(int Percentage, string File);

// the display of the progress reports in the console
class ConsoleProgress : IProgress<Advancement>
{
    public void Report(Advancement advancement)
    {
        // a 20-character bar
        string bar = new string('#', advancement.Percentage / 5).PadRight(20, '.');
        Console.WriteLine($"[{bar}] {advancement.Percentage,3} % {advancement.File}");
    }
}

Results without cancellation:

> dotnet run async-15.cs
Copy in progress, type [Enter] to cancel it
[##..................]  12 % file1.dat
[#####...............]  25 % file2.dat
[#######.............]  37 % file3.dat
[##########..........]  50 % file4.dat
[############........]  62 % file5.dat
[###############.....]  75 % file6.dat
[#################...]  87 % file7.dat
[####################] 100 % file8.dat
Copy finished: 8 files copied

Results when the user enters [Enter] during the copy operation, after one and a half seconds:

1
2
3
4
5
6
7
> dotnet run async-15.cs
Copy in progress, type [Enter] to cancel it
[##..................]  12 % file1.dat
[#####...............]  25 % file2.dat
[#######.............]  37 % file3.dat
[##########..........]  50 % file4.dat
Copy cancelled by the user

Comments:

  • Lines 24–33: The operation receives an [IProgress<Advancement>] and a cancellation token. After each file, it reports its progress via an [Advancement] object (a [record] containing the percentage and the file name). The parameter is declared as nullable ([IProgress<Advancement>?]) and used with the [?.] operator: a caller who does not want reports passes [null];
  • lines 40–46: the console implementation of [IProgress<Advancement>] displays a 20-character bar;
  • lines 4–7: A task in the pool waits for the user to type [Enter] ([Console.ReadLine] is a blocking read, hence [Task.Run]: it is a thread in the pool that is blocked, not the program) and then requests cancellation. The program does not wait for it, hence the discard [_]: at the end of the program, this task is abandoned;
  • lines 15–20: the program initiates the copy and waits for it. A cancellation results in an [OperationCanceledException].

9.8. Asynchronous Input/Output

I/O is the very essence of asynchrony. The classes in .NET provide an asynchronous version of all their I/O methods: files ([File], [FileStream], [StreamReader], [StreamWriter]), network ([Socket], [TcpClient], [NetworkStream], [HttpClient], as discussed in Chapter 12), databases (ADO.NET and Entity Framework Core, Chapters 10 and 11). The program [async-16.cs] demonstrates asynchronous file I/O by reading the tax bracket file from the example directory, [TaxData.txt], located in the program folder:

1
2
3
4
5
6
7
4962:0:0
8382:0.068:291.09
14753:0.191:1322.92
23888:0.283:2668.39
38868:0.374:4846.98
47932:0.426:6883.66
0:0.481:9505.54

Each line contains the bracket threshold, the coefficient [coeffR], and the coefficient [coeffN], separated by the character [:] and written with a decimal point (the tax scale is the 2004 scale for 2003 income, retained as-is for this exercise). The program runs in the [09-async] folder and searches for its files in the current folder.

// asynchronous input / output on files
using System.Globalization;

const string data = "TaxData.txt";
const string report = "report-16.txt";

// 1 - reading the whole file in a single operation
string[] lines = await File.ReadAllLinesAsync(data);
Console.WriteLine($"1 - File.ReadAllLinesAsync: {lines.Length} lines");

// 2 - reading line by line with a StreamReader
var brackets = new List<TaxBracket>();
using (var reader = new StreamReader(data))
{
    string? line;
    // ReadLineAsync returns null at the end of the file
    while ((line = await reader.ReadLineAsync()) is not null)
    {
        brackets.Add(TaxBracket.Parse(line));
    }
}
Console.WriteLine($"2 - StreamReader.ReadLineAsync: {brackets.Count} brackets, the 2nd is {brackets[1]}");

// 3 - File.ReadLinesAsync returns an asynchronous stream of lines, iterated with await foreach
int nbBrackets = 0;
await foreach (string line in File.ReadLinesAsync(data))
{
    nbBrackets++;
}
Console.WriteLine($"3 - File.ReadLinesAsync: {nbBrackets} lines");

// 4 - writing with a StreamWriter: await using releases the stream asynchronously
await using (var writer = new StreamWriter(report))
{
    await writer.WriteLineAsync("Brackets of the tax scale (exercise: 2004 scale on 2003 income)");
    foreach (var (bracket, i) in brackets.Select((t, i) => (t, i + 1)))
    {
        string limit = bracket.Limit == 0 ? "beyond" : $"up to {bracket.Limit:N0} €";
        await writer.WriteLineAsync($"bracket {i} : {limit,-18} rate {bracket.CoeffR:P1}");
    }
}

// 5 - reading the whole file as a string
string text = await File.ReadAllTextAsync(report);
Console.Write($"4 and 5 - content of [{report}]:\n{text}");

// 6 - appending at the end of the file then deletion
await File.AppendAllTextAsync(report, "end of the report\n");
Console.WriteLine($"6 - last line: {(await File.ReadAllLinesAsync(report))[^1]}");
File.Delete(report);

// a tax bracket; the file uses the decimal point: we read it with the invariant culture
readonly record struct TaxBracket(decimal Limit, decimal CoeffR, decimal CoeffN)
{
    public static TaxBracket Parse(string line)
    {
        string[] fields = line.Split(':');
        return new TaxBracket(decimal.Parse(fields[0], CultureInfo.InvariantCulture),
                                decimal.Parse(fields[1], CultureInfo.InvariantCulture),
                                decimal.Parse(fields[2], CultureInfo.InvariantCulture));
    }
}

Results:

> dotnet run async-16.cs
1 - File.ReadAllLinesAsync: 7 lines
2 - StreamReader.ReadLineAsync: 7 brackets, the 2nd is TaxBracket { Limit = 8382, CoeffR = 0.068, CoeffN = 291.09 }
3 - File.ReadLinesAsync: 7 lines
4 and 5 - content of [report-16.txt]:
Brackets of the tax scale (exercise: 2004 scale on 2003 income)
bracket 1 : up to 4,962 €      rate 0.0 %
bracket 2 : up to 8,382 €      rate 6.8 %
bracket 3 : up to 14,753 €     rate 19.1 %
bracket 4 : up to 23,888 €     rate 28.3 %
bracket 5 : up to 38,868 €     rate 37.4 %
bracket 6 : up to 47,932 €     rate 42.6 %
bracket 7 : beyond             rate 48.1 %
6 - last line: end of the report

Comments:

  • Line 8: [File.ReadAllLinesAsync] reads the entire file and returns a [Task<string[]>]. The read and write methods of the [File] class have asynchronous versions: [ReadAllTextAsync], [ReadAllBytesAsync], [WriteAllTextAsync], [WriteAllLinesAsync], [AppendAllTextAsync]… They all accept a cancellation token as the last parameter;
  • lines 13–19: line-by-line reading using a [StreamReader]. [ReadLineAsync] returns a [Task<string?>], which results in [null] at the end of the file. Each line is converted to [TaxBracket] by the [Parse] method (lines 55–60), which reads the numbers in culture-invariant format so that the decimal point is recognized on a French machine;
  • lines 26–28: [File.ReadLinesAsync] (.NET 7) returns an asynchronous stream of lines ([IAsyncEnumerable<string>]), which is traversed using [await foreach]. Unlike [ReadAllLinesAsync], it does not load the entire file into memory: it reads the lines on demand. This is the preferred method for large files;
  • lines 33–39: writing using a [StreamWriter] and its method [WriteLineAsync]. [StreamWriter] is declared with [await using] (C# 8): upon exiting the block, it is released by its method [DisposeAsync], which flushes its buffer asynchronously. [await using] is used with objects that implement the [IAsyncDisposable] interface ([StreamWriter], [FileStream], database connections, etc.); [StreamReader], which performs only a single read, implements only [IDisposable] and uses a standard [using];
  • line 36: the loop iterates over tuples (bracket, number) produced by [Select] and deconstructs them;
  • Lines 44–50: [ReadAllTextAsync] re-reads the written file, [AppendAllTextAsync] adds a line to it, and then the file is deleted. [File.Delete] has no asynchronous version: deleting a file is a very short operation.

Note: On Windows, a [FileStream] opened without any specific options performs its asynchronous operations by delegating synchronous reads to the thread pool; for true asynchronous system I/O, open it with the [FileOptions.Asynchronous] option (or [useAsync: true]). The methods of the [File] class handle this automatically. For local files, the difference is rarely noticeable; it is noticeable for network operations, where asynchronous I/O is always true asynchronous I/O.

9.9. Asynchronous streams: IAsyncEnumerable<T>

An asynchronous method returns a result after a certain amount of time. An iterator (a method that returns an [IEnumerable<T>] with [yield return]) returns a sequence of results, produced on demand. Asynchronous streams (C# 8) combine both: a sequence of values, each of which may require an asynchronous wait before being produced. They are used to read lines from a large file, the rows of an SQL query result, messages arriving over a network connection, sensor measurements, and so on…

  • the type is [IAsyncEnumerable<T>];
  • it is produced using an [async] method, which returns [IAsyncEnumerable<T>] and contains both [await] and [yield return];
  • it is consumed by [await foreach].

We have already encountered two of them: [Task.WhenEach] and [File.ReadLinesAsync]. The program [async-17.cs] writes one:

// asynchronous streams: IAsyncEnumerable<T>, yield return, await foreach
using System.Diagnostics;
using System.Runtime.CompilerServices;

var stopwatch = Stopwatch.StartNew();

// 1 - each value is processed as soon as it is produced
await foreach (Measure measure in ReadMeasuresAsync("sensor-1", 4))
{
    Display($"1 - received {measure}");
}

// 2 - we can leave the loop before the end: the producer stops too
stopwatch.Restart();
await foreach (Measure measure in ReadMeasuresAsync("sensor-2", 100))
{
    Display($"2 - received {measure}");
    if (measure.Value >= 22)
    {
        break;
    }
}

// 3 - cancellation: WithCancellation passes the token to the parameter marked [EnumeratorCancellation]
stopwatch.Restart();
using var source = new CancellationTokenSource(TimeSpan.FromMilliseconds(700));
try
{
    await foreach (Measure measure in ReadMeasuresAsync("sensor-3", 100).WithCancellation(source.Token))
    {
        Display($"3 - received {measure}");
    }
}
catch (OperationCanceledException)
{
    Display("3 - reading cancelled");
}

// 4 - LINQ on asynchronous streams (System.Linq.AsyncEnumerable, .NET 10)
stopwatch.Restart();
List<double> high = await ReadMeasuresAsync("sensor-4", 6)
    .Where(m => m.Value > 21)
    .Select(m => m.Value)
    .ToListAsync();
Display($"4 - measures above 21: [{string.Join(" ; ", high)}]");

// an asynchronous producer: one measure every 200 ms
static async IAsyncEnumerable<Measure> ReadMeasuresAsync(string sensor, int count,
    [EnumeratorCancellation] CancellationToken token = default)
{
    try
    {
        for (int i = 1; i <= count; i++)
        {
            // waiting for the next measure
            await Task.Delay(200, token);
            // the measure is delivered to the consumer; the producer will resume here on the next request
            yield return new Measure(sensor, i, 19.5 + i * 0.75);
        }
    }
    finally
    {
        // executed on normal end, on break by the consumer or on cancellation
        Console.WriteLine($"          {sensor} : end of the production");
    }
}

void Display(string message)
{
    Console.WriteLine($"{stopwatch.ElapsedMilliseconds,5} ms : {message}");
}

// a measure
record Measure(string Sensor, int Number, double Value)
{
    public override string ToString() => $"{Sensor} no. {Number} : {Value} °C";
}

Results:

> dotnet run async-17.cs
  214 ms : 1 - received sensor-1 no. 1 : 20.25 °C
  426 ms : 1 - received sensor-1 no. 2 : 21 °C
  626 ms : 1 - received sensor-1 no. 3 : 21.75 °C
  827 ms : 1 - received sensor-1 no. 4 : 22.5 °C
          sensor-1 : end of the production
  204 ms : 2 - received sensor-2 no. 1 : 20.25 °C
  404 ms : 2 - received sensor-2 no. 2 : 21 °C
  605 ms : 2 - received sensor-2 no. 3 : 21.75 °C
  809 ms : 2 - received sensor-2 no. 4 : 22.5 °C
          sensor-2 : end of the production
  201 ms : 3 - received sensor-3 no. 1 : 20.25 °C
  404 ms : 3 - received sensor-3 no. 2 : 21 °C
  604 ms : 3 - received sensor-3 no. 3 : 21.75 °C
          sensor-3 : end of the production
  703 ms : 3 - reading cancelled
          sensor-4 : end of the production
 1213 ms : 4 - measures above 21: [21.75 ; 22.5 ; 23.25 ; 24]

Comments:

  • Lines 48–64: the producer. It waits 200 ms ([await]) and then delivers a measurement ([yield return]), and so on. For each [yield return], it pauses until the consumer requests the next value. The [finally] block is executed when the producer stops, regardless of the reason;
  • line 49: the cancellation token for an asynchronous stream is a parameter marked with the attribute [[EnumeratorCancellation]] (namespace [System.Runtime.CompilerServices]). It is set to [default] (a token that is never canceled) if the caller does not provide one;
  • lines 7–10: The [await foreach] loop waits for each measure without blocking a thread and processes it as soon as it is produced: one every 200 ms (lines 2–6);
  • lines 13–20: When the consumer exits the loop via [break], the stream is released (method [DisposeAsync], called by [await foreach]): the producer does not produce the remaining 96 measurements and executes its [finally] block (line 11);
  • lines 24–36: the extension method [WithCancellation] passes a token to the parameter marked [[EnumeratorCancellation]]. The token is canceled after 700 ms: the wait for the 4th measure is interrupted; an [OperationCanceledException] passes through the producer (which executes its [finally]) and then through the consumer’s [await foreach] loop;
  • Lines 41–44: .NET 10 incorporates LINQ for asynchronous streams (class [System.Linq.AsyncEnumerable], previously available in the NuGet package [System.Linq.Async]): [Where], [Select], [Take], [FirstAsync], [ToListAsync]… apply to an [IAsyncEnumerable<T>]. Methods that produce a stream ([Where], [Select]) return another stream; those that produce a value ([ToListAsync], [CountAsync], [FirstAsync]) return a task that is pending;
  • Line 76: The method [ToString] of the [record] [Measure] is redefined for a more compact display.

9.10. ValueTask, ConfigureAwait, and TaskCompletionSource

9.10.1. ValueTask<T>

A task is an object: each call to a method that returns a [Task<T>] (generally) allocates an object on the heap, which the garbage collector will have to reclaim. This is negligible for I/O that lasts milliseconds, but not for a method called millions of times per second that, most of the time, already has the result (a cache, a pre-filled buffer). For this case, .NET offers the [ValueTask<T>] structure (C# 7, 2017): it contains either the result directly (no allocation) or a true task when the operation is truly asynchronous. The program [async-18.cs] uses it for a cached exchange rate service:

// ValueTask<T>: an asynchronous method that, most of the time, already has the result
using System.Diagnostics;

var service = new RateService();
var stopwatch = Stopwatch.StartNew();
foreach (string code in new[] { "EUR", "USD", "EUR", "EUR", "USD", "GBP", "EUR" })
{
    long start = stopwatch.ElapsedMilliseconds;
    decimal rate = await service.ReadRateAsync(code);
    Console.WriteLine($"{code} : {rate,6} read in {stopwatch.ElapsedMilliseconds - start,3} ms");
}
Console.WriteLine($"server accesses: {service.NbServerAccesses}, cache answers: {service.NbCacheAccesses}");

class RateService
{
    private readonly Dictionary<string, decimal> cache = [];
    public int NbServerAccesses { get; private set; }
    public int NbCacheAccesses { get; private set; }

    // ValueTask<decimal>: no Task allocation when the result is in the cache
    public ValueTask<decimal> ReadRateAsync(string code)
    {
        if (cache.TryGetValue(code, out decimal rate))
        {
            // fast and synchronous path: the result is already known
            NbCacheAccesses++;
            return ValueTask.FromResult(rate);
        }
        // slow and asynchronous path: the server must be queried
        return new ValueTask<decimal>(ReadServerRateAsync(code));
    }

    private async Task<decimal> ReadServerRateAsync(string code)
    {
        NbServerAccesses++;
        // simulates querying a remote server
        await Task.Delay(300);
        decimal rate = code switch
        {
            "EUR" => 1m,
            "USD" => 1.17m,
            "GBP" => 0.87m,
            _ => throw new ArgumentException($"unknown currency: {code}"),
        };
        cache[code] = rate;
        return rate;
    }
}

Results:

1
2
3
4
5
6
7
8
9
> dotnet run async-18.cs
EUR :      1 read in 323 ms
USD :   1.17 read in 301 ms
EUR :      1 read in   0 ms
EUR :      1 read in   0 ms
USD :   1.17 read in   0 ms
GBP :   0.87 read in 301 ms
EUR :      1 read in   0 ms
server accesses: 3, cache answers: 4

Comments:

  • Lines 21–30: The method is not [async]. If the rate is in the cache, it returns a [ValueTask<decimal>] that directly contains the result ([ValueTask.FromResult]): no object is created. Otherwise, it wraps the task of the [async] method—which queries the server—in a [ValueTask<decimal>];
  • lines 33–45: the simulated server query (300 ms), which stores the result in the cache. An [async] method can also directly return a [ValueTask<T>];
  • line 9: for the caller, nothing changes: it waits for the [ValueTask] with [await]. The results show the 3 server accesses (300 ms each) and the 4 immediate responses from the cache.

[ValueTask<T>] has constraints: it must be waited for only once (no two [await] on the same [ValueTask], no simultaneous waiting from multiple locations), and it cannot be passed directly to [Task.WhenAll]: it must first be converted to a task using [AsTask()]. The rule of thumb is simple: use [Task] and [Task<T>] by default, and [ValueTask] only in code that has been measured to be heavily loaded and often synchronous. You’ll mostly encounter [ValueTask] as the return type for methods of .NET: [Stream.ReadAsync(Memory<byte>)], [DisposeAsync], [ChannelWriter.WriteAsync]…

9.10.2. ConfigureAwait and the synchronization context

The [async-05.cs] program demonstrated that, in the console, the remainder of a method following an [await] is executed by any thread in the pool. This is not the case in a graphical application (Windows Forms, WPF, MAUI). A graphical component can only be manipulated by the UI thread: the 2008 course had to use [listBoxEvts.Invoke(...)] from its callback methods to have the update executed by that thread. With [await], this is no longer necessary: the interface thread has a synchronization context ([SynchronizationContext.Current] is not [null]), which [await] stores; at the end of the wait, the rest of the method is returned to this context and thus executed by the interface thread. We can write:

1
2
3
4
5
6
private async void DownloadButton_Click(object sender, EventArgs e)
{
    statusLabel.Text = "downloading…";                     // interface thread
    string page = await client.GetStringAsync(address);     // the interface stays responsive during the wait
    pageTextBox.Text = page;                                 // back on the interface thread
}

This return to the original context has a cost and a risk: if code blocks the interface thread while waiting for a task ([task.Result]), and that task needs that same thread to execute its remaining code, the two will wait for each other indefinitely (“deadlock”). This is the main reason for the “no [.Result]” rule.

The method [ConfigureAwait(false)] instructs [await] not to return to the original context: [await stream.ReadAsync(buffer).ConfigureAwait(false);]. The rest of the code is then executed by a thread from the pool. The recommendations are as follows:

  • In the code of a general-purpose library (which does not handle a graphical user interface and does not know who will call it), use [ConfigureAwait(false)] for each [await]: this is slightly more efficient and prevents deadlocks if a careless caller uses [.Result];
  • in application code (graphical user interface, console program, ASP.NET Core application), it is not written. Console programs and ASP.NET Core applications do not have a synchronization context anyway: [ConfigureAwait(false)] makes no difference there.

The examples in this course, which are applications, do not use [ConfigureAwait].

9.10.3. TaskCompletionSource<T>: Converting an Event into a Task

Sometimes you need to use an API that signals the end of its operations via an event (the EAP model, or any class that publishes events), while the rest of the code is written using [await]. The [TaskCompletionSource<T>] class allows you to create a task that you terminate yourself at the desired time:

  • its [Task] property is an unfinished [Task<T>], which is returned to the caller;
  • [SetResult(value)] / [TrySetResult(value)] successfully completes the task;
  • [SetException(exception)] / [TrySetException(exception)] terminates it with a failure;
  • [SetCanceled()] / [TrySetCanceled()] cancels it.

The [TryXxx] methods return [false] instead of throwing an exception if the task has already completed. In fact, it was a [TaskCompletionSource<int>] that was used by the simplified state machine of the [CountSugarsAsync] method presented above. The [async-19.cs] program waits for a thermometer, which publishes its measurements via an event, to reach a certain temperature:

// TaskCompletionSource<T>: turn an event-based API into a task
using System.Diagnostics;

var stopwatch = Stopwatch.StartNew();
var thermometer = new Thermometer();
thermometer.Start();

// 1 - we wait, without blocking a thread, for the first measure greater than or equal to 21 °C
double measure = await WaitForTemperatureAsync(thermometer, 21, CancellationToken.None);
Console.WriteLine($"{stopwatch.ElapsedMilliseconds,5} ms : 1 - temperature reached: {measure} °C");

// 2 - the same wait, limited to one second: the task is cancelled
using (var timeout = new CancellationTokenSource(1000))
{
    try
    {
        measure = await WaitForTemperatureAsync(thermometer, 30, timeout.Token);
        Console.WriteLine($"{stopwatch.ElapsedMilliseconds,5} ms : 2 - temperature reached: {measure} °C");
    }
    catch (OperationCanceledException)
    {
        Console.WriteLine($"{stopwatch.ElapsedMilliseconds,5} ms : 2 - 30 °C not reached within one second");
    }
}
thermometer.Stop();

// turns the MeasureTaken events of the thermometer into a task
static Task<double> WaitForTemperatureAsync(Thermometer thermometer, double threshold, CancellationToken token)
{
    // the source of a task that we complete ourselves
    var source = new TaskCompletionSource<double>(TaskCreationOptions.RunContinuationsAsynchronously);
    // the registration with the cancellation token, to release when the task is finished
    CancellationTokenRegistration registration = default;
    // the event handler completes the task when the threshold is reached
    void OnMeasure(object? sender, double temperature)
    {
        if (temperature >= threshold && source.TrySetResult(temperature))
        {
            thermometer.MeasureTaken -= OnMeasure;
            registration.Dispose();
        }
    }
    thermometer.MeasureTaken += OnMeasure;
    // a cancellation request completes the task with the Canceled status
    registration = token.Register(() =>
    {
        if (source.TrySetCanceled(token))
        {
            thermometer.MeasureTaken -= OnMeasure;
        }
    });
    // the task is returned right away; it will complete later
    return source.Task;
}

// a thermometer that publishes a measure every 200 ms through an event ("old-style" API)
class Thermometer
{
    public event EventHandler<double>? MeasureTaken;
    private Timer? timer;
    private double temperature = 19;

    public void Start()
    {
        timer = new Timer(_ =>
        {
            temperature += 0.5;
            Console.WriteLine($"          measure: {temperature} °C");
            MeasureTaken?.Invoke(this, temperature);
        }, null, 200, 200);
    }

    public void Stop()
    {
        timer?.Dispose();
    }
}

Results:

> dotnet run async-19.cs
          measure: 19.5 °C
          measure: 20 °C
          measure: 20.5 °C
          measure: 21 °C
  805 ms : 1 - temperature reached: 21 °C
          measure: 21.5 °C
          measure: 22 °C
          measure: 22.5 °C
          measure: 23 °C
          measure: 23.5 °C
 1812 ms : 2 - 30 °C not reached within one second

Comments:

  • Lines 57–75: The thermometer, an “old-school” class: A timer ([System.Threading.Timer], which executes a method at regular intervals on a thread from the pool) increases the temperature by 0.5 °C every 200 ms and publishes it via the event [MeasureTaken], of type [EventHandler<double>] (events were discussed in Chapter 7);
  • lines 28–53: the method that converts events into a task. It is not [async]: it creates a [TaskCompletionSource<double>], subscribes a handler to the event, and immediately returns the source task, which is not yet complete;
  • Line 31: This option specifies that the remaining code awaiting the task should not be executed by the thread calling [TrySetResult] (in this case, the timer thread, in the middle of emitting the event), but by a thread from the pool. This is the recommended option in almost all cases;
  • lines 35–40: At each measurement, the handler checks the threshold. When it is reached, the handler terminates the task with the temperature as the result, then unsubscribes from the event and releases the registration with the cancellation token;
  • lines 45–47: [token.Register(action)] registers an action to be executed if cancellation is requested. Here, it cancels the task (status [Canceled]) and unsubscribes the handler. Without this, a task waiting for a threshold that is never reached would never terminate;
  • lines 9–22: For the caller, waiting for a temperature is an asynchronous method like any other: it is awaited using [await] and time-limited with a cancellation token.

[Task.Factory.FromAsync], mentioned above, plays the same role for the APIs of the APM model ([BeginXxx] / [EndXxx]).

9.11. Asynchronous Parallelism

9.11.1. Parallel.ForEachAsync

[Task.WhenAll] launches all operations at the same time. This is not always desirable: to download 10,000 web pages, you don’t want to open 10,000 simultaneous connections to the same server. The previous chapter introduced [Parallel.ForEach], which distributes the processing of a collection across multiple threads; its asynchronous version, [Parallel.ForEachAsync] (.NET 6), processes the elements of a collection using an asynchronous function, limiting the number of concurrent operations. The program [async-20.cs] downloads nine pages, no more than three at a time:

// Parallel.ForEachAsync: process a collection with a limited number of simultaneous operations
using System.Diagnostics;

var stopwatch = Stopwatch.StartNew();
string[] pages = [.. Enumerable.Range(1, 9).Select(i => $"page{i}.html")];
int inProgress = 0;

// at most 3 downloads at the same time
var options = new ParallelOptions { MaxDegreeOfParallelism = 3 };
await Parallel.ForEachAsync(pages, options, async (page, token) =>
{
    // counter shared by the simultaneous operations: we modify it atomically
    int n = Interlocked.Increment(ref inProgress);
    Display($"start of {page} ({n} in progress)");
    // the simulated download of a page
    await Task.Delay(1000, token);
    Interlocked.Decrement(ref inProgress);
    Display($"end of {page}");
});
Display($"the {pages.Length} pages are downloaded");

void Display(string message)
{
    Console.WriteLine($"{stopwatch.ElapsedMilliseconds,5} ms [thread {Environment.CurrentManagedThreadId,2}] : {message}");
}

Results:

> dotnet run async-20.cs
   12 ms [thread  4] : start of page1.html (1 in progress)
   12 ms [thread  6] : start of page2.html (2 in progress)
   29 ms [thread  6] : start of page3.html (3 in progress)
 1024 ms [thread  6] : end of page1.html
 1025 ms [thread  6] : start of page4.html (3 in progress)
 1028 ms [thread  4] : end of page3.html
 1028 ms [thread  6] : end of page2.html
 1029 ms [thread  4] : start of page5.html (2 in progress)
 1029 ms [thread  6] : start of page6.html (3 in progress)
 2023 ms [thread  6] : end of page4.html
 2023 ms [thread  6] : start of page7.html (3 in progress)
 2028 ms [thread  6] : end of page6.html
 2028 ms [thread  6] : start of page8.html (3 in progress)
 2030 ms [thread  4] : end of page5.html
 2030 ms [thread  4] : start of page9.html (3 in progress)
 3025 ms [thread  8] : end of page9.html
 3026 ms [thread  8] : end of page7.html
 3025 ms [thread  4] : end of page8.html
 3026 ms [thread  4] : the 9 pages are downloaded

Comments:

  • line 5: the nine page names, constructed using a collection expression with the spread operator [..];
  • line 9: the property [MaxDegreeOfParallelism] of [ParallelOptions] sets the maximum number of concurrent operations (by default, the number of processors). A cancellation token ([CancellationToken]) can also be set here;
  • Lines 10–18: [Parallel.ForEachAsync] receives the collection, the options, and an asynchronous function (an [async] lambda) that is called for each element with the element and a cancellation token, to be passed to the function’s asynchronous operations. [Parallel.ForEachAsync] returns a task that completes when all elements have been processed;
  • lines 13–17: the functions run simultaneously on multiple threads: the counter [inProgress] that they share is modified atomically using the [Interlocked] class from the previous chapter;
  • The results show three waves of three pages: as soon as one page is finished, another begins, and the number of downloads in progress never exceeds 3. The entire process takes 3 seconds instead of 9. When two threads write at nearly the same time, the order of the lines may vary from one execution to another, and a displayed time may even be slightly earlier than that of the previous line;
  • only two or three threads executed all the functions: during wait periods, no thread is busy.

9.11.2. Producer/Consumer: System.Threading.Channels

The previous chapter introduced the producer/consumer pattern using the [BlockingCollection<T>] collection: producer threads enqueue elements into a queue, and consumer threads dequeue them; [Take] (or the enumeration [GetConsumingEnumerable]) blocks a consumer as long as the queue is empty, while [Add] blocks a producer as long as a bounded queue is full. These waits block threads. Channels in the [System.Threading.Channels] namespace (.NET Core 3.0) provide the same service with asynchronous waits:

  • [Channel.CreateBounded<T>(capacity)] creates a bounded channel, [Channel.CreateUnbounded<T>()] creates an unbounded channel;
  • the [Writer] property ([ChannelWriter<T>]) is used by producers: [WriteAsync] publishes an item (waiting asynchronously for space to become available), [TryWrite] attempts to add it immediately, [Complete] indicates that there will be no more elements;
  • The property [Reader] ([ChannelReader<T>]) is used by consumers: [ReadAsync] retrieves an element, while [ReadAllAsync] returns an asynchronous stream of all elements, which ends when the channel is closed by [Complete] and is empty.

The program [async-21.cs] processes orders, generated every 50 ms, using two consumers that each take 400 ms to process an order:

// producer / consumers with a channel (System.Threading.Channels)
using System.Diagnostics;
using System.Threading.Channels;

var stopwatch = Stopwatch.StartNew();
// a bounded channel: it contains at most 2 orders waiting to be processed
Channel<int> channel = Channel.CreateBounded<int>(2);

// the producer and two consumers work at the same time
Task producer = ProduceAsync(channel.Writer, 8);
Task consumer1 = ConsumeAsync("C1", channel.Reader);
Task consumer2 = ConsumeAsync("C2", channel.Reader);
await Task.WhenAll(producer, consumer1, consumer2);
Display("all the orders have been processed");

// the producer: one order every 50 ms
async Task ProduceAsync(ChannelWriter<int> writer, int nbOrders)
{
    for (int i = 1; i <= nbOrders; i++)
    {
        await Task.Delay(50);
        // TryWrite drops the order right away if there is room in the channel
        if (writer.TryWrite(i))
        {
            Display($"P  : order {i} dropped");
        }
        else
        {
            // channel full: WriteAsync waits (without blocking a thread) for a slot to be freed
            Display($"P  : channel full, order {i} waits");
            await writer.WriteAsync(i);
            Display($"P  : order {i} dropped");
        }
    }
    // nothing more will be written: the consumers will leave their loop
    writer.Complete();
    Display("P  : end of the production");
}

// a consumer: processing an order takes 400 ms
async Task ConsumeAsync(string name, ChannelReader<int> reader)
{
    // ReadAllAsync returns an asynchronous stream that ends when the channel is closed and empty
    await foreach (int order in reader.ReadAllAsync())
    {
        Display($"{name} : processing of order {order}");
        await Task.Delay(400);
    }
    Display($"{name} : no more orders");
}

void Display(string message)
{
    Console.WriteLine($"{stopwatch.ElapsedMilliseconds,5} ms : {message}");
}

Results:

> dotnet run async-21.cs
   63 ms : C1 : processing of order 1
   63 ms : P  : order 1 dropped
  130 ms : P  : order 2 dropped
  130 ms : C2 : processing of order 2
  181 ms : P  : order 3 dropped
  233 ms : P  : order 4 dropped
  284 ms : P  : channel full, order 5 waits
  479 ms : P  : order 5 dropped
  479 ms : C1 : processing of order 3
  530 ms : P  : channel full, order 6 waits
  530 ms : C2 : processing of order 4
  530 ms : P  : order 6 dropped
  583 ms : P  : channel full, order 7 waits
  879 ms : C1 : processing of order 5
  879 ms : P  : order 7 dropped
  930 ms : P  : channel full, order 8 waits
  930 ms : C2 : processing of order 6
  930 ms : P  : order 8 dropped
  935 ms : P  : end of the production
 1277 ms : C1 : processing of order 7
 1330 ms : C2 : processing of order 8
 1679 ms : C1 : no more orders
 1731 ms : C2 : no more orders
 1731 ms : all the orders have been processed

Comments:

  • line 7: an integer queue (containing the order numbers) that holds at most 2 pending orders;
  • Lines 10–13: The producer and the two consumers are three asynchronous methods launched simultaneously; the program waits for all three to finish;
  • lines 23–31: the producer attempts to submit each order using [TryWrite]. If the channel is full, it signals this and waits for space to become available with [WriteAsync]: the producer is throttled to the consumers’ pace (lines 8–9), without any thread being blocked. This is the benefit of a bounded channel: a producer that is too fast cannot fill up the memory;
  • line 36: production is complete. The consumers will empty the channel and then exit their loop;
  • lines 44–49: Each consumer processes the asynchronous stream [ReadAllAsync]. An order is delivered to only one consumer: orders are distributed between C1 and C2 based on their availability;
  • As in the previous example, two lines displayed at nearly the same time may appear out of order (the displayed timestamps are therefore not always in ascending order).

Channels are the modern tool for the producer-consumer pattern, both between asynchronous methods and between threads.

9.12. Time: PeriodicTimer and TimeProvider

Two recent classes round out the time-related tools:

  • [PeriodicTimer] (.NET 6) is a timer used with [await]: its method [WaitForNextTickAsync] returns a task that completes at the next “tick.” Unlike [System.Threading.Timer] (used in [async-19.cs]), which calls a callback method on a thread from the pool—possibly while the previous call is still in progress— [PeriodicTimer] is written as a simple loop and never overlaps two executions;
  • [TimeProvider] (.NET 8) is an abstract class that represents the clock: the current time ([GetUtcNow], [GetLocalNow]), timing ([GetTimestamp], [GetElapsedTime]), and the creation of timers. [TimeProvider.System] is the system clock. A class that depends on the time receives a [TimeProvider] instead of using [DateTime.Now] directly: it can then be tested with a controlled clock.

The program [async-22.cs] uses both:

// time: PeriodicTimer and TimeProvider
// 1 - PeriodicTimer: an action every 500 ms, stopped after 2.2 seconds
TimeProvider clock = TimeProvider.System;
long start = clock.GetTimestamp();
using (var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(500)))
using (var stop = new CancellationTokenSource(TimeSpan.FromSeconds(2.2)))
{
    int nbTicks = 0;
    try
    {
        // WaitForNextTickAsync returns true on each tick, false if the timer is stopped by Dispose
        while (await timer.WaitForNextTickAsync(stop.Token))
        {
            nbTicks++;
            Console.WriteLine($"{clock.GetElapsedTime(start).TotalMilliseconds,6:F0} ms : tick no. {nbTicks}");
        }
    }
    catch (OperationCanceledException)
    {
        Console.WriteLine($"{clock.GetElapsedTime(start).TotalMilliseconds,6:F0} ms : timer stopped after {nbTicks} ticks");
    }
}

// 2 - TimeProvider: a class that depends on the time receives its clock instead of reading DateTime.Now
var morning = new FixedClock(new DateTimeOffset(2026, 10, 3, 9, 30, 0, TimeSpan.Zero));
var evening = new FixedClock(new DateTimeOffset(2026, 10, 3, 19, 45, 0, TimeSpan.Zero));
Console.WriteLine(new Welcome(morning).Greet("Pierre"));
Console.WriteLine(new Welcome(evening).Greet("Pierre"));

// a class that depends on the current time
class Welcome(TimeProvider clock)
{
    public string Greet(string name)
    {
        DateTimeOffset now = clock.GetLocalNow();
        string greeting = now.Hour < 18 ? "Hello" : "Good evening";
        return $"[{now:dd/MM/yyyy HH:mm}] {greeting} {name}";
    }
}

// a stopped clock, for the tests: it always gives the same time (in universal time)
class FixedClock(DateTimeOffset instant) : TimeProvider
{
    public override DateTimeOffset GetUtcNow() => instant;
    public override TimeZoneInfo LocalTimeZone => TimeZoneInfo.Utc;
}

Results:

1
2
3
4
5
6
7
8
> dotnet run async-22.cs
   507 ms : tick no. 1
  1003 ms : tick no. 2
  1505 ms : tick no. 3
  2001 ms : tick no. 4
  2227 ms : timer stopped after 4 ticks
[03/10/2026 09:30] Hello Pierre
[03/10/2026 19:45] Good evening Pierre

Comments:

  • Lines 5–20: a 500-ms timer and a 2.2-second cancel source. [WaitForNextTickAsync(token)] returns [true] on each clock pulse and [false] if the timer has been cleared ([Dispose]); here, the cancellation stops the loop after 4 ticks;
  • line 4: [GetTimestamp] and [GetElapsedTime] from [TimeProvider] measure a duration, just like the methods of the same name in [Stopwatch];
  • Lines 31–37: The class [Welcome] says “Hello” or “Good evening” depending on the time. It receives its clock in its primary constructor instead of reading from [DateTime.Now]: this is an injected dependency, just as the [dao] layer is injected into the [business] layer in Chapter 6;
  • Lines 42–45: A stopped clock, derived from [TimeProvider], which redefines universal time and the time zone. It allows you to verify the behavior of [Welcome] in the morning and evening without having to wait until nightfall. The NuGet package [Microsoft.Extensions.TimeProvider.Testing] provides a more comprehensive test clock, [FakeTimeProvider], which can be advanced at will, including for delays and timers ([Task.Delay(TimeSpan, TimeProvider)], [new PeriodicTimer(TimeSpan, TimeProvider)]).

9.13. Application: Tax Calculation with Asynchronous I/O

The program [async-23.cs] brings together several tools from this chapter related to the tax calculation that runs through the whole course. It reads the tax brackets from the file [TaxData.txt] (which has already been used) and reads a list of taxpayers from the file [taxpayers.txt]:

1
2
3
4
5
6
7
y 2 60000
y 3 60000
n 0 60000
n 2 25000
y 5 120000
x 1 20000
y 1 -5

(each line contains: married [y/n], number of children, annual salary), calculates each taxpayer’s tax, and writes the results to the file [results.txt]. All I/O operations are asynchronous, and the entire process is limited to 10 seconds.

// application: the tax calculation with asynchronous input / output
// - the brackets are read from [TaxData.txt], the taxpayers from [taxpayers.txt]
// - the results are written to [results.txt]
using System.Globalization;

using var cancellation = new CancellationTokenSource(TimeSpan.FromSeconds(10));
try
{
    TaxBracket[] brackets = await LoadBracketsAsync("TaxData.txt", cancellation.Token);
    int nbResults = await CalculateTaxesAsync(brackets, "taxpayers.txt", "results.txt", cancellation.Token);
    Console.WriteLine($"{nbResults} lines written to [results.txt]:");
    Console.Write(await File.ReadAllTextAsync("results.txt", cancellation.Token));
}
catch (OperationCanceledException)
{
    Console.WriteLine("processing abandoned: timeout exceeded");
}
catch (Exception e) when (e is IOException or FormatException)
{
    Console.WriteLine($"error: {e.GetType().Name} : {e.Message}");
}

// reads the tax brackets (limit:coeffR:coeffN, decimal point)
static async Task<TaxBracket[]> LoadBracketsAsync(string file, CancellationToken token)
{
    string[] lines = await File.ReadAllLinesAsync(file, token);
    return [.. lines.Where(l => l.Trim() != "").Select(TaxBracket.Parse)];
}

// reads the taxpayers (married children salary), calculates their tax and writes the results
static async Task<int> CalculateTaxesAsync(TaxBracket[] brackets, string input, string output, CancellationToken token)
{
    int nbLines = 0;
    await using var writer = new StreamWriter(output);
    await foreach (string line in File.ReadLinesAsync(input, token))
    {
        string[] fields = line.Split(' ', StringSplitOptions.RemoveEmptyEntries);
        string result = fields switch
        {
            [var m, var c, var s] when m is "y" or "n" && int.TryParse(c, out int children) && children >= 0
                                       && int.TryParse(s, out int salary) && salary >= 0
                => $"tax = {CalculateTax(brackets, m == "y", children, salary)} €",
            _ => "incorrect line",
        };
        await writer.WriteLineAsync($"{line,-12} : {result}");
        nbLines++;
    }
    return nbLines;
}

// the tax calculation (2004 scale on 2003 income, kept for the exercise)
static int CalculateTax(TaxBracket[] brackets, bool married, int nbChildren, int salary)
{
    decimal shares = married ? nbChildren / 2m + 2 : nbChildren / 2m + 1;
    if (nbChildren >= 3)
    {
        shares += 0.5m;
    }
    decimal income = 0.72m * salary;
    decimal quotient = income / shares;
    // the first bracket whose limit exceeds the family quotient, otherwise the last one
    TaxBracket bracket = brackets.FirstOrDefault(t => quotient <= t.Limit, brackets[^1]);
    return (int)(bracket.CoeffR * income - bracket.CoeffN * shares);
}

// a tax bracket
readonly record struct TaxBracket(decimal Limit, decimal CoeffR, decimal CoeffN)
{
    public static TaxBracket Parse(string line)
    {
        string[] fields = line.Split(':');
        return new TaxBracket(decimal.Parse(fields[0], CultureInfo.InvariantCulture),
                                decimal.Parse(fields[1], CultureInfo.InvariantCulture),
                                decimal.Parse(fields[2], CultureInfo.InvariantCulture));
    }
}

Results:

1
2
3
4
5
6
7
8
9
> dotnet run async-23.cs
7 lines written to [results.txt]:
y 2 60000    : tax = 4282 €
y 3 60000    : tax = 2959 €
n 0 60000    : tax = 11519 €
n 2 25000    : tax = 792 €
y 5 120000   : tax = 11109 €
x 1 20000    : incorrect line
y 1 -5       : incorrect line

Comments:

  • line 6: a cancellation source that limits the entire process to 10 seconds. Its token is passed to all asynchronous methods;
  • Lines 24–27: Reading the brackets. The collection expression with spreading [[.. sequence]] converts the LINQ sequence into an array;
  • Lines 31–48: processing of taxpayers. The rows are read one by one using the asynchronous stream [File.ReadLinesAsync], and the results are written using [WriteLineAsync] into a [StreamWriter] released by [await using];
  • Lines 38–43: Validation of a line using a [switch] expression with a three-element list pattern ([[var m, var c, var s]]) and a [when] clause that checks the three fields;
  • lines 52–63: the tax calculation. The [FirstOrDefault] method of LINQ, with a default value, returns the first bracket whose limit is greater than or equal to the family quotient, or the last bracket if none applies (without modifying the data, unlike the 2008 code);
  • lines 14–20: Possible errors are handled in a single location within the main program: time-limit violations, and I/O or format errors (missing file, incorrectly entered number), filtered by [when] with an [or] pattern;
  • The results provide the benchmark values: €4,282 for a married couple with 2 children and a combined income of €60,000; €2,959 with 3 children; and €11,519 for a single person without children.

The calculation itself is synchronous: it performs no I/O and takes a few microseconds. It would be pointless—and even harmful—to make it asynchronous. Only operations that involve waiting are made asynchronous.

9.14. The Golden Rules

Let’s summarize the rules of asynchronous programming with .NET:

  • async all the way: a method that waits for an asynchronous operation becomes asynchronous itself ([async Task]), and so do its callers, all the way back to the entry point ([Main] asynchronous, event handler, controller action ASP.NET Core). Mixing asynchronous code with blocking waits negates the benefits of asynchrony;
  • do not use [.Result], [.Wait()], or [Task.WaitAll] on an unfinished task: use [await], [Task.WhenAll], [Task.WhenAny], or [Task.WhenEach] instead. These blocking forms freeze a thread and can cause permanent deadlocks in graphical applications;
  • Do not use [Thread.Sleep] in asynchronous code: use [await Task.Delay(...)] instead;
  • Do not write [async void] methods, except in GUI event handlers; an asynchronous method should use [Task] or [Task<T>] (in exceptional cases, [ValueTask<T>]);
  • The name of an asynchronous method ends with [Async]: [CalculateTaxesAsync], [ReadRateAsync];
  • A cancelable asynchronous method receives a [CancellationToken] as its last parameter and passes it to all the asynchronous operations it calls; a canceled operation ends with an [OperationCanceledException];
  • [Task.Run] is reserved for calculations that you want to execute in the background (to avoid blocking a GUI thread, for example). Do not wrap a synchronous I/O operation in [Task.Run] to “make it asynchronous”: use the [XxxAsync] version of the operation;
  • start first, then wait: to run multiple independent operations simultaneously, start them all and then wait for them using [Task.WhenAll]; to limit the number of operations, use [Parallel.ForEachAsync] or a channel;
  • [ConfigureAwait(false)] is used in libraries, not in applications;
  • a launched task must be waited for (or explicitly ignored using [_ =], while handling its exceptions): a forgotten task hides its exceptions and may be interrupted by the end of the program.

The following table summarizes the equivalences between the world of threads (previous chapter, 2008 course) and that of tasks:

Threads, blocking waits
Tasks, asynchronous waits
[Thread.Sleep(1000)]
[await Task.Delay(1000)]
[thread.Start()] followed by [thread.Join()]
[var t = MethodAsync();] then [await t]
Wait for multiple threads ([Join] in a loop, [Task.WaitAll])
[await Task.WhenAll(...)]
[Thread.Abort()] (obsolete), [BackgroundWorker.CancelAsync]
[CancellationTokenSource.Cancel()] and [CancellationToken]
[BackgroundWorker.ReportProgress] / [ProgressChanged]
[IProgress<T>.Report]
[BackgroundWorker.RunWorkerCompleted]
The code following [await]
[BeginXxx] / [EndXxx] and callback methods (APM)
[await XxxAsync(...)]
[Control.Invoke] to return to the interface thread
[await], which automatically returns to the synchronization context
[BlockingCollection<T>] ([Add] / [GetConsumingEnumerable])
[Channel<T>] ([WriteAsync] / [ReadAllAsync])
[System.Threading.Timer]
[PeriodicTimer]

The following chapters will apply these rules: asynchronous database access with ADO.NET (Chapter 10) and Entity Framework Core (Chapter 11), asynchronous multi-client TCP server and [HttpClient] client (Chapter 12), ASP.NET Core web services and their [HttpClient] clients (Chapter 13).