Parallel and asynchronous Computing in C#/ .Net using tasks and async/await

Bob Code
35 min readJun 14, 2024

--

Bob Code Originals

This is a sequel to my post on Multithreading in C# where I also introduce Asynchrony and parallelism are and when to use each.

I recommend you — of course - to read that first ;)

But here’s a short introduction anyway

What is Asynchrony & Parallelism?

Let’s first define each term separately

  • Synchronous

Each task must complete before the next one starts.

  • Asynchronous (one thread)

A single thread handles multiple tasks by switching between them

  • Asynchronous (multiple threads)

Multiple threads handle different tasks simultaneously

https://blog.devgenius.io/multi-threading-vs-asynchronous-programming-what-is-the-difference-3ebfe1179a5

Now let’s see the advantage of using the multithreading approach.

What if we have multiples tasks that each produces a result, we want to combine all these results into a final one.

What we can do it is break each task into a separate operation and run each in parallel.

https://pythonnumericalmethods.studentorg.berkeley.edu/notebooks/chapter13.01-Parallel-Computing-Basics.html

So why do we need asynchrony in parallel computing?

With asynchrony we can wait on threads. What does that mean?

Basically, we create worker threads to do work and in the meantime we are free to do something else until the worker threads are finished and give us the results.

Async and parallel computing c# by Bob Code

With a comparison if we had a synchronous execution

https://www.c-sharpcorner.com/article/thread-behaviour-in-synchronous-and-asynchronous-method/

Why should you use Async/Await?

If we don’t await, the worker thread is blocking, i.e. the main threads is frozen until the worker thread is finished.

With async, we can now run long tasks on another thread without blocking the application.

How does async work in C#?

To make an asynchronous call we need two things:

  • An async method
  • An awaited operation

Let’s an example below

// Caller (Main thread)
var userId = await GetUserId();
var userName = await GetUserName();

// Combine results
ConsoleWriteline(userName + userId);

// Asynchronous method 1: use async + await
async Task<int> GetUserId()
{
// awaitable operation
return await _database.GetId();
}

// Asynchronous method 2: use async + await
async Task<int> GetUserName()
{
// awaitable operation
return await _apiClient.GetUserName();
}
Main thread making async calls in C#

Now that we understand the basics, let’s delve deeper!

It is a rather long post so get ready :)

Bob Code Originals

Agenda

I/ Asynchronous and parallel computing in C#/.Net

  • The Task-based Asynchronous pattern (TAP)
  • Async and await keywords
  • When should we use async/await?
  • Does async/await create a new thread?
  • The Task Parallel Library (TPL)
  • When to use the TPL?
  • What is a Task?
  • How does a Task create a thread?
  • Must Tasks be awaited?

II/ Task LifeCycle

  • Lifecycle of a Task
  • Creating and Starting a task
  • Do we need to manually create and start a Task?
  • Returning a value from a Task
  • Pausing a Task
  • Waiting for a Task
  • ConfigureAwait
  • ValueTask
  • Child and Inner Tasks

III/ Cancellation and Exceptions in Tasks

  • Cancelling Tasks
  • Exceptions in Tasks
  • Unpropagated Exceptions
  • Handling exceptions using Wait/Await
  • Other ways to handle exceptions
  • Exceptions in Task Best practices

IV/ A deeper look into Async/Await

  • The Async keyword
  • The Await operator
  • StateMachines

V/ A deeper look into Task

  • What is the difference between threads and tasks?
  • Task properties
  • What Happens When a Task is Created?

VI/ Task Execution under the hood

  • Process
  • How the ThreadPool Works
  • The Scheduler
  • Work-Stealing Algorithm
  • Atomic Operations, Thread Safety and Synchronisation

VII/ Some tips

VIII/ Sources

I/ Asynchronous and parallel computing in C#/.Net

The Task-based Asynchronous pattern (TAP)

.NET provides a framework for working with asynchronous code known as the Task-based Asynchronous Pattern (TAP).

TAP uses the Task and Task<TResult> as method types.

Async and await keywords

The async and await keywords are the heart of asynchronous programming in C#.

Let’s see how they work:

  • The method must be flagged as async
  • Operations within the method must be awaited using the await keyword
// Asynchronous method: use async + await
async Task<int> GetUserId()
{
// awaitable operation
return await _database.GetId();
}
  • The method runs synchronously until it reaches the await keyword.

Good to know: the main thread cannot be awaited

Now that we have our async method ready, it is best practice to call it using the await keyword

// Caller (Main thread)
var userId = await GetUserId();

When should we use async/await?

  • I/O-bound code

When your code will be “waiting” for something, like database queries or web service calls that involve waiting for I/O operations (asynchronous computing)

Does async/await create a new thread?

In .Net the async/await keywords don’t cause additional threads to be created.

Therefore, it will not cause any operations to run in parallel.

To run in parallel, we could create a new thread and run the operation on it (see my blog)

However, it is not quite handy to work with threads, and thankfully .Net created the Task class to manage the complexity of threads!

The Task Parallel Library (TPL)

TPL is the .Net standard of working with threads and parallelism.

It provides high level components to handle threads, abstracting away the complexity involved in thread management.

The library provides types that simplify the work of writing multithreaded and asynchronous code.

The main types are:

  • Task/ Task<TResult> an asynchronous operation that can be waited, cancelled and can return a value.
  • The TaskFactory provides static methods for creating and starting tasks
  • The TaskScheduler provides the default thread scheduling operation

When to use the TPL?

CPU-bound code

Use TPL for CPU-intensive computations that can benefit from parallel processing to improve performance.

Example: Running complex algorithms or processing large data sets in parallel.

What is a Task?

A task is a unit of work that can be executed asynchronously on a separate thread. It resembles a thread but is actually at a higher level.

It is managed by the Task Scheduler, supports cancellation, continuation, and exception handling.

A Task can be run on ThreadPool threads or custom threads.

How does a Task create a thread?

The Task class creates a new thread by using the threadpool (more on this later!)

https://www.c-sharpcorner.com/article/task-and-thread-in-c-sharp/

Basically, when a method is marked as a Task, the threadpool will allocate an available thread to complete it.

Upon completion, the thread goes back to the threadpool to pick-up new tasks.

