Skip to content

Global Interpreter Lock (GIL): Python's Threading Limitation

Overview

The GIL is Python's most misunderstood feature: - Prevents true parallelism: Only one thread executes Python code at a time - Released during I/O: Network, disk operations release the GIL - Released by native code: NumPy, PyTorch, C extensions release the GIL - Implications for ML: Pure Python code can't parallelize, but NumPy/PyTorch can

Understanding the GIL is critical for building concurrent systems.


What is the GIL?

The Problem GIL Solves

Python uses reference counting for memory management:

# Each object has a reference count
x = []  # ref_count = 1
y = x   # ref_count = 2
del x   # ref_count = 1
del y   # ref_count = 0, object freed

# Reference count is NOT thread-safe
# Thread 1: decrements ref_count
# Thread 2: decrements ref_count
# Race condition: ref_count goes to 0 too early or too late!

The GIL ensures reference counting is thread-safe by allowing only one thread to execute Python code at a time.

GIL Mechanics

import threading
import time

counter = 0
lock = threading.Lock()  # Simulating GIL

def increment_with_gil():
    """Thread can only run when holding GIL."""
    global counter

    # Acquire GIL (simplified)
    with lock:  # Can't proceed without lock (GIL)
        temp = counter
        # ... other thread could acquire GIL here in real Python
        counter = temp + 1
    # Release GIL

# Two threads trying to increment
threads = []
for _ in range(2):
    t = threading.Thread(target=increment_with_gil)
    threads.append(t)
    t.start()

for t in threads:
    t.join()

print(counter)  # Correct result (with proper locking)

When GIL is Released

import threading
import time

# GIL released during I/O
def network_io():
    """GIL released during network operations."""
    # Other threads can run while this waits for network
    time.sleep(1)  # Simulates I/O (GIL released)
    print("Network done")

# GIL released by native code
import numpy as np

def numpy_compute():
    """GIL released during NumPy operations."""
    # Other threads can run while NumPy computes
    arr = np.random.randn(1000, 1000)
    result = np.sum(arr)
    print(f"NumPy result: {result}")

# Both can run in parallel!
t1 = threading.Thread(target=network_io)
t2 = threading.Thread(target=numpy_compute)

t1.start()
t2.start()

t1.join()
t2.join()

# Both finished in ~1 second (parallel)
# Without GIL release, would take ~2 seconds (serial)

GIL Impact on Threading

Demonstrate GIL Contention

import threading
import time

# CPU-bound work (GIL HELD)
def cpu_bound(n):
    """Pure Python computation - GIL prevents parallelism."""
    total = 0
    for i in range(n):
        total += i
    return total

# Single-threaded
start = time.time()
result1 = cpu_bound(100000000)
result2 = cpu_bound(100000000)
single_time = time.time() - start
print(f"Single-threaded: {single_time:.2f}s")

# Multi-threaded (SLOWER!)
start = time.time()
t1 = threading.Thread(target=cpu_bound, args=(100000000,))
t2 = threading.Thread(target=cpu_bound, args=(100000000,))
t1.start()
t2.start()
t1.join()
t2.join()
multi_time = time.time() - start
print(f"Multi-threaded: {multi_time:.2f}s")

print(f"Slowdown: {multi_time / single_time:.1f}x")
# Result: Multi-threaded is SLOWER due to GIL contention!

I/O-Bound Work Benefits from Threading

import threading
import time

# I/O-bound work (GIL RELEASED)
def io_bound(n):
    """Simulate I/O - GIL is released."""
    for i in range(n):
        time.sleep(0.001)  # Simulate I/O, GIL released

# Single-threaded: 5 seconds
start = time.time()
io_bound(5)
single_time = time.time() - start
print(f"Single-threaded I/O: {single_time:.2f}s")

# Multi-threaded: ~1 second (all 5 threads run in parallel)
start = time.time()
threads = []
for _ in range(5):
    t = threading.Thread(target=io_bound, args=(5,))
    threads.append(t)
    t.start()

for t in threads:
    t.join()
multi_time = time.time() - start
print(f"Multi-threaded I/O: {multi_time:.2f}s")

print(f"Speedup: {single_time / multi_time:.1f}x")
# Result: 5x speedup with threading!

GIL in ML Systems

NumPy Releases GIL

import threading
import numpy as np
import time

def numpy_compute(name):
    """NumPy operations release GIL."""
    print(f"{name} starting...")

    # GIL released during NumPy computation
    arr = np.random.randn(2000, 2000)
    result = np.dot(arr, arr)

    print(f"{name} done: {result.shape}")

# Two threads can compute in parallel with NumPy
start = time.time()

t1 = threading.Thread(target=numpy_compute, args=("Thread 1",))
t2 = threading.Thread(target=numpy_compute, args=("Thread 2",))

t1.start()
t2.start()

t1.join()
t2.join()

elapsed = time.time() - start
print(f"Parallel time: {elapsed:.2f}s")

# Sequential for comparison
start = time.time()
numpy_compute("Sequential 1")
numpy_compute("Sequential 2")
sequential_time = time.time() - start
print(f"Sequential time: {sequential_time:.2f}s")

print(f"Speedup: {sequential_time / elapsed:.1f}x")
# Result: ~1.8-2x speedup (not quite 2x due to overhead)

PyTorch Releases GIL

import threading
import torch
import time

def torch_compute(name, device='cpu'):
    """PyTorch operations release GIL."""
    print(f"{name} starting...")

    # GIL released during PyTorch computation
    x = torch.randn(2000, 2000, device=device)
    y = torch.randn(2000, 2000, device=device)
    result = torch.matmul(x, y)

    print(f"{name} done: {result.shape}")

