Asynchronous programming is a key topic every modern C# developer needs to master. It allows you to create high-performance and responsive applications, especially when dealing with I/O operations such as network requests or file access. In this post, we will explore how the async
and await
keywords work in C# and what happens under the hood.
Basic Concepts
async
keyword:- Used to declare a method as asynchronous.
async
method always returns aTask
,Task<T>
, orvoid
.
await
keyword:- Used to wait for the completion of an asynchronous operation.
await
can only be used inside methods marked withasync
.
Benefits of Asynchronous Programming
Asynchronous programming provides several benefits:
- Improved application responsiveness: Asynchronous operations do not block the main thread, which is especially important for user interfaces.
- Increased performance: Asynchronous methods allow multiple operations to run simultaneously, improving overall application performance.
- Scalability: Asynchronous operations make better use of system resources, especially when dealing with a large number of concurrent tasks.
Examples of Using async
and await
Let’s look at a few examples of using asynchronous methods:
1. Asynchronous Database Request
public async Task<List<Asset>> GetAssetsAsync()
{
using (var context = new AppDbContext())
{
return await context.Assets.ToListAsync();
}
}
2. Asynchronous File Operations
public async Task<string> ReadFileAsync(string filePath)
{
using (StreamReader reader = new StreamReader(filePath))
{
return await reader.ReadToEndAsync();
}
}
3. Asynchronous Processing of Large Data
public async Task ProcessLargeDataAsync()
{
List<int> assets = await GetAssetsAsync();
var processedAssets = await Task.Run(() => ProcessAssetsData(assets));
await SaveAssetsAsync(processedAssets);
}
How It Works Under the Hood
When the C# compiler processes a method marked with async
, it transforms it into a state machine. This allows the asynchronous method to pause and resume execution while preserving the context between operations.
Creating the State Machine
- Creating the state machine:
- The compiler creates a state machine class that implements the
IAsyncStateMachine
interface. This class manages the states of the asynchronous method.
- The compiler creates a state machine class that implements the
- Initial state:
- The initial code of the method runs synchronously up to the first
await
.
- The initial code of the method runs synchronously up to the first
- State transitions:
- When execution reaches an
await
, the current state is saved, and the method returns control to the caller, but the method itself continues to run in the background.
- When execution reaches an
- Resuming execution:
- When the asynchronous operation completes, the state machine resumes execution from where it left off.
Example of State Machine Transformation
Let’s take a simple example and break down what happens under the hood:
public async Task<int> FetchDataAsync()
{
HttpClient client = new HttpClient();
string result = await client.GetStringAsync("https://amarozka.dev");
return result.Length;
}
State Machine Transformation:
- Initial Code and Creating the State Machine
public Task<int> FetchDataAsync()
{
FetchDataAsyncStateMachine stateMachine = new FetchDataAsyncStateMachine();
stateMachine.builder = AsyncTaskMethodBuilder<int>.Create();
stateMachine.state = -1;
stateMachine.builder.Start(ref stateMachine);
return stateMachine.builder.Task;
}
- An instance of
FetchDataAsyncStateMachine
is created. - The
AsyncTaskMethodBuilder
is initialized to manage the asynchronous execution and result of the task. - The state machine is started using the
Start
method.
- State Machine Class
private struct FetchDataAsyncStateMachine : IAsyncStateMachine
{
public int state;
public AsyncTaskMethodBuilder<int> builder;
private HttpClient client;
private TaskAwaiter<string> awaiter;
public void MoveNext()
{
int result;
try
{
if (state == -1)
{
client = new HttpClient();
var task = client.GetStringAsync("https://amarozka.dev");
awaiter = task.GetAwaiter();
if (!awaiter.IsCompleted)
{
state = 0;
builder.AwaitUnsafeOnCompleted(ref awaiter, ref this);
return;
}
}
else
{
awaiter = builder.TaskAwaiter;
}
string resultString = awaiter.GetResult();
result = resultString.Length;
}
catch (Exception ex)
{
builder.SetException(ex);
return;
}
builder.SetResult(result);
}
public void SetStateMachine(IAsyncStateMachine stateMachine) {}
}
- The
state
field keeps track of the current execution state. - The
MoveNext
method implements the main logic of the state machine: - If the state is -1, a
HttpClient
is created, and an asynchronous operation starts. - If the asynchronous operation is not complete (
!awaiter.IsCompleted
), the current state (0) is saved, and execution returns to the caller. - When the asynchronous operation completes,
MoveNext
is called again, continuing execution from the point after theawait
.
Context Switching
When execution reaches await
, the current execution context (e.g., the current thread) is saved. The builder.AwaitUnsafeOnCompleted
method is used to resume execution after the asynchronous operation completes:
builder.AwaitUnsafeOnCompleted(ref awaiter, ref this);
Completing the Asynchronous Method
After the asynchronous operation completes, execution returns to the state machine, and the method continues from where it was paused:
string resultString = awaiter.GetResult();
result = resultString.Length;
builder.SetResult(result);
State Machine Visualization
State -1:
[Entry Point] -> client = new HttpClient();
var task = client.GetStringAsync("https://amarozka.dev");
awaiter = task.GetAwaiter();
if (!awaiter.IsCompleted) {
state = 0;
builder.AwaitUnsafeOnCompleted(ref awaiter, ref this);
return;
}
State 0:
[Resume Point] <- awaiter.GetResult();
string resultString = awaiter.GetResult();
result = resultString.Length;
builder.SetResult(result);
Common Mistakes and How to Avoid Them
Asynchronous programming can be challenging for beginners, and there are several common mistakes developers make. Let’s look at these mistakes and how to avoid them.
- Using
async void
:- Problem: Methods with
async void
are hard to handle and test because they don’t return aTask
that can be awaited. - Solution: Always use
async Task
orasync Task<T>
. The only exception is event handlers, whereasync void
is necessary. - Examples:
- Problem: Methods with
// Incorrect Code Example:
public async void ProcessSomethingAsync()
{
await Task.Delay(5000);
}
// Correct Code Example:
public async Task ProcessSomethingAsync()
{
await Task.Delay(5000);
}
- Forgetting
await
:- Problem: If you don’t use
await
, the asynchronous method will start execution, but the main thread won’t wait for it to complete, which can lead to unexpected results. - Solution: Make sure all asynchronous method calls are awaited.
- Examples:
- Problem: If you don’t use
// Incorrect Code Example:
public async Task FetchAssetsAsync()
{
var task = GetAssetsAsync(); // Without await
Console.WriteLine("Got assets");
}
// Correct Code Example:
public async Task FetchAssetsAsync()
{
await GetAssetsAsync();
Console.WriteLine("Got assets");
}
- Incorrect use of
ConfigureAwait(false)
:- Problem: By default,
await
resumes execution in the original context, which can be inefficient. - Solution: Use
ConfigureAwait(false)
for operations not related to UI or synchronization context. - Examples:
- Problem: By default,
// Incorrect Code Example:
public async Task LoadAssetsDataAsync()
{
var data = await GetAssetsDataAsync(); // Resumes in original context
}
// Correct Code Example:
public async Task LoadAssetsDataAsync()
{
var data = await GetAssetsDataAsync().ConfigureAwait(false); // Resumes in any available context
}
- Blocking on
Task.Result
orTask.Wait()
:- Problem: Using
Task.Result
orTask.Wait()
to get the result of a task blocks the thread and can lead to deadlocks. - Solution: Use
await
for asynchronous result retrieval. - Examples:
- Problem: Using
// Incorrect Code Example:
public void DoWork()
{
var result = GetAssetsDataAsync().Result; // Blocking
}
// Correct Code Example:
public async Task DoWorkAsync()
{
var result = await GetAssetsDataAsync();
}
- Improper exception handling in asynchronous methods:
- Problem: Exceptions in asynchronous methods can go unhandled if you don’t use
try-catch
. - Solution: Always wrap asynchronous code in
try-catch
blocks. - Examples:
- Problem: Exceptions in asynchronous methods can go unhandled if you don’t use
// Incorrect Code Example:
public async Task ProcessDataAsync()
{
await ProcessAssesAsync(); // Exception not handled
}
// Correct Code Example:
public async Task ProcessDataAsync()
{
try
{
await ProcessAssesAsync();
}
catch (Exception ex)
{
Console.WriteLine(ex.Message);
}
}
Tips for Debugging Asynchronous Code
Debugging asynchronous code can be challenging due to its nature. Here are some tips and tools to help you effectively debug asynchronous methods.
- Use
try-catch
for exception handling:- Exceptions in asynchronous methods should be handled just like in synchronous methods. Using
try-catch
blocks will help identify and handle errors. - Example:
- Exceptions in asynchronous methods should be handled just like in synchronous methods. Using
public async Task ProcessDataAsync()
{
try
{
await ProcessAssetsAsyncMethod();
}
catch (Exception ex)
{
Console.WriteLine($"Error: {ex.Message}");
}
}
- Use breakpoints:
- In Visual Studio, you can set breakpoints in asynchronous methods just like in synchronous ones. This allows you to pause execution and inspect the state of variables.
- When a breakpoint is hit in asynchronous code, Visual Studio will preserve and show the call stack, which helps you understand how execution reached that point.
- Use the Tasks window:
- Visual Studio has a Tasks window that shows all active tasks. This is useful for tracking the state of all asynchronous operations in your application.
- You can open it from the menu
Debug -> Windows -> Task
.
- Use logging:
- Enable logging in your asynchronous code to track method execution and identify issues. Using libraries like
Serilog
orNLog
will help you easily integrate logging. - Example:
- Enable logging in your asynchronous code to track method execution and identify issues. Using libraries like
public async Task ProcessDataAsync()
{
Logger.Information("Starting assets data processing");
try
{
await ProcessAssetsAsyncMethod();
Logger.Information("Assets Data processing completed successfully");
}
catch (Exception ex)
{
Logger.Error(ex, "Error during data processing");
}
}
- Write async tests:
- Writing tests for asynchronous code helps catch issues before they occur in production. Use
MSTest
,xUnit
, orNUnit
, which support asynchronous methods. - Example:
- Writing tests for asynchronous code helps catch issues before they occur in production. Use
[TestMethod]
public async Task TestProcessAssetsAsync()
{
await ProcessAssetsAsync();
// Assertions to verify the outcome
}
- Understanding and using
SynchronizationContext
:- It’s important to understand how
SynchronizationContext
affects asynchronous execution. By default,await
resumes execution in the original context, which can be inefficient. - Using
ConfigureAwait(false)
helps avoid capturing the context, which is particularly useful in libraries and server applications. - Example:
- It’s important to understand how
public async Task LoadDataAsync()
{
var data = await GetDataAsync().ConfigureAwait(false);
// Continue execution in any available context
}
- Utilize parallel debugging features (Parallel Stacks, Tasks, Watch Windows):
- Visual Studio provides powerful tools for parallel debugging, which can be helpful when working with asynchronous code.
- The Parallel Stacks window shows the relationship between tasks and threads.
- The Watch window allows you to monitor the state of variables across different threads and tasks.
If you have any questions or need more examples, feel free to reach out! Happy Coding!