Parallel Class and PLINQ — CPU-Bound Parallelism

Async/await is for I/O-bound operations — database queries, HTTP calls, file reads — where the bottleneck is waiting for an external resource. For CPU-bound work — image processing, encryption, data transformation, complex calculations — the bottleneck is the CPU itself, and async/await alone does not help (the CPU is busy, not waiting). The Task Parallel Library (TPL) provides Parallel.For, Parallel.ForEach, and PLINQ for distributing CPU work across multiple processor cores simultaneously. Understanding which tool to use for which workload is fundamental for correct performance optimisation.

Parallel.For and Parallel.ForEach

// ── Parallel.For — index-based CPU-bound loop ─────────────────────────────
var results = new int[1000];

Parallel.For(0, 1000, i =>
{
    results[i] = ExpensiveComputation(i);   // runs on multiple threads concurrently
});

// ── Parallel.ForEach — collection-based CPU-bound loop ────────────────────
var posts = GetAllPosts();
var processedPosts = new ConcurrentBag<Post>();   // thread-safe collection

Parallel.ForEach(posts, post =>
{
    var processed = ProcessContent(post);   // CPU-intensive work
    processedPosts.Add(processed);
});

// ── With options — control degree of parallelism ──────────────────────────
var options = new ParallelOptions
{
    MaxDegreeOfParallelism = Environment.ProcessorCount,  // use all cores
    CancellationToken      = cancellationToken,
};
Parallel.ForEach(posts, options, post => ProcessContent(post));

// ── .NET 6+ Parallel.ForEachAsync — async work with parallelism control ───
await Parallel.ForEachAsync(posts,
    new ParallelOptions { MaxDegreeOfParallelism = 10 },
    async (post, ct) =>
    {
        await _imageService.ResizeAsync(post.CoverImagePath, ct);
    });
Note: Parallel.ForEach works by partitioning the collection among threads. It uses the thread pool internally — the degree of parallelism defaults to the number of logical processors. Unlike async/await which frees threads to handle other work, Parallel.ForEach actively uses multiple CPU threads simultaneously. This is appropriate when you genuinely have CPU-intensive work (compression, encryption, image processing, scientific computing) and you want to saturate all available cores.
Tip: Prefer Parallel.ForEachAsync (.NET 6+) over manually combining Parallel.ForEach with async delegates. Wrapping async delegates in Parallel.ForEach with async void or .Wait() causes problems — exceptions are swallowed, cancellation does not work correctly, and the thread pool is used inefficiently. Parallel.ForEachAsync properly integrates async operations with degree-of-parallelism control and cancellation.
Warning: Avoid using Task.Run() for I/O-bound work in ASP.NET Core. Task.Run queues work to the thread pool — appropriate for CPU-bound work that would otherwise block a UI thread, but in ASP.NET Core it just moves the work from one thread pool thread to another, gaining nothing. Only use Task.Run in ASP.NET Core when you must call a synchronous blocking library that has no async version and you want to offload it to avoid blocking a request thread for an unreasonably long time.

PLINQ — Parallel LINQ

// Regular LINQ — sequential, single-threaded
var result = posts
    .Where(p => p.IsPublished)
    .Select(p => ExpensiveTransform(p))
    .ToList();

// PLINQ — parallel, uses multiple CPU cores
var parallelResult = posts
    .AsParallel()                            // switch to parallel execution
    .WithDegreeOfParallelism(4)             // use up to 4 cores
    .WithCancellation(cancellationToken)    // support cancellation
    .Where(p => p.IsPublished)
    .Select(p => ExpensiveTransform(p))
    .ToList();

// AsOrdered — preserves original sequence order (reduces parallelism benefit)
var ordered = posts
    .AsParallel()
    .AsOrdered()   // results maintain original order (with overhead)
    .Select(p => ExpensiveTransform(p))
    .ToList();

I/O-Bound vs CPU-Bound — Choosing the Right Tool

Scenario Bottleneck Right Tool
Database query I/O (network) async/await
HTTP API call I/O (network) async/await
File read/write I/O (disk) async/await
Image resizing (CPU) CPU Parallel.ForEachAsync
PDF generation (CPU) CPU Task.Run or Parallel
Encryption (CPU) CPU Parallel or dedicated thread
Mixed I/O + CPU Both async/await + Parallel.ForEachAsync

Common Mistakes

Mistake 1 — Using Parallel.ForEach with async delegates (fire-and-forget)

❌ Wrong — async void lambda in Parallel.ForEach; exceptions are swallowed:

Parallel.ForEach(posts, async post => await _service.ProcessAsync(post));
// The async delegate returns void from Parallel's perspective — not awaited!

✅ Correct — use Parallel.ForEachAsync (.NET 6+) for async work.

Mistake 2 — Using Task.Run for I/O-bound work in ASP.NET Core

❌ Wrong — queues to thread pool, gains nothing for I/O operations.

✅ Correct — use genuinely async I/O methods (await File.ReadAllTextAsync(), await httpClient.GetAsync()).

🧠 Test Yourself

An ASP.NET Core background service needs to process 500 images — each resize takes 200ms of CPU time. Should you use async/await, Parallel.ForEachAsync, or sequential processing?