Hello, developers! 🥷🏻
Today, we are going to talk about common mistake made when using asynchronous programming in C# projects.
First of all, let’s understand what is asynchronous programming and why it is so powerful.
Let’s imagine we have the following 4 tasks:
- Task A;
- Task B;
- Task C;
- Task D;
Each of these tasks takes 4 seconds to complete. So, running this code as synchronous, it will take 16 seconds to finish all tasks. In synchronous programming, the code runs sequentially. Task D will run when all those 3 (A, B and C) have finished. In this scenario, the thread will be blocked until the task is finished.
using System;
using System.Threading;
class Program
{
static void Main(string[] args)
{
RunSynchronously();
}
static void Task(string name)
{
Console.WriteLine($"Starting {name}...");
Thread.Sleep(4000);
Console.WriteLine($"{name} finished.");
}
static void RunSynchronously()
{
Console.WriteLine("Running tasks synchronously...");
Task("Task A");
Task("Task B");
Task("Task C");
Task("Task D");
Console.WriteLine("All tasks completed synchronously.");
}
}
Running tasks synchronously...
Starting Task A...
Task A finished.
Starting Task B...
Task B finished.
Starting Task C...
Task C finished.
Starting Task D...
Task D finished.
All tasks completed synchronously.
Now, running the same code asynchronously, we can finish all tasks in approximately 5 seconds instead of 16. Why is that? The answer is simple: in this case, there are no blocked threads, and every task can run independently.
using System;
using System.Threading.Tasks;
class Program
{
static async Task TaskAsync(string name)
{
Console.WriteLine($"Starting {name}...");
await Task.Delay(4000);
Console.WriteLine($"{name} finished.");
}
static async Task RunAsynchronously()
{
Console.WriteLine("Running tasks asynchronously...");
await Task.WhenAll(
TaskAsync("Task A"),
TaskAsync("Task B"),
TaskAsync("Task C"),
TaskAsync("Task D")
);
Console.WriteLine("All tasks completed asynchronously.");
}
}
Running tasks asynchronously...
Starting Task A...
Starting Task B...
Starting Task C...
Starting Task D...
Task A finished.
Task B finished.
Task C finished.
Task D finished.
All tasks completed asynchronously.
Since we have the general understating of how async programming works, lets dive into common mistakes using async programming.
Avoid async void
methods
In C#, async void
methods are generally considered dangerous and should be avoided in most cases, and the reasons of this are:
- async void methods cannot be not awaited**: When you use async void, the method does not return a Task or Task. This means the caller cannot await it or know when it has completed. This makes it impossible to handle exceptions or control the flow of execution properly. As the consequence, this unhandled exception has the potential to crash the application.
async void DangerousMethod()
{
await Task.Delay(1000);
throw new Exception("Unhandled exception!");
}
void Caller()
{
DangerousMethod(); // Exception will crash the app
}
async Task SafeMethod()
{
await Task.Delay(1000);
throw new Exception("Something went wrong!");
}
async Task Caller()
{
try
{
await SafeMethod(); // Exception can be caught here
}
catch (Exception ex)
{
Console.WriteLine($"Caught exception: {ex.Message}");
}
}
There are very few scenarios where async void is acceptable, for example:
- Event Handlers: Event handlers are inherently void, so async void is the only option (e.g., Button.Click in WPF or WinUI3).
private async void Button_Click(object sender, EventArgs e)
{
try
{
await Task.Delay(1000);
throw new Exception("Something went wrong!");
}
catch (Exception ex)
{
Console.WriteLine($"Caught exception: {ex.Message}");
}
}
Avoid .Wait()
and .Result
whenever possible
The .Wait()
method and .Result
property in .NET are blocking calls that wait for a Task
to complete. While they may seem straightforward, they have significant downsides, particularly in certain contexts:
-
On the Main Thread: Using
.Wait()
or.Result
on the main thread can freeze the UI. This happens because both methods block the thread until the task completes, preventing the UI thread from processing other events. -
Deadlocks: Using
.Wait()
or.Result
inappropriately can lead to deadlocks, especially in applications with a synchronization context (e.g., UI applications). A deadlock occurs when the blocked thread is waiting for a task to complete, but the task itself is trying to post back to the same thread to finish execution. Since the thread is blocked, the task cannot complete, resulting in a deadlock.
Task.Run(() => DoWork()).Wait();
await Task.Run(() => DoWork());
Key Differences Between .Result
and .Wait()
Feature | .Result |
.Wait() |
---|---|---|
Return Value | Returns the result of the Task . |
Does not return a value. |
Blocking | Blocks until the task completes. | Blocks until the task completes. |
Deadlock Risk | High in synchronization contexts. | High in synchronization contexts. |
Prefer .GetAwaiter().GetResult()
over .Result
or .Wait()
Using .GetAwaiter().GetResult()
is sometimes recommended as an alternative to .Result
or .Wait()
because it avoids wrapping exceptions in an AggregateException
.
When you use .Result
or .Wait()
on a task that throws an exception, the exception is wrapped in an AggregateException
. This makes it harder to handle the actual exception because you need to unwrap it using InnerException
.
try
{
var result = SomeAsyncMethod().GetAwaiter().GetResult();
}
catch (Exception ex)
{
Console.WriteLine(ex.Message);
}
Try to use .ConfigureAwait(false)
in async methods whenever is possible
By default, when you await
a task in an asynchronous method, the continuation (the code after the await
) tries to resume execution on the original synchronization context or thread where the await
was called. This is particularly important in UI frameworks like WPF or WinUI3, where updates to the UI must happen on the main thread!
When you use ConfigureAwait(false)
, you tell the runtime not to capture the synchronization context. This means the continuation can run on any available thread, rather than being forced to return to the original thread.
When not to use .ConfigureAwait(false)
Avoid using .ConfigureAwait(false)
in methods that need to update the UI. The continuation must run on the main thread to safely interact with UI elements.
Remember, the asynchronous task itself runs independently of the main thread. However, the continuation after the await
may return to the main thread!
public async Task UpdateUIAsync()
{
await Task.Delay(1000);
MyLabel.Text = "Updated!";
}
CancellationToken
should be used in all async methods
A CancellationToken
is a mechanism in .NET that allows you to signal and handle the cancellation of an asynchronous operation. It is part of the System.Threading
namespace and is commonly used in async
methods to provide a way for the caller to cancel the operation if it is no longer needed. It allows you to gracefully cancel long-running or resource-intensive operations.
Best practices using CancellationToken
Always include a CancellationToken
- Add a
CancellationToken
parameter to allasync
methods, even if you don't currently use it. - If your method doesn't require cancellation, you can use
CancellationToken.None
as a default value.
Check for cancellation regularly
For long-running loops or operations, periodically check:
cancellationToken.IsCancellationRequested
Or call
cancellationToken.ThrowIfCancellationRequested()
Avoid swallowing cancellation exceptions
- Do not catch and ignore
OperationCanceledException
. - Always handle it appropriately to ensure the operation is canceled cleanly.
Conclusion!
Asynchronous programming in C# is a powerful tool that can significantly improve the performance and responsiveness of your applications. However, it comes with its own set of challenges and pitfalls. By understanding and following these best practices, you can write more efficient, maintainable, and error-free asynchronous code.
Happy coding! 👨🏻💻