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.
Let’s start.
VIDEO: The Reason to Avoid the Await Keyword in Loops.
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.
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.