Post

C# Advanced Async/Await Patterns and Best Practices

Introduction to Async/Await in C#

The async/await pattern in C# revolutionized asynchronous programming by making asynchronous code nearly as simple to write and maintain as synchronous code. Introduced in C# 5.0, this feature enables developers to write non-blocking code that maintains readability and reduces callback complexity.

This guide explores advanced async/await patterns, common pitfalls, performance considerations, and best practices for building scalable, responsive applications.

Understanding Async/Await Fundamentals

What Happens Under the Hood

When you mark a method with the async keyword and use await, the compiler transforms your code into a state machine. This state machine manages the execution flow, allowing the method to return control to the caller while waiting for an operation to complete.

1
2
3
4
5
6
7
// Simple async method
public async Task<string> GetDataAsync()
{
    // This doesn't block the calling thread
    var result = await httpClient.GetStringAsync("https://api.example.com/data");
    return result;
}

Task vs ValueTask

Understanding when to use Task versus ValueTask is crucial for performance optimization.

Task:

  • Reference type allocated on the heap
  • Suitable for most scenarios
  • Can be awaited multiple times
  • Use when the operation is truly asynchronous
1
2
3
4
5
public async Task<int> CalculateAsync()
{
    await Task.Delay(100);
    return 42;
}

ValueTask:

  • Struct type that can avoid heap allocations
  • Best for operations that often complete synchronously
  • Cannot be awaited multiple times
  • Use for hot paths with caching
1
2
3
4
5
6
7
8
9
10
11
12
13
public async ValueTask<int> GetCachedValueAsync(string key)
{
    if (_cache.TryGetValue(key, out int value))
    {
        // Synchronous path - no allocation
        return value;
    }
    
    // Asynchronous path
    value = await LoadFromDatabaseAsync(key);
    _cache[key] = value;
    return value;
}

Advanced Async Patterns

1. ConfigureAwait(false) Pattern

The ConfigureAwait(false) pattern is essential for library code to avoid capturing the synchronization context unnecessarily.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public async Task ProcessDataAsync()
{
    // Library code - avoid capturing context
    var data = await GetDataAsync().ConfigureAwait(false);
    
    // More processing without needing the original context
    var processed = await ProcessAsync(data).ConfigureAwait(false);
    
    return processed;
}

// UI code - keep synchronization context
public async Task UpdateUIAsync()
{
    var data = await GetDataAsync(); // ConfigureAwait(true) is default
    
    // This needs to run on the UI thread
    textBox.Text = data;
}

When to use ConfigureAwait(false):

  • In library code that doesn’t interact with UI
  • In ASP.NET Core (no synchronization context)
  • To improve performance by avoiding context switches
  • In the middle of long async chains

When NOT to use ConfigureAwait(false):

  • In UI applications when updating UI elements
  • When you need thread affinity
  • When accessing thread-local storage

2. Parallel Async Operations

Running multiple async operations in parallel can significantly improve performance.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// Sequential execution - slow
public async Task<CombinedResult> GetDataSequentialAsync()
{
    var user = await GetUserAsync();
    var orders = await GetOrdersAsync();
    var products = await GetProductsAsync();
    
    return new CombinedResult(user, orders, products);
}

// Parallel execution - fast
public async Task<CombinedResult> GetDataParallelAsync()
{
    var userTask = GetUserAsync();
    var ordersTask = GetOrdersAsync();
    var productsTask = GetProductsAsync();
    
    await Task.WhenAll(userTask, ordersTask, productsTask);
    
    return new CombinedResult(
        userTask.Result, 
        ordersTask.Result, 
        productsTask.Result
    );
}

// Even better - using deconstruction
public async Task<CombinedResult> GetDataParallelBetterAsync()
{
    var (user, orders, products) = await (
        GetUserAsync(),
        GetOrdersAsync(),
        GetProductsAsync()
    );
    
    return new CombinedResult(user, orders, products);
}

3. Task.WhenAny for Racing Operations