# CPU compute (GIL released)
start = time.time()

t1 = threading.Thread(target=torch_compute, args=("Thread 1", "cpu"))
t2 = threading.Thread(target=torch_compute, args=("Thread 2", "cpu"))

t1.start()
t2.start()

t1.join()
t2.join()

cpu_time = time.time() - start
print(f"Parallel CPU time: {cpu_time:.2f}s")

# GPU compute (no GIL even relevant, computation on GPU)
start = time.time()

t1 = threading.Thread(target=torch_compute, args=("Thread 1", "cuda"))
t2 = threading.Thread(target=torch_compute, args=("Thread 2", "cuda"))

t1.start()
t2.start()

t1.join()
t2.join()

gpu_time = time.time() - start
print(f"Parallel GPU time: {gpu_time:.2f}s")

Strategies for GIL Avoidance

Strategy 1: Multiprocessing Instead of Threading

from multiprocessing import Process
import time

def cpu_bound_task(n):
    """Pure Python CPU-bound work."""
    total = 0
    for i in range(n):
        total += i
    return total

# Single process
start = time.time()
result1 = cpu_bound_task(100000000)
result2 = cpu_bound_task(100000000)
single_time = time.time() - start

# Multi-process (true parallelism, no GIL!)
start = time.time()
p1 = Process(target=cpu_bound_task, args=(100000000,))
p2 = Process(target=cpu_bound_task, args=(100000000,))
p1.start()
p2.start()
p1.join()
p2.join()
multi_time = time.time() - start

print(f"Single-process: {single_time:.2f}s")
print(f"Multi-process: {multi_time:.2f}s")
print(f"Speedup: {single_time / multi_time:.1f}x")
# Result: ~2x speedup with 2 processes!

Strategy 2: Use NumPy/PyTorch for Heavy Lifting

import numpy as np
import threading
import time

# SLOW: Pure Python with threading (GIL contention)
def slow_array_sum():
    """Pure Python - GIL prevents parallelism."""
    data = list(range(10000000))
    total = 0
    for x in data:
        total += x
    return total

# FAST: NumPy (releases GIL, parallelizes internally)
def fast_array_sum():
    """NumPy - releases GIL, uses SIMD."""
    data = np.arange(10000000)
    return np.sum(data)

# Benchmark
start = time.time()
for _ in range(10):
    slow_array_sum()
slow_time = time.time() - start

start = time.time()
for _ in range(10):
    fast_array_sum()
fast_time = time.time() - start

print(f"Pure Python: {slow_time:.2f}s")
print(f"NumPy: {fast_time:.2f}s")
print(f"Speedup: {slow_time / fast_time:.0f}x")
# Typical: 100-1000x faster with NumPy!

Inference Server Pattern

Single-Threaded (Good for GPU)

import torch
import threading

class GPUInferenceServer:
    """Single-threaded GPU inference (simplest)."""

    def __init__(self, model_path):
        self.model = torch.load(model_path)
        self.model = self.model.cuda().eval()
        self.lock = threading.Lock()  # Serialize GPU access

    def infer(self, request):
        """Serialize all GPU access."""
        with self.lock:  # Only one inference at a time
            x = torch.tensor(request, device='cuda')
            with torch.no_grad():
                return self.model(x).cpu().numpy()

# All requests wait for lock (sequential inference)

Multi-Threaded (Good for CPU or I/O)

class CPUInferenceServer:
    """Multi-threaded CPU inference."""

    def __init__(self, model_path):
        self.model = torch.load(model_path)
        self.model = self.model.cpu().eval()
        # No lock needed - PyTorch releases GIL during compute

    def infer(self, request):
        """No locking needed - PyTorch releases GIL."""
        x = torch.tensor(request)
        with torch.no_grad():
            return self.model(x).numpy()

# Threads can infer in parallel!

Async (Good for High Concurrency)

import asyncio
import torch

class AsyncInferenceServer:
    """Async inference for high concurrency."""

    def __init__(self, model_path):
        self.model = torch.load(model_path)
        self.model = self.model.cpu().eval()

    async def infer_async(self, request):
        """Async inference - submit and wait."""
        # Move to thread pool (release GIL, don't block event loop)
        loop = asyncio.get_event_loop()
        x = torch.tensor(request)

        # Run in thread pool
        result = await loop.run_in_executor(
            None,  # Use default thread pool
            lambda: self._infer_impl(x)
        )
        return result

    def _infer_impl(self, x):
        """Actual inference (runs in thread, GIL released by PyTorch)."""
        with torch.no_grad():
            return self.model(x).numpy()

# Can handle 1000s of concurrent requests!

GIL Behavior Visualization

# Timeline of GIL with threading

# Pure Python (CPU-bound)
# Time: 0-1s
# Thread 1: ████████████ (GIL held entire time)
# Thread 2: waiting, waiting, waiting, ████████████
# Total: 2 seconds

# NumPy (I/O-bound with threading)
# Time: 0-1s
# Thread 1: ██░░░░████░░░░
# Thread 2: ░░░░████░░░░██
# Total: ~1 second (parallelism!)

# Real parallel (Multiprocessing)
# Process 1: ████████████ (CPU 0)
# Process 2: ████████████ (CPU 1)
# Total: 1 second (true parallelism!)

Summary: GIL Decision Tree

Is your code CPU-bound pure Python?
  - YES → Use multiprocessing
  - NO → Is it I/O-bound?
  - YES → Use threading (GIL released during I/O)
  - NO → Is it NumPy/PyTorch?
  - YES → Use threading (GIL released by native code)
  - NO → Use multiprocessing (CPU-bound)