Must Tasks be awaited?

Actually the class Task does not have to use the await keyword/

Why is that?

A Task already represents an asynchronous operation.

The await keyword is only used to asynchronously wait for a task to complete and to capture its result or handle any exceptions that may occur.

Let’s see the below code example — which is valid!

void Testingclass()
{
whatATask();
Console.WriteLine("Task started, but not awaited.");
}

public Task whatATask()
{
return Task.Run(() =>
{
Console.WriteLine("Hello from task");
});
}

The code will be executed asynchronously on another thread, however, a couple of serious issues could arise!

  • No Continuation: The main thread does not wait for the task to complete because it is not awaited
  • No Exception handling: If an exception is thrown and not awaited, it might be caught later potentially crashing the app
  • Fire-and-Forget Scenarios/Resource Management: The task might not complete properly, keeping threads busy without notifying the application

In conclusion, while it’s technically valid to use tasks without awaiting them…

it’s generally recommended to await tasks to handle their results and potential exceptions properly

II/ Task Lifecycle

Bob Code Originals

Lifecycle of a Task

Created: The task is instantiated but not yet started.

var task = new Task(SomeMethod);

WaitingForActivation: The task is waiting to be activated and scheduled for execution. This state occurs internally after creation and before scheduling.

WaitingToRun: The task has been scheduled and is waiting for a thread to be available to run. task.Start();

Running: The task is currently executing. The method SomeMethod is running.

WaitingForChildrenToComplete: The task has finished its own execution but is waiting for any attached child tasks to complete. This state is less common and typically occurs in complex task hierarchies.

Cancelled: The task was cancelled before it could complete. cancellationTokenSource.Cancel();

RanToCompletion: The task completed execution successfully. The task reached the end of SomeMethod without exceptions.

Faulted: The task completed due to an unhandled exception. An exception was thrown during the execution of SomeMethod.

C# Task Lifecycle by Bob Code

Creating and Starting a task

Tasks take an Action which is a delegate (method, function or lambda) to be executed

Explicitly creating a task using new Task

var task = new Task(() =>
{
PrintPluses(10); // The method delegate to be executed
});

Using Task Factory

// Using Task Factory 
Task.Factory.StartNew(() => DoWork());


public void UnitOfWork()
{
Console.WriteLine("This is a unit of work");
}

Using Task.Run, which is a shortcut for calling Task.Factory.StartNew() and calls Unwrap on the result

// Create and start the task
var task = Task.Run(() =>
{
DoWork();
});

So what’s the difference?

  • new Task()

The task is created by not started, to start it you would need to do the following

task.Start();
  • Task.Factory.StartNew() & Task.Run()

Two things at the same time: you’re creating and starting the task at the same time

For every Task that you create and run, a new thread will be allocated to run the action (method delegate, function or lambda) indicated.

Difference between new Task() and Task.Factory.StartNew() by Bob Code

Do we need to manually create and start a Task?

Although the previous code enables us to manually manage the state of a Task, most commonly we’d simply do the following

// This line creates and starts the Task
// Task Status: Running
var userId = await GetUserId();

async Task<int> GetUserId()
{
// Task Status: Waiting for _database.GetId() to complete.
// The Task representing GetUserId is still running, but it is in waiting state for _database.GetId() to finish.
return await _database.GetId();

// Task Status: When _database.GetId() completes, GetUserId resumes execution.
// The Task transitions to Completed once the return statement is executed.
}

State Transition: The task representing GetUserId is running, but is in a waiting state until _database.GetId() finishes.

Once _database.GetId() completes, GetUserId resumes and transitions to completed after the return statement.

Returning a value from a Task

The Task class is very much like a promise, there is either eventual completion or failure of the operation.

To get a result from a task, we need to wait for it to complete and then retrieve the value encapsulated in the Task.

How to do this?

Result

If the method returns a Task, then you can use the Result method

var taskWithResult = Task.Run(() => CalculateLength("Hello from task with result"));
var result = taskWithResult.Result

The problem with the Result property is that it is a blocking operation, therefore leading to:

  • The main thread being stopped until the task is finished
  • The whole app freezing and waiting for the result to be produced
  • The Code suddenly executing synchronously

There are four main ways on returning a value from a Task asynchronously:

  • Task<T>and await: Most straightfoward
  • ContinueWith: Use it to chain execution with another Task
  • WhenAll/WhenAny: Use it to chain execution with all or any Task when all mentioned Tasks are completed
  • Unwrap: Use it for tasks returning tasks

Task<T>

public Task<int> CalculateAsync(int number)
{
return Task.Run(() => number + 1);
}

ContinueWith

Function that will be executed after a task is completed

var continuedTask = 
Task.Run( () => WorkItem) // When the method WorkItem is done, then the ContinueWith starts
.ContinueWith(continuedTask => Console.Writeline("Task resul is:" + continuedTask.Result)));

We can chain-up continuation closes using more .ContinueWith

var continuedTask = 
Task.Run( () => WorkItem)
.ContinueWith(continuedTask => Console.Writeline("Task resul is:" + continuedTask.Result))
.ContinueWith(continuedTask => {Thread.Sleep(500);});

WhenAll/ WhenAny

// The array of tasks to wait for
var tasks = new[]
{
Task.Run(() => DoWork()),
Task.Run(() => DoWork()),
Task.Run(() => DoWork())
};

// Create a finalTask that continues when all tasks in the array have completed
var finalTask = Task.WhenAll(
tasks,
completedTasks => // The action to perform when all tasks have completed
{
Console.WriteLine("All tasks completed.");
});
}

OnlyOnRanToCompletion enables us to Continue to another Task based on certains conditions:

  • OnlyOnRanToCompletion
  • OnlyOnFaulted
  • None
var task = Task.Run(() => DoWork(new CancellationToken()));

// Example with OnlyOnRanToCompletion
task.ContinueWith(t =>
{
Console.WriteLine("Task completed");
}, TaskContinuationOptions.OnlyOnRanToCompletion);

Threads used by Continuation

When the code reaches the Continue methods, it might not use the same thread and thus create/ use another one

When a continuation is scheduled to be run, a thread pool is involved, we ask from the pool any thread that can do the work, which could be a different thread than the one that did the initial work