Use Task.WhenAny when you want the first result from multiple operations.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public async Task<string> GetDataFromMultipleSourcesAsync()
{
    var primaryTask = GetFromPrimaryAsync();
    var backupTask = GetFromBackupAsync();
    var cacheTask = GetFromCacheAsync();
    
    var completedTask = await Task.WhenAny(primaryTask, backupTask, cacheTask);
    return await completedTask;
}

// With timeout
public async Task<string> GetDataWithTimeoutAsync(TimeSpan timeout)
{
    var dataTask = GetDataAsync();
    var timeoutTask = Task.Delay(timeout);
    
    var completedTask = await Task.WhenAny(dataTask, timeoutTask);
    
    if (completedTask == timeoutTask)
    {
        throw new TimeoutException("Operation timed out");
    }
    
    return await dataTask;
}

4. Async Lazy Initialization

Implement lazy initialization in async scenarios using Lazy<Task<T>>.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class AsyncLazyService
{
    private readonly Lazy<Task<ExpensiveResource>> _resource;
    
    public AsyncLazyService()
    {
        _resource = new Lazy<Task<ExpensiveResource>>(
            () => InitializeResourceAsync()
        );
    }
    
    private async Task<ExpensiveResource> InitializeResourceAsync()
    {
        await Task.Delay(1000); // Simulate expensive initialization
        return new ExpensiveResource();
    }
    
    public Task<ExpensiveResource> GetResourceAsync()
    {
        return _resource.Value;
    }
}

5. Async Producer-Consumer Pattern

Implement efficient producer-consumer patterns using channels.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
using System.Threading.Channels;

public class AsyncProducerConsumer
{
    private readonly Channel<WorkItem> _channel;
    
    public AsyncProducerConsumer()
    {
        _channel = Channel.CreateBounded<WorkItem>(
            new BoundedChannelOptions(100)
            {
                FullMode = BoundedChannelFullMode.Wait
            }
        );
    }
    
    // Producer
    public async Task ProduceAsync(CancellationToken cancellationToken)
    {
        await foreach (var item in GenerateWorkItemsAsync(cancellationToken))
        {
            await _channel.Writer.WriteAsync(item, cancellationToken);
        }
        
        _channel.Writer.Complete();
    }
    
    // Consumer
    public async Task ConsumeAsync(CancellationToken cancellationToken)
    {
        await foreach (var item in _channel.Reader.ReadAllAsync(cancellationToken))
        {
            await ProcessItemAsync(item);
        }
    }
    
    private async IAsyncEnumerable<WorkItem> GenerateWorkItemsAsync(
        [EnumeratorCancellation] CancellationToken cancellationToken)
    {
        for (int i = 0; i < 100; i++)
        {
            if (cancellationToken.IsCancellationRequested)
                yield break;
                
            await Task.Delay(10, cancellationToken);
            yield return new WorkItem { Id = i };
        }
    }
    
    private async Task ProcessItemAsync(WorkItem item)
    {
        await Task.Delay(50);
        Console.WriteLine($"Processed item {item.Id}");
    }
}

Common Pitfalls and How to Avoid Them

1. Async Void Methods

Never use async void except for event handlers. Use async Task instead.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// BAD - async void
public async void ProcessDataBad()
{
    await GetDataAsync();
    // Exceptions are swallowed!
}

// GOOD - async Task
public async Task ProcessDataGood()
{
    await GetDataAsync();
    // Exceptions can be caught
}

// EXCEPTION - event handlers
private async void Button_Click(object sender, EventArgs e)
{
    try
    {
        await ProcessDataAsync();
    }
    catch (Exception ex)
    {
        // Handle exception in event handler
        MessageBox.Show(ex.Message);
    }
}

2. Blocking on Async Code

Never block on async code using .Result or .Wait() as it can cause deadlocks.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// BAD - can cause deadlock
public void ProcessData()
{
    var result = GetDataAsync().Result; // Deadlock risk!
    ProcessResult(result);
}

// GOOD - async all the way
public async Task ProcessDataAsync()
{
    var result = await GetDataAsync();
    ProcessResult(result);
}

// If you must block (rare cases)
public void ProcessDataSync()
{
    var result = Task.Run(async () => await GetDataAsync()).GetAwaiter().GetResult();
    ProcessResult(result);
}

