Mastering Async and Concurrency in .NET - Part 4: Parallel Processing Patterns

Introduction

In Part 3, we settled the debate about async and threads. Now we’re going to explore parallel processing patterns - when to use Parallel.ForEach vs Task.WhenAll, understanding execution order, and choosing the right tool for your workload.

Parallel.ForEach vs Task.WhenAll: The Core Distinction

Both Parallel.ForEach and Task.WhenAll leverage the .NET ThreadPool, but they serve fundamentally different purposes:

FeatureParallel.ForEachTask.WhenAll
Primary Use CaseCPU-bound data parallelismI/O-bound async operations
Execution ModelSynchronous, blocks calling threadAsynchronous, non-blocking
Thread ControlLimited (MaxDegreeOfParallelism)Explicit task creation
PartitioningAutomatic chunkingManual (no built-in)
Best ForNumber-crunching, image processingWeb requests, file I/O, DB calls

Important: Task.WhenAll Is Async, Not Parallel (CPU Sense)

Task.WhenAll enables asynchronous concurrency, not CPU parallelism.

When used with I/O-bound operations, Task.WhenAll:

  • Frees threads during waits (no threads blocked during I/O)
  • May use zero threads during the wait period
  • Does NOT guarantee simultaneous execution on different CPU cores

This is fundamentally different from Parallel.ForEach, which:

  • Explicitly uses multiple CPU threads
  • Keeps threads busy with CPU-bound work
  • Distributes work across multiple cores

Parallel.ForEach: CPU-Bound Data Parallelism

Design Goals

Parallel.ForEach is part of the Task Parallel Library (TPL) designed for CPU-intensive work that can be broken into independent iterations.

Example: Image Processing

public void ProcessImages(string[] imagePaths)
{
    var options = new ParallelOptions
    {
        MaxDegreeOfParallelism = Environment.ProcessorCount
    };

    Parallel.ForEach(imagePaths, options, imagePath =>
    {
        // CPU-intensive: load, resize, apply filters
        var image = LoadImage(imagePath);
        ResizeImage(image);
        ApplyFilters(image);
        SaveImage(image);
    });
}

Why this works:

  • Each iteration is CPU-bound
  • Work saturates available cores
  • No I/O waits that would waste threads

Performance Profile

// Scenario: Process 10,000 images (CPU-bound)
// Each image takes 50ms to process

// Sequential: 10,000 × 50ms = 500 seconds
foreach (var image in images)
{
    ProcessImage(image);
}

// Parallel.ForEach on 8-core machine: ~500s / 8 = 62.5 seconds
Parallel.ForEach(images, image =>
{
    ProcessImage(image);
});

Speedup: ~8x (near-linear with core count)

Task.WhenAll: Concurrent Async Operations

Design Goals

Task.WhenAll is for waiting on multiple async operations to complete concurrently.

Example: API Calls

public async Task<IEnumerable<WeatherForecast>> GetWeatherForecastAsync()
{
    var tasks = new List<Task<IEnumerable<WeatherForecast>>>();

    // Start all operations (don't await yet!)
    var result1 = FetchWeatherAsync("London");
    var result2 = FetchWeatherAsync("Paris");
    var result3 = FetchWeatherAsync("Berlin");

    tasks.Add(result1);
    tasks.Add(result2);
    tasks.Add(result3);

    // Wait for all to complete
    var combinedResults = await Task.WhenAll(tasks);
    return combinedResults.SelectMany(cr => cr);
}

Why this works:

  • Each operation is I/O-bound (network calls)
  • Threads released during network waits
  • All operations run concurrently
  • Calling thread not blocked

Performance Profile

// Scenario: Call 3 APIs, each takes 2 seconds

// Sequential: 3 × 2s = 6 seconds
var result1 = await DownloadFile1Async(); // 2 seconds
var result2 = await DownloadFile2Async(); // 2 seconds
var result3 = await DownloadFile3Async(); // 2 seconds

// Concurrent with Task.WhenAll: 2 seconds (max of all)
var task1 = DownloadFile1Async();
var task2 = DownloadFile2Async();
var task3 = DownloadFile3Async();
var results = await Task.WhenAll(task1, task2, task3);

Speedup: ~3x (limited by slowest operation)

Common Pitfalls

❌ Pitfall 1: Task.WhenAll Without Throttling

// BAD: Creates millions of tasks - thread pool exhaustion!
var tasks = millionsOfItems.Select(item => ProcessItemAsync(item));
await Task.WhenAll(tasks);

Solution: Use Throttling

// GOOD: Process in batches with SemaphoreSlim
var semaphore = new SemaphoreSlim(10); // Max 10 concurrent
var tasks = new List<Task>();