Unwrap

Especially useful for nested Tasks (Task within a Task)

var nestedTask = CalculateDoubleAsync(number);
var result = await nestedTask.Unwrap();

public Task<Task<int>> CalculateDoubleAsync(int number)
{
return Task.Run(() => Task.Run(() => number + 1));
}
Bob Code orignals

Pausing a Task

Thread.Sleep

var task = Task.Run(() =>
{
Thread.Sleep(2000);
});

This not only pauses the Task, it also tells the scheduler that the thread can be used for another unit of work

Thread.SpinWait(int)

var task = Task.Run(() =>
{
Thread.SpinWait(100000000);
});

The thread will be paused, but this time the scheduler won’t get busy with other threads.

It will stay on this thread, so the thread priority won’t be affected and the thread won’t do any context switching which makes it optimal for short pauses.

This however wastes the thread cycle since it waits doing nothing.

Or use Task.Delay()

Which actually creates another Task that basically waits

async Task<int> CalculateAsync()
{
await Task.Delay(1000); // Delays for one second
// if await is not here, the thread will be blocked

return Task.Run(() =>
{
return 1 + 2;
});
}

Task.Delay() introduces a non-blocking delay

Waiting for a Task

In .NET, you often need to wait for a task to complete before proceeding with subsequent operations.

However, waiting might cause blocking operations!

The Wait Method

When you need to ensure that the code does not proceed until a task has completed, you can use the Wait method.

This will block the current thread until the task is done.

var task = Task.Run(() =>
{
Thread.Sleep(2000); // Simulating work
});
task.Wait(); // Blocking wait

Or for several tasks

Task task1 = Task.Run(() => Thread.Sleep(2000));
Task task2 = Task.Run(() => Thread.Sleep(3000));
Task.WaitAll(task1, task2); // Blocking wait for all tasks to complete

// as soon as one thread is done, code proceeds
Task.WaitAny(task1, task3); // WaitAny is also available

The Await Method

To wait for a task completion in a non-blocking way, you can use the await keyword in an asynchronous method.

This allows the current thread to continue executing other code while waiting for the task to complete.

public async Task ExampleMethodAsync()
{
var task = Task.Run(() => Thread.Sleep(2000));
await task; // Non-blocking wait
Console.WriteLine("Task completed");
}

If the awaited task is already in a completed state, the await keyword will not introduce any delay, and the program will continue executing as if the task had already completed.

ConfigureAwait(bool)

Before delving into the use of ConfigureAwait we need to go over a few definitions

What is a SynchronizationContext and a Task scheduler?

SynchronizationContext is a type in .NET that async operations use for synchronisation.

In UI applications (like WinForms or WPF), the original context often refers to the UI thread’s synchronization context.

This context ensures that UI-related operations (such as updating controls) occur on the UI thread, which is crucial for thread safety and avoiding cross-threading exceptions.

A TaskScheduler in .NET is analogous to SynchronizationContext but focuses on scheduling tasks rather than UI.

// Capture the current synchronization context
SynchronizationContext synchronizationContext = SynchronizationContext.Current;

// Example Task that does some work asynchronously
Task<int> task = Task.Run(() =>
{
// Simulate some work
Thread.Sleep(2000);
return 42;
});

// Await the Task
// indicates that we want the sync to keep happening on the current sync context
int result = await task.ConfigureAwait(true);

// After the Task completes, use SynchronisationContext to post back to UI thread
synchronizationContext.Post(state =>
{
Console.WriteLine($"Result from Task: {result}");

// Example UI update in a Windows Forms/WPF context
// This delegate executes on the UI thread
// Update UI controls, etc.
}, null);

Console.WriteLine("Waiting for async operation to complete...");
Console.ReadLine(); // To keep console application open

How do they work with async operations?

When you await a task in C#, the compiler captures the current SynchronizationContext (if present) or the TaskScheduler.Current.

If a SynchronizationContext is active,await ensures that the continuation (the code after await) runs on the captured context.

This is crucial for updating UI elements because UI controls can only be accessed from the thread that owns them.

If you await a task that completes with a result (Task<T>), once the task completes, the continuation (the code after await) is posted back to the captured SynchronizationContext for execution.

Thread Context in C# by Bob Code

How async/await made things easier to deal with the context

Explicit callback methods using ContinueWith or SynchronizationContext.Post are older approaches to achieve the same behavior before async/await syntax was introduced.

async/await syntax simplifies the code and makes it more readable by automatically handling the context for you.

Internally, when you await a task, the compiler generates code that captures the current context (either SynchronizationContext.Current or TaskScheduler.Current).

await ensures that the continuation runs on the correct context captured at await time.

Why did ConfigureAwait(false) come along?

Normally, when you await a task in C#, the continuation (the code that comes after await) is executed in the current context which is the one that existed at the time of the await.

ConfigureAwait(false) instructs the compiler that the continuation after the await does not need to execute in the original context. Instead, it can continue in any available context, typically on a thread pool thread.

When to use ConfigureAwait(false)?

Libraries are agnostic to the execution context and may be used across various environments without knowledge of their specific synchronization contexts.

The recommendation evolved to apply ConfigureAwait(false) primarily in library code, not application code, simplifying guidelines.

Library code consists of reusable components or modules that are independent of the specific app logic (e.g. shared client)

Application code refers to the specific implementation of business logic, user interfaces, it is context specific to the app it servers

Let’s see a couple of examples of libraries:

  • Networking Library (HttpClients)
  • Database Access Library
  • Background Services (e.g. message queues)
  • Utility Libraries (e.g. file operations, data transformation or other non-UI specific tasks)
// Http Job
public async Task<string> GetDataAsync(string url)
{
HttpResponseMessage response = await httpClient.GetAsync(url).ConfigureAwait(false);
return await response.Content.ReadAsStringAsync().ConfigureAwait(false);
}

// Database Job
public async Task<List<Customer>> GetCustomersAsync()
{
var customers = await dbContext.Customers.ToListAsync().ConfigureAwait(false);
return customers;
}

// File Job
public static async Task<string> ReadFileAsync(string filePath)
{
using (var streamReader = new StreamReader(filePath))
{
return await streamReader.ReadToEndAsync().ConfigureAwait(false);
}
}

