C# Async and Await: Advanced Patterns
This document explores advanced patterns and considerations when working with asynchronous programming in C#, building upon the foundational knowledge of async and await.
1. Task-Returning Methods and Exceptions
Understanding how exceptions are propagated through Task objects is crucial for robust asynchronous code. When an asynchronous operation initiated by a Task throws an exception, that exception is captured within the Task itself. When you await this Task, the exception is re-thrown on the awaiting thread.
try-catch blocks around await expressions that might throw exceptions.
using System;
using System.Threading.Tasks;
public class AdvancedAsync
{
public async Task FetchDataAsync()
{
await Task.Delay(100); // Simulate I/O
throw new InvalidOperationException("Failed to fetch data.");
}
public async Task ProcessDataAsync()
{
try
{
string data = await FetchDataAsync();
Console.WriteLine("Data fetched: " + data);
}
catch (InvalidOperationException ex)
{
Console.WriteLine("Error processing data: " + ex.Message);
}
catch (Exception ex)
{
Console.WriteLine("An unexpected error occurred: " + ex.Message);
}
}
}
2. Configuring Task Continuations
The Task.ContinueWith() method allows you to schedule a continuation that runs after a task completes. You can configure its behavior using TaskContinuationOptions, such as specifying when the continuation should run (e.g., only on success, only on failure, etc.) and whether it should run synchronously.
public async Task DoWorkAndLogAsync()
{
Task longRunningTask = Task.Run(() => {
// Simulate long-running operation
System.Threading.Thread.Sleep(2000);
Console.WriteLine("Long running operation completed.");
});
// Schedule a continuation to run only if the task completes successfully
longRunningTask.ContinueWith(task =>
{
if (task.Status == TaskStatus.RanToCompletion)
{
Console.WriteLine("Operation succeeded. Logging success.");
// Log success
}
}, TaskContinuationOptions.OnlyOnRanToCompletion);
// Schedule a continuation to run only if the task faults
longRunningTask.ContinueWith(task =>
{
Console.WriteLine($"Operation failed with exception: {task.Exception?.InnerException?.Message}");
// Log error
}, TaskContinuationOptions.OnlyOnFaulted);
await longRunningTask; // Await the main task to ensure it finishes
}
3. Cancellation Tokens
Cancellation is a critical aspect of managing long-running asynchronous operations. CancellationToken and CancellationTokenSource provide a mechanism to signal cancellation requests to asynchronous operations, allowing them to gracefully stop their work.
CancellationToken as the last parameter to asynchronous methods that support cancellation.
public async Task DownloadFileAsync(string url, string outputPath, CancellationToken cancellationToken)
{
using (var httpClient = new HttpClient())
{
using (var response = await httpClient.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, cancellationToken))
{
response.EnsureSuccessStatusCode();
using (var streamToReadFrom = await response.Content.ReadAsStreamAsync())
using (var streamToWriteTo = new FileStream(outputPath, FileMode.Create, FileAccess.Write, FileShare.None, 8192, true))
{
var buffer = new byte[8192];
long totalRead = 0;
long totalLength = response.Content.Headers.ContentLength ?? -1;
while (true)
{
// Check for cancellation *before* reading
cancellationToken.ThrowIfCancellationRequested();
int bytesRead = await streamToReadFrom.ReadAsync(buffer, 0, buffer.Length, cancellationToken);
if (bytesRead == 0)
{
break; // End of stream
}
await streamToWriteTo.WriteAsync(buffer, 0, bytesRead, cancellationToken);
totalRead += bytesRead;
// Optionally report progress and check cancellation again
// ReportProgress(totalRead, totalLength);
}
}
}
}
}
// Example usage:
public async Task StartDownloadWithCancellation()
{
using (var cts = new CancellationTokenSource())
{
var downloadTask = DownloadFileAsync("http://example.com/largefile.zip", "downloaded.zip", cts.Token);
// Simulate cancelling after a few seconds
await Task.Delay(5000);
Console.WriteLine("Requesting cancellation...");
cts.Cancel();
try
{
await downloadTask;
Console.WriteLine("Download completed successfully.");
}
catch (OperationCanceledException)
{
Console.WriteLine("Download was cancelled.");
}
catch (Exception ex)
{
Console.WriteLine($"Download failed: {ex.Message}");
}
}
}
4. IAsyncEnumerable and Asynchronous Streams
Introduced in C# 8.0, IAsyncEnumerable<T> and the await foreach syntax provide a way to process sequences of data asynchronously, useful for scenarios like streaming large datasets or handling events over time without blocking.
using System.Collections.Generic;
public static class AsyncStreamExample
{
public static async IAsyncEnumerable GenerateNumbersAsync()
{
for (int i = 0; i < 10; i++)
{
await Task.Delay(100); // Simulate async work
yield return i;
}
}
public static async Task ConsumeStreamAsync()
{
Console.WriteLine("Consuming asynchronous stream:");
await foreach (var number in GenerateNumbersAsync())
{
Console.WriteLine($"Received: {number}");
}
Console.WriteLine("Finished consuming stream.");
}
}
5. Synchronization Context and Deadlocks
When dealing with UI applications or ASP.NET (pre-Core), the SynchronizationContext can be a source of deadlocks if not handled carefully. By default, await captures the current context and attempts to resume on it. For library code, it's often recommended to use ConfigureAwait(false) to avoid capturing the context and prevent potential deadlocks.
ConfigureAwait(false) judiciously. It's appropriate for library code that shouldn't assume a specific UI or web context, but should be avoided in UI event handlers or code that *must* run on a specific context.
// In a library class:
public async Task<string> GetDataFromExternalServiceAsync()
{
using (var httpClient = new HttpClient())
{
// ConfigureAwait(false) prevents capturing the calling context,
// thus avoiding potential deadlocks in some scenarios.
var response = await httpClient.GetStringAsync("http://api.example.com/data").ConfigureAwait(false);
return response;
}
}
6. Task-Returning vs. `void` Returning Async Methods
Asynchronous methods should generally return Task or Task<TResult>. Returning void from an async method is discouraged as it makes it impossible to track the completion or exceptions of the operation. The primary exception is for event handlers, where the signature is fixed to void.
7. Using Parallelism with `Parallel.ForEachAsync`
For scenarios where you need to perform multiple asynchronous operations concurrently on a collection, Parallel.ForEachAsync (available from .NET 6) offers a convenient and efficient way to achieve this.
using System.Collections.Generic;
using System.Threading.Tasks;
public class ParallelAsyncExample
{
public static async Task ProcessItemsInParallelAsync(IEnumerable<string> items)
{
await Parallel.ForEachAsync(items, async (item, cancellationToken) =>
{
Console.WriteLine($"Processing item: {item} on thread {System.Threading.Thread.CurrentThread.ManagedThreadId}");
await Task.Delay(500, cancellationToken); // Simulate async work for each item
Console.WriteLine($"Finished processing item: {item}");
});
Console.WriteLine("All items processed in parallel.");
}
}
Mastering these advanced patterns will enable you to write more efficient, responsive, and robust asynchronous applications in C#.