Back to blog

Python GIL and Kubernetes CPU Limits: The Threading Trap

I learned about the GIL the hard way, inside a CPU-limited pod. “Our Python API has four workers with four threads each. We gave it 4 CPU cores. Why is it throttled 80% of the time and only using 25% CPU?” I’ve heard this question dozens of times, and the answer always catches people off guard. It’s a classic case of two systems—Python’s GIL and Linux’s CFS scheduler—interacting in ways that neither documentation mentions.

I first encountered this problem years ago when we migrated a Django application to Kubernetes. The app had been running beautifully on a 4-core VM, handling hundreds of requests per second with gunicorn configured for 4 workers and 4 threads each. After the migration, with the same configuration and CPU resources, latency tripled and we saw constant CFS throttling. It took us two days to understand why.

Tested on: Python 3.11, Kubernetes 1.28, gunicorn with 4 workers

Understanding the Global Interpreter Lock

Before diving into the Kubernetes interaction, we need to understand what the GIL actually is and why Python has it.

The Global Interpreter Lock is a mutex that protects access to Python objects, preventing multiple threads from executing Python bytecode simultaneously. This exists because CPython’s memory management isn’t thread-safe. The reference counting mechanism that Python uses for garbage collection would race condition itself into oblivion without the GIL protecting it.

When you create multiple threads in Python, only one can execute Python code at any moment. The others wait, holding references to Python objects but unable to do anything with them. The GIL is released approximately every 5 milliseconds (configurable via sys.setswitchinterval()), allowing another thread to acquire it.

This leads to a counterintuitive reality: adding more threads to a CPU-bound Python application doesn’t make it faster. In fact, it often makes it slower due to the overhead of acquiring and releasing the GIL. Threads in Python are only beneficial for I/O-bound work, where they spend most of their time waiting for external operations (network, disk) and the GIL is released during those waits.

import sys
import threading
import time

# Check the default switch interval
print(f"GIL switch interval: {sys.getswitchinterval()} seconds")

# This CPU-bound work won't parallelize across threads
def cpu_intensive():
    total = 0
    for i in range(10_000_000):
        total += i
    return total

# Single thread: ~0.5 seconds
start = time.time()
cpu_intensive()
print(f"Single thread: {time.time() - start:.2f}s")

# Four threads: Still ~0.5 seconds (or worse!)
# They take turns holding the GIL
start = time.time()
threads = [threading.Thread(target=cpu_intensive) for _ in range(4)]
for t in threads:
    t.start()
for t in threads:
    t.join()
print(f"Four threads: {time.time() - start:.2f}s")

Understanding CFS CPU Quotas

Now let’s look at the other half of the equation: how Kubernetes enforces CPU limits.

Kubernetes uses Linux’s Completely Fair Scheduler (CFS) bandwidth control to enforce CPU limits. When you set a CPU limit of 1000m (one core), Kubernetes translates this into CFS parameters:

  • Period: 100ms (how often the quota resets)
  • Quota: 100ms (how much CPU time the container can use per period)

The critical insight is that CFS counts CPU time across all threads in the cgroup. If you have 4 threads, each running for 25ms in a period, that’s 100ms of CPU time consumed—your entire quota—even though wall-clock time was only 25ms.

Here’s where it gets tricky with Python. The GIL means only one thread executes Python code at a time, but CFS doesn’t know about the GIL. CFS sees threads requesting CPU time, being scheduled, and consuming cycles. When a thread is waiting for the GIL, it’s in a runnable state, and when it gets scheduled just to find the GIL is taken, that scheduling still counts against your quota.

Python threading reality:

GIL (Global Interpreter Lock):
- Only ONE thread executes Python bytecode at a time
- Threads take turns holding the GIL
- Context switch every 5ms (default)

Kubernetes CFS (Completely Fair Scheduler):
- CPU limit = quota per period (100ms default)
- 1000m = 100ms quota per 100ms period
- ALL threads share this quota

The clash:
┌─────────────────────────────────────────────────────────────┐
│ 4 Python threads, 1000m limit                                │
│                                                              │
│ Thread 1: [====GIL====].........[====GIL====]...            │
│ Thread 2: ............[====GIL====].........[====]           │
│ Thread 3: Wait for GIL                                       │
│ Thread 4: Wait for GIL                                       │
│                                                              │
│ But CFS sees: 4 threads × time = exceeds quota              │
│ Result: THROTTLED even though only 1 runs at a time!        │
└─────────────────────────────────────────────────────────────┘

The result is a worst-of-both-worlds scenario: you get the concurrency limitations of the GIL (only one thread runs Python code at a time) combined with the resource accounting of having multiple threads (CFS counts all of them).

Diagnosing the Problem

