DEV Community

Cover image for Asynchronous Programming in C#: Async/Await Patterns
Tapesh Mehta for WireFuture

Posted on

Asynchronous Programming in C#: Async/Await Patterns

Asynchronous programming is nowadays common in modern software development and allows applications to do work without stalling the main thread. The async and await keywords in C# provide a much cleaner method for creating asynchronous code. This article explores the internals of asynchronous programming in C# including error handling and performance optimization.

Understanding Async and Await

The Basics of Async and Await

The async keyword is used to declare a method as asynchronous. This means the method can perform its work asynchronously without blocking the calling thread. The await keyword is used to pause the execution of an async method until the awaited task completes.

public async Task<int> FetchDataAsync()
{
    await Task.Delay(1000); // Simulate an asynchronous operation
    return 42;
}

public async Task MainAsync()
{
    int result = await FetchDataAsync();
    Console.WriteLine(result);
}
Enter fullscreen mode Exit fullscreen mode

In this example, FetchDataAsync is an asynchronous method that simulates a delay before returning a result. The await keyword in MainAsync ensures that the method waits for FetchDataAsync to complete before proceeding.

How Async and Await Work Under the Hood

When the compiler encounters the await keyword, it breaks the method into two parts: the part before the await and the part after it. The part before the await runs synchronously. When the awaited task completes, the part after the await resumes execution.

The compiler generates a state machine to handle this asynchronous behavior. This state machine maintains the state of the method and the context in which it runs. This allows the method to pause and resume without blocking the main thread.

Task vs. ValueTask

In addition to Task, C# 7.0 introduced ValueTask. While Task is a reference type, ValueTask is a value type that can reduce heap allocations, making it more efficient for scenarios where performance is critical, and the operation completes synchronously most of the time.

Hereโ€™s an example using ValueTask:

public async ValueTask<int> FetchDataValueTaskAsync()
{
    await Task.Delay(1000);
    return 42;
}

public async Task MainValueTaskAsync()
{
    int result = await FetchDataValueTaskAsync();
    Console.WriteLine(result);
}

Enter fullscreen mode Exit fullscreen mode

Best Practices for Error Handling

Error handling in asynchronous methods can be challenging. Here are some best practices to ensure robust error handling in your async code:

Use Try-Catch Blocks

Wrap your await statements in try-catch blocks to handle exceptions gracefully.

public async Task MainWithErrorHandlingAsync()
{
    try
    {
        int result = await FetchDataAsync();
        Console.WriteLine(result);
    }
    catch (Exception ex)
    {
        Console.WriteLine($"An error occurred: {ex.Message}");
    }
}
Enter fullscreen mode Exit fullscreen mode

Use Cancellation Tokens

Cancellation tokens allow you to cancel asynchronous operations gracefully. This is particularly useful for long-running tasks.

public async Task<int> FetchDataWithCancellationAsync(CancellationToken cancellationToken)
{
    await Task.Delay(1000, cancellationToken);
    return 42;
}

public async Task MainWithCancellationAsync(CancellationToken cancellationToken)
{
    try
    {
        int result = await FetchDataWithCancellationAsync(cancellationToken);
        Console.WriteLine(result);
    }
    catch (OperationCanceledException)
    {
        Console.WriteLine("Operation was canceled.");
    }
}
Enter fullscreen mode Exit fullscreen mode

Handle AggregateException

When multiple tasks are awaited using Task.WhenAll, exceptions are wrapped in an AggregateException. Use a try-catch block to handle these exceptions.

