Cautionary Tale – Do not use async lambda expression in Parallel.ForEach()
Recently, I have fixed one very strange bug and want to share this cautionary tale of using async lambda expression inside Parallel.ForEach()
loop.
Context
In the code base, we have a Parallel.ForEach()
that look something like this:
public void DoParallelWork(List<int> ids)
{
var parallelOptions = new ParallelOptions
{
MaxDegreeOfParallelism = 2
};
Parallel.ForEach(
ids,
parallelOptions,
async id =>
{
await SomeWorkAsync(id);
});
}
Imagine SomeWorkAsync()
being some sort of I/O work, like inserting data to a database. The issue we observe is that the I/O operation does not always complete. However, we are not seeing exception being thrown.
Cause
After long investigation, the cause of the issue is the async lambda inside Parallel.ForEach()
. If we look inside the definition of Parallel.ForEach()
, it accepts Action as the body parameter. Action
is a method delegate that return nothing (void
). This means when we put the async
keyword in, it becomes async void
method. The issue of async void
is that it does not wait for task completion and will return prematurely. It is something to be avoided in general (Detail in Microsoft doc). So what happened in our case is probably either the application returned too early or the I/O operation failed and exception was not being caught.
Fix
The fix for this is relatively easy (at least if you are on .NET 6 and above). Parallel.ForEachAsync()
was introduced in .NET 6 and addressed this issue. To replace Parallel.ForEach()
, do something like below:
public async Task DoParallelWork(List<int> ids)
{
var parallelOptions = new ParallelOptions
{
CancellationToken = new CancellationToken(),
MaxDegreeOfParallelism = 2
};
await Parallel.ForEachAsync(
ids,
parallelOptions,
async (id, CancellationToken) =>
{
await SomeWorkAsync(id);
});
}
If you are not in .NET 6, you can either remove the async
keyword and use the sync version of whatever async operation you need, use GetAwaiter().GetResult()
to force async method into sync method (which has its potential danger) or get remove Parallel.ForEach()
all together. None of these solution is great to be honest but is very likely better then async void
.
Takeaway
The main takeaway is to avoid passing async lambda to a method taking an Action
parameter. What tripped me up this time is Parallel.ForEach()
but this should really apply to any any method that take an Action
parameter.
Conclusion
I hope this cautionary tale can help you avoid a potential long and dreadful debugging section. Good luck coding away!