How to limit the maximum number of parallel tasks in C
Master System Design with Codemia
Enhance your system design skills with over 120 practice problems, detailed solutions, and hands-on exercises.
Introduction
In C#, the three main ways to limit parallel tasks are SemaphoreSlim for async/await patterns, Parallel.ForEach with MaxDegreeOfParallelism for CPU-bound work, and Channel<T> or ActionBlock<T> for producer-consumer patterns. SemaphoreSlim is the most flexible and works with async code. Parallel.ForEach is simpler but blocks the calling thread. Choose based on whether your workload is I/O-bound (use SemaphoreSlim) or CPU-bound (use Parallel.ForEach).
SemaphoreSlim (Async/Await)
The most common approach for limiting concurrent async operations:
SemaphoreSlim(5) allows at most 5 tasks to execute concurrently. When a task finishes and calls Release(), the next waiting task proceeds.
Parallel.ForEach with MaxDegreeOfParallelism
For CPU-bound synchronous work:
MaxDegreeOfParallelism limits the number of threads used by the Parallel.ForEach loop. Set it to Environment.ProcessorCount to match the number of CPU cores.
Parallel.ForEachAsync (.NET 6+)
Combines Parallel.ForEach with async support:
This is the cleanest approach in .NET 6+ — it handles semaphore-like limiting internally and supports CancellationToken.
ActionBlock from TPL Dataflow
ActionBlock<T> from the System.Threading.Tasks.Dataflow NuGet package provides built-in concurrency limiting:
Channel-Based Producer-Consumer
Exactly 5 consumer tasks process items from the channel concurrently.
Comparison
| Approach | Async Support | Best For | .NET Version |
SemaphoreSlim | Yes | I/O-bound async work | All |
Parallel.ForEach | No | CPU-bound sync work | All |
Parallel.ForEachAsync | Yes | I/O-bound (simplest API) | .NET 6+ |
ActionBlock<T> | Yes | Producer-consumer pipelines | NuGet |
Channel<T> + consumers | Yes | Custom backpressure | .NET Core 3.0+ |
With Cancellation Support
Common Pitfalls
- Forgetting to Release the semaphore: If an exception occurs before
semaphore.Release(), the semaphore count decreases permanently, eventually deadlocking the application. Always release in afinallyblock. - Using
Parallel.ForEachwith async lambdas:Parallel.ForEachdoes not await async lambdas — it treats them as fire-and-forget. UseParallel.ForEachAsync(.NET 6+) orSemaphoreSlimfor async work. - Setting
MaxDegreeOfParallelismtoo high for I/O: For I/O-bound tasks (HTTP calls, database queries), more parallelism does not always mean faster. It can overwhelm the target server or exhaust connection pools. Start with 5-10 and tune based on results. Task.WhenAllwith no semaphore:Task.WhenAll(items.Select(ProcessAsync))launches ALL tasks immediately with no concurrency limit. If you have 10,000 URLs, all 10,000 HTTP requests fire at once. Always use a semaphore orParallel.ForEachAsyncto limit concurrency.- Disposing
SemaphoreSlimwhile tasks are waiting: Disposing the semaphore while tasks are blocked onWaitAsync()throwsObjectDisposedException. Wait for all tasks to complete before disposing the semaphore.
Summary
- Use
SemaphoreSlimwithWaitAsync/Releasefor async concurrency limiting - Use
Parallel.ForEachwithMaxDegreeOfParallelismfor synchronous CPU-bound work - Use
Parallel.ForEachAsync(.NET 6+) for the simplest async concurrency limiting API - Always release semaphores in
finallyblocks to prevent deadlocks - Choose concurrency limits based on the workload type — CPU cores for CPU-bound, lower limits for I/O-bound

