Async Await: Unlock the Power of Asynchronous Programming in C#

asynchronous coding

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

  1. async keyword:
    • Used to declare a method as asynchronous.
    • async method always returns a Task, Task<T>, or void.
  2. await keyword:
    • Used to wait for the completion of an asynchronous operation.
    • await can only be used inside methods marked with async.

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

  1. 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.
  2. Initial state:
    • The initial code of the method runs synchronously up to the first await.
  3. 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.
  4. 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:

  1. 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.
  1. 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 the await.

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.

  1. Using async void:
    • Problem: Methods with async void are hard to handle and test because they don’t return a Task that can be awaited.
    • Solution: Always use async Task or async Task<T>. The only exception is event handlers, where async void is necessary.
    • Examples:
// Incorrect Code Example:
public async void ProcessSomethingAsync()
{
    await Task.Delay(5000);
}

// Correct Code Example:
public async Task ProcessSomethingAsync()
{
    await Task.Delay(5000);
}
  1. 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:
// 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");
}
  1. 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:
// 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
}
  1. Blocking on Task.Result or Task.Wait():
    • Problem: Using Task.Result or Task.Wait() to get the result of a task blocks the thread and can lead to deadlocks.
    • Solution: Use await for asynchronous result retrieval.
    • Examples:
// Incorrect Code Example:
public void DoWork()
{
    var result = GetAssetsDataAsync().Result; // Blocking
}

// Correct Code Example:
public async Task DoWorkAsync()
{
    var result = await GetAssetsDataAsync();
}
  1. 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:
// 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.

  1. 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:
public async Task ProcessDataAsync()
{
    try
    {
        await ProcessAssetsAsyncMethod();
    }
    catch (Exception ex)
    {
        Console.WriteLine($"Error: {ex.Message}");
    }
}
  1. 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.
  1. 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.
  1. Use logging:
    • Enable logging in your asynchronous code to track method execution and identify issues. Using libraries like Serilog or NLog will help you easily integrate logging.
    • Example:
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");
    }
}
  1. Write async tests:
    • Writing tests for asynchronous code helps catch issues before they occur in production. Use MSTest, xUnit, or NUnit, which support asynchronous methods.
    • Example:
[TestMethod]
public async Task TestProcessAssetsAsync()
{
    await ProcessAssetsAsync();
    // Assertions to verify the outcome
}
  1. 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:
public async Task LoadDataAsync()
{
    var data = await GetDataAsync().ConfigureAwait(false);
    // Continue execution in any available context
}
  1. 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!

Leave a Reply

Your email address will not be published. Required fields are marked *