Tasks and Parallelism
A Task represents a unit of asynchronous work, but Tasks serve two distinct purposes that are easy to conflate: representing asynchronous, I/O-bound operations (as covered under async/await) and representing parallel, CPU-bound work distributed across multiple cores via the thread pool. The Task Parallel Library (TPL) provides Task.Run for offloading CPU-bound work onto a thread-pool thread, plus higher-level constructs like Parallel.For, Parallel.ForEach, and PLINQ (Parallel LINQ) for expressing data-parallel algorithms concisely without manually managing threads.
Cricket analogy: A Task is like a scorecard-update request that could mean waiting on the operator (I/O-bound) or a statistician crunching a whole season's numbers alone (CPU-bound); Task.Run assigns that crunching to an analyst, while Parallel.ForEach splits the season's matches across a team at once.
Task.Run for CPU-bound work
Task.Run queues a delegate onto the thread pool and returns a Task representing its completion, making it the right tool when you have genuinely CPU-intensive work — image processing, complex calculations — that would otherwise block a thread doing nothing but computing. It is the wrong tool for I/O-bound work: wrapping an already-async I/O call in Task.Run wastes a thread-pool thread waiting on it instead of letting the natural async I/O completion mechanism free that thread entirely.
Cricket analogy: Task.Run is right for a statistician manually recalculating a full career batting average (CPU-bound), but wrong for waiting on the stadium scoreboard's network feed to update - assigning an analyst just to stare at the scoreboard wastes their time when the feed updates itself.
public static class ImageBatchProcessor
{
public static async Task<int[]> ComputeHistogramsAsync(IReadOnlyList<byte[]> images)
{
// Offload CPU-bound work to the thread pool, one Task per image
var tasks = images.Select(img => Task.Run(() => ComputeHistogram(img)));
return await Task.WhenAll(tasks);
}
private static int ComputeHistogram(byte[] image)
{
int sum = 0;
foreach (var b in image) sum += b; // stand-in for real CPU work
return sum;
}
public static void ProcessAllParallel(IReadOnlyList<string> paths)
{
// Parallel.ForEach partitions the work across available cores
Parallel.ForEach(paths, path =>
{
var data = File.ReadAllBytes(path);
ComputeHistogram(data);
});
}
}Parallel.For, Parallel.ForEach, and PLINQ
Parallel.For and Parallel.ForEach automatically partition a loop's iterations across multiple threads, applying a degree of parallelism the runtime tunes based on available cores (or one you cap via ParallelOptions.MaxDegreeOfParallelism). PLINQ extends LINQ with .AsParallel(), letting you parallelize a query pipeline declaratively. These are best suited to CPU-bound, independent (or safely synchronized) iterations; adding parallelism to work dominated by I/O or with heavy shared-state contention often makes things slower, not faster, due to synchronization overhead and thread scheduling costs.
Cricket analogy: Parallel.ForEach splits the job of re-scoring every match of a tournament across several analysts at once, capped by how many are available (MaxDegreeOfParallelism); PLINQ's AsParallel auto-splits a stats query, but parallelizing a single live scoreboard feed just causes analysts to collide.
Task composition: WhenAll, WhenAny, and continuations
Task.WhenAll awaits a collection of tasks and completes when they all finish, aggregating exceptions from any that failed. Task.WhenAny completes as soon as the first task finishes, useful for timeout patterns or racing multiple approaches. ContinueWith attaches a continuation directly to a Task without await, but is rarely needed in modern code since await expresses the same intent more readably and with better exception and context handling.
Cricket analogy: Task.WhenAll is like waiting for every fielder's throw-in before confirming a run-out review, aggregating any missed throws; Task.WhenAny is like accepting whichever of two runners reaches the crease first for a race call; ContinueWith is an old relay of instructions that await now handles more clearly.
The thread pool is a shared, bounded resource. Task.Run and Parallel.* both draw from it, so CPU-bound parallel work and I/O-bound async work compete for the same threads under heavy load — over-parallelizing CPU work can starve the pool of threads needed to service completing I/O callbacks.
Parallel.ForEach with a lambda that mutates shared state (a List<T>, a Dictionary<K,V>, a running total via +=) without synchronization is a common source of race conditions and corrupted collections. Use thread-safe collections (ConcurrentBag<T>, ConcurrentDictionary<K,V>) or Interlocked operations, or aggregate results with Parallel.ForEach's thread-local overload instead.
- Tasks represent both async I/O-bound work and parallel CPU-bound work — the two use cases require different tools.
- Task.Run offloads genuinely CPU-bound work to the thread pool; avoid it for already-async I/O calls.
- Parallel.For/ForEach and PLINQ distribute independent, CPU-bound loop iterations across cores automatically.
- Task.WhenAll aggregates results (and exceptions) from multiple concurrent tasks; Task.WhenAny races them.
- Parallelism adds synchronization overhead, so it only pays off for sufficiently expensive, independent work.
- Shared mutable state accessed from parallel code must be protected with thread-safe collections or locking.
Practice what you learned
1. When is Task.Run the appropriate choice?
2. What does Parallel.ForEach do?
3. What is the difference between Task.WhenAll and Task.WhenAny?
4. What is a common risk of using Parallel.ForEach to mutate a plain List<T> from multiple threads?
5. Why might adding parallelism make an operation slower rather than faster?
Was this page helpful?
You May Also Like
async/await Explained
Understand how async and await let you write non-blocking, readable asynchronous code in C#, and how the compiler transforms it into a state machine.
using and IDisposable
Learn how .NET manages unmanaged resources through the IDisposable pattern and how the using statement and using declarations guarantee deterministic cleanup.
Generics Explained
Understand how C# generics let you write type-safe, reusable code that works across many types without sacrificing performance or requiring casts.
Common C# Pitfalls
A tour of the mistakes that trip up C# developers most often — from closures over loop variables to silent null reference bugs and misused async void.
Related Reading
Related Study Notes in Programming
Browse all study notesApache Spark Study Notes
Programming · 30 topics
ProgrammingApache Flink Study Notes
Programming · 30 topics
ProgrammingHadoop Study Notes
Programming · 30 topics
ProgrammingSnowflake Study Notes
Programming · 30 topics
ProgrammingApache Airflow Study Notes
Programming · 30 topics
Programmingdbt (Data Build Tool) Study Notes
Programming · 30 topics