Parallel and asynchronous Computing in C#/ .Net using tasks and async/await
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
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.
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.
With a comparison if we had a synchronous execution
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();
}
Now that we understand the basics, let’s delve deeper!
It is a rather long post so get ready :)
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!)
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
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
.
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.
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));
}
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.
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);
}
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
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.WhenAll
or accessingtask.Result
)
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
orawait
) propagates exceptions to the creator thread as anAggregateException
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?
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
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.
VI/ Task Execution under the hood
Process
- 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.
How the ThreadPool Works
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:
- QueueTask: Queues a task to the scheduler.
- TryExecuteTaskInline: Attempts to execute a task synchronously.
- 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.
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 beforeThread A
, it checksThread A
's deque for tasks to steal. Thread C
andThread 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!
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
orTask.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
orasync 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 between
return Task
andreturn await
: Unless you need to manipulate the result of an async operation, usingreturn 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
intry/catch
orusing
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
: UseGetAwaiter().GetResult()
for blocking async tasks to avoidAggregateException
.
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 ofThread.Sleep
to wait asynchronously. - Awaiting the completion of multiple async tasks: Use
Task.WaitAny
orTask.WaitAll
to manage multiple asynchronous operations. - Prefer
ValueTask
overTask
for asynchronous methods returning quickly or synchronously.
VIII/ Sources
https://www.udemy.com/course/parallel-dotnet/