3. Not Using CancellationToken

Always support cancellation in async operations.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// BAD - no cancellation support
public async Task ProcessManyItemsAsync(List<Item> items)
{
    foreach (var item in items)
    {
        await ProcessItemAsync(item);
    }
}

// GOOD - supports cancellation
public async Task ProcessManyItemsAsync(
    List<Item> items, 
    CancellationToken cancellationToken)
{
    foreach (var item in items)
    {
        cancellationToken.ThrowIfCancellationRequested();
        await ProcessItemAsync(item, cancellationToken);
    }
}

4. Not Handling Exceptions Properly

Understand how exceptions propagate in async code.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
public async Task ProcessWithExceptionHandlingAsync()
{
    try
    {
        var tasks = new List<Task>
        {
            ProcessAsync(1),
            ProcessAsync(2),
            ProcessAsync(3)
        };
        
        await Task.WhenAll(tasks);
    }
    catch (Exception ex)
    {
        // Only gets the first exception!
        Console.WriteLine($"Error: {ex.Message}");
    }
}

// Better - handle all exceptions
public async Task ProcessWithAllExceptionHandlingAsync()
{
    var tasks = new List<Task>
    {
        ProcessAsync(1),
        ProcessAsync(2),
        ProcessAsync(3)
    };
    
    try
    {
        await Task.WhenAll(tasks);
    }
    catch
    {
        // Get all exceptions from the aggregate
        var exceptions = tasks
            .Where(t => t.IsFaulted)
            .SelectMany(t => t.Exception.InnerExceptions);
            
        foreach (var ex in exceptions)
        {
            Console.WriteLine($"Error: {ex.Message}");
        }
    }
}

Performance Optimization Techniques

1. Minimize Allocations with ValueTask

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class CachedDataService
{
    private readonly Dictionary<string, Data> _cache = new();
    
    // Returns ValueTask to avoid allocation when cached
    public ValueTask<Data> GetDataAsync(string key)
    {
        if (_cache.TryGetValue(key, out var data))
        {
            return new ValueTask<Data>(data);
        }
        
        return new ValueTask<Data>(LoadDataAsync(key));
    }
    
    private async Task<Data> LoadDataAsync(string key)
    {
        var data = await _dataSource.GetAsync(key);
        _cache[key] = data;
        return data;
    }
}

2. Pool Task Continuations

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class TaskPoolExample
{
    private static readonly TaskCompletionSource<bool> _completedTask = 
        new TaskCompletionSource<bool>();
    
    static TaskPoolExample()
    {
        _completedTask.SetResult(true);
    }
    
    public Task DoWorkAsync()
    {
        if (IsWorkNeeded())
        {
            return PerformWorkAsync();
        }
        
        // Return cached completed task
        return _completedTask.Task;
    }
}

3. Use IAsyncEnumerable for Streaming

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public async IAsyncEnumerable<DataItem> StreamDataAsync(
    [EnumeratorCancellation] CancellationToken cancellationToken = default)
{
    await using var connection = await OpenConnectionAsync();
    await using var reader = await ExecuteReaderAsync(connection);
    
    while (await reader.ReadAsync(cancellationToken))
    {
        yield return new DataItem
        {
            Id = reader.GetInt32(0),
            Name = reader.GetString(1)
        };
    }
}

// Consumer
public async Task ProcessStreamAsync()
{
    await foreach (var item in StreamDataAsync())
    {
        await ProcessItemAsync(item);
    }
}

Best Practices Summary

1. General Guidelines

  • Use async Task instead of async void (except event handlers)
  • Always propagate async all the way up (avoid blocking)
  • Use ConfigureAwait(false) in library code
  • Support cancellation with CancellationToken
  • Use ValueTask<T> for hot paths with frequent synchronous completions