// Background Job
public async Task ProcessQueueAsync()
{
while (await queueClient.HasMessagesAsync().ConfigureAwait(false))
{
var message = await queueClient.ReceiveMessageAsync().ConfigureAwait(false);
await ProcessMessageAsync(message).ConfigureAwait(false);
}
}

Why would I want to use ConfigureAwait(false)?

  • Performance

By not capturing the current context (SynchronizationContext or TaskScheduler.Current), you avoid the overhead associated with going back to the original context. This can improve performance, especially in high-throughput scenarios.

  • Deadlock

In some specific situations, capturing the context can lead to deadlocks. For example, when the current context has limited concurrency (like UI threads or custom context with limited threads), using ConfigureAwait(false) ensures that the continuation does not deadlock waiting for the original context to become available.

What does ConfigureAwait(true) do?

ConfigureAwait(true) essentially does nothing meaningful because it defaults to the behaviour of capturing the current context. Functionally, await task and await task.ConfigureAwait(true) are identical.

In some cases, ConfigureAwait(true) might be used to suppress static analysis warnings or to indicate explicitly that the context should be captured, though this is rare and generally not recommended.

When NOT to use ConfigureAwait(false)?

For applications, you typically want to respect the synchronization context that may exist in the environment, such as UI frameworks (e.g., WinForms, WPF) or ASP.NET contexts.

Misconceptions about ConfigureAwait(false)

  • Deadlocks

It’s not primarily a tool to avoid deadlocks. Using it as such is not a maintainable solution.

  • Task Configuration

It configures the await, not the task itself. Incorrect use, like task.ConfigureAwait(false); await task;,ignores ConfigureAwait(false).

  • Thread Pool Execution

It doesn’t guarantee running on a different thread or thread pool thread, only that the context isn’t captured if the await yields.

Summary

ConfigureAwait(false) is a method used to optimise asynchronous code execution by allowing the continuation of an await operation to execute on any available thread, without the overhead and potential issues of capturing and returning to the original context

ValueTask

ValueTask and ValueTask<TResult> types were introduced in .NET to improve asynchronous performance.

Task performance issue

Task is a class, meaning every asynchronous operation involves object allocation, adding pressure on the garbage collector.

What is ValueTask?

ValueTask is a lightweight alternative to Task designed to reduce allocation overhead.

It is astruct capable of wrapping either a TResult or Task<TResult>.

Here’s a simple example of using ValueTask:

public ValueTask<int> GetValueAsync() {
return new ValueTask<int>(42);
}
  • If returned from an async method and it completes synchronously, nothing is allocated
  • If the method completes asynchronously, Task<TResult> is allocated, wrapped by ValueTask<TResult>

Benefits of ValueTask

  • Memory Efficiency: Stored on the stack, reducing garbage collection pressure.
  • Performance: Better for frequently executed synchronous paths.

Caveats of ValueTask<T>

  • Single Await: Should only be awaited once.
  • Not for Long Operations: Best for short, synchronous operations.
  • Concurrency Issues: Not suitable for concurrent awaits or multiple thread access.
// Proper Usage
int result = await SomeValueTaskReturningMethodAsync();

// Proper Usage with Configuration
int result = await SomeValueTaskReturningMethodAsync().ConfigureAwait(false);

// Convert to Task for multiple awaits
Task<int> t = SomeValueTaskReturningMethodAsync().AsTask();

// Improper Usage: Multiple Awaits
ValueTask<int> vt = SomeValueTaskReturningMethodAsync();
int result1 = await vt;
int result2 = await vt; // BAD: Reusing ValueTask

// Improper Usage: Concurrent Awaits
ValueTask<int> vt = SomeValueTaskReturningMethodAsync();
Task.Run(async () => await vt);
Task.Run(async () => await vt); // BAD: Concurrent awaits

Child and Inner Tasks

Child tasks are created within a parent task. The parent task waits for all child tasks to complete before completing itself.

var parentTask = Task.Run(() => {
Console.WriteLine("Parent task starting.");
var childTask = Task.Run(() => {
Console.WriteLine("Child task starting.");
});
});
parentTask.Wait();

Inner Tasks tasks that returns another task, allowing for complex task chaining.

async Task<Task<int>> OuterTaskAsync() {
return Task.FromResult(42);
}
Thanks Mr. Toub

III/ Cancellation and Exception in Tasks

Cancelling Tasks

Cancelling a task in C# involves using the CancellationTokenSource and CancellationToken

Creating the Cancellation token source

var tokenSource = new CancellationTokenSource();

Creating the Cancellation token

var token = tokenSource.Token;

Start a Task and pass the CancellationToken

