I was scrolling the Mastodon timeline when I noticed a fellow .NET
developer, Bill Seipel, having an unexpected experience with List.ForEach
and async/await
. At first glance, I thought he was modifying
the collection he was iterating over, but then I realized the issue was much more subtle.
In this post, we’ll discuss what’s happening in a recreation of his code and how you might fix it.
Let’s get started.
List.ForEach and Tasks lead to problems
For those unaware, the List
type has a ForEach
method, which allows the user to pass an Action<T>
. The method
passes the iteration’s item, allowing you to execute something similar to a foreach
method. In a sense, it’s syntactic
sugar. In another, it’s a relic before async/await
. Let’s see an example code that can get you in trouble.
List<string> projects = new();
List<string> measures = ["cm", "m", "km"];
measures.ForEach(async x =>
{
var result = await GetResultOfMeasure(x);
projects.Add(result);
});
Console.WriteLine(projects.Count);
async Task<string> GetResultOfMeasure(string s)
{
await Task.Delay(100);
return $"Measured as {s}";
}
Looking at the code, we see an async/await
, so what’s the problem? Well, let’s run the code and see what the result
is.
0
What?! Why did we get a result of 0
?
If you’re using a tool like ReSharper
or JetBrains Rider you’ll have seen a warning around the async
keyword.
Avoid using 'async' lambda when delegate type returns 'void'
Oops! While the Action
uses async/await
semantics, nothing awaits the iterative process. This means our iterations
may or may not be complete by the time we reach the Console.WriteLine
statement. Since we use Task.Delay
, it’s
likely we won’t.
If you’re using something that fluctuates in performance, you will likely pull out your hair due to indeterminate behavior.
So, what’s the fix?
Fixing List.ForEach with ForEachAsync
The most straightforward solution is not to use ForEach
but to use a simple for each iteration instead.
List<string> projects = new();
List<string> measures = ["cm", "m", "km"];
foreach (var measure in measures)
{
var result = await GetResultOfMeasure(measure);
projects.Add(result);
}
Console.WriteLine(projects.Count);
But honestly, where’s the fun in that?! Another approach is to write a ForEachAsync
extension method.
public static class ListExtension
{
public static async Task ForEachAsync<T>(
this List<T> collection,
Func<T, Task> action)
{
foreach (var i in collection) await action(i);
}
}
From here, you can tweak the original sample code to be sure to await the entire iterative process.
List<string> projects = new();
List<string> measures = ["cm", "m", "km"];
await measures.ForEachAsync(async x =>
{
var result = await GetResultOfMeasure(x);
projects.Add(result);
});
Console.WriteLine(projects.Count);
async Task<string> GetResultOfMeasure(string s)
{
await Task.Delay(100);
return $"Measured as {s}";
}
There you have it. Pretty cool, right?!
Conclusion
This particular issue is insidious as it all compiles and generally looks “correct.” With good tooling, like that
provided by JetBrains, you get a hint as to a potential issue lurking in your code, but it doesn’t scream at you since
it’s still code that compiles. It might make sense for .NET to include a ForEachAsync
method on List
, but at this
point, it might also make sense to Obsolete
the method entirely since it likely does more harm than good.
What do you think? Let me know if you’ve ever run into this issue. You can see the full thread on Mastodon here, along with debugging videos describing what is happening.
Thanks for reading and sharing my work with friends and colleagues. Cheers.