Python GIL: Global Interpreter Lock Explained

The Global Interpreter Lock is a mutex that protects access to Python objects in CPython, the reference implementation of Python. It ensures that only one thread executes Python bytecode at any given...

Key Insights

  • The GIL is a mutex that allows only one thread to execute Python bytecode at a time, making multi-threaded CPU-bound code slower than single-threaded execution
  • I/O-bound operations benefit from threading despite the GIL because threads release the lock during I/O waits, while CPU-bound tasks require multiprocessing for true parallelism
  • Python 3.13 introduces experimental no-GIL builds (PEP 703), potentially eliminating this decades-old limitation while maintaining backward compatibility

What is the GIL?

The Global Interpreter Lock is a mutex that protects access to Python objects in CPython, the reference implementation of Python. It ensures that only one thread executes Python bytecode at any given moment, even on multi-core processors.

The GIL exists primarily because of CPython’s memory management strategy. Python uses reference counting to track object lifetimes—each object maintains a count of how many references point to it. When this count reaches zero, the memory is freed. Without the GIL, multiple threads could simultaneously modify reference counts, leading to race conditions where objects might be freed prematurely or memory leaks could occur.

Here’s a simple example showing thread creation in Python:

import threading
import time

def worker(name):
    print(f"Thread {name} starting")
    time.sleep(2)
    print(f"Thread {name} finishing")

threads = []
for i in range(3):
    t = threading.Thread(target=worker, args=(i,))
    threads.append(t)
    t.start()

for t in threads:
    t.join()

print("All threads completed")

This code creates three threads that appear to run concurrently. However, due to the GIL, only one thread executes Python code at any moment.

How the GIL Impacts Performance

The GIL’s impact depends entirely on whether your workload is CPU-bound or I/O-bound. For CPU-intensive tasks, the GIL becomes a severe bottleneck. For I/O-bound operations, threading remains effective.

Let’s demonstrate with CPU-bound work:

import threading
import time

def cpu_bound_task(n):
    """Calculate sum of squares - CPU intensive"""
    total = 0
    for i in range(n):
        total += i ** 2
    return total

# Single-threaded execution
start = time.time()
result1 = cpu_bound_task(10_000_000)
result2 = cpu_bound_task(10_000_000)
single_time = time.time() - start
print(f"Single-threaded: {single_time:.2f} seconds")

# Multi-threaded execution
start = time.time()
t1 = threading.Thread(target=cpu_bound_task, args=(10_000_000,))
t2 = threading.Thread(target=cpu_bound_task, args=(10_000_000,))
t1.start()
t2.start()
t1.join()
t2.join()
multi_time = time.time() - start
print(f"Multi-threaded: {multi_time:.2f} seconds")
print(f"Speedup: {single_time/multi_time:.2f}x")

You’ll find the multi-threaded version is actually slower due to thread switching overhead. The GIL ensures only one thread computes at a time, eliminating any parallelism benefit.

Now contrast this with I/O-bound work:

import threading
import time
import requests

urls = [
    "https://api.github.com/users/github",
    "https://api.github.com/users/google",
    "https://api.github.com/users/microsoft",
    "https://api.github.com/users/facebook"
]

def fetch_url(url):
    """Simulate I/O-bound operation"""
    response = requests.get(url)
    return len(response.content)

# Single-threaded
start = time.time()
for url in urls:
    fetch_url(url)
single_time = time.time() - start
print(f"Single-threaded: {single_time:.2f} seconds")

# Multi-threaded
start = time.time()
threads = []
for url in urls:
    t = threading.Thread(target=fetch_url, args=(url,))
    threads.append(t)
    t.start()

for t in threads:
    t.join()
multi_time = time.time() - start
print(f"Multi-threaded: {multi_time:.2f} seconds")
print(f"Speedup: {single_time/multi_time:.2f}x")

Here you’ll see significant speedup because threads release the GIL during network I/O, allowing other threads to execute.

Observing the GIL in Action

You can observe the GIL’s behavior through thread switching intervals and race conditions. Python switches between threads approximately every 5 milliseconds by default:

import sys
import threading

print(f"Thread switch interval: {sys.getswitchinterval()} seconds")

# Demonstrating race conditions with shared state
counter = 0
iterations = 1_000_000

def increment():
    global counter
    for _ in range(iterations):
        counter += 1

# Without locks, race conditions occur
counter = 0
t1 = threading.Thread(target=increment)
t2 = threading.Thread(target=increment)

t1.start()
t2.start()
t1.join()
t2.join()

expected = iterations * 2
print(f"Expected: {expected}, Got: {counter}")
print(f"Lost updates: {expected - counter}")