var task = Task.Run(() => DoWork(token);

Implement Cancellation logic in the Task

// Add logic to handle cancellation
public void DoWork(CancellationToken cancellationToken)
{
// Check if the cancellation token has been canceled
cancellationToken.ThrowIfCancellationRequested();

Console.WriteLine("Hello from thread");
}

Request Cancellation

tokenSource.Cancel();

How do we know if a task has been cancelled?

We can just add Register after the creation of the token

var cts = new CancellationTokenSource();
var token = cts.Token;

token.Register(() =>
{
Console.WriteLine("Cancellation requested.");
});

Passing multiple tokens

var planned = new CancellationTokenSource();
var preventative = new CancellationTokenSource();
var emergency = new CancellationTokenSource();

var paranoid = CancellationTokenSource.CreateLinkedTokenSource(
planned.Token, preventative.Token, emergency.Token);
// use the token
var token = paranoid.Token

Handling Cancellation as Exception

It is recommended to properly handle the exception “OperationCanceledException” thrown by the token using a catch block with an AggregateException

try
{
// Wait for the task to complete
task.Wait();
}
catch (AggregateException ex)
{
foreach (var innerEx in ex.InnerExceptions)
{
if (innerEx is OperationCanceledException)
{
Console.WriteLine("Task was cancelled.");
}
else
{
Console.WriteLine(innerEx.Message);
}
}
}

Overview of Exceptions

Let’s first see how exceptions are thrown:

On the Main Thread

If an exception is thrown on the main thread and it is not caught by a try-catch block, it will cause the application to terminate.

On a Worker Thread

Exceptions thrown on worker threads do not automatically propagate to the main thread unless explicitly handled or awaited in the main thread context.

On a Task

Unobserved exceptions in tasks do not immediately crash the application. When using Task.Wait or await on a task that completes with exceptions, the exceptions are wrapped in an AggregateException.

Exceptions in Tasks

Unhandled exceptions thrown by user code inside a task are generally propagated back to the calling thread, except in certain scenarios.

These exceptions are wrapped in an AggregateException instance, which can be handled using try/catch.

AggregateException

  • Exceptions from tasks are wrapped in an AggregateException
  • Use the InnerExceptions property to enumerate and handle each original exception
  • Even a single exception is wrapped in an AggregateException

Handling Exceptions:

Use Task.Wait or await to wait for a task’s completion and handle exceptions in a try/catch block.

Alternatively, use the task’s Exception property to retrieve the AggregateException.

Unpropagated Exceptions

Bob Code Originals

Example Scenario

Consider the following code where exceptions are thrown inside tasks

var task1 = Task.Run(() =>
{
throw new Exception("This is exception 1");
});

var task2 = Task.Run(() =>
{
throw new Exception("This is exception 2");
});

Exceptions won’t be propagated!

What does that mean?

If you run this code, the exceptions won’t be thrown immediately, and the application will not crash.

However, when you wait for the tasks to complete, the exceptions are propagated, and the application might crash if the exceptions are not handled properly

Task.WaitAll(task1, task2); // will make the app to crash

Handling Exceptions with AggregateException

To handle exceptions properly, use a try-catch block and catch AggregateException:

try
{
Task.WaitAll(task1, task2);
}
catch (AggregateException ae)
{
foreach (var innerException in ae.InnerExceptions)
{
Console.WriteLine(innerException.Message);
}
}

Exception Behaviours

  • Main Thread: An unhandled exception causes the application to crash unless it’s caught using a try-catch block
  • A worker Thread: Exceptions thrown on a separate thread can only be caught on that same thread
  • A Task: Exceptions thrown within a task do not crash the application unless they are propagated (e.g. using Task.Wait, Task.WhenAllor accessing task.Result)
Bob Code Originals

Exception Management in Task Parallel Library (TPL)

  • Without Waiting: If you do not wait for the task (e.g., using Task.Wait), exceptions are not propagated to the main thread
  • With Waiting: Waiting for the task (e.g., using Task.Wait or await) propagates exceptions to the creator thread as an AggregateException

Handling exceptions using Wait/Await

Using Task.Wait to handle an exception:

var task = Task.Run(() => throw new CustomException("This exception is expected!"));

try
{
task.Wait();
}
catch (AggregateException ae)
{
foreach (var ex in ae.InnerExceptions)
{
if (ex is CustomException)
{
Console.WriteLine(ex.Message);
}
else
{
throw;
}
}
}

One issue with waiting for a task to complete is that it blocks the main thread, making the entire process synchronous.

To avoid blocking, consider using async/await

var task = Task.Run(() => throw new CustomException("This exception is expected!"));

try
{
await task;
}
catch (Exception ex)
{
Console.WriteLine(ex.Message);
}

Other ways to handle exceptions

Exception Property

var task = Task.Run(() => throw new CustomException("This exception is expected!"));

try
{
await task;
}
catch
{
if (task.Exception != null)
{
// Use the exception property
foreach (var ex in task.Exception.InnerExceptions)
{
if (ex is CustomException customEx)
{
Console.WriteLine(customEx.Message);
}
else
{
throw; // Rethrow other exceptions
}
}
}
}

Handle Method

Filtering exceptions using the Handle method:

var task = Task.Run(() => throw new CustomException("This exception is expected!"));

try
{
task.Wait();
}
catch (AggregateException ae)
{
ae.Handle(ex =>
{
if (ex is CustomException)
{
Console.WriteLine(ex.Message);
}
return ex is CustomException;
});
}

Observing Exceptions

Use a continuation to observe an exceptions

Task.Run(() => throw new CustomException("task1 faulted."))
.ContinueWith(t =>
{
if (t.Exception?.InnerException is Exception inner)
{
Console.WriteLine($"{inner.GetType().Name}: {inner.Message}");
}
}, TaskContinuationOptions.OnlyOnFaulted);

Thread.Sleep(500);

Exceptions in Task Best practices

Exception Propagation: Unhandled exceptions in tasks are propagated back to the calling thread via an AggregateException.

AggregateException.Handle: Use this method to filter and handle specific exceptions.

Task.Exception Property: Useful for examining exceptions when a task completes in a faulted state.

Continuations: Use continuations with TaskContinuationOptions.OnlyOnFaulted to handle exceptions.

IV/ A deeper look into Async/Await

The Async keyword

An async method runs synchronously until it reaches its first await

The async keyword in C# doesn't do anything on its own; rather, it in enables the use of the await keyword.

When you mark a method with async, you're essentially signaling to the compiler that the method contains asynchronous operations.

So why bother with async if await is the star of the show?

Bob Code Originals

Historically, await wasn't always a reserved word in C#, so async serves as a marker for the compiler to recognize await as a special keyword.

It's the presence of async that allows await to function within a method. Without async, the compiler would interpret await as an ordinary identifier, leading to compilation errors.

The Await operator

Now, what does await do exactly? It doesn't perform a conventional wait, which would block the threaddoes not perform a typical wait (which is blocking).

It immediately returns the result if the operation is already completed without blocking.

The await expression extracts the result of the task or propagates any exceptions that occurred during its execution.

Instead, it asynchronously waits for the completion of a Task or Task<TResult> and retrieves the result.

Essentially, await indicates to the compiler that the code following it is a continuation, akin to ContinueWith() in the Task Parallel Library (TPL).

However, unlike ContinueWith(), the execution after an await happens elsewhere, typically on a thread from the TPL thread pool.

To illustrate, consider this async/await snippet:

int result = await CalculateAsync();
Console.WriteLine(result)

This can be conceptually converted into the following using ContinueWith():

CalculateAsync()
.ContinueWith(t => Console.WriteLine(t.Result));

StateMachines

Bob Code Originals

Whenever we use async/await we’re actually building state machines.

So what is a state machine?

A state machine is a mathematical abstraction used to describe the behaviour of a system through a finite number of states, transitions between these states, and actions associated with these transitions.

Basically, it’s just a set of rules that indicate when a process goes from one stage to the other.

What does that mean?

The compiler generates a state machine behind the scenes to manage asynchronous operations, simplifying asynchronous code without needing explicit state management.

Behind the scenes, every async method in C# is transformed by the compiler into a state machine that implements the IAsyncStateMachine interface.

This interface manages the state of the asynchronous operation and allows the method to suspend and resume execution as needed.

State machine in the context of Async/Await

In the context of async and await, the compiler generates a state machine to manage the asynchronous operations going from one state (remember the lifecycle) to another.

It basically enables having all these continuations without having us to write explicit code

The compiler creates a new object to orchestrate the asynchronous method’s execution.

This object keeps track of the method’s state and manages the flow of execution when await is encountered.

V/ A deeper look into Task

What is the difference between threads and tasks?

Threading Model: Threads are managed by the operating system, while tasks are managed by the .NET runtime

Resource Management: Threads require explicit resource management, while tasks are managed by the runtime

Ease of Use: Tasks provide a simpler, higher-level abstraction for concurrent programming

I/O vs CPU bound distinction

The .NET’s task-based asynchronous programming model offers significant advantages over traditional thread-per-request approaches by separating CPU-bound and I/O-bound tasks and leveraging the thread pool and asynchronous I/O operations.

Tasks are broken down into CPU and I/O tasks, with CPU tasks executed on threads from the thread pool and I/O tasks managed asynchronously by the .NET runtime.

Task properties

Id task unique identifier

Task.CurrentId unique identifier of the current task

Status: Represents the current status of the task (values include Created, WaitingForActivation, WaitingToRun, Running, WaitingForChildrenToComplete, Cancelled, RanToCompletion, and Faulted)

AsyncState: Provides a reference to an optional user-defined object associated with the task, allowing additional data to be passed along with the task.

Task task = Task.Run(() =>
{
// Simulate work
Thread.Sleep(2000);
});

await task; // Wait for task to complete

// Extracting task properties into variables
int taskId = task.Id;
TaskStatus taskStatus = task.Status;
object asyncState = task.AsyncState;

What Happens When a Task is Created?

Task Creation: A Task object is instantiated, and a delegate (method) is assigned to it.

Scheduling: The task is scheduled by the TPL, which queues it for execution.

Execution: The TPL allocates a thread from the thread pool to execute the task.

Task vs Threads C# Bob Code

VI/ Task Execution under the hood

Bob Originals

Process

  1. Task Execution

Tasks are executed by a task scheduler, which determines when and where a task should be executed.

The default behavior is to queue tasks to the thread pool for execution.

2. Task Scheduler

The task scheduler decides how tasks are executed, which can include queuing them to the default .NET thread pool, creating new threads, or scheduling them on custom thread pools or other task schedulers.

3. Thread Pool Usage

Tasks queued to the default task scheduler are executed by thread pool worker threads, optimising resource utilisation and scalability.

4. Task Queuing

Tasks are queued for execution either globally or locally.

Global queues handle top-level tasks, while local queues are specific to individual threads and handle nested or child tasks efficiently.

5. Work-Stealing Algorithm

The thread pool employs a work-stealing algorithm to ensure efficient utilisation of resources, allowing threads to execute tasks from their local queue or steal tasks from other threads’ queues when idle.

Basic Task execution
Detailed Task execution

How the ThreadPool Works

Bob Code Originals

ThreadPool Management

The treadpool in .NET is a managed resource that automatically handles thread creation and reuse might provide additional context

  • Thread Creation

When an application starts, the ThreadPool creates a number of worker threads based on the system’s capabilities and the ThreadPool’s settings (e.g., minimum and maximum threads).

  • Thread Reuse

Threads in the ThreadPool are reused to execute multiple tasks sequentially, reducing the overhead of thread creation and destruction.

Task Execution

  • Task Queueing

Tasks are queued to the ThreadPool using methods like ThreadPool.QueueUserWorkItem or by using constructs like Task.Run() which internally uses the ThreadPool.

  • Task Scheduling

The ThreadPool schedules tasks from its queue to available worker threads based on thread availability and priority.

Optimisation (Work Stealing Algorithm)

The ThreadPool uses a work-stealing algorithm to optimise task execution among threads.

Idle threads can steal tasks from other threads’ queues, balancing workload and improving efficiency.

Resource Management

  • Thread Balancing

The ThreadPool dynamically adjusts the number of active threads based on workload and system resources to maintain optimal performance.

  • Thread Termination

Threads in the ThreadPool may be terminated or returned to the pool after a period of inactivity to conserve resources.

Here’s a simple example demonstrating how to use ThreadPool.QueueUserWorkItem to execute tasks using the ThreadPool:


// Queue three tasks to the ThreadPoolfor (int i = 1; i <= 3; i++)
{
int taskNumber = i; // Capture loop variable
ThreadPool.QueueUserWorkItem((state) =>
{
Console.WriteLine($"Task {taskNumber} started on thread {Thread.CurrentThread.ManagedThreadId}");
Thread.Sleep(1000); // Simulate work
Console.WriteLine($"Task {taskNumber} completed");
});
}
// Ensure tasks are completed before exiting
Console.ReadLine();
}
}
  • Task Queueing

