Technology1 min read

Your .NET parallel loop can become serial in a one-CPU container

By · Published by Everything Blog

In short

The performance drop occurs because .NET's `Parallel.ForEachAsync` relies by default on processor detection, which resolves to one in constrained container environments. While manually increasing concurrency settings restores simultaneous operations, doing so introduces the danger of overwhelming dependent services. Applications must implement isolated concurrency limits and rate limiters for distinct external endpoints rather than depending on a single global throttle.

Key points

  • Asynchronous tasks bound by container CPU detection risk running serially without produci…: Asynchronous tasks bound by container CPU detection risk running serially without producing outward application errors.
  • Other execution environments such as Go 1.25 and the JVM similarly detect container proce…: Other execution environments such as Go 1.25 and the JVM similarly detect container processor caps.
  • Production systems need separate, independent concurrency boundaries and rate limits tail…: Production systems need separate, independent concurrency boundaries and rate limits tailored to each downstream dependency.

Sources checked by our analysis pipeline: 1

Same loop, twice the time

In my lab, changing a container's CPU limit from two CPUs to one almost doubled the time needed to process 200 records: 42.1 seconds became 83.6 seconds. These are medians from five runs of each configuration. The two-CPU version processed two records at once; the one-CPU version processed one. Both wrote all 200 records.

I think this kind of slowdown is easy to miss: the batch still finishes and every record gets written. Without a baseline for how long it should take, the extra waiting can look like ordinary service latency.

Each record needed three enrichment calls followed by a write to a system of record. I used this familiar pattern:

await Parallel.ForEachAsync(records, cancellationToken, async (record, ct) =>

{

await ProcessAsync(record, ct);

});

Why the default becomes one

Parallel.ForEachAsync without ParallelOptions allows at most Environment.ProcessorCount concurrent operations. Passing new ParallelOptions { CancellationToken = ct } leaves MaxDegreeOfParallelism at its initial value, -1. For ForEachAsync, that means ProcessorCount again with the default scheduler. Setting -1 explicitly does not remove the limit.

The cancellation-token-only options are what…

Technology