It is essential to carefully consider when to use the await keyword in a for or for-each loop to prevent inefficiency and performance issues in software development. In this article, we will explore scenarios where using the await keyword in a loop is appropriate and when it is not.

To download the source code for this article, you can visit our GitHub repository.

Let’s start.

The Fundamental Operations Occurring in a Loop

A loop iterates through a specific method until it meets a particular condition. Although it is tempting to incorporate asynchronous programming by employing the await keyword, it can become counterproductive if the awaited method can run concurrently, thereby neglecting the inherent nature of the loop. To learn more about asynchronous programming check out our article: How to Execute Multiple Tasks Asynchronously.

Support Code Maze on Patreon to get rid of ads and get the best discounts on our products!
Become a patron at Patreon!

What Happens When the await Keyword is Added Inside a Loop?

By using await  in our loop, we pause the iteration to allow the awaited method to execute before proceeding to the next iteration. Therefore, using await within our loop means we are executing everything within the loop synchronously. 

Let’s take a look at using await in a loop:

public static async Task<List<int>> ResultAsync(int delayMilliseconds = 1000)
{
    var numbers = new List<int> { 10, 20, 30, 40, 50, 60, 70, 80, 90 };

    var result = new List<int>();

    foreach (var number in numbers)
    {
        Console.WriteLine($"Processing {number}");

        await Task.Delay(delayMilliseconds);

        Console.WriteLine($"Processed {number}");

        result.Add(number);
    }
    Console.WriteLine("Done Processing");

    return result;
}

Here we add the await keyword to Task.Delay(). This means that we have to wait for Task.Delay() to complete before finishing the remaining work in the loop and continuing to the next iteration. 

Using await in the loop facilitates the regulated execution of the elements within the loop. Allowing our code to pause while we “await” the completion of some additional long-running task. However, this also means that it may take a long time to completely iterate our collection. 

Now, let’s check out the console output:

Processing 10 
Processed 10 
Processing 20 
Processed 20 
Processing 30 
Processed 30 
Processing 40 
Processed 40 
Processing 50 
Processed 50 
Processing 60 
Processed 60 
Processing 70 
Processed 70 
Processing 80 
Processed 80 
Processing 90 
Processed 90 
Done Processing

Here, we observe that the program processed the numbers precisely in the order we provided them in the list. There was no out-of-order execution that occurred in the processing.

Avoiding await to Make a Loop Asynchronous

Since we know that using await in our loop makes it synchronous, we can delve into how to make our for-each loop asynchronous. This also practices parallelism by using Task.WhenAll(). In simple terms, Task.WhenAll() allows us to await multiple methods simultaneously and returns a completed Task when they are all completed.

Let’s explore the use of Task.WhenAll() to improve throughput while using loops:

public static async Task<List<Task>> ResultAsync(int delayMilliseconds = 1000)
{
    var numbers = new List<int> { 10, 20, 30, 40, 50, 60, 70, 80, 90 };

    var result = new List<Task>();

    foreach (int number in numbers)
    {            
        result.Add(ProcessNumberAsync(number, delayMilliseconds));
    }

    await Task.WhenAll(result);

    Console.WriteLine("Done Processing");

    return result;
}

public static async Task ProcessNumberAsync(int number, int delayMilliseconds)
{
    Console.WriteLine($"Processing {number}");
    // Simulate an asynchronous operation
    await Task.Delay(delayMilliseconds);

    Console.WriteLine($"Processed {number}");
}

Here we are not adding the await keyword to the for-each loop. We simply iterate over each of the numbers, launching a ProcessNumberAsync() task for each of them. We add each of these tasks to a list and then pass the list into Task.WhenAll(). Outside the for-each loop, we simply await the Task.WhenAll() to complete before returning the result.

When comparing both of our sample methods, execution without using await in the loop is faster.

Another thing we should take note of is that, unlike our synchronous for-each, in our Task.WhenAll() the execution of the ProcessNumberAsync() tasks, does not process the values in any guaranteed order as each Task computes asynchronously. 

Again, let’s check out the console output:

Processing 10
Processing 20
Processing 30
Processing 40
Processing 50
Processing 60
Processing 70
Processing 80
Processing 90
Processed 90
Processed 80
Processed 50
Processed 40
Processed 30
Processed 20
Processed 70
Processed 60
Processed 10
Done Processing

As expected, the result shows that the numbers are processed in no specific order. They were printed differently from how they were arranged in the list. Also, we did not have to wait for one number to finish processing before moving to the next.

When to Trade Off One for the Other

Task.WhenAll()  makes it hard to track execution orders as operations may be done in parallel. On the other hand, using await within the loop enables execution tracking by waiting for completion before moving to the next iteration.

Furthermore, managing exceptions becomes more challenging when thrown within Task.WhenAll() because if any of the awaited tasks throws an exception, it will propagate to the awaiting thread. When we await each task within the for loop, we have a clearer view of the exceptional condition. The tradeoff, however, may result in decreased performance due to enforcing linear processing of each step in our loop.

Task.WhenAll() is useful when multiple tasks operate independently of each other. This means the execution of a task is not dependent on the result of the previous task.

The choice between the two approaches depends on the specific goals we aim to achieve.

Conclusion

We employ the await keyword in a for-each loop when the method requires a specific execution sequence. Conversely, use Task.WhenAll() when the order of execution is not crucial, aiming to enhance performance.

Liked it? Take a second to support Code Maze on Patreon and get the ad free reading experience!
Become a patron at Patreon!