The ThreadPool.QueueUserWorkItem method queues tasks to the ThreadPool. In this example, it queues three tasks.

  • Task Execution

Each task simulates work by sleeping for 1 second using Thread.Sleep.

Upon running the example, you might see output similar to:

Task 1 started on thread 4
Task 2 started on thread 5
Task 3 started on thread 6
Task 1 completed
Task 2 completed
Task 3 completed

Each task starts on a different thread from the ThreadPool (thread IDs may vary).

Tasks complete sequentially as threads become available, demonstrating the ThreadPool’s efficient management of worker threads.

The Scheduler

In .NET, the Task Scheduler is a crucial component that determines how and when tasks are executed.

It is responsible for managing the lifecycle of tasks, from queuing them to their eventual execution on threads.

The default scheduler provided by .NET is the ThreadPoolTaskScheduler, but custom schedulers can also be created to meet specific needs.

Default Task Scheduler

The default task scheduler in .NET is the ThreadPoolTaskScheduler, which queues tasks to the .NET thread pool.

The thread pool maintains a pool of worker threads, optimising resource utilization and enabling efficient execution of tasks.

Key Features of the Default Task Scheduler:

  • Global Queues: Used for scheduling top-level tasks.
  • Local Queues: Used by worker threads to schedule nested or child tasks.
  • Work-Stealing Algorithm: Optimises performance by allowing threads to steal tasks from other threads’ local queues when they are idle.