2. Exception Handling

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public async Task<Result> SafeOperationAsync()
{
    try
    {
        var data = await GetDataAsync();
        return Result.Success(data);
    }
    catch (HttpRequestException ex)
    {
        _logger.LogError(ex, "Network error occurred");
        return Result.Failure("Network error");
    }
    catch (Exception ex)
    {
        _logger.LogError(ex, "Unexpected error occurred");
        return Result.Failure("Unexpected error");
    }
}

3. Timeout Implementation

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public async Task<T> WithTimeoutAsync<T>(
    Task<T> task, 
    TimeSpan timeout, 
    CancellationToken cancellationToken = default)
{
    using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
    cts.CancelAfter(timeout);
    
    try
    {
        return await task.WaitAsync(cts.Token); // .NET 6+
    }
    catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
    {
        throw new TimeoutException($"Operation timed out after {timeout}");
    }
}

4. Retry Logic

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public async Task<T> RetryAsync<T>(
    Func<Task<T>> operation,
    int maxRetries = 3,
    TimeSpan? delay = null)
{
    delay ??= TimeSpan.FromSeconds(1);
    
    for (int i = 0; i < maxRetries; i++)
    {
        try
        {
            return await operation();
        }
        catch (Exception ex) when (i < maxRetries - 1)
        {
            _logger.LogWarning(ex, $"Attempt {i + 1} failed, retrying...");
            await Task.Delay(delay.Value);
        }
    }
    
    return await operation(); // Last attempt without catch
}

5. Async Initialization Pattern

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
public class AsyncInitializedService : IAsyncDisposable
{
    private readonly Task _initializationTask;
    private ExpensiveResource _resource;
    
    public AsyncInitializedService()
    {
        _initializationTask = InitializeAsync();
    }
    
    private async Task InitializeAsync()
    {
        _resource = await ExpensiveResource.CreateAsync();
    }
    
    public async Task<Data> GetDataAsync()
    {
        await _initializationTask;
        return await _resource.GetDataAsync();
    }
    
    public async ValueTask DisposeAsync()
    {
        if (_resource != null)
        {
            await _resource.DisposeAsync();
        }
    }
}

Testing Async Code

Unit Testing Async Methods

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
[TestClass]
public class AsyncServiceTests
{
    [TestMethod]
    public async Task GetDataAsync_ReturnsExpectedData()
    {
        // Arrange
        var service = new DataService();
        
        // Act
        var result = await service.GetDataAsync();
        
        // Assert
        Assert.IsNotNull(result);
        Assert.AreEqual("expected", result.Value);
    }
    
    [TestMethod]
    public async Task GetDataAsync_WithCancellation_ThrowsOperationCanceledException()
    {
        // Arrange
        var service = new DataService();
        var cts = new CancellationTokenSource();
        cts.Cancel();
        
        // Act & Assert
        await Assert.ThrowsExceptionAsync<OperationCanceledException>(
            () => service.GetDataAsync(cts.Token)
        );
    }
}

Mocking Async Dependencies

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public interface IDataRepository
{
    Task<Data> GetDataAsync(int id);
}

[TestClass]
public class ServiceTests
{
    [TestMethod]
    public async Task ProcessData_CallsRepository()
    {
        // Arrange
        var mockRepo = new Mock<IDataRepository>();
        mockRepo.Setup(r => r.GetDataAsync(It.IsAny<int>()))
                .ReturnsAsync(new Data { Id = 1 });
                
        var service = new DataService(mockRepo.Object);
        
        // Act
        var result = await service.ProcessDataAsync(1);
        
        // Assert
        mockRepo.Verify(r => r.GetDataAsync(1), Times.Once);
    }
}

Conclusion

Mastering async/await patterns in C# is essential for building modern, scalable applications. Key takeaways include:

  • Understand the difference between Task and ValueTask
  • Use ConfigureAwait(false) appropriately in library code
  • Always support cancellation with CancellationToken
  • Avoid async void methods except for event handlers
  • Never block on async code
  • Use parallel operations with Task.WhenAll for better performance
  • Implement proper exception handling for all async operations
  • Test async code thoroughly with appropriate mocking

By following these patterns and best practices, you can write efficient, maintainable asynchronous code that scales well and provides excellent user experience.

References

This post is licensed under CC BY 4.0 by the author.