🚀 Parallel.ForEachAsync vs Task.Run in C#: A Beginner’s Guide
When you start writing C# code that needs to do things at the same time, two methods often come up:
- Task.Run
- Parallel.ForEachAsync
At first, they both look like “magic ways to run things in parallel.”
But actually, they are built for different jobs. Let’s explore them step by step.
- First things first: what do they do?
🟢 Task.Run
Think of Task.Run as saying:
👉 “Run this one heavy piece of work on a background thread so my app doesn’t freeze.”
It’s great for CPU-bound work like:
- resizing an image
- encrypting a file
- running a long calculation
🟢 Parallel.ForEachAsync
This one is more like:
👉 “I have a list of things to process. Please handle them in parallel, but don’t overload me—keep it under control.”
It’s built for async I/O work like:
- calling multiple APIs
- downloading files
- querying a database
It also lets you set a limit (MaxDegreeOfParallelism) so you don’t spam the server or your own machine.
And yes, it supports CancellationToken out of the box.
- 📜 A quick history lesson
When Parallel.ForEach was first introduced in .NET 4 (2010), it was designed for CPU-bound, in-memory operations — things like processing arrays, crunching numbers, or running loops over data in memory.
Back then, calling web services inside Parallel.ForEach was discouraged because:
- Each request would block a thread while waiting.
- This caused thread-pool starvation and poor scalability.
That’s why the advice was: “Parallel.ForEach is only for CPU-bound in-memory work.”
🔹 Fast forward to .NET 6 (2021): the .NET team introduced Parallel.ForEachAsync.
Why?
- To bring the power of parallel loops to async/await workloads.
- To make it safe and efficient to run I/O-bound tasks (like HTTP requests, DB queries, or file I/O) in parallel.
- To give developers built-in throttling with MaxDegreeOfParallelism, so you don’t need to hand-roll SemaphoreSlim loops.
- So if you’ve heard that “Parallel is only for in-memory data” — that’s true for the old synchronous API, but with .NET 6+, Parallel.ForEachAsync is the recommended way to handle async I/O in parallel.
- Examples
Example A: Calling APIs in parallel (I/O-bound)
Here’s how you fetch multiple web pages at the same time:
var urls = new[]
{
"https://api.site.com/page/1",
"https://api.site.com/page/2",
"https://api.site.com/page/3"
};
await Parallel.ForEachAsync(
urls,
new ParallelOptions { MaxDegreeOfParallelism = 3, CancellationToken = cancellationToken },
async (url, ct) =>
{
var response = await httpClient.GetStringAsync(url, ct);
Console.WriteLine($"{url} => {response.Length} chars");
});
👉 At most 3 requests run at the same time.
👉 Each call respects cancellation.
👉 No threads are blocked while waiting on I/O.
📌 Alternative (simpler, no throttling):
await Task.WhenAll(urls.Select(u => httpClient.GetStringAsync(u, cancellationToken)));
Example B: Heavy image processing (CPU-bound)
Now let’s say you’re processing a huge image. This is CPU work, not I/O.
var result = await Task.Run(
() => HeavyImageProcessing(inputImage),
cancellationToken);
Console.WriteLine($"Processed {result.Count} pixels");
👉 Here, Task.Run makes sure the CPU-heavy task doesn’t block your main thread (like an ASP.NET request or UI).
**
Example C: CPU-heavy loop (many items)**
If you have lots of CPU work to spread across cores, use Parallel.ForEach:
Parallel.ForEach(
files,
new ParallelOptions { MaxDegreeOfParallelism = Environment.ProcessorCount },
file =>
{
ProcessFile(file); // CPU-heavy
});
👉 This keeps CPU cores busy without tying up async infrastructure.
- Mistakes to avoid
❌ Wrapping async calls in Task.Run
// Bad: don’t wrap async I/O
await Task.Run(() => httpClient.GetStringAsync(url));
This just wastes a thread. Use await httpClient.GetStringAsync(url) directly, or batch with Task.WhenAll/Parallel.ForEachAsync.
❌ Using Parallel.ForEachAsync for CPU-heavy loops
// Bad: CPU-heavy work inside Parallel.ForEachAsync
await Parallel.ForEachAsync(files, async (file, ct) =>
{
var data = ProcessFile(file); // CPU-bound
});
This ties up thread-pool threads and isn’t optimal.
âś… Use Parallel.ForEach/PLINQ (CPU) or throttle multiple Task.Runs.
- Rule of Thumb
📝 Cheat sheet:
- Parallel.ForEachAsync → many async I/O tasks (API calls, DB queries, file downloads) with throttling.
- Parallel.ForEach/PLINQ → bulk CPU-bound loops.
- Task.Run → a CPU task (or a few, but throttle if many).
- Both → accept a CancellationToken, so you can stop them if needed.
Think of it like this:
🔹 Parallel.ForEachAsync = “lots of async things at once”
🔹 Parallel.ForEach = “lots of CPU work in parallel”
🔹 Task.Run = “this one CPU-heavy thing, off the main thread”
🔹 CancellationToken = “and I can pull the plug anytime”
- Takeaway
Don’t mix them up:
If you’re waiting on the network, use Parallel.ForEachAsync (or Task.WhenAll if throttling isn’t needed).
If you’re burning the CPU across multiple items, use Parallel.ForEach or PLINQ.
If you need to push one heavy CPU job off the caller thread, use Task.Run.
Both make your code faster and more responsive when used in the right place.
👉 Next step: Try rewriting one of your loops. Ask yourself: is this I/O or CPU? That’s how you’ll know which tool to grab.
📚 References
Microsoft Docs – Parallel.ForEachAsync
Stephen Toub – Introducing Parallel.ForEachAsync in .NET 6
Top comments (2)
"This eats threads without benefit." This is not correct and I'm not sure what you trying trying to get at.
You’re right my wording wasn’t precise. What I meant is that using Task.Run inside a loop for I/O-bound operations can lead to unnecessary thread usage compared to Parallel.ForEachAsync, which schedules more efficiently. I’ll rephrase this part to avoid confusion.