Custom Task Schedulers

While the default scheduler is suitable for most scenarios, custom task schedulers can be implemented to provide specific behavior or to meet particular requirements, such as prioritization, task throttling, or execution on specific threads.

Creating a Custom Task Scheduler

To create a custom task scheduler, you need to inherit from the TaskScheduler class and override its methods:

  1. QueueTask: Queues a task to the scheduler.
  2. TryExecuteTaskInline: Attempts to execute a task synchronously.
  3. GetScheduledTasks: Returns an enumerable of the tasks currently scheduled.

Here is an example of a simple custom task scheduler:

private readonly ConcurrentQueue<Task> _tasks = new ConcurrentQueue<Task>();

// Indicates whether the current thread is processing tasks
private int _delegatesQueuedOrRunning = 0;

// Queues a task to the scheduler
protected override void QueueTask(Task task)
{
_tasks.Enqueue(task);
if (Interlocked.CompareExchange(ref _delegatesQueuedOrRunning, 1, 0) == 0)
{
ThreadPool.UnsafeQueueUserWorkItem(ProcessTasks, null);
}
}

// Executes a task synchronously on this scheduler
protected override bool TryExecuteTaskInline(Task task, bool taskWasPreviouslyQueued)
{
// If this thread isn't already processing tasks, we don't support inlining
if (Thread.CurrentThread.ManagedThreadId == 1) // Example condition for illustration
return false;

// Try to run the task inline on the current thread
return TryExecuteTask(task);
}

// Gets the tasks currently scheduled to this scheduler
protected override IEnumerable<Task> GetScheduledTasks()
{
return _tasks.ToArray();
}

// Processes tasks in the queue
private void ProcessTasks(object? state)
{
while (_tasks.TryDequeue(out var task))
{
TryExecuteTask(task);
}
Interlocked.Exchange(ref _delegatesQueuedOrRunning, 0);
}

The condition Thread.CurrentThread.ManagedThreadId == 1 used in the example is illustrative and should not be interpreted as a practical check in real-world scenarios.

Work-Stealing Algorithm

The work-stealing algorithm is a key feature of modern thread pool implementations, aiming to maximise CPU utilisation by dynamically distributing tasks among available threads.

This algorithm is particularly effective in scenarios where threads may have varying workloads or when tasks are queued asynchronously.

It aims to minimise thread idle time by dynamically assigning tasks to threads, even if those tasks were initially queued for other threads.

https://www.google.com/url?sa=i&url=https%3A%2F%2Fwww.linkedin.com%2Fpulse%2Fwork-stealing-algorithm-saral-saxena&psig=AOvVaw1Iv1J57y9mVTRPhWQaXFOz&ust=1718439428334000&source=images&cd=vfe&opi=89978449&ved=0CBMQjhxqFwoTCIC8gvDT2oYDFQAAAAAdAAAAABAD

Components of the Work-Stealing Algorithm

  • ThreadPool Configuration

The algorithm typically operates within a ThreadPool, where a fixed number of worker threads are created upon initialisation.

  • Deque (Double-Ended Queue)

Each worker thread maintains its own deque for storing tasks awaiting execution. The deque supports efficient task addition (push) and removal (pop) operations from both ends.

  • Work Stealing

When a worker thread exhausts its own tasks and becomes idle, it doesn’t remain dormant.

Instead, it attempts to “steal” tasks from the end of another thread’s deque. This allows idle threads to remain productive by utilizing tasks that other threads may not have started processing yet.

Example Scenario

// Create four worker threads
Thread threadA = new Thread(WorkerThread);
Thread threadB = new Thread(WorkerThread);
Thread threadC = new Thread(WorkerThread);
Thread threadD = new Thread(WorkerThread);

// Start the threads
threadA.Start("Thread A");
threadB.Start("Thread B");
threadC.Start("Thread C");
threadD.Start("Thread D");

// Wait for all threads to complete
threadA.Join();
threadB.Join();
threadC.Join();
threadD.Join();

Console.WriteLine("All threads have completed.");

Consider a ThreadPool with four worker threads (Thread A, Thread B, Thread C, and Thread D) and a deque for each thread:

  • Thread A starts executing tasks from its own deque.
  • If Thread B finishes its tasks before Thread A, it checks Thread A's deque for tasks to steal.
  • Thread C and Thread D similarly operate, ensuring all threads remain active and productive.
static void WorkerThread(object threadNameObj)
{
string threadName = (string)threadNameObj;
ConcurrentQueue<Task> taskDeque = new ConcurrentQueue<Task>();

// Simulate queuing tasks
for (int i = 0; i < 5; i++)
{
int taskId = i + 1;
taskDeque.Enqueue(Task.Run(() => Console.WriteLine($"{threadName} executing Task {taskId}")));
}

while (!taskDeque.IsEmpty)
{
Task nextTask;
if (taskDeque.TryDequeue(out nextTask))
{
nextTask.Wait(); // Simulate task execution
}
else
{
// Attempt to steal tasks from another thread's deque
bool stealSuccess = false;
foreach (var otherThreadDeque in new ConcurrentQueue<Task>[] { /* Deques of other threads */ })
{
if (otherThreadDeque.TryDequeue(out nextTask))
{
Console.WriteLine($"{threadName} stealing task from {otherThreadDeque}");
nextTask.Wait(); // Simulate task execution
stealSuccess = true;
break;
}
}

if (!stealSuccess)
{
// Optional: Sleep briefly to reduce spinning
Thread.Sleep(10);
}
}
}
}
}

