Parallel.ForEach
threading
task parallelism
.NET
concurrency

Does Parallel.ForEach limit the number of active threads?

Interview Questions practice on Codemia

Over 8,000 real interview questions from top companies, searchable by company and role.

Browse interview questions

Introduction

Yes, Parallel.ForEach limits the number of active threads. It uses the .NET ThreadPool and an internal partitioner that dynamically adjusts concurrency based on system load, available cores, and work item duration. By default, it does not spin up one thread per element — it starts conservatively and scales up or down. You can explicitly set a maximum degree of parallelism using ParallelOptions.MaxDegreeOfParallelism to cap the number of concurrent operations.

Default Behavior

csharp
1var items = Enumerable.Range(1, 100).ToList();
2
3Parallel.ForEach(items, item =>
4{
5    Console.WriteLine($"Item {item} on thread {Thread.CurrentThread.ManagedThreadId}");
6    Thread.Sleep(100); // Simulate work
7});

By default, Parallel.ForEach uses the ThreadPool's hill-climbing algorithm to determine how many threads to use. It starts with a small number (typically equal to the processor count) and adds or removes threads based on throughput measurements. For CPU-bound work, it generally converges to one thread per core.

Setting MaxDegreeOfParallelism

csharp
1var options = new ParallelOptions
2{
3    MaxDegreeOfParallelism = 4 // Never use more than 4 threads
4};
5
6Parallel.ForEach(items, options, item =>
7{
8    Console.WriteLine($"Item {item} on thread {Thread.CurrentThread.ManagedThreadId}");
9    Thread.Sleep(100);
10});

MaxDegreeOfParallelism sets an upper bound on concurrent executions. The runtime may still use fewer threads if it determines that is optimal. Setting it to -1 (the default) means no explicit limit — the runtime decides.

When to Limit Parallelism

I/O-bound work (database, HTTP, file access)

csharp
1// Limit to avoid overwhelming a database connection pool
2var options = new ParallelOptions { MaxDegreeOfParallelism = 10 };
3
4Parallel.ForEach(userIds, options, userId =>
5{
6    using var connection = new SqlConnection(connectionString);
7    connection.Open();
8    var user = QueryUser(connection, userId);
9    ProcessUser(user);
10});

Without a limit, Parallel.ForEach on I/O-bound work can spin up dozens of threads (because the ThreadPool detects threads are idle during I/O waits), overwhelming the database server or exhausting the connection pool.

Rate-limited APIs

csharp
1// Limit to match API rate limits
2var options = new ParallelOptions { MaxDegreeOfParallelism = 5 };
3
4Parallel.ForEach(urls, options, url =>
5{
6    var response = httpClient.GetStringAsync(url).Result;
7    ProcessResponse(response);
8});

Cancellation Support

csharp
1var cts = new CancellationTokenSource();
2
3var options = new ParallelOptions
4{
5    MaxDegreeOfParallelism = 4,
6    CancellationToken = cts.Token
7};
8
9try
10{
11    Parallel.ForEach(items, options, (item, state) =>
12    {
13        if (ShouldStop(item))
14        {
15            state.Stop();  // Stop processing new items
16            return;
17        }
18
19        ProcessItem(item);
20    });
21}
22catch (OperationCanceledException)
23{
24    Console.WriteLine("Operation was cancelled");
25}

ParallelLoopState.Stop() prevents new iterations from starting but lets in-progress iterations finish. ParallelLoopState.Break() stops after all iterations with indices lower than the current one complete.

Parallel.ForEach vs Task.WhenAll

csharp
1// Parallel.ForEach — blocks the calling thread, good for CPU-bound work
2Parallel.ForEach(items, new ParallelOptions { MaxDegreeOfParallelism = 4 }, item =>
3{
4    CpuIntensiveWork(item);
5});
6
7// Task.WhenAll with SemaphoreSlim — async, good for I/O-bound work
8var semaphore = new SemaphoreSlim(4); // Limit concurrency
9var tasks = items.Select(async item =>
10{
11    await semaphore.WaitAsync();
12    try
13    {
14        await IoIntensiveWorkAsync(item);
15    }
16    finally
17    {
18        semaphore.Release();
19    }
20});
21await Task.WhenAll(tasks);