foreach (var item in millionsOfItems)
{
    await semaphore.WaitAsync();
    tasks.Add(Task.Run(async () =>
    {
        try
        {
            await ProcessItemAsync(item);
        }
        finally
        {
            semaphore.Release();
        }
    }));
}

await Task.WhenAll(tasks);

❌ Pitfall 2: Parallel.ForEach in Web Apps

// BAD: Exhausts ThreadPool in web app
[HttpPost]
public IActionResult ProcessItems(List<Item> items)
{
    Parallel.ForEach(items, item =>
    {
        ProcessItem(item); // CPU-intensive work
    });

    return Ok();
}

Why it’s bad:

  • Exhausts thread pool threads
  • Other requests starved
  • Poor scalability

Solution: Use Background Service

// GOOD: Offload to background service
[HttpPost]
public async Task<IActionResult> ProcessItemsAsync(List<Item> items)
{
    await _backgroundJobQueue.EnqueueAsync(items);
    return Accepted(); // 202 - processing started
}

Parallel.ForEachAsync (.NET 6+)

What It Is

Parallel.ForEachAsync is the async-aware version of Parallel.ForEach introduced in .NET 6.

Example: Mixed CPU and I/O Work

public async Task ProcessImagesFromStorageAsync(IEnumerable<string> imageIds)
{
    var options = new ParallelOptions
    {
        MaxDegreeOfParallelism = Environment.ProcessorCount
    };

    await Parallel.ForEachAsync(imageIds, options, async (imageId, ct) =>
    {
        // I/O-bound: Download from cloud storage
        var imageData = await blobStorage.DownloadAsync(imageId, ct);

        // CPU-bound: Process image
        var processedImage = await Task.Run(() =>
            ProcessImage(imageData), ct);

        // I/O-bound: Upload result
        await blobStorage.UploadAsync(processedImage, ct);
    });
}

Benefits:

  • Handles both I/O and CPU work naturally
  • Controlled parallelism with MaxDegreeOfParallelism
  • Clean async/await syntax
  • Built-in cancellation support

Task.WhenAny: Processing First Completed

What It Is

Task.WhenAny returns when any of the provided tasks completes (not all).

Use Case 1: Timeout Pattern

public async Task<string> DownloadWithTimeoutAsync(string url, TimeSpan timeout)
{
    using var cts = new CancellationTokenSource(timeout);

    var downloadTask = httpClient.GetStringAsync(url, cts.Token);
    var timeoutTask = Task.Delay(timeout, cts.Token);

    var completedTask = await Task.WhenAny(downloadTask, timeoutTask);

    if (completedTask == timeoutTask)
    {
        throw new TimeoutException($"Download from {url} timed out");
    }

    return await downloadTask;
}

Use Case 2: Racing Multiple Services

public async Task<WeatherData> GetWeatherFromFastestSourceAsync(string city)
{
    using var cts = new CancellationTokenSource();

    var task1 = GetWeatherFromServiceAAsync(city, cts.Token);
    var task2 = GetWeatherFromServiceBAsync(city, cts.Token);
    var task3 = GetWeatherFromServiceCAsync(city, cts.Token);

    var completedTask = await Task.WhenAny(task1, task2, task3);

    // Cancel remaining tasks
    cts.Cancel();

    return await completedTask;
}

Execution Order and Task Scheduling

Understanding execution order is critical for correct concurrent programming.

Execution Order Summary

ConstructStart OrderExecution OrderCompletion OrderResult Order
Task.WhenAllSequential (task creation)Non-deterministic (concurrent)Non-deterministicPreserves creation order
Parallel.ForEachNon-deterministicNon-deterministic (partitioned)Non-deterministicN/A (no return array)
Parallel.ForEachAsyncNon-deterministicNon-deterministic (partitioned)Non-deterministicN/A (no return array)

Critical: Task.WhenAll Preserves Result Order

// Even though tasks complete in random order...
var results = await Task.WhenAll(
    SlowTaskAsync(),   // Finishes 3rd (300ms)
    FastTaskAsync(),   // Finishes 1st (100ms)
    MediumTaskAsync()  // Finishes 2nd (200ms)
);

// Results array ALWAYS matches creation order:
// results[0] = SlowTask result
// results[1] = FastTask result
// results[2] = MediumTask result

Why this matters:

var users = new[] { "Alice", "Bob", "Charlie" };
var tasks = users.Select(user => GetUserDataAsync(user));
var results = await Task.WhenAll(tasks);

// results[0] is ALWAYS Alice's data
// results[1] is ALWAYS Bob's data
// results[2] is ALWAYS Charlie's data
// Even if Bob's API call finished first!

TPL, PLINQ, and the ThreadPool

What is TPL?