When you’re throttled by CFS, the symptoms can be confusing. Your monitoring shows low CPU utilization—maybe 25-30% on a 4-core limit—but your application is slow and unresponsive. That’s because throttling happens at the container level, and when you’re throttled, you’re throttled, regardless of how much CPU you were actually using.

Here’s how to check for CFS throttling:

# Check throttling
cat /sys/fs/cgroup/cpu.stat

nr_periods 10000
nr_throttled 8000      # 80% throttled!
throttled_usec 450000000

# Despite low CPU usage in top/htop
# GIL means threads wait, but CFS counts their CPU time

The nr_throttled value shows how many periods the container was throttled. In this example, 80% of all scheduling periods hit the CPU quota and were throttled. The throttled_usec shows the total time the container spent throttled—450 seconds in this case.

From inside the container, you can also check these values:

# In cgroups v2 (modern kernels)
cat /sys/fs/cgroup/cpu.stat | grep throttled

# In cgroups v1 (older systems)
cat /sys/fs/cgroup/cpu/cpu.stat
cat /sys/fs/cgroup/cpu/cpu.cfs_quota_us    # Your quota
cat /sys/fs/cgroup/cpu/cpu.cfs_period_us   # The period

The Real Cost of GIL + CFS

Let me illustrate with real numbers from a production incident. A team was running a Flask application with gunicorn configured like this:

# Their original gunicorn config
workers = 1
threads = 8
worker_class = "gthread"

Their Kubernetes resources:

resources:
  requests:
    cpu: "1"
  limits:
    cpu: "1"

They expected 8 concurrent requests to be handled. Instead, they saw:

  • 75% CFS throttling rate
  • 200ms p99 latency (should have been 50ms)
  • CPU usage appearing as only 30% in metrics

The math of what was happening:

  1. Period: 100ms
  2. Quota: 100ms (1 CPU core)
  3. 8 threads competing for GIL: Each thread gets scheduled, but only one actually runs Python
  4. Overhead: Thread scheduling, GIL acquisition, context switches—all count as CPU time
  5. Result: 100ms quota exhausted in ~30ms wall-clock time

The threads weren’t doing useful work. They were burning CPU time waiting for the GIL, getting scheduled just to find it locked, spinning briefly, and sleeping again.

Solutions

1. Use Processes Instead of Threads

The most effective solution is to use multiple worker processes instead of threads. Each Python process has its own GIL, so they can truly run in parallel. The kernel schedules them independently, and CFS accounting works as expected.

# gunicorn.conf.py

# Bad: Threads compete for GIL
# workers = 1
# threads = 4

# Good: Separate processes, each with own GIL
workers = 4
threads = 1

# Or for async workloads
worker_class = "uvicorn.workers.UvicornWorker"
workers = 4

With this configuration:

  • 4 separate processes, each with its own Python interpreter and GIL
  • Each process can fully utilize its share of CPU
  • CFS accounting matches actual CPU usage

The tradeoff is memory: each process loads the full Python runtime and your application code. For a typical web application, this might be 100-300MB per worker. Plan your memory limits accordingly.

2. Match Workers to CPU Limit

Don’t set more workers than you have CPU quota. If your limit is 2 CPUs, use 2 workers. Going higher creates contention without benefit—the same situation as threads with the GIL, just at the process level.

# deployment.yaml
apiVersion: apps/v1
kind: Deployment
spec:
  template:
    spec:
      containers:
        - name: app
          resources:
            requests:
              cpu: "2"
            limits:
              cpu: "2"
          env:
            # Workers = CPU limit
            - name: WEB_CONCURRENCY
              value: "2"
            # OR use downward API
            - name: CPU_LIMIT
              valueFrom:
                resourceFieldRef:
                  resource: limits.cpu
# gunicorn.conf.py
import os

cpu_limit = int(os.environ.get('CPU_LIMIT', 1))
workers = cpu_limit  # Match workers to CPU limit
threads = 1

Using the Kubernetes Downward API to inject the CPU limit as an environment variable ensures your application always scales correctly with its resource allocation. When you increase the limit, the application automatically spawns more workers.

3. Async Instead of Threads

For I/O-bound workloads—which describes most web applications that call databases, APIs, and other services—async/await is often the best choice. A single async worker can handle thousands of concurrent requests because it releases the GIL during I/O waits and never needs multiple threads to achieve concurrency.

# For I/O-bound work, use async (no GIL contention)
from fastapi import FastAPI
import asyncio
import httpx

app = FastAPI()

@app.get("/")
async def handler():
    async with httpx.AsyncClient() as client:
        # Concurrent I/O without GIL issues
        results = await asyncio.gather(
            client.get("http://service-a/"),
            client.get("http://service-b/"),
            client.get("http://service-c/"),
        )
    return {"results": [r.json() for r in results]}

