# Python3 - 'GIL'T Free parallel processing is finally here

Ever since the free-threaded Python came out in (v3.13) and became official in Oct 2025 (v3.14), I have always really wanted to test-drive it. Since I don't write Python much these days, this was also a good way to play around and sharpen my concurrency skills.

Seeing is believing, so I wanted to build a demo to compare the GIL vs No-GIL impact on a concurrent setup. As i've been working on K8s controllers a bit those days, I made a multi-threaded benchmark that mimics a CPU-bound **k8s Controller**.

### Why does the GIL exist anyway?

If you ever wrote multi-threaded Python, you have hit the **Global Interpreter Lock (GIL)**.

It exists for one main reason: **memory safety**. CPython uses **reference counting** to manage memory. Every var., list, or dict tracks how many parts of your code point to it. When that count hits zero, Python end up frees the memory.

But if multiple threads try to update this counter at the same time, you get a **race condition**. This can crash your app or leak memory. To stop this, the creators of CPython added the GIL: a single giant lock. Only the thread holding the lock can run Python code.

This kept Python simple, but it created a big limit: even on a 32-core workstation, your multi-threaded Python runs on **one core at a time**.

### PEP 703: The No-GIL Fix

To remove the GIL safely, PEP 703 made 3 big upgrades:

1.  **Biased Ref Counting:** Objects are tracked differently depending on which thread made them. This stops CPU cache problems.
    
2.  **Thread-Safe Allocator:** It uses `mimalloc`, a very fast concurrent memory allocator.
    
3.  **Fine-Grained Locks:** It locks individual dicts and collections instead of locking the whole app.
    

### The k8s Use Case

In the cloud-native world, many custom operators and controllers are written in Python (using tools like `Kopf`). They use threads to handle events from the k8s API server.

If your operator gets too many events at once, it starts threads to handle them. If those threads do CPU work—like parsing JSON, changing config keys, or making crypto hashes—they fight for the GIL. On standard Python, this lock fight can make your multi-threaded code **slower** than a simple sequential loop!

To prove this, I wrote a script to simulate this exact workload.

### The Benchmark: `k8s_bench.py`

This script starts 4 threads to run 80k mock k8s tasks.

In each task, the worker must:

1.  Decode a raw JSON string of a Pod manifest.
    
2.  Mutate a key (injecting a unique `uid`).
    
3.  Serialize it back to bytes.
    
4.  Calculate a SHA-256 crypto hash to simulate config-drift checks.
    

```plaintext
import sys
import time
import threading
import json
import hashlib

def get_gil_status():
    try:
        enabled = sys._is_gil_enabled()
        return "ENABLED" if enabled else "DISABLED (No-GIL!)"
    except AttributeError:
        return "ENABLED (Legacy Interpreter)"

def simulate_k8s_reconciliation(worker_id, num_items):
    mock_pod = {
        "apiVersion": "v1",
        "kind": "Pod",
        "metadata": {"name": "nginx-ingress", "namespace": "ingress-nginx"},
        "spec": {
            "containers": [{"name": "nginx", "image": "nginx:1.25", "resources": {"requests": {"cpu": "100m"}}}]
        }
    }
    raw_json = json.dumps(mock_pod)
    
    hash_accumulator = 0
    for _ in range(num_items):
        parsed = json.loads(raw_json)
        parsed["metadata"]["uid"] = f"worker-{worker_id}-{_}"
        serialized = json.dumps(parsed).encode('utf-8')
        
        # CPU work: calculate SHA-256 hash
        hasher = hashlib.sha256()
        hasher.update(serialized)
        hash_accumulator += int(hasher.hexdigest()[:8], 16) % 1000
        
    return hash_accumulator

def run_benchmark():
    TOTAL_PODS = 80000
    NUM_WORKERS = 4
    PODS_PER_WORKER = TOTAL_PODS // NUM_WORKERS
    
    print("================================================================")
    print(f" PYTHON RUNTIME: {sys.version.split()[0]}")
    print(f" GIL STATUS:     {get_gil_status()}")
    print(f" WORKLOAD:       Reconciling {TOTAL_PODS:,} Pods via {NUM_WORKERS} tasks")
    print("================================================================\n")

    # Sequential Run
    print("Running Sequential (1 Thread)...")
    start_time = time.time()
    start_cpu = time.process_time()
    
    seq_acc = 0
    for i in range(NUM_WORKERS):
        seq_acc += simulate_k8s_reconciliation(worker_id=i, num_items=PODS_PER_WORKER)
        
    seq_real_time = time.time() - start_time
    seq_cpu_time = time.process_time() - start_cpu
    
    print(f"--> [SEQ] Real Time: {seq_real_time:.4f}s | CPU Time: {seq_cpu_time:.4f}s (Sum: {seq_acc})\n")

    # Multi-Threaded Run
    print(f"Running Multi-Threaded ({NUM_WORKERS} Threads)...")
    threads = []
    
    start_time = time.time()
    start_cpu = time.process_time()
    
    for i in range(NUM_WORKERS):
        t = threading.Thread(target=simulate_k8s_reconciliation, args=(i, PODS_PER_WORKER))
        threads.append(t)
        t.start()
        
    for t in threads:
        t.join()
        
    multi_real_time = time.time() - start_time
    multi_cpu_time = time.process_time() - start_cpu
    
    print(f"--> [MUT] Real Time: {multi_real_time:.4f}s | CPU Time: {multi_cpu_time:.4f}s")
    print(f"--> [MUT] Speedup: {seq_real_time / multi_real_time:.2f}x\n")

if __name__ == "__main__":
    run_benchmark()
```

### Analyzing the Results

When you run this on standard Python vs. No-GIL Python, the numbers show a huge difference.

#### **Standard Python (GIL Enabled)**

*   **Real Time:** The sequential run takes about 10s. The multi-threaded run *also* takes around ~10s (sometimes slower due to lock overhead).
    
*   **OS CPU Time:** Both runs show almost the same CPU time as Real Time. Only one core worked at any second. Threads were just taking turns.
    
*   **Speedup:** 1x
    

#### Free-Threaded Python (GIL Disabled)

*   **Real Time:** The sequential run takes 10s, but the multi-threaded run finishes in just under 3s - hooray it works!
    
*   **OS CPU Time:** Here is the math proof—the CPU time spikes to almost 11s even though the clock only ticked for 3s. This is because the OS adds up CPU cycles of all 4 cores working at the exact same time.
    
*   **Speedup:** ~=3.5 - 3.8x (almost perfect scaling on a 4-core CPU).
    

### What about Network (I/O) tasks?

While testing, there was an additional thought:

> *What if our controller was mostly waiting on API n/w calls?*

As it turns out, **n/w-bound apps don't really need No-GIL.**

***Standard Python automatically releases the GIL*** whenever a thread is waiting on n/w I/O (like waiting for a response from `etcd`). While Thread A waits for the n/w, Thread B grabs the GIL and runs. Since the CPU is mostly waiting, standard Python handles thousands of concurrent threads just fine.

But once the n/w data arrives, you still have to parse the JSON. In large scale clusters where you parse megabytes of JSON every second, that parsing step is heavy CPU work. In that hybrid scenario, a No-GIL build helps your threads parse those payloads in parallel across all your CPU cores.

### Wrap Up

Testing the free-threaded interpreter was a great way to refresh my concurrency skills. Python is no longer locked to a single track; it can finally use modern multi-core CPUs natively- but of-course with strings attached :-)

If you build high-throughput tools, k8s operators, or data pipelines in Python, the No-GIL build is a massive step forward.