For async/I/O-bound operations, Task.WhenAll with a semaphore is preferred over Parallel.ForEach because it does not block threads while waiting for I/O.

How the ThreadPool Scales

csharp
1// Observe thread scaling behavior
2Console.WriteLine($"Processor count: {Environment.ProcessorCount}");
3Console.WriteLine($"Min threads: {ThreadPool.GetMinThreads(out _, out _)}");
4Console.WriteLine($"Max threads: {ThreadPool.GetMaxThreads(out _, out _)}");
5
6Parallel.ForEach(Enumerable.Range(1, 50), item =>
7{
8    Console.WriteLine($"Thread {Thread.CurrentThread.ManagedThreadId} " +
9                      $"at {DateTime.Now:HH:mm:ss.fff}");
10    Thread.Sleep(1000); // Simulate slow work
11});

The ThreadPool starts with Environment.ProcessorCount threads. If threads are blocked (e.g., by Thread.Sleep or I/O), the pool injects new threads every 500ms-1s. This slow injection rate is why Parallel.ForEach appears to gradually increase parallelism for blocking operations.

Parallel.ForEachAsync (.NET 6+)

.NET 6 introduced Parallel.ForEachAsync for async workloads:

csharp
1await Parallel.ForEachAsync(urls,
2    new ParallelOptions { MaxDegreeOfParallelism = 10 },
3    async (url, cancellationToken) =>
4    {
5        var response = await httpClient.GetStringAsync(url, cancellationToken);
6        ProcessResponse(response);
7    });

This is the recommended approach for I/O-bound parallel work in modern .NET. It respects MaxDegreeOfParallelism and does not block threads during async waits.

Common Pitfalls

  • Not setting MaxDegreeOfParallelism for I/O work: The ThreadPool keeps injecting threads when existing ones block on I/O, potentially creating hundreds of threads that overwhelm external services. Always set an explicit limit for I/O-bound operations.
  • Using Parallel.ForEach for async work: Parallel.ForEach expects synchronous delegates. Calling .Result or .Wait() inside it blocks ThreadPool threads and can cause deadlocks. Use Parallel.ForEachAsync or Task.WhenAll with a semaphore instead.
  • Assuming one thread per item: Parallel.ForEach partitions the collection and reuses threads across partitions. The number of threads is typically far less than the number of items.
  • Shared mutable state without locking: Parallel.ForEach executes delegates on multiple threads simultaneously. Accessing shared variables (counters, lists, dictionaries) without lock, Interlocked, or ConcurrentDictionary causes race conditions.
  • Setting MaxDegreeOfParallelism = 1 for debugging: While this serializes execution for debugging, it changes behavior (no parallel exceptions, different timing). Use conditional compilation or a debugger conditional instead.

Summary

  • Parallel.ForEach dynamically limits threads using the ThreadPool's hill-climbing algorithm
  • Use ParallelOptions.MaxDegreeOfParallelism to set an explicit upper bound on concurrency
  • For CPU-bound work, the default (processor count) is usually optimal
  • For I/O-bound work, always set an explicit limit to avoid thread explosion
  • Use Parallel.ForEachAsync (.NET 6+) for async operations instead of blocking inside Parallel.ForEach

Related reading
Course
Intermediate
27 lessons
14 hours
OOD Fundamentals

Master object-oriented design from first principles, SOLID, design patterns, and classic interview problems with hands-on coding.

View the course
Track what you have practised

A free account saves your progress, solutions and study plan across every problem on Codemia.

Interview Questions practice on Codemia

Over 8,000 real interview questions from top companies, searchable by company and role.

Browse interview questions

All Rights Reserved.