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
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}");
}
}
}
|
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