In a real-world scenario, threads would use synchronisation primitives (e.g., locks or atomic operations) to ensure safe access to shared resources like task deques.

Key Features and Benefits

  • Load Balancing

By allowing threads to steal tasks from each other’s deques, the algorithm naturally balances the workload across all available threads.

This reduces the risk of some threads being overloaded with tasks while others remain idle.

  • Minimising Thread Creation Overhead

Unlike traditional thread-per-task models, which incur overhead from thread creation and management, the work-stealing algorithm reuses a fixed pool of threads.

This reduces context-switching overhead and memory consumption.

  • Efficient Task Execution

Tasks are executed in a queue-like fashion, respecting their initial order of submission but leveraging idle threads to maximize throughput and responsiveness.

Atomic Operations, Thread Safety and Synchronisation

An atomic operation executes as a single, uninterruptible unit. It can’t be split into smaller parts. They don’t allow more than one thread to work on it.

These operations ensure that data transitions are seamless and consistent across all threads accessing the data.

In order to do so, they need to use a mechanism to “lock” shared data to avoid a race condition.

Shared Data Access & Race Conditions

When multiple tasks access and modify shared data concurrently, it’s crucial to ensure thread safety to prevent data corruption or inconsistent state.

Race conditions can occur if multiple tasks access and modify shared data without proper synchronisation.

For example, if two tasks concurrently increment a shared counter without synchronization, the final value might be incorrect due to interleaved operations.

Synchronisation Mechanisms

Protect critical sections of code using lock statements

private static int _sharedCounter = 0;
private static readonly object _lock = new object();

private static async Task ExampleAsync()
{
await Task.Delay(100); // Async operation

lock (_lock)
{
_sharedCounter++;
}
}

Tasks can also utilize thread-local storage (ThreadLocal<T> in C#)

private static ThreadLocal<int> _threadLocalCounter = new ThreadLocal<int>(() => 0);

public static void IncrementThreadLocalCounter()
{
_threadLocalCounter.Value++;
}

For scenarios where tasks need to access shared collections concurrently, use thread-safe collections (ConcurrentDictionary, ConcurrentQueue, etc., in .NET)

private static ConcurrentDictionary<string, int> _concurrentDictionary = new ConcurrentDictionary<string, int>();

public static void AddOrUpdateDictionary(string key, int value)
{
_concurrentDictionary.AddOrUpdate(key, value, (k, v) => v + value);
}

They’re many ways of synchronising Tasks, this section is a brief overview since the topic requires a whole blog by itself!

Bob Code

Well, this was quite a long post, so thanks if you made it through

Some tips with example

  • Avoid blocking async methods with Task.Result or Task.Wait, as this can lead to deadlocks.
// Incorrect usage
var result = SomeAsyncMethod().Result;

// Correct usage
var result = await SomeAsyncMethod();
  • Avoid async void: Instead, use async Task or async Task<T> to properly handle exceptions and enable easier testing.
// Incorrect usage
async void EventHandler(object sender, EventArgs e)
{
await Task.Delay(1000);
}

// Correct usage
async Task EventHandlerAsync(object sender, EventArgs e)
{
await Task.Delay(1000);
}
  • Choose betweenreturn Task andreturn await: Unless you need to manipulate the result of an async operation, using return Task can reduce overhead by avoiding the creation of a state machine.
// When a result is not needed
public Task SomeAsyncMethod()
{
return AnotherAsyncMethod();
}

// When a result is needed
public async Task<int> CalculateAsync()
{
await Task.Delay(1000);
return 42;
}
  • Avoid wrapping return Task in try/catch or using blocks: This could lead to unpropagated exceptions
// Unpropagated exception
public async Task<int> BadAsyncMethod()
{
try
{
// Simulated async operation
await Task.Delay(1000);

// Simulated potential exception
if (DateTime.Now.Second % 2 == 0)
throw new InvalidOperationException("Even second encountered.");

return 42;
}
catch (Exception ex)
{
// Incorrect handling within the async method
Console.WriteLine($"Exception caught: {ex.Message}");
return -1; // Return a default value or handle the exception improperly
}
}

By wrapping the await Task.Delay and the return statement in a try/catch block within the async method itself, exceptions are handled locally

public async Task<int> GoodAsyncMethod()
{
// Simulated async operation
await Task.Delay(1000);

// Simulated potential exception
if (DateTime.Now.Second % 2 == 0)
throw new InvalidOperationException("Even second encountered.");

return 42;
}

Exceptions are allowed to propagate naturally through the await keyword

  • Avoid .Wait() or .Result: Use GetAwaiter().GetResult() for blocking async tasks to avoid AggregateException.
public void BlockingMethod()
{
Task<int> task = SomeAsyncTask();

try
{
// Block synchronously and get the result
int result = task.GetAwaiter().GetResult();
Console.WriteLine($"Result: {result}");
}
catch (Exception ex)
{
Console.WriteLine($"Exception: {ex.Message}");
}
}
  • Consider ConfigureAwait(false) for performance: Particularly in library code, this can improve responsiveness by avoiding unnecessary context switches.
  • Utilise Task.Run() for CPU-bound Operations: Use Task.Run() to offload CPU-bound work to the thread pool. This allows the current thread to continue executing other operations while awaiting the CPU-bound task.
// I/O-bound operation (such as accessing a file or making an API call)
var resultFromIO = await SomeIOBoundOperationAsync();

// CPU-bound operation (calculating factorial)
var resultFromCPU = await Task.Run(() => CalculateFactorial(number));

Reporting Progress and Cancellation

  • Cancelling Async Tasks: Utilide CancellationToken to cancel asynchronous operations gracefully, checking for cancellation at appropriate intervals.

Additional Considerations

  • Waiting a period of time: Use Task.Delay instead of Thread.Sleep to wait asynchronously.
  • Awaiting the completion of multiple async tasks: Use Task.WaitAny or Task.WaitAll to manage multiple asynchronous operations.
  • Prefer ValueTask over Task for asynchronous methods returning quickly or synchronously.

VIII/ Sources

https://www.udemy.com/course/parallel-dotnet/

--

--

Bob Code

All things related to Memes, .Net/C#, Azure, DevOps and Microservices