# A Container Given 1.2 CPUs Tells the Runtime It Has 2

> Environment.ProcessorCount is the CPU quota rounded up, never below 1. The thread pool minimum follows it, and Server GC switches itself off at 1.

- Published: 2026-10-04
- Tags: runtime, containers, async
- Source: https://csharpmind.com/blog/how-many-cpus-a-container-tells-the-runtime-it-has/
- Language: en-GB
- Author: Callum Reeve

---

## TL;DR

- On .NET 10.0.12 in a Linux arm64 container, Environment.ProcessorCount was the --cpus quota rounded up to the next integer and never below 1, so 0.4, 0.5 and 1 all reported 1, 1.2 reported 2 and 2.3 reported 3.
- The kernel kept enforcing the fractional quota, since at --cpus 1.2 the process burned between 1.13 and 1.21 CPU-seconds per second while the runtime sized itself for 2.
- The thread pool minimum equalled ProcessorCount in every configuration tested, and Server GC heap count followed it, except that a count of 1 turned a Server GC request into workstation GC.
- With a cpuset the smaller of quota and affinity won, and DOTNET_PROCESSOR_COUNT overrode the reported count but did not raise the Server GC heap count above the number of CPUs in the affinity mask.

---

At `--cpus 1.2`, `Environment.ProcessorCount` reported 2, while the process burned 1.13 to 1.21 CPU-seconds per second. At `--cpus 2.3` it reported 3. At `--cpus 0.4`, 0.5 and 1 it reported 1.

Measured on .NET 10.0.12 in a Linux arm64 container, the count is the quota rounded up and floored at one, and the kernel does not round with it. Every default the runtime derives from the count inherits the rounded figure, while the scheduler keeps enforcing the fractional one. That gap is the whole subject: the number the runtime reports and the number the container is granted stop agreeing as soon as the limit is not an integer.

## The count is the quota rounded up, floored at one

The rule is short enough to quote. In the runtime's cgroup code at the `v10.0.0` tag, the conversion from quota and period to a processor count is:

```cpp
static void ComputeCpuLimit(long long period, long long quota, uint32_t *val)
{
    // Cannot have less than 1 CPU
    if (quota <= period)
    {
        *val = 1;
        return;
    }

    // Calculate cpu count based on quota and round it up
    double cpu_count = (double) quota / period  + 0.999999999;
    *val = (cpu_count < UINT32_MAX) ? (uint32_t)cpu_count : UINT32_MAX;
}
```

