Advanced Asynchronous Programming

Welcome to the advanced section of our asynchronous programming tutorials. This guide delves into more complex scenarios and patterns for handling asynchronous operations efficiently and robustly in your applications.

Prerequisites

Before proceeding, ensure you have a solid understanding of basic asynchronous concepts, including:

  • async and await keywords
  • Task and Task<TResult>
  • ConfigureAwait(false)

If you need a refresher, please review the Networking Basics tutorial which covers these fundamentals.

Understanding the Challenges

While async/await simplifies many asynchronous scenarios, complex applications often present challenges such as:

Patterns for Advanced Async Programming

1. Parallelism with Task.WhenAll

When you need to execute multiple independent asynchronous operations concurrently and wait for all of them to complete, Task.WhenAll is your best friend. It returns a task that completes when all of the supplied tasks have completed.

// Example: Fetching data from multiple sources concurrently
async Task<string[]> FetchMultipleDataAsync()
{
    var task1 = GetDataAsync("source1");
    var task2 = GetDataAsync("source2");
    var task3 = GetDataAsync("source3");

    string[] results = await Task.WhenAll(task1, task2, task3);
    return results;
}

async Task<string> GetDataAsync(string source)
{
    await Task.Delay(TimeSpan.FromSeconds(1)); // Simulate network latency
    return $"Data from {source}";
}

2. Handling Multiple Results and Exceptions with Task.WhenAll

Task.WhenAll collects results in the order the tasks were provided. If any of the tasks throw an exception, Task.WhenAll will aggregate these exceptions into an AggregateException.

async Task ProcessItemsAsync()
{
    var tasks = new List<Task>();
    foreach (var item in _items)
    {
        tasks.Add(ProcessItemAsync(item));
    }

    try
    {
        await Task.WhenAll(tasks);
        Console.WriteLine("All items processed successfully.");
    }
    catch (AggregateException ae)
    {
        Console.WriteLine("One or more exceptions occurred:");
        foreach (var ex in ae.Flatten().InnerExceptions)
        {
            Console.WriteLine($"- {ex.Message}");
        }
    }
}

3. Concurrent Operations with Limited Concurrency using SemaphoreSlim

Sometimes you want to run many tasks concurrently, but you need to limit the number of operations running at any given time to avoid overwhelming resources (e.g., database connections, network sockets).

SemaphoreSlim is a lightweight semaphore that can be used for limiting concurrency.

private readonly SemaphoreSlim _semaphore = new SemaphoreSlim(4); // Allow up to 4 concurrent operations

async Task ProcessItemsWithConcurrencyLimitAsync()
{
    var tasks = new List<Task>();
    foreach (var url in _urls)
    {
        tasks.Add(DownloadUrlAsync(url));
    }

    await Task.WhenAll(tasks);
    Console.WriteLine("All downloads attempted.");
}

async Task DownloadUrlAsync(string url)
{
    await _semaphore.WaitAsync(); // Wait for a slot to become available
    try
    {
        Console.WriteLine($"Starting download from {url}");
        await Task.Delay(TimeSpan.FromSeconds(Random.Shared.Next(1, 5))); // Simulate download
        Console.WriteLine($"Finished download from {url}");
    }
    finally
    {
        _semaphore.Release(); // Release the slot
    }
}

4. Implementing Cancellation with CancellationTokenSource

Cancellation is crucial for long-running operations, especially in UI applications where users might want to abort a process. CancellationTokenSource and CancellationToken provide a standard mechanism.

async Task PerformLongRunningOperationAsync(CancellationToken cancellationToken)
{
    for (int i = 0; i < 100; i++)
    {
        cancellationToken.ThrowIfCancellationRequested(); // Check for cancellation request

        // Simulate work
        await Task.Delay(100, cancellationToken);
        Console.WriteLine($"Working... step {i}");
    }
    Console.WriteLine("Long running operation completed.");
}

// How to use it:
async Task RunWithCancellation()
{
    using var cts = new CancellationTokenSource();
    var longOperationTask = PerformLongRunningOperationAsync(cts.Token);

    // Simulate cancelling after 3 seconds
    await Task.Delay(3000);
    cts.Cancel();

    try
    {
        await longOperationTask;
    }
    catch (OperationCanceledException)
    {
        Console.WriteLine("Operation was cancelled.");
    }
}

Always check for cancellation requests periodically within your asynchronous methods, especially during I/O operations or long loops. Pass the CancellationToken down to any nested asynchronous calls that also support cancellation.

5. Async Streams (IAsyncEnumerable<T>)

For scenarios where you need to process sequences of data asynchronously, especially when the data arrives over time (e.g., from a stream, a network socket, or a database cursor), async streams are the modern solution.

async IAsyncEnumerable<string> StreamDataFromApiAsync()
{
    // Simulate receiving data in chunks
    await Task.Delay(500);
    yield return "Chunk 1";
    await Task.Delay(500);
    yield return "Chunk 2";
    await Task.Delay(500);
    yield return "Chunk 3";
}

// How to consume it:
async Task ConsumeStream()
{
    await foreach (var dataChunk in StreamDataFromApiAsync())
    {
        Console.WriteLine($"Received: {dataChunk}");
    }
    Console.WriteLine("Stream finished.");
}

Best Practices and Considerations