async/await frees a thread from waiting. Parallel programming puts multiple cores to work at the same instant. They solve two completely different problems.
You've spent the entire Async Programming part of this course learning to avoid blocking a thread while it waits — for a network response, a database query, a file read. That was always about waiting, never about work. But what happens when there's no waiting involved at all — just a genuinely large amount of computation? Resizing ten thousand images. Recalculating risk scores for every account in a portfolio. Running a numerical simulation across a huge grid of data points.
Awaiting doesn't help here. There's nothing to wait on — the CPU is already the bottleneck, fully busy the entire time. If you have a modern multi-core machine — and virtually every machine today has four, eight, sixteen, or more cores — running that computation on a single core while the other cores sit idle is leaving most of your hardware's actual power completely untouched.
In this lesson, you'll learn the second half of concurrent programming in .NET: parallel programming, built around the Task Parallel Library (TPL) and its Parallel class. You'll see exactly how it's different from everything you've learned about async/await, meet Parallel.Invoke for running a handful of independent operations at once, and understand "Degree of Parallelism" — the idea that anchors every parallel-programming lesson in this Part.
Parallel programming means splitting a chunk of work into pieces and running those pieces at the exact same instant, on different CPU cores, so the whole job finishes sooner than it would running on just one core. If resizing one image takes 100 milliseconds and you need to resize eight of them, one core does it in 800 milliseconds — but eight cores, each handling one image simultaneously, can do it in roughly 100 milliseconds. The work isn't avoided or hidden; it's genuinely divided among more workers.
The Task Parallel Library (TPL) is the set of .NET APIs — living mostly in System.Threading.Tasks — built for exactly this kind of data- and task-parallel execution. Its most approachable entry point is the static Parallel class, which offers three core members:
Parallel.Invoke(params Action[] actions); // run a fixed set of independent actions at once
Parallel.For(int fromInclusive, int toExclusive, Action<int> body); // parallel numeric loop
Parallel.ForEach<TSource>(IEnumerable<TSource> source, Action<TSource> body); // parallel loop over a collectionUnder the hood, all three are built on the same foundation as everything else in this Part: ordinary Task objects, scheduled onto the ThreadPool (the internals of which are covered elsewhere in this Part). What Parallel adds on top is the bookkeeping — partitioning the work, deciding how many pieces to run at once, and waiting for every piece to finish — so you don't have to hand-write that coordination yourself with raw Task.Run calls.
Parallel.Invoke and Parallel.For get a proper introduction here. Parallel.ForEach — the member of this family you'll actually reach for most often in real code — gets its own full lesson right after this one, because it deserves a deeper look at partitioning, ParallelOptions, and thread-safety.
Go back to the very first lesson of the Async Programming part: "asynchronous does not make an operation faster — it changes what the thread does while waiting." That distinction matters enormously here. If you take a CPU-heavy method and sprinkle async/await on it without ever actually awaiting anything that involves real waiting, you haven't made it faster — you've just added state-machine overhead around a loop that still runs on one thread, one core, exactly as slowly as before.
The confusion is understandable, because both problems get grouped under "concurrency," and both problems involve Task. But they are solving two genuinely different problems:
What's needed for CPU-bound work is a way to say: "take this chunk of computation, split it into independent pieces, and hand those pieces to however many CPU cores are actually available, so they run genuinely simultaneously — not one after another, and not one thread quietly juggling all of it alone."
The TPL's Parallel class exists to do exactly that, with a minimum of manual thread management. You describe the work — a fixed set of actions, or a loop over a collection — and the TPL takes care of partitioning it across the ThreadPool's worker threads, running those partitions on separate cores at the same time, and waiting for everything to finish before control returns to your code.
Picture four independent, CPU-heavy reports that need to be generated — no network calls, no disk waits, pure calculation:
Sequential (one core does all four, one after another):
0s ─────[ Report A: 1s ]─────[ Report B: 1s ]─────[ Report C: 1s ]─────[ Report D: 1s ]─────▶ 4s total
Parallel (four cores, each doing one report, at the same instant):
Core 1: 0s ─────[ Report A: 1s ]─────▶
Core 2: 0s ─────[ Report B: 1s ]─────▶
Core 3: 0s ─────[ Report C: 1s ]─────▶
Core 4: 0s ─────[ Report D: 1s ]─────▶
▲ all four genuinely overlap, on separate cores — total ≈ 1s
Notice how this looks similar to the Task.WhenAll timeline from earlier in this course — several things finishing in the time of the slowest one. But the mechanism underneath is completely different. Task.WhenAll's speedup came from not wasting threads on waiting — often with no extra thread involved at all. This speedup comes from genuinely running on separate CPU cores at the same instant — real, simultaneous computation, not the absence of blocking.
Action). They must not depend on each other's results.Task.WhenAll, which is awaited asynchronously, Parallel.Invoke is a synchronous, blocking call — it's meant to be used from a thread that's expected to be busy anyway (often itself running inside a background or worker context), not from a thread you're trying to keep responsive.AggregateException containing every failure that occurred — not just the first one.Parallel.Invoke is the right shape for exactly this scenario: a small, fixed, known number of independent CPU-bound operations.
Parallel.Invoke(
() => GenerateSalesReport(),
() => GenerateInventoryReport(),
() => GenerateTaxReport()
);
Console.WriteLine("All three reports are done.");
// This line only runs once every one of the three has finished.Walking through it:
Parallel.Invoke itself doesn't return until all three are complete — this is a blocking call, not something you await.A nightly batch job that has to validate three completely independent, computation-heavy datasets before it can proceed to the next stage of a pipeline:
public void RunNightlyValidation()
{
try
{
Parallel.Invoke(
() => ValidatePricingRules(_pricingSnapshot),
() => ValidateInventoryCounts(_inventorySnapshot),
() => ValidateTaxTables(_taxSnapshot)
);
}
catch (AggregateException ex)
{
foreach (Exception inner in ex.InnerExceptions)
_logger.LogError(inner, "A nightly validation step failed.");
throw;
}
_logger.LogInformation("Nightly validation completed successfully.");
}Each validation routine crunches through an in-memory dataset — comparing prices against rules, reconciling counts, cross-checking tax tables — with no network or disk waiting involved at all. Running them one after another would waste two of the machine's cores for the entire duration. Parallel.Invoke lets all three run genuinely simultaneously, and the catch (AggregateException) block ensures that if, say, both the pricing and tax validations fail, you find out about both failures — not just whichever one happened to be reported first.
Imagine a kitchen that needs to chop vegetables, grill meat, and bake bread — three genuinely independent, hands-on tasks, none of which involves standing around waiting for a delivery. One chef doing all three, one after another, takes the sum of all three durations. Three chefs, each assigned one task, working at the same time, finish in roughly the time of the slowest single task.
This is different from the "buzzer" analogy from the async lessons, where one person placed an order and went off to do something else while waiting for food to be ready. Here, nobody is waiting on an external delivery — every chef is actively, continuously working the entire time. That's the essence of CPU-bound parallel work: more hands, genuinely busy at once, not one hand freed up during idle time.
Every member of the Parallel class follows the same general model: take a body of work, split ("partition") it into pieces, and hand each piece to a worker task drawn from the ThreadPool — the same pool whose internals are covered elsewhere in this Part. For Parallel.Invoke, the "pieces" are simply the actions you passed in — each one becomes its own unit of scheduled work. For Parallel.For and Parallel.ForEach (the next lesson's focus), the partitioning is more involved — a range of numbers or a whole collection has to be divided into chunks first, which is exactly what that lesson covers in depth.
The degree of parallelism is how many pieces of work are genuinely executing at the exact same instant. It's bounded by two things:
ParallelOptions.MaxDegreeOfParallelism, covered in full in the next lesson) — to leave headroom for other work on the machine, or to respect a downstream resource's own concurrency limits.By default, the TPL lets the degree of parallelism scale up to roughly the number of available cores, managed dynamically by the ThreadPool's own scheduling heuristics. You rarely need to think about the exact number — what matters conceptually is that "parallel" doesn't mean "unlimited simultaneous work"; it means "as much genuinely simultaneous work as the hardware (and any limits you set) actually allow."
| Concept | What it actually means |
|---|---|
| Concurrency | Multiple operations are in progress during overlapping time — doesn't require multiple cores (this is what async/await achieves for I/O) |
| Parallelism | Multiple operations are running at the literal same instant, on separate cores — requires multiple cores, and is what this lesson is about |
| Degree of Parallelism | How many pieces of work are running simultaneously right now, bounded by cores and any explicit cap |
Every example in the Async Programming part of this course was concurrent — multiple operations in flight during overlapping time — but rarely parallel in the strict sense, because I/O-bound async work often involves no dedicated thread actively computing anything during the wait at all; there's nothing to run "at the same instant" because nothing is running. Parallel programming, by contrast, is specifically about genuine simultaneous execution on multiple cores. All parallel work is concurrent; not all concurrent work is parallel. This lesson is squarely about the second, stricter idea.
A machine with 8 cores cannot run 100 pieces of CPU-bound work 100x faster by throwing them all at Parallel.Invoke at once — at most 8 of them can be genuinely simultaneous; the rest queue up and wait their turn, same as if you'd split the work into 8 groups yourself. Parallelism speeds things up up to the number of available cores (and even that has overhead costs, covered in the mistakes below) — it isn't a magic multiplier beyond what the hardware can actually do at once.
Wrapping three independent network calls in Parallel.Invoke to make them run concurrently.
Independent I/O-bound work belongs to Task.WhenAll, not Parallel.Invoke. Parallel.Invoke is a blocking call built for CPU-bound work — using it for I/O-bound operations would tie up a thread for each one, waiting synchronously, which is precisely the thread-wasting problem the whole Async Programming part exists to avoid.
Calling Parallel.Invoke directly on a UI thread or a request-handling thread and expecting it to stay responsive while the work runs.
Parallel.Invoke blocks the calling thread until everything finishes. If that thread needs to remain free (a UI thread, for instance), dispatch the whole parallel operation onto a background thread first (e.g. with Task.Run), rather than calling it directly from a thread you need to stay free.
Manually building a giant array of lambdas — one per item in a collection of thousands — and passing them all to Parallel.Invoke.
Parallel.Invoke is meant for a small, fixed, known-in-advance set of distinct operations. For "run this same operation across every item in a large collection," Parallel.ForEach — the next lesson — is the right tool: it partitions the collection efficiently instead of requiring you to build a huge list of delegates by hand.
Parallel class exists for.
Parallel class — Invoke, For, ForEach — partitions work across the ThreadPool and runs the pieces on separate cores at once.Parallel.Invoke runs a small, fixed set of independent operations at once, blocks until all of them finish, and collects every exception into a single AggregateException rather than surfacing just the first one.You've drawn the line between async/await and parallel programming. Let's make sure it's solid before moving on to Parallel.ForEach.
1. A method needs to call three independent, slow web APIs. Which tool is the right fit?
Correct: B
Why B is correct: Calling a web API is I/O-bound — the thread is waiting on a network response, not doing CPU work. That's exactly the problem async/await and Task.WhenAll solve: freeing the thread during the wait rather than blocking it.
Why A and C are incorrect: Parallel.Invoke and Parallel.ForEach are blocking, CPU-bound tools built on top of the ThreadPool. Using them for I/O-bound waiting would tie up a thread per call, blocking it for the entire wait — exactly the waste async programming was designed to eliminate.
Why D is incorrect: A sequential loop with await inside it runs the calls one after another, adding up their durations instead of overlapping them.
Reinforcement: I/O-bound waiting → async/await family. CPU-bound computation → Parallel family.
2. A method needs to run three independent, CPU-heavy calculations — no network or disk involved — and can tolerate blocking the calling thread until they're all done. Which is the best fit?
Correct: C
Why C is correct: Three independent, CPU-bound operations with an acceptable blocking wait is precisely Parallel.Invoke's use case — it can run all three genuinely simultaneously on separate cores, finishing in roughly the time of the slowest one instead of the sum of all three.
Why A is incorrect: Task.WhenAll's benefit comes from not wasting threads during I/O waits — it doesn't, by itself, make CPU-bound work run on multiple cores at once.
Why B is incorrect: Running them sequentially uses only one core for the entire duration, leaving other cores idle for no reason.
Why D is incorrect: Adding async/await alone doesn't parallelize CPU-bound computation — there's nothing to "await" if there's no external wait involved.
Reinforcement: Parallel.Invoke is built specifically for a small, fixed set of independent, CPU-bound operations that can block the calling thread.
3. What does "Degree of Parallelism" refer to?
Correct: B
Why B is correct: Degree of Parallelism describes true simultaneity — how many pieces of work are actually executing at the same instant. It's capped by the number of available CPU cores, and can be capped further by an explicit limit you set (covered fully in the next lesson's ParallelOptions.MaxDegreeOfParallelism).
Why A is incorrect: The total count of Tasks created over a program's lifetime says nothing about how many were running simultaneously at any given moment.
Why C is incorrect: Loop iteration count is unrelated — a loop could iterate a million times while only a handful of iterations ever run in parallel at once.
Why D is incorrect: .NET's Parallel class doesn't have a "priority" concept for parallel operations in this sense.
Reinforcement: Degree of Parallelism is about genuine simultaneity, not total volume of work.
4. Two of the three actions passed to Parallel.Invoke throw exceptions. What happens?
Correct: B
Why B is correct: Parallel.Invoke waits for every action to finish (or fail), then throws a single AggregateException whose InnerExceptions collection contains every exception that occurred — not just the first one.
Why A is incorrect: Both exceptions are preserved and accessible; neither is silently dropped.
Why C is incorrect: The AggregateException is an ordinary .NET exception — it can be caught with a standard try/catch, exactly like any other exception.
Why D is incorrect: Exceptions are not swallowed — they propagate out of the Parallel.Invoke call as an AggregateException.
Reinforcement: Like Task.WhenAll, Parallel.Invoke captures every failure rather than only reporting the first one it encounters.
5. Why is it inaccurate to say "async/await and parallel programming solve the same problem, just with different syntax"?
Correct: B
Why B is correct: This is the central distinction of the lesson. async/await's win is not wasting a thread during an external wait — often without any thread actively working at all during that wait. Parallel programming's win is genuinely running computation on multiple cores at the same instant, when the CPU itself is the bottleneck.
Why A is incorrect: The underlying mechanisms and the problems they solve are fundamentally different, not just a difference in syntax.
Why C is incorrect: Both are general-purpose .NET techniques usable in web apps, desktop apps, console apps, and services alike.
Why D is incorrect: Neither is a "faster version" of the other — applying parallel techniques to I/O-bound waiting doesn't help, and applying async/await to CPU-bound computation doesn't help either. Each is the right tool for a different kind of problem.
Reinforcement: Match the tool to the bottleneck: waiting → async/await; computing → parallel programming.
You now have the core distinction this entire Part builds on: I/O-bound waiting vs. CPU-bound computation. Next up: a deep dive into Parallel.ForEach — the member of the Parallel family you'll use most often in real code.
dotnetmadeeasy.com — Learn C# and .NET, the right way.