Why useWhenAll
is so useful for run list of tasks in Dotnet applications?
C#’s WhenAll
method helps save time when processing lists of tasks. When thinking about exceptions, I couldn’t find good patterns that allowed me to access the full list of tasks after they’ve completed. This post outlines the solution I came up with, which hopefully will help you out too.
Introduction
As a web developer, I understand the importance of efficient and responsive software applications. In today’s world, where users demand faster and more interactive experiences, multithreading has become a crucial aspect of modern software development.
Multithreading allows us to execute multiple tasks concurrently, enabling better utilization of system resources and improved performance. In C#, we have powerful tools at our disposal, such as the Task and Parallel classes, that simplify the implementation of multithreaded applications.
In this blog post, I will guide you through the world of multithreading in C# and delve into two essential concepts: Task.WhenAll and Parallel.ForEach. These features offer efficient and streamlined approaches to parallel execution, allowing you to unlock the full potential of multithreading in your projects.
Before we dive into the specifics of Task.WhenAll and Parallel.ForEach, let’s first gain a solid understanding of multithreading in C# and its benefits.
Explanation of the Task class and its purpose in C#
In C#, the Task class represents an asynchronous operation or a unit of work that can be executed concurrently. It allows us to work with tasks, schedule their execution, and handle the results asynchronously. The Task class provides a high-level abstraction for managing multithreading in our applications.
Detailed exploration of Task.WhenAll and its significance in parallel execution
Task.WhenAll is a powerful method provided by the Task class that enables us to execute multiple tasks concurrently and await their completion. It accepts an array or collection of tasks and returns a new task that completes when all the input tasks have completed.
By utilizing Task.WhenAll, we can easily parallelize our code and leverage the benefits of multithreading without dealing with the complexities of manual thread management. It simplifies the coordination and synchronization of tasks, making it easier to write efficient and responsive applications.
Benefits of using Task.WhenAll over manual multithreading
When compared to manual multithreading, Task.WhenAll offers several benefits. Firstly, it abstracts away the low-level details of thread management, allowing us to focus on the logic of our application. It simplifies the coordination of multiple tasks, reducing the chances of race conditions and deadlocks.
Task.WhenAll also provides better scalability and resource utilization. Under the hood, it utilizes the thread pool to manage the execution of tasks, which optimizes the allocation and reuse of threads, resulting in improved performance.
The Benefits of WhenAll
Lets backup and give a brief introduction to why WhenAll
is so useful. Say you have a list of tasks, how might you go about running them all? A naive approach would be to use a for loop and await them all.
Here’s is a simple interface that defines a method, then an implementation that takes 3 seconds to complete, simulating some sort of I/O request.
public interface IPing {
Task<bool> Ping();
}
public class WorkingPing : IPing {
public async Task<bool> Ping() {
await Task.Delay(3000);
return true;
}
}
If I had 4 of these such tasks, I could get the results using a for loop.
public class PingTest
{
public async Task Test()
{
var pingTasks = new List<IPing>()
{
new WorkingPing(),
new WorkingPing(),
new WorkingPing(),
new WorkingPing()
};
var pingResult = new List<bool>();
foreach (IPing ping in pingTasks)
{
pingResult.Add(await ping.Ping());
}
}
}
What’s wrong with this approach?
Since I’m processing these pings one at a time inside of our for loop, this method takes ~12 seconds to complete. (3 + 3 + 3 + 3 = 12).
A much better approach would be to use WhenAll
to start them all at the same time, then process them whenever they all are finished.
public class PingTest
{
public async Task Test()
{
var pingTasks = new List<IPing>()
{
new WorkingPing(),
new WorkingPing(),
new WorkingPing(),
new WorkingPing()
};
var pingResult = new List<bool>();
var tasks = pingTasks.Select(p => p.Ping());
foreach (bool ping in await Task.WhenAll(tasks))
{
pingResult.Add(ping);
}
}
}
Notice how I’m calling Ping
outside of my for loop, which starts all these tasks at the same time. Then the call to WhenAll
will wait until they’re all finished, then process each in my for loop.
Since all tasks start at the same time, this method only takes ~3 seconds to complete. Significantly speeding up this code.
WhenAll Exceptions
If all you take away from this article is to not await things inside a for each loop, that’s great. But another problem now exists, what happens if one of our tasks throws an exception?
Here’s a new ping type that simulates some failure.
public class BrokenPing : IPing
{
public async Task<bool> Ping()
{
throw new Exception("Ping Broken");
}
}
Lets also change our pingTasks to include two of these BrokenPings
.
var pingTasks = new List<IPing>()
{
new WorkingPing(),
new BrokenPing(),
new WorkingPing(),
new BrokenPing()
};
Now what happens to my code? The WhenAll
call still waits for everything to complete, but now there’s an exception to deal with. Since there’s no error mechanism to catch an exception the exception goes up the call stack.
What happens if you introduce a try catch around the WhenAll
call?
try
{
var completedPings = await Task.WhenAll(tasks);
foreach (bool ping in completedPings)
{
pingResult.Add(ping);
}
}
catch (Exception e)
{
Console.WriteLine(e.Message);
}
That only catches the first exception. But what about all of our tasks? We had a total of four tasks, 2 working and 2 broken.
As far as I can tell, WhenAll
only throws the first exception. If you had 100 tasks and 1 of them threw an exception, you don’t have access to the 99 tasks that completed successfully.
Which is a bit annoying, but I found a fairly simple work around.
Conclusion
By leveraging Task.WhenAll, we can easily manage asynchronous operations and achieve parallel execution without the complexities of manual multithreading. The ability to await multiple tasks simultaneously and efficiently handle their results greatly enhances our productivity.
When comparing Task.WhenAll and Parallel.ForEach, it is essential to consider the specific requirements of our projects. Task.WhenAll shines in scenarios where we deal with multiple independent tasks, such as calling external APIs or performing database operations. Parallel.ForEach, on the other hand, excels when we need to process data in parallel, such as transforming large collections or performing computationally intensive operations.
To make the most of multithreading in C#, it is crucial to follow best practices. Optimize your code by using appropriate synchronization techniques, handling errors effectively, and ensuring thread safety. Consider the performance implications of your multithreading approach and continually monitor and fine-tune your implementation.