Skip to content

CPU footguns: runtimes that size themselves from the wrong CPU count

Almost every language runtime and compute framework decides its parallelism — thread pools, worker counts, GC threads — from some CPU number, read at some moment. Inside Kubernetes both parts go wrong:

  • The wrong number. APIs like nproc, os.cpu_count(), and os.cpus() report the node's cores (or the CPU affinity mask) — not your cgroup quota. A pod with limits.cpu: 2 on a 64-core node sees 64.
  • The wrong moment. Runtimes that are quota-aware usually read the quota once at startup. Under Headroom the limit changes at runtime (in-place resize), so a boot-sized pool never grows into a raised limit — and a pool sized on an idle node may be oversized once the node fills.

The kernel is unaffected either way: CFS enforces the current quota and cpu.weight regardless of what your runtime believes. The damage is second-order — too many threads timeslicing inside a small quota (latency, throttling) or too few threads using a large one (wasted capacity).

At a glance

Runtime / libraryReadsWhenFix
Go ≤ 1.24node coresstartupGOMAXPROCS, automaxprocs
Go 1.25+quotastartup + periodic(behaves correctly)
JVM (JDK 10+)quotapools/GC/JIT fixed at startup-XX:ActiveProcessorCount
Python cpu_count()node coresper callPYTHON_CPU_COUNT (3.13+)
Celerynode coresworker boot--concurrency=N
Gunicorn(defaults to 1)master bootworkers / WEB_CONCURRENCY
joblib/lokyquotapool creation(quota-aware; n_jobs to pin)
Node.js os.cpus()node coresper callsize workers explicitly
Node.js availableParallelism()affinity; quota on libuv ≥ 1.49per callverify your version
.NETquota (rounded up)fixed at startupDOTNET_PROCESSOR_COUNT
Rust available_parallelismquotaper call (pools cache it)TOKIO_WORKER_THREADS, RAYON_NUM_THREADS
OpenMP / OpenBLAS / MKLnode coreslibrary initOMP_NUM_THREADS etc.
PyTorch / TensorFlow / ONNXnode coresfirst op / sessionexplicit thread APIs
Rayquotanode start--num-cpus=N
nprocaffinity; cgroup v2 quota in coreutils ≥ 9.8per callmake -j"$CPU_LIMIT"

This page catalogs each in detail. The general-purpose fixes come first; the runtime notes assume them.

The two universal workarounds

1. Pin parallelism explicitly from the Downward API. Expose your CPU limit (or request) as an environment variable and size from it:

yaml
env:
  - name: CPU_LIMIT
    valueFrom:
      resourceFieldRef:
        resource: limits.cpu
        divisor: "1"        # whole cores, fractional values round UP (500m → 1)

Use divisor: "1m" if you need exact millicores. Two caveats: if the container has no limit set, the kubelet substitutes node allocatable; and resourceFieldRef env vars are static — an in-place resize does not update them (only downwardAPI volumes update live), so this is a boot-time value. It is still your value, derived from your actual allocation instead of the node's core count. Cap Headroom to match with the kube-headroom.dev/max-cpu annotation if your pool must never be undersized relative to the limit.

2. Accept birth-limit sizing. Headroom's admission webhook (when enabled) seeds a generous initial limit at pod CREATE time, so quota-aware runtimes boot with a sensible number even though they won't track later raises. Good enough for most services; not for pools you've tuned by hand.

Go

  • GOMAXPROCS on Go ≤ 1.24 defaults to the node's logical cores — not cgroup-aware at all.
  • uber-go/automaxprocs reads the cgroup quota once at init() and pins GOMAXPROCS; later resizes are ignored until restart.
  • Go 1.25+ sets GOMAXPROCS from the cgroup CPU limit natively (rounded up, never below 2) and updates it periodically as the limit changes (release notes) — the best-behaved runtime on this page, and the one case where Headroom's live raises are picked up automatically. Setting GOMAXPROCS explicitly disables the automatic behavior (GODEBUG=containermaxprocs=0 / updatemaxprocs=0 opt out).

Workaround: upgrade to Go 1.25+, or set GOMAXPROCS from the Downward API: GOMAXPROCS: $(CPU_LIMIT).

JVM (Java, Kotlin, Scala)

  • Container support (JDK-8146115, default on since JDK 10, backported to 8u191) derives the active processor count from the cgroup quota, rounded up.
  • The subtle part: Runtime.availableProcessors() is actually re-read dynamically (HotSpot caches container metrics for only ~20 ms) — but the sizings that matter are computed once at JVM startup and never revisited: GC worker threads, JIT compiler threads, default heap sizing, and ForkJoinPool.commonPool() (statically constructed at availableProcessors() - 1). A live-raised limit is visible to your code but not to the pools the JVM already built.

Workaround: -XX:ActiveProcessorCount=<n> (drive it from the Downward API); -Djava.util.concurrent.ForkJoinPool.common.parallelism=N for the common pool; size explicit executors from configuration, not availableProcessors().