Task Parallel Library (TPL) is a set of APIs that simplify parallel and concurrent programming:

  • Namespace: System.Threading.Tasks
  • Key Types: Task, Task<T>, Parallel, TaskFactory
  • Built on: Managed ThreadPool
  • Purpose: Simplify parallel programming

What is PLINQ?

Parallel LINQ (PLINQ) is a parallel implementation of LINQ:

// Sequential LINQ
var results = numbers
    .Where(n => IsPrime(n))
    .Select(n => n * 2)
    .ToList();

// Parallel LINQ (PLINQ)
var results = numbers
    .AsParallel()                    // ← Enable parallelism
    .Where(n => IsPrime(n))
    .Select(n => n * 2)
    .ToList();

// With ordering preserved
var results = numbers
    .AsParallel()
    .AsOrdered()                     // ← Preserve order (performance cost!)
    .Where(n => IsPrime(n))
    .Select(n => n * 2)
    .ToList();

ThreadPool Usage Comparison

ConstructUses ThreadPool?How?
Parallel.ForEach✅ YesVia default task scheduler
Parallel.ForEachAsync✅ YesVia default task scheduler
Task.Run✅ YesQueues work on ThreadPool
Task.WhenAll (I/O)⚠️ PartiallyNo threads during I/O wait
Task.WhenAll (CPU)✅ YesIf using Task.Run
PLINQ (.AsParallel())✅ YesVia TPL/ThreadPool
async/await (I/O)❌ NoNo thread during await

Decision Matrix

Use Parallel.ForEach When:

CPU-bound work that benefits from parallelism ✅ Processing a large collection in parallel ✅ Each iteration is independent ✅ Operations are synchronous ✅ Want automatic chunking and load balancing

Examples:

  • Image/video processing
  • Data transformation
  • Complex calculations
  • Batch processing

Use Task.WhenAll When:

I/O-bound operations (network, disk, database) ✅ Need fine-grained control over task creation ✅ Mixing CPU and I/O operations ✅ Want to avoid blocking the calling thread ✅ Server applications requiring high scalability

Examples:

  • Multiple API calls
  • Database queries
  • File I/O operations
  • Email sending

Use Parallel.ForEachAsync When:

✅ Processing a collection with async operations ✅ Need throttling (MaxDegreeOfParallelism) ✅ Mixed CPU and I/O work ✅ Want clean async/await syntax ✅ Need built-in cancellation

Examples:

  • Batch API calls
  • Processing files from cloud storage
  • Data transformation pipelines

Use Task.WhenAny When:

✅ Need result from first completed operation ✅ Implementing timeoutsRacing multiple sources ✅ Processing results as they arriveInterruptible async operations

Examples:

  • API timeout patterns
  • Multi-source redundancy
  • Progressive UI updates

Real-World Pattern: Combining Both

public async Task<ProcessingResult> ProcessWithTimeoutPerItemAsync(
    IEnumerable<int> itemIds,
    TimeSpan timeoutPerItem)
{
    var results = new ConcurrentBag<ItemResult>();
    var failures = new ConcurrentBag<(int id, string error)>();

    var options = new ParallelOptions
    {
        MaxDegreeOfParallelism = 5
    };

    await Parallel.ForEachAsync(itemIds, options, async (id, ct) =>
    {
        // Use Task.WhenAny for per-item timeout
        var processTask = ProcessItemAsync(id, ct);
        var timeoutTask = Task.Delay(timeoutPerItem, ct);

        var completedTask = await Task.WhenAny(processTask, timeoutTask);

        if (completedTask == timeoutTask)
        {
            failures.Add((id, "Timeout"));
        }
        else
        {
            try
            {
                var result = await processTask;
                results.Add(result);
            }
            catch (Exception ex)
            {
                failures.Add((id, ex.Message));
            }
        }
    });

    return new ProcessingResult
    {
        SuccessCount = results.Count,
        Failures = failures.ToList()
    };
}

Pattern Benefits:

  • Parallel processing with Parallel.ForEachAsync
  • Per-item timeout with Task.WhenAny
  • Controlled concurrency
  • Graceful failure handling

Key Takeaways

  1. Parallel.ForEach: Synchronous, CPU-bound, automatic partitioning, blocks until complete
  2. Task.WhenAll: Asynchronous, I/O-bound, manual control, non-blocking
  3. Parallel.ForEachAsync: Async-friendly parallel processing with throttling (.NET 6+)
  4. Task.WhenAny: Returns when first task completes (timeouts, racing)
  5. Choose based on workload: CPU-bound → Parallel, I/O-bound → async patterns
  6. In web apps: Prefer async patterns over Parallel.ForEach
  7. Always throttle: When creating many tasks with Task.WhenAll
  8. TPL and PLINQ: Both built on ThreadPool for efficient resource usage

References

Comments