public async Task MainWithAggregateExceptionHandlingAsync()
{
    var tasks = new List<Task<int>>
    {
        FetchDataAsync(),
        FetchDataAsync()
    };

    try
    {
        int[] results = await Task.WhenAll(tasks);
        Console.WriteLine($"Results: {string.Join(", ", results)}");
    }
    catch (AggregateException ex)
    {
        foreach (var innerException in ex.InnerExceptions)
        {
            Console.WriteLine($"An error occurred: {innerException.Message}");
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Performance Optimization Techniques

Optimizing the performance of asynchronous code involves understanding how the runtime handles async/await and employing best practices to minimize overhead.

Avoid Unnecessary Async

If a method does not perform any asynchronous operations, avoid marking it as async. This can save the overhead of the state machine.

public Task<int> FetchDataWithoutAsync()
{
    return Task.FromResult(42);
}

public async Task MainWithoutUnnecessaryAsync()
{
    int result = await FetchDataWithoutAsync();
    Console.WriteLine(result);
}
Enter fullscreen mode Exit fullscreen mode

Use ConfigureAwait(False)

By default, await captures the current synchronization context and uses it to resume execution. This can lead to performance issues, especially in UI applications. Using ConfigureAwait(false) avoids capturing the context, improving performance.

public async Task<int> FetchDataWithConfigureAwaitAsync()
{
    await Task.Delay(1000).ConfigureAwait(false);
    return 42;
}

public async Task MainWithConfigureAwaitAsync()
{
    int result = await FetchDataWithConfigureAwaitAsync();
    Console.WriteLine(result);
}
Enter fullscreen mode Exit fullscreen mode

Parallelize Independent Tasks

If you have multiple independent tasks, you can run them in parallel to improve performance using Task.WhenAll.

public async Task MainWithParallelTasksAsync()
{
    var tasks = new List<Task<int>>
    {
        FetchDataAsync(),
        FetchDataAsync()
    };

    int[] results = await Task.WhenAll(tasks);
    Console.WriteLine($"Results: {string.Join(", ", results)}");
}
Enter fullscreen mode Exit fullscreen mode

Use ValueTask for Performance-Critical Paths

As mentioned earlier, ValueTask can be more efficient than Task for performance-critical paths where most operations complete synchronously.

public ValueTask<int> FetchDataWithValueTask()
{
    return new ValueTask<int>(42);
}

public async Task MainWithValueTaskAsync()
{
    int result = await FetchDataWithValueTask();
    Console.WriteLine(result);
}
Enter fullscreen mode Exit fullscreen mode

Debugging Asynchronous Code

Debugging asynchronous code can be challenging due to the state machine and context switching. Here are some tips to make debugging easier:

Use Task.Exception Property

Check the Exception property of a task to inspect the exception without causing the application to crash.

public async Task MainWithTaskExceptionAsync()
{
    Task<int> task = FetchDataAsync();

    try
    {
        int result = await task;
        Console.WriteLine(result);
    }
    catch
    {
        if (task.Exception != null)
        {
            Console.WriteLine($"Task failed: {task.Exception.Message}");
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Use Debugger Attributes

Use the DebuggerStepThrough and DebuggerHidden attributes to control how the debugger steps through async methods.

[DebuggerStepThrough]
public async Task<int> FetchDataWithDebuggerStepThroughAsync()
{
    await Task.Delay(1000);
    return 42;
}

[DebuggerHidden]
public async Task<int> FetchDataWithDebuggerHiddenAsync()
{
    await Task.Delay(1000);
    return 42;
}
Enter fullscreen mode Exit fullscreen mode

Use Logging

Implement logging to track the flow of asynchronous operations and capture exceptions.

public async Task<int> FetchDataWithLoggingAsync(ILogger logger)
{
    logger.LogInformation("Fetching data...");
    try
    {
        await Task.Delay(1000);
        logger.LogInformation("Data fetched successfully.");
        return 42;
    }
    catch (Exception ex)
    {
        logger.LogError($"An error occurred: {ex.Message}");
        throw;
    }
}

public async Task MainWithLoggingAsync(ILogger logger)
{
    int result = await FetchDataWithLoggingAsync(logger);
    Console.WriteLine(result);
}
Enter fullscreen mode Exit fullscreen mode

Conclusion

Asynchronous programming with async and await in C# is a powerful tool to write responsive, scalable applications. Asynchronous programming requires knowing async/await internals, following best practices for error handling and optimizing performance. Using techniques like using ValueTask, avoiding unnecessary async and running tasks in parallel can improve the performance and reliability of your applications. Also, debugging and logging practices are important to maintain and troubleshoot asynchronous code.

Top comments (0)