That is the behaviour reported in [dotnet/runtime issue 48094](https://github.com/dotnet/runtime/issues/48094), where `--cpus 0.4` returned 1; the resolution, pull request 52492, added `DOTNET_PROCESSOR_COUNT` as an opt-in override instead of changing the default. The sweep below confirms it against the 10.0.12 build. The `cpu.max` column is the raw cgroup value read inside each container, and the last column is CPU time from `dotnet-counters`:

```text
limit        cpu.max         ProcessorCount   user CPU s/s (min-max)
none         max 100000      12               11.08 (10.91-11.35)
--cpus 0.4   40000 100000    1                0.39 (0.39-0.40)
--cpus 0.5   50000 100000    1                0.49 (0.47-0.50)
--cpus 1     100000 100000   1                1.00 (0.95-1.04)
--cpus 1.2   120000 100000   2                1.17 (1.13-1.21)
--cpus 1.5   150000 100000   2                1.48 (1.47-1.49)
--cpus 2     200000 100000   2                1.98 (1.93-2.02)
--cpus 2.3   230000 100000   3                2.27 (2.25-2.30)
--cpus 4     400000 100000   4                3.94 (3.92-3.96)
```

Two rows carry the finding. The 1.2 and 2.3 limits separate rounding up from rounding to nearest, which would have given 1 and 2. And the last column shows the quota being enforced fractionally regardless: the runtime sizes for the integer, the kernel grants the fraction. How a quota a hair above an integer, such as 1.01, is treated was not measured.

## Two defaults that follow the rounded count

The thread pool minimum equalled `ProcessorCount` in every configuration tested. At `--cpus 1.5`, `ThreadPool.GetMinThreads` returned a worker minimum of 2 alongside a `ProcessorCount` of 2, although nothing forces the two to agree, and they never diverged. [Where thread pool starvation actually comes from](/blog/where-thread-pool-starvation-actually-comes-from/) showed the pool starting at one thread per core with twelve on a twelve-core machine. Inside a container the same rule applies to the rounded count, so a service limited to 1.5 CPUs gets a minimum of two threads on a quota that throttles it near 1.5.

The second default is the Server GC heap count. Read from `GC.GetConfigurationVariables()`, the value the collector actually initialised with, it tracked the rounded count: 2 heaps at `--cpus 1.2`, 3 at 2.3, 4 at 4.

```text
DOTNET_gcServer=1    ProcessorCount   IsServerGC   HeapCount
--cpus 0.4           1                False        1
--cpus 1             1                False        1
--cpus 1.2           2                True         2
--cpus 2.3           3                True         3
```

At a reported count of 1, a request for Server GC silently produced workstation GC. The result was identical with `DOTNET_GCDynamicAdaptationMode=0`, so it was not a side effect of the adaptive mode. What that adaptive mode does with heaps later, under allocation load, was not measured; the figures above are the count at initialisation.

## What a cpuset and the override change

A cpuset and a quota are two separate limits, and the smaller won in both directions. `--cpuset-cpus 0-1` alone reported 2, from the affinity mask. Adding `--cpus 0.5` reported 1. Adding `--cpus 4` reported 2 again, because the cpuset was the smaller.

`DOTNET_PROCESSOR_COUNT` replaced the reported value in every configuration. Set to 3 or 8, it produced a `ProcessorCount` and a thread pool minimum of 3 or 8 under every `--cpus` limit and under the cpuset. Heap count behaved differently. Under `--cpus` alone the affinity mask still held all twelve CPUs, and Server GC followed the override: 3 heaps, or 8. Under `--cpuset-cpus 0-1` the override raised the count to 3 or 8 and Server GC still created 2 heaps. In these runs the heap count was the smaller of `ProcessorCount` and the number of CPUs in the affinity mask, which is a measured relationship and not a documented one. The override could also switch Server GC back on: `DOTNET_PROCESSOR_COUNT=3` with `DOTNET_gcServer=1` at `--cpus 0.4` gave three heaps inside a 0.4 CPU quota.

Thread counts under load were not stable enough to state as a rule. The maximum in a six-second CPU-bound run was `ProcessorCount` plus 1 in most runs, but it ranged from plus 1 to plus 5 across the two twelve-run sets, the largest gap coming without a limit, and some runs were still rising when the window closed.

## What this changes

Three checks, in the order they are usually got wrong.

1. Predict the count from the manifest before reading it. Divide the quota by the period, round up, floor at 1, then take the smaller of that and the cpuset. A limit of `1500m` in an orchestrator is `--cpus 1.5` in these terms, which reports 2 here; that translation was not measured.
2. Treat a fractional limit as a request for more threads than the quota funds. The pool minimum and the heap count are sized from the rounded figure, and the difference is paid as throttling, not as an error. If the workload blocks, that is the cost side of the thread pool arithmetic.
3. Set `DOTNET_PROCESSOR_COUNT` only for a reason that can be read back. It changed the count everywhere it was tried, and it was the only setting tried that revived Server GC below one full CPU, but it does not lift the heap count above the affinity mask, and it does not change what the kernel grants.

---

**Measured on** .NET SDK 10.0.401 / runtime 10.0.12, Linux (Ubuntu 24.04) arm64, in the project's Docker sandbox: Docker Desktop 29.8.1 on an Apple M4 Pro, a VM with 12 CPUs, kernel 7.0.14-linuxkit, cgroup v2, one container per limit; `nproc` reported 12 inside the container limited to `--cpus 1.2`. The `cpu.max` values are the raw cgroup files read inside each container. Configuration values come from fresh child processes started inside each container, because the runtime reads the cgroup and `DOTNET_*` settings once at startup; CPU time comes from `dotnet-counters collect` over five one-second samples of a six-second CPU-bound load. The probe tables of a second, independent set of twelve runs were byte-identical to the first. This is a configuration probe and no throughput claim is made, so BenchmarkDotNet was not used. What the harness cannot see: memory limits (none were set), Windows containers, x64, a cgroup v1 host, quotas just above an integer, and any runtime other than 10.0.12; the source quoted is from the `v10.0.0` tag, not from 10.0.12.

---

## Frequently asked

### Does Environment.ProcessorCount round a fractional CPU limit down or to the nearest integer?

Neither. On .NET 10.0.12 it rounded up. A limit of 1.2 reported 2 and 2.3 reported 3, which excludes round-to-nearest, and 0.4 reported 1 because the count is never below 1.

### Does the thread pool minimum use the fractional quota?

No. In every configuration measured, ThreadPool.GetMinThreads returned a worker count equal to Environment.ProcessorCount, so a 1.5 CPU limit gave a minimum of 2 threads. The kernel still throttled the process to roughly 1.5 CPUs.

### Will Server GC run in a container limited to one CPU?

Not by default. With a reported ProcessorCount of 1, a request for Server GC produced workstation GC with a single heap in the measurement. Setting DOTNET_PROCESSOR_COUNT to 3 alongside DOTNET_gcServer=1 produced Server GC with 3 heaps inside a 0.4 CPU quota.

