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(), andos.cpus()report the node's cores (or the CPU affinity mask) — not your cgroup quota. A pod withlimits.cpu: 2on 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 / library | Reads | When | Fix |
|---|---|---|---|
| Go ≤ 1.24 | node cores | startup | GOMAXPROCS, automaxprocs |
| Go 1.25+ | quota | startup + periodic | (behaves correctly) |
| JVM (JDK 10+) | quota | pools/GC/JIT fixed at startup | -XX:ActiveProcessorCount |
Python cpu_count() | node cores | per call | PYTHON_CPU_COUNT (3.13+) |
| Celery | node cores | worker boot | --concurrency=N |
| Gunicorn | (defaults to 1) | master boot | workers / WEB_CONCURRENCY |
| joblib/loky | quota | pool creation | (quota-aware; n_jobs to pin) |
Node.js os.cpus() | node cores | per call | size workers explicitly |
Node.js availableParallelism() | affinity; quota on libuv ≥ 1.49 | per call | verify your version |
| .NET | quota (rounded up) | fixed at startup | DOTNET_PROCESSOR_COUNT |
Rust available_parallelism | quota | per call (pools cache it) | TOKIO_WORKER_THREADS, RAYON_NUM_THREADS |
| OpenMP / OpenBLAS / MKL | node cores | library init | OMP_NUM_THREADS etc. |
| PyTorch / TensorFlow / ONNX | node cores | first op / session | explicit thread APIs |
| Ray | quota | node start | --num-cpus=N |
nproc | affinity; cgroup v2 quota in coreutils ≥ 9.8 | per call | make -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:
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
GOMAXPROCSon Go ≤ 1.24 defaults to the node's logical cores — not cgroup-aware at all.uber-go/automaxprocsreads the cgroup quota once atinit()and pinsGOMAXPROCS; later resizes are ignored until restart.- Go 1.25+ sets
GOMAXPROCSfrom 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. SettingGOMAXPROCSexplicitly disables the automatic behavior (GODEBUG=containermaxprocs=0/updatemaxprocs=0opt 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, andForkJoinPool.commonPool()(statically constructed atavailableProcessors() - 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()andmultiprocessing.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
--concurrencydefaults to the machine's CPU count — node cores, read once at worker boot. Gunicorn'sworkersactually defaults to1(viaWEB_CONCURRENCY) — the footgun is the documented2-4 × $(NUM_CORES)recipe that teams copy with node-derived core counts.ProcessPoolExecutordefaults tocpu_count()workers (affinity-basedprocess_cpu_count()since 3.13). - NumPy/SciPy pull in OpenMP/BLAS pools sized at import time — see the OpenMP section.
joblib/lokyis the Python exception: itscpu_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().lengthreports the node's cores; the docs themselves say not to size parallelism from it. The standardcluster-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.ProcessorCounthas 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.ThreadPoolheuristics 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-awarenum_cpuscrate) 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'snum_workersdefaults to 0; the widespreadnum_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
RuntimeErrorafter the first op. Usetf.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_threadsexplicitly. - Ray — actually cgroup-quota-aware (reads
cpu.max/ CFS quota at node start, rounding sub-core quotas up to 1), but reads it once atray.init(); bursting (no quota) falls back to node cores. Pass--num-cpus=Nexplicitly. - NVIDIA DALI / Triton — pipeline
num_threadsand instance-group settings are explicit configuration; the footgun is copyingcpu_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."