Implementing Parallel and Async Programming in C#
Recently, we encountered a scenario where we needed to process multiple request items simultaneously and in parallel. One of the simplest ways to achieve this is using the following pattern:
This approach allows us to spawn multiple tasks, each executing an asynchronous method in parallel.
While this works well when the number of request items is small and the async operation is lightweight, it becomes problematic with a large number of requests—especially when each operation is resource-intensive.
In such cases, running too many tasks at once can overwhelm system resources, potentially leading to errors and causing the application to fail.
To address this, we need a way to run multiple asynchronous operations concurrently, but in a controlled and safe manner—where we can limit the number of items being processed in parallel to avoid resource exhaustion.
This is where Partitioning comes into play. In this blog, we’ll explore how to use partitioning to manage and process large sets of asynchronous tasks efficiently and safely.
Converting a Collection into Partitions
Let’s say we have a collection of request items. Our system can handle a specific number of these items—say n—concurrently without running into errors. In this context, n represents the degree of parallelism.
To manage this, we want to split the collection into partitions—each containing n items—so that we can process them in parallel, while respecting the limit on concurrency.
We can achieve this by using the Partitioner<TSource>
class from the System.Collections.Concurrent
namespace. Here's how it's done:
- source is your input collection of type
IEnumerable<TSource>
. - maxDoP (maximum degree of parallelism) defines how many parallel partitions will be created.
Running Partitions in Parallel
After creating the partitions, we want them to run in parallel. This can be done by calling the AsParallel()
method on the partitions:
This converts the IEnumerable<TSource>
into a ParallelQuery<TSource>
, allowing us to use LINQ methods to process the partitioned items in parallel.
Creating a function to process each partition
We need a function that does the following:
- Can be called for each partition
- Disposes of the partition after use
- Iterates over the partition items and invokes a user-defined asynchronous method for each item
This can be achieved with the following code:
Explanation:
- The
partition
is passed as a parameter to the function. - By using the
using
statement, we ensure that the partition (an enumerator) is properly disposed of after use. - We iterate through the partition using
MoveNext()
. If it returnsfalse
, it means there are no more items to process, so we exit the loop. - For each item, we call the user-defined async method
funcBody(current)
. - Additionally, we use
Task.Yield()
, which yields control back to the task scheduler, allowing other parallel tasks to run. This promotes fair CPU usage and prevents a single task from blocking others. If no other tasks are ready, the current task resumes execution immediately.
Handling Exceptions: Using AggregateException
When tasks run in parallel, exceptions can occur in one or more of them. Rather than stopping execution immediately when a single task fails, it's often better to collect individual exceptions and throw a single AggregateException
after all tasks have completed.
To achieve this, we can use a thread-safe ConcurrentBag<Exception>
to store any exceptions that occur while tasks are executing in parallel. Once all tasks are finished, we can check this bag and throw an AggregateException
if any errors were captured.
Here’s how this can be implemented:
Next, we create the tasks and process the partitions in parallel:
Finally, after all tasks are complete, we check if any exceptions were recorded:
Making the method accessible
To make this method reusable and easy to access, we define it as an extension method on IEnumerable<T>
within a static class. This allows you to call it directly on any collection.
Demonstrating the ParallelAsync<T>
method call
In the following example, we have a list of integers representing request items. An asynchronous function is defined to check whether each number is even or odd. This function will be invoked for every item in the list, and the processing will be performed in parallel.
Output preview:
Benefits of the ParallelAsync Method We Defined Earlier
- Scalable: Prevents overwhelming the system by not launching thousands of tasks simultaneously.
- Resilient: Captures and aggregates all exceptions, making centralized error handling easier.
- Reusable: Works with any
IEnumerable<T>
—simply pass in your async function.
Conclusion
By leveraging data partitioning, asynchronous execution, and robust error handling, this approach provides a clean and dependable way to scale your code across large datasets—without compromising readability or stability.