asynchronous programming
delegate pattern
software development
event-driven programming
concurrency

Async call for delegate in cycle

Master System Design with Codemia

Enhance your system design skills with over 120 practice problems, detailed solutions, and hands-on exercises.

Introduction

Invoking async delegates inside loops in C# requires careful handling of closures, concurrency, and exception propagation. The common pattern is iterating over a collection and calling an async delegate (like Func<T, Task>) for each item. The main decision is whether to run delegates sequentially (one at a time) or concurrently (all at once). Getting this wrong causes bugs like captured loop variables, swallowed exceptions, and race conditions. This article covers both patterns with correct implementations.

The Problem: Async Delegate in a Loop

csharp
1public async Task ProcessItems(
2    IEnumerable<string> items,
3    Func<string, Task> processAsync)
4{
5    // Sequential — each item waits for the previous one
6    foreach (var item in items)
7    {
8        await processAsync(item);
9    }
10}

This is the simplest correct pattern. foreach with await runs each delegate invocation sequentially. The loop does not advance until the current Task completes.

Sequential Execution

csharp
1public async Task ProcessSequentially(
2    IList<Order> orders,
3    Func<Order, Task> handleOrder)
4{
5    foreach (var order in orders)
6    {
7        await handleOrder(order);
8        Console.WriteLine($"Processed order {order.Id}");
9    }
10    Console.WriteLine("All orders processed");
11}
12
13// Usage
14await ProcessSequentially(orders, async order =>
15{
16    await _repository.SaveAsync(order);
17    await _notificationService.SendAsync(order.CustomerId);
18});

Sequential execution is correct when order matters (database inserts with dependencies) or when the downstream service cannot handle concurrent requests.

Concurrent Execution with Task.WhenAll

csharp
1public async Task ProcessConcurrently(
2    IList<Order> orders,
3    Func<Order, Task> handleOrder)
4{
5    var tasks = orders.Select(order => handleOrder(order));
6    await Task.WhenAll(tasks);
7    Console.WriteLine("All orders processed");
8}
9
10// With results
11public async Task<IList<Result>> ProcessWithResults(
12    IList<Order> orders,
13    Func<Order, Task<Result>> handleOrder)
14{
15    var tasks = orders.Select(order => handleOrder(order)).ToList();
16    var results = await Task.WhenAll(tasks);
17    return results.ToList();
18}

Task.WhenAll starts all delegates immediately and waits for all to complete. This is faster when operations are independent (API calls, file uploads).

Controlling Concurrency with SemaphoreSlim

csharp
1public async Task ProcessWithThrottle(
2    IList<string> urls,
3    Func<string, Task> downloadAsync,
4    int maxConcurrency = 5)
5{
6    var semaphore = new SemaphoreSlim(maxConcurrency);
7
8    var tasks = urls.Select(async url =>
9    {
10        await semaphore.WaitAsync();
11        try
12        {
13            await downloadAsync(url);
14        }
15        finally
16        {
17            semaphore.Release();
18        }
19    });
20
21    await Task.WhenAll(tasks);
22}
23
24// Usage — at most 5 concurrent downloads
25await ProcessWithThrottle(urls, async url =>
26{
27    using var client = new HttpClient();
28    var content = await client.GetStringAsync(url);
29    await File.WriteAllTextAsync($"{Guid.NewGuid()}.html", content);
30}, maxConcurrency: 5);

SemaphoreSlim limits how many delegates run at the same time, preventing resource exhaustion when processing large collections.

Closure Bug with Loop Variables

csharp
1// BUG in older C# (pre-C# 5) with for loops
2var tasks = new List<Task>();
3for (int i = 0; i < items.Count; i++)
4{
5    // BUG: i is captured by reference — all tasks see the final value
6    tasks.Add(processAsync(items[i]));  // This is fine — items[i] evaluated immediately
7
8    // BUG example with a closure capturing i:
9    tasks.Add(Task.Run(async () =>
10    {
11        await Task.Delay(100);
12        Console.WriteLine(i);  // Prints items.Count for all iterations
13    }));
14}
15
16// Fix: capture in a local variable
17for (int i = 0; i < items.Count; i++)
18{
19    var index = i;  // Captured per iteration
20    tasks.Add(Task.Run(async () =>
21    {
22        await Task.Delay(100);
23        Console.WriteLine(index);  // Correct value
24    }));
25}
26
27// Best: use foreach — loop variable captured correctly in C# 5+
28foreach (var item in items)
29{
30    tasks.Add(processAsync(item));  // item captured correctly
31}

In C# 5+, foreach captures the loop variable per iteration. With for loops, the index variable is shared across iterations and must be copied to a local variable.

Exception Handling

csharp
1// Sequential — exceptions propagate naturally
2public async Task ProcessWithErrorHandling(
3    IList<Order> orders,
4    Func<Order, Task> handleOrder)
5{
6    var errors = new List<Exception>();
7
8    foreach (var order in orders)
9    {
10        try
11        {
12            await handleOrder(order);
13        }
14        catch (Exception ex)
15        {
16            errors.Add(ex);
17            // Continue processing remaining orders
18        }
19    }
20
21    if (errors.Any())
22    {
23        throw new AggregateException("Some orders failed", errors);
24    }
25}
26
27// Concurrent — Task.WhenAll collects all exceptions
28public async Task ProcessConcurrentWithErrors(
29    IList<Order> orders,
30    Func<Order, Task> handleOrder)
31{
32    var tasks = orders.Select(order => handleOrder(order)).ToList();
33
34    try
35    {
36        await Task.WhenAll(tasks);
37    }
38    catch
39    {
40        // Task.WhenAll throws only the first exception
41        // Check each task for individual exceptions
42        var failures = tasks
43            .Where(t => t.IsFaulted)
44            .Select(t => t.Exception!.InnerException!);
45
46        throw new AggregateException("Some orders failed", failures);
47    }
48}

Task.WhenAll only throws the first exception. Inspect individual Task.Exception properties to get all failures.

Common Pitfalls

  • Fire-and-forget in loops: Calling processAsync(item) without await in a foreach loop starts all tasks but does not wait for any. Exceptions are silently lost and the method returns before processing finishes.
  • Capturing loop variable by reference: In for loops, the index i is shared across iterations. Closures that capture i see the final value unless you copy to a local variable.
  • Task.WhenAll swallowing exceptions: await Task.WhenAll(tasks) throws only the first exception. If multiple tasks fail, you must inspect each task's Exception property to find all errors.
  • Unbounded concurrency: Task.WhenAll with thousands of tasks can exhaust thread pool, connections, or memory. Use SemaphoreSlim to cap concurrency when processing large collections.
  • Mixing sync and async delegates: If the delegate is Action<T> instead of Func<T, Task>, async lambdas become async void, which cannot be awaited and crash the process on unhandled exceptions.

Summary

  • Use foreach with await for sequential async delegate execution
  • Use Task.WhenAll(items.Select(x => delegateAsync(x))) for concurrent execution
  • Limit concurrency with SemaphoreSlim to prevent resource exhaustion
  • Always use Func<T, Task> (not Action<T>) for async delegates to enable proper awaiting
  • Copy for loop variables to local variables to avoid closure capture bugs
  • Inspect individual Task.Exception properties after Task.WhenAll to find all failures

Course illustration
Course illustration

All Rights Reserved.