Skip to content

Global Interpreter Lock (GIL)

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
# Thread 2
# Race condition

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

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
start = time.time()
io_bound(5)
single_time = time.time() - start
print(f"Single-threaded I/O: {single_time:.2f}s")

# Multi-threaded
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

-

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

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

Strategy 2: Use NumPy/PyTorch for Heavy Lifting

import numpy as np
import threading
import time

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

# FAST
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

-

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
# Thread 1
# Thread 2
# Total

# NumPy (I/O-bound with threading)
# Time
# Thread 1
# Thread 2
# Total

# Real parallel (Multiprocessing)
# Process 1
# Process 2
# Total

-

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)

-