Python

  • os.cpu_count() and multiprocessing.cpu_count() return the node's core count — never the cgroup quota (cpython#80235, open since 2019). os.process_cpu_count() (3.13+) honors the affinity mask — still not the quota.
  • Celery's --concurrency defaults to the machine's CPU count — node cores, read once at worker boot. Gunicorn's workers actually defaults to 1 (via WEB_CONCURRENCY) — the footgun is the documented 2-4 × $(NUM_CORES) recipe that teams copy with node-derived core counts. ProcessPoolExecutor defaults to cpu_count() workers (affinity-based process_cpu_count() since 3.13).
  • NumPy/SciPy pull in OpenMP/BLAS pools sized at import time — see the OpenMP section.
  • joblib/loky is the Python exception: its cpu_count() explicitly accounts for CFS quotas (evaluated at pool creation, not import).

Workaround: Python 3.13+ honors PYTHON_CPU_COUNT=N (or python -X cpu_count=N) for both cpu_count APIs; set worker counts explicitly (WEB_CONCURRENCY, celery --concurrency=N) from the Downward API; set the BLAS env vars below before interpreter start.

OpenMP, OpenBLAS, MKL (and everything built on them)

  • Default thread count = cores visible at library initialization (first parallel region, or import time for NumPy/SciPy/PyTorch CPU ops). Affinity is respected; cgroup quota is not. MKL sizes from physical cores.
  • One oversubscribed BLAS pool per process, multiplied by forked workers, is the classic "why is my 2-CPU pod at 6400% internal contention" incident.

Workaround: OMP_NUM_THREADS, OPENBLAS_NUM_THREADS, MKL_NUM_THREADS (set them in the pod spec from the Downward API — they must exist before import/init).

Node.js

  • os.cpus().length reports the node's cores; the docs themselves say not to size parallelism from it. The standard cluster-module pattern (availableParallelism() workers, forked by your own loop) inherits whatever that call returns.
  • os.availableParallelism() is affinity-aware; cgroup quota awareness arrived in libuv 1.49 (bundled from Node 23.1) via libuv#4278 — but it is version-dependent and has had correctness bugs under Kubernetes (libuv#4740). Verify on your Node version before trusting it.
  • The libuv pool (UV_THREADPOOL_SIZE) defaults to a fixed 4 — undersized for big quotas rather than oversized, but the same class of problem.

Workaround: explicit worker count from the Downward API; UV_THREADPOOL_SIZE=$(CPU_LIMIT) where I/O-bound.

.NET

  • Environment.ProcessorCount has been cgroup-quota-aware since .NET Core 3.0 (quota rounded up), and is fixed at runtime startup for the process lifetime — the docs say so explicitly. ThreadPool heuristics and GC sizing derive from it at startup.

Workaround: DOTNET_PROCESSOR_COUNT=N from the Downward API; ThreadPool.SetMinThreads/SetMaxThreads for the pool.

Rust

  • std::thread::available_parallelism()is cgroup-quota-aware on Linux (v2 since 1.61, v1 since 1.64) and is deliberately not cached — it recomputes on every call. The footgun is downstream: Tokio sizes its workers once at runtime construction (via the quota-aware num_cpus crate) and never resizes; Rayon builds its global pool once at first use.

Workaround: TOKIO_WORKER_THREADS / RAYON_NUM_THREADS from the Downward API when you need a different number than the boot-time quota.

ML / GPU frameworks

GPU workloads are CPU victims here: dataloaders, preprocessing, and inter-op pools are CPU-bound, and starving them idles the GPU — the exact failure Headroom exists to relieve (see the applicability matrix).

  • PyTorch — intra-op and inter-op pools default to the visible core count (via OpenMP/native backends — node cores, not quota), fixed when the parallel backend initializes; the docs warn to call torch.set_num_threadsbefore running eager, JIT or autograd code. DataLoader's num_workers defaults to 0; the widespread num_workers=os.cpu_count() convention imports the node-cores problem.
  • TensorFlow — inter-op/intra-op pools default to "system-picked" (host cores) and are frozen at context initialization — the setters literally raise RuntimeError after the first op. Use tf.config.threading.set_*_parallelism_threads(N) before any op runs.
  • ONNX Runtime — thread pools are created at session creation, one thread per physical core by default; set SessionOptions.intra_op_num_threads explicitly.
  • Ray — actually cgroup-quota-aware (reads cpu.max / CFS quota at node start, rounding sub-core quotas up to 1), but reads it once at ray.init(); bursting (no quota) falls back to node cores. Pass --num-cpus=N explicitly.
  • NVIDIA DALI / Triton — pipeline num_threads and instance-group settings are explicit configuration; the footgun is copying cpu_count()-derived values into them.

Workaround: every one of these has an explicit knob — drive it from the Downward API, not from cpu_count().

nproc and shell scripts

nproc honors the affinity mask, and only in coreutils ≥ 9.8 the cgroup v2 quota (v1 never). On the typical CI image, make -j$(nproc) in a pod on a 96-core node is 96 compile jobs inside whatever quota you were given. Prefer make -j"${CPU_LIMIT}" via the Downward API.


The pattern across the whole page: only Go 1.25+ tracks the quota live; the JVM re-reads it but its pools don't; Rust recomputes it but its pools don't; everything else reads node cores, affinity, or a boot-time quota snapshot. So a live-raised CPU limit generally does not grow existing thread pools without a restart — which is why the tenant guide recommends explicit sizing or birth-limit acceptance, and why the applicability matrix lists boot-time-sized runtimes as "partial benefit."