Notes on Using Parallel.ForEachAsync() in .NET 10
If you google this term the search results aren’t very helpful. Scott Hanselman’s blog post from 2021 is more useful than the Microsoft Learn docs or Stack Overflow posts or even the paywalled Medium blogs. But it feels dated and after reading it, I remained confused on how to convert my foreach loop with an HTTP call inside into an equivalent Parallel.ForEachAsync() implementation. This goal of this post is to keep myself from having to Google Parellel.ForEachAsync() yet again.
Let’s start with a traditional for each loop in C# written in .NET 10 and C# 14.
We’ve got a list of phone numbers, and we want to get more information on them from a third-party vendor’s API endpoint. We’ll start with this list of phone numbers, in this example an array of objects called “numbers”. Then we’ll create a place to keep the results of our queries, in this example a list named “results”.
Finally, we have a simple foreach loop that iterates over every phone number and calls an async function that makes an HTTP request and returns an object with more info on that number. We dump the result of this request into our list of results.
This method is great because it’s simple to understand. Input data, output data, and a loop that does some async work in the middle. It also good choice for dealing with flaky APIs because it will only submit queries at the same rate that the API can manage to respond to them. This works because while we are awaiting the result of the async function, the foreach loop is blocked from continuing to the next iteration. Thus, we only have at most one API request currently executing from this loop.
Now that we have our foreach loop and we have a shared understanding of it, let’s convert it to the Parallel.ForEachAsync alternative.
Thoughtful Thread-safety
We need to begin by updating our output data structure named “results” in the foreach loop example. We have defined it as a list because we want to use the .Add() method and a list is a simple and convenient way to get that. You can continue using a list as the output data structure for a Parallel.ForEachAsync() loop without a warning or complier error, but lists in C# are not thread safe. If you do this, the number of elements in the output list will vary, even if the number of elements in the input array does not vary.
In my experience it can be hard to notice this problem and debug this behavior unless you write a test that runs multiple times and you compare the number of elements in the output array between each of the runs. The counts should all be the same, if they are not, then you have a problem. Now you can see clearly why we must replace our list with a thread-safe data structure.
Given that we can’t keep using our output list what should we convert it to? Microsoft Learn has a page on Thread-safe collections that’s pretty good.
For this loop I think the ConcurrentBag is the right choice because we only need two things: an .Add() method and a way to dump the final contents out to an array. We don’t need a dictionary, as we’re not doing any lookups based on the results and we also don’t need a stack or queue as the order doesn’t matter. ConcurrentBag offers us what we need without paying for the things we don’t, so it’s the right fit. It is worth noting at this point that ConcurrentBag is a more expensive data structure than a list because of the thread-safe guarantees that it offers us. But we need correctness, so using more memory is a small price to pay.
With the input and output out of the way let’s look at the meat of converting to Parallel.ForEachAsync(). First off, because this function is async, you’ll need to await it. So please don’t forget to place an await before it. Luckily C# will mark the whole function with a warning if you forget it, so it’s hard to miss.
The first parameter will be your input data. Then you’ll use an async lambda where the first parameter is the name of the element you’re iterating over from your input data, and the second parameter is a cancellation token which you can pass down to any async calls you make in your function.
Cancellation tokens are a way for a function higher up in the call chain to signal to an async methods lower down that they should stop, because the Task has been cancelled. Why exactly would the Task get cancelled? Well, that depends on your app, but it could be as simple as someone navigating to another web page. Cancellations tokens provide a way to softly stop everything, rather than continuing to run Tasks that will return to nowhere, for no reason, without purpose.
There are also optional parameters you can add here by creating a ParrellelOptions object and dropping it between “number” and “cls”.
This options object gives you three levers, but only the MaxDegreeOfParallelism field is worth setting as changing the CancellationToken or the TaskScheduler isn’t necessary and probably a bad idea unless you have a specific reason to mess with it. MaxDegreesOfParallelism is cool because it allows you to cap how many Tasks this parallel loop will have in executing at any time. By default, this number is set to the number of hardware threads detected by the .NET runtime. You can see this value yourself by looking at Environment.ProcessorCount.
This is almost always the correct behavior, but if you want to set the cap even lower you can use this parameter to do it. It is possible to set the cap higher, but that will consume more memory as more Tasks will be created and may hurt performance from the additional overhead of managing more Tasks. With all of that said, we’re not going to use the ParallelOptions object as the defaults are good and it complicates the code.
With our loop defined we arrive at the hardest part. Copying and pasting the middle of our foreach loop directly into the middle of our Parallel.ForEachAsync loop. There’s no need to change anything if you kept the names of the input, output, and iteration variables the same. It will just work.
If you want to improve things a bit you can pass down your CancellationToken to the async method. Although the method will have to support it, HTTP calls typically do.
And that’s it. We’ve now successfully converted our foreach loop that makes a HTTP request to a Parallel.ForEachAsync loop that executes multiple HTTP requests at the same time. The goal of doing this is to achieve better performance by processing multiple input items at the same time, while knowing that each item takes a different amount of time to process.
Is It Worth It?
Which brings us to the final consideration, performance. It’s worth taking the time to benchmark your starting implementation and your Parallel.ForEachAsync() reimplementation using a test or something like Benchmark.NET. I say this because I have run into multiple scenarios where the basic foreach loop version was in practice faster and more memory efficient than the Parallel.ForEachAsync() version.
This could happen for a variety of reasons, and it’s often not clear from just looking at the code and thinking about it. So rather than guessing, it’s best to benchmark it for hard evidence in the specific context of your app or project. That way you can be sure that the added complexity of using Parallel.ForEachAsync() results in a meaningful performance gain.