Skip to content

The GIL and Threading

CPython (the standard Python interpreter) contains a mutex called the Global Interpreter Lock (GIL). The GIL ensures that only one thread executes Python bytecode at any given moment — even on a machine with many CPU cores.

The GIL exists for a practical reason: CPython’s memory management (reference counting) is not thread-safe. Rather than adding fine-grained locks to every object, the CPython developers took the simpler approach of a single global lock.

Consequences:

  • CPU-bound Python code running in multiple threads does not run in parallel — threads take turns.
  • I/O-bound threads do benefit from threading because the GIL is released while a thread waits for an OS I/O call to complete.
import threading
import time
def count_down(n: int) -> None:
while n > 0:
n -= 1
N = 50_000_000
# Single thread
start = time.perf_counter()
count_down(N)
single = time.perf_counter() - start
# Two threads — same total work split across threads
start = time.perf_counter()
t1 = threading.Thread(target=count_down, args=(N // 2,))
t2 = threading.Thread(target=count_down, args=(N // 2,))
t1.start(); t2.start()
t1.join(); t2.join()
threaded = time.perf_counter() - start
print(f"Single thread: {single:.2f}s")
print(f"Two threads: {threaded:.2f}s")
# Two threads is often SLOWER due to GIL contention and context-switch overhead

When threads block on I/O, the GIL is released, allowing other threads to run Python code. This is why threading is still useful for I/O-bound tasks like HTTP requests or file reads.

import threading
import time
def simulate_io(label: str, delay: float) -> None:
time.sleep(delay) # releases the GIL — other threads run freely
print(f"{label} complete after {delay}s")
# Sequential: ~0.6 s total
# Threaded: ~0.2 s total (all three sleep concurrently)
threads = [
threading.Thread(target=simulate_io, args=(f"task-{i}", 0.2))
for i in range(3)
]
for t in threads: t.start()
for t in threads: t.join()
import threading
def worker(name: str, count: int) -> None:
for i in range(count):
print(f"[{name}] step {i}")
t = threading.Thread(target=worker, args=("background", 3))
t.start()
print("main thread continues here")
t.join() # wait for the thread to finish
print("background thread done")

t.join() blocks the calling thread until t finishes. Without join(), the main thread may exit while the background thread is still running.

Shared mutable state requires explicit synchronization. A threading.Lock ensures that only one thread modifies the shared data at a time.

import threading
counter: int = 0
lock = threading.Lock()
def increment(n: int) -> None:
global counter
for _ in range(n):
with lock:
counter += 1
threads = [threading.Thread(target=increment, args=(100_000,)) for _ in range(5)]
for t in threads: t.start()
for t in threads: t.join()
print(counter) # reliably 500_000

Without the lock, counter += 1 is three bytecode operations (LOAD, ADD, STORE) and two threads can interleave them, producing a lower total (a race condition).

concurrent.futures.ThreadPoolExecutor manages a pool of threads and returns Future objects. It integrates cleanly with asyncio via loop.run_in_executor.

from concurrent.futures import ThreadPoolExecutor
import time
def blocking_io(label: str) -> str:
time.sleep(0.1) # simulates a blocking library call
return f"{label} done"
with ThreadPoolExecutor(max_workers=4) as executor:
futures = [executor.submit(blocking_io, f"job-{i}") for i in range(8)]
results = [f.result() for f in futures]
print(results)

executor.map(fn, iterable) is a convenient shorthand when all calls share the same function and you want results in input order.

Bridging threads and asyncio: run_in_executor

Section titled “Bridging threads and asyncio: run_in_executor”

When you need to call a blocking library from an async context without blocking the event loop:

import asyncio
from concurrent.futures import ThreadPoolExecutor
import time
def blocking_call(x: int) -> int:
time.sleep(0.1) # a blocking library — e.g. psycopg2, boto3 (sync)
return x * x
async def main() -> None:
loop = asyncio.get_running_loop()
with ThreadPoolExecutor(max_workers=4) as pool:
results = await asyncio.gather(
*[loop.run_in_executor(pool, blocking_call, i) for i in range(5)]
)
print(results)
asyncio.run(main())
CriterionUse asyncioUse threading
I/O-bound, async-native libsYesNo
I/O-bound, blocking libs (boto3, psycopg2)run_in_executorYes
CPU-bound workNoNo — use multiprocessing
Need structured cancellationYes (TaskGroup)Harder
Legacy synchronous codebaseNoYes
Why does adding more threads NOT speed up CPU-bound Python code in CPython?
When does a Python thread release the GIL?
What is the purpose of `threading.Lock` when shared mutable state is accessed from multiple threads?
How do you call a blocking synchronous function from inside an `async def` coroutine without blocking the event loop?