# Run with:
# uvicorn main:app --workers 2  # Workers = CPU limit

The async model shines when your application spends most of its time waiting for I/O. A single event loop can juggle thousands of in-flight requests, using minimal CPU. Combined with multiple worker processes (each running its own event loop), you get both concurrency and true parallelism.

However, async isn’t magic. If you have CPU-intensive code paths—image processing, complex calculations, data transformation—those will block the event loop and degrade performance. For mixed workloads, you might need to offload CPU-intensive work to a thread pool or separate workers.

# Mixing async with CPU-bound work
import asyncio
from concurrent.futures import ProcessPoolExecutor

executor = ProcessPoolExecutor(max_workers=2)

async def process_image(image_data: bytes) -> bytes:
    # Run CPU-intensive work in a separate process
    loop = asyncio.get_event_loop()
    result = await loop.run_in_executor(
        executor,
        cpu_intensive_image_processing,
        image_data
    )
    return result

Python 3.12+ and the Future of the GIL

It’s worth mentioning that Python is evolving. PEP 703 introduced an optional “free-threaded” build of CPython 3.13+ that can run without the GIL. This is experimental and opt-in, but it points to a future where Python threads might actually parallelize CPU-bound work.

As of December 2024, the GIL-free mode is not recommended for production. Many C extensions assume the GIL exists and aren’t thread-safe without it. But if you’re reading this in 2026 or later, check the current state of PEP 703—it might have changed the Python threading landscape.

For now, the advice remains: use processes for parallelism, async for I/O concurrency, and match your worker count to your CPU limit.

Monitoring for GIL + CFS Issues

Set up alerting on CFS throttling before it becomes a problem:

# Alert on Python GIL contention
- alert: PythonHighThrottling
  expr: |
    rate(container_cpu_cfs_throttled_periods_total[5m]) /
    rate(container_cpu_cfs_periods_total[5m]) > 0.5
  for: 10m
  labels:
    severity: warning
  annotations:
    summary: "Container {{ $labels.container }} throttled >50%"

A throttling rate above 10-15% warrants investigation. Above 50%, you’re definitely leaving performance on the table. Zero throttling is ideal but not always achievable—some burst usage is normal.

Also monitor the relationship between CPU usage and throttling:

# Low CPU but high throttling = GIL/threading issue
- alert: PossibleGILContention
  expr: |
    rate(container_cpu_cfs_throttled_periods_total[5m]) /
    rate(container_cpu_cfs_periods_total[5m]) > 0.3
    AND
    rate(container_cpu_usage_seconds_total[5m]) < 0.5
  for: 10m
  labels:
    severity: warning
  annotations:
    summary: "Container {{ $labels.container }} throttled but low CPU - check GIL"

If you’re being throttled but your CPU utilization is low, that’s a classic sign of the GIL + CFS interaction. Time to review your worker/thread configuration.

Best Practices Checklist

## Python + Kubernetes CPU

### Configuration
- [ ] Use workers, not threads for parallelism
- [ ] Match worker count to CPU limit
- [ ] Consider async for I/O-bound workloads
- [ ] Set memory limits to accommodate multiple workers

### Monitoring
- [ ] Track CFS throttling rate
- [ ] Alert on throttling > 25%
- [ ] Correlate throttling with CPU utilization
- [ ] Profile with py-spy or cProfile to find CPU-intensive code paths

### Testing
- [ ] Load test with production-like concurrency
- [ ] Verify no throttling under normal load
- [ ] Ensure graceful degradation under overload

Conclusion

The interaction between Python’s GIL and Kubernetes’ CFS scheduler creates a trap that’s easy to fall into and confusing to diagnose. Multiple threads in Python don’t give you parallelism, but they do give you increased CFS accounting overhead. The result is throttling despite low actual CPU usage.

The solution is straightforward once you understand the problem:

  1. GIL means one thread at a time but CFS counts all threads
  2. Use processes instead of threads for parallelism
  3. Match workers to CPU limit exactly—no more, no less
  4. Consider async for I/O-bound workloads where threads aren’t needed

Check your throttling stats with cat /sys/fs/cgroup/cpu.stat. If you’re seeing high nr_throttled numbers with low CPU utilization, you’ve found the GIL + CFS trap. Fix your worker configuration, and watch both your throttling and your latency improve.


Related posts

Cite this article

If you reference this post, please link to the original URL and credit the author.

Michal Drozd. "Python GIL and Kubernetes CPU Limits: The Threading Trap". https://www.michal-drozd.com/en/blog/python-gil-kubernetes-cpu-limits/ (Published October 27, 2025).