Despite the GIL, you’ll see lost updates because the increment operation (counter += 1) isn’t atomic—it involves reading, incrementing, and writing. The GIL can switch threads between these operations.

Here’s a timing comparison showing the GIL’s impact:

import time
import threading

def fibonacci(n):
    if n <= 1:
        return n
    return fibonacci(n-1) + fibonacci(n-2)

# CPU-bound workload
start = time.time()
fibonacci(35)
fibonacci(35)
single = time.time() - start

start = time.time()
t1 = threading.Thread(target=fibonacci, args=(35,))
t2 = threading.Thread(target=fibonacci, args=(35,))
t1.start()
t2.start()
t1.join()
t2.join()
multi = time.time() - start

print(f"Single: {single:.2f}s, Multi: {multi:.2f}s")
print(f"Multi is {multi/single:.2f}x the time")

Workarounds and Alternatives

When the GIL becomes a bottleneck, you have several options.

Multiprocessing creates separate Python processes, each with its own GIL:

from multiprocessing import Pool
import time

def cpu_bound_task(n):
    total = 0
    for i in range(n):
        total += i ** 2
    return total

if __name__ == '__main__':
    # Using multiprocessing
    start = time.time()
    with Pool(processes=4) as pool:
        results = pool.map(cpu_bound_task, [10_000_000] * 4)
    multi_proc_time = time.time() - start
    print(f"Multiprocessing: {multi_proc_time:.2f} seconds")

This achieves true parallelism by running separate interpreter processes.

NumPy and C extensions release the GIL during computations:

import numpy as np
import threading
import time

def numpy_computation():
    """NumPy releases the GIL for array operations"""
    arr = np.random.rand(5000, 5000)
    result = np.dot(arr, arr)
    return result

start = time.time()
numpy_computation()
numpy_computation()
single = time.time() - start

start = time.time()
t1 = threading.Thread(target=numpy_computation)
t2 = threading.Thread(target=numpy_computation)
t1.start()
t2.start()
t1.join()
t2.join()
multi = time.time() - start

print(f"Single: {single:.2f}s, Multi: {multi:.2f}s")
print(f"Speedup: {single/multi:.2f}x")

Asyncio provides concurrency for I/O-bound tasks without threading:

import asyncio
import aiohttp

async def fetch_url(session, url):
    async with session.get(url) as response:
        return await response.text()

async def main():
    urls = [
        "https://api.github.com/users/github",
        "https://api.github.com/users/google",
        "https://api.github.com/users/microsoft"
    ]
    
    async with aiohttp.ClientSession() as session:
        tasks = [fetch_url(session, url) for url in urls]
        results = await asyncio.gather(*tasks)
        return results

# asyncio.run(main())

When the GIL Doesn’t Matter

The GIL isn’t always a problem. It’s irrelevant for:

  • Single-threaded applications (most scripts and web apps)
  • I/O-bound workloads where threads spend time waiting
  • Applications using GIL-releasing extensions like NumPy, Pandas, or database drivers

Web scraping demonstrates threading benefits despite the GIL:

import threading
import requests
import time

def scrape_page(url):
    response = requests.get(url)
    return len(response.content)

urls = [f"https://example.com/page{i}" for i in range(20)]

# Sequential
start = time.time()
results = [scrape_page(url) for url in urls]
seq_time = time.time() - start

# Threaded
start = time.time()
threads = []
for url in urls:
    t = threading.Thread(target=scrape_page, args=(url,))
    threads.append(t)
    t.start()

for t in threads:
    t.join()
thread_time = time.time() - start

print(f"Sequential: {seq_time:.2f}s")
print(f"Threaded: {thread_time:.2f}s")
print(f"Speedup: {seq_time/thread_time:.2f}x")

The Future: PEP 703 and GIL Removal

Python 3.13 introduces experimental support for running without the GIL through PEP 703. This optional feature allows building Python with --disable-gil, enabling true multi-threaded parallelism.

The no-GIL builds use biased reference counting and deferred reference counting to manage memory safely without the global lock. This is a multi-year project with backward compatibility as a priority.

To use no-GIL Python, you’ll need to build from source with the flag enabled. Performance characteristics will change significantly—CPU-bound threaded code will finally achieve parallelism, but single-threaded code may see slight slowdowns due to additional synchronization overhead.

The timeline suggests optional GIL removal in Python 3.13 (experimental), with potential default no-GIL behavior in Python 3.14 or later, depending on ecosystem compatibility and performance validation.

Understanding the GIL is crucial for writing performant Python. Choose multiprocessing for CPU-bound parallelism, threading or asyncio for I/O-bound concurrency, and leverage C extensions when possible. With PEP 703, Python’s concurrency story is evolving, but these patterns remain valuable for years to come.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.