Skip to content

The GIL — Global Interpreter Lock

JavaScript is single-threaded — Python has threads, but with a catch

Section titled “JavaScript is single-threaded — Python has threads, but with a catch”

As a TypeScript developer, you are used to JavaScript’s concurrency model: one thread, one event loop, non-blocking I/O via callbacks and async/await. There are no data races because there is only ever one piece of JavaScript code running at a time. Worker threads exist, but they share no memory by default — they communicate via message passing.

Python has true OS threads via the threading module. You can spawn multiple threads and they run in the same process with shared memory. However, CPython (the reference Python implementation) includes a mechanism called the Global Interpreter Lock that fundamentally limits what those threads can do in parallel. Understanding the GIL is essential for writing Python programs that need to scale across CPU cores.

The GIL is a mutex — a mutual exclusion lock — that protects CPython’s internal state. CPython’s memory management (reference counting for garbage collection) is not thread-safe. Rather than add fine-grained locks throughout the interpreter, Guido van Rossum’s team added one coarse lock: only one thread may execute Python bytecode at any given moment.

Threads do take turns — the GIL is released and re-acquired periodically (roughly every 5ms in Python 3.2+, or whenever a thread makes an I/O system call). But for pure CPU work, having two threads does not give you two CPU cores of throughput. One thread runs; the other waits.

TypeScript
// TypeScript/Node.js: worker_threads for CPU parallelism
// Workers share no memory by default — communicate with postMessage
import { Worker, isMainThread, parentPort } from 'worker_threads';
if (isMainThread) {
const worker = new Worker(__filename);
worker.on('message', (result) => {
console.log('result from worker:', result);
});
worker.postMessage({ start: 0, end: 1_000_000 });
} else {
parentPort?.on('message', ({ start, end }) => {
let sum = 0;
for (let i = start; i < end; i++) sum += i;
parentPort?.postMessage(sum);
});
}
Python
# Python: threads exist but the GIL limits CPU parallelism
import threading
results = []
def cpu_sum(start, end, out, index):
total = sum(range(start, end))
out[index] = total
# Two threads — but only one runs Python bytecode at a time
t1 = threading.Thread(target=cpu_sum, args=(0, 500_000, results, 0))
t2 = threading.Thread(target=cpu_sum, args=(500_000, 1_000_000, results, 1))
results = [None, None]
t1.start(); t2.start()
t1.join(); t2.join()
print(sum(results)) # correct answer, but not 2x faster

The GIL only blocks concurrent execution of Python bytecode. Many operations that appear to be Python code actually drop into C extensions or the OS, and those can release the GIL voluntarily:

WorkloadGIL effectRecommended tool
CPU-bound Python loopsBlocks parallelismmultiprocessing or C extensions
Network I/O (requests, sockets)GIL released during syscallthreading or asyncio
File I/OGIL released during syscallthreading or asyncio
NumPy / pandas operationsGIL released in C layerthreading is fine
Subprocess callsGIL releasedthreading is fine

The practical rule: if your bottleneck is the Python interpreter executing your code, threads will not help — use multiprocessing. If your bottleneck is waiting on external systems (network, disk, databases), threads or asyncio work well.

multiprocessing spawns separate Python interpreter processes — each with its own GIL and its own CPU core. It is the standard tool for CPU-bound parallelism.

TypeScript
// TypeScript: CPU parallelism via worker_threads pool
import { Worker } from "worker_threads";
import * as os from "os";
function runWorker(data: number): Promise<number> {
return new Promise((resolve, reject) => {
const w = new Worker(`
const { parentPort, workerData } = require("worker_threads");
let total = 0;
for (let i = 0; i < workerData; i++) total += i;
parentPort.postMessage(total);
`, { eval: true, workerData: data });
w.on("message", resolve);
w.on("error", reject);
});
}
const cpus = os.cpus().length;
Promise.all(Array.from({ length: cpus }, (_, i) =>
runWorker(1_000_000 / cpus)
)).then(results => console.log(results.reduce((a, b) => a + b, 0)));
Python
# Python: multiprocessing bypasses the GIL entirely
from multiprocessing import Pool
import os
def cpu_work(n):
return sum(range(n))
if __name__ == "__main__":
cpus = os.cpu_count()
chunk = 1_000_000 // cpus
with Pool(cpus) as pool:
results = pool.map(cpu_work, [chunk] * cpus)
print(sum(results))

Python 3.13 introduced an experimental build option (--disable-gil, or python3.13t on some platforms) that removes the GIL entirely. This is the result of PEP 703, authored by Sam Gross. The free-threaded build allows multiple threads to genuinely run Python bytecode in parallel on multiple cores.

The trade-off: reference counting is now done with atomic operations, and many C extensions that assumed the GIL were implicitly thread-safe must be audited and updated. As of 3.13 the feature is opt-in and marked experimental; it is expected to stabilize in Python 3.14 and 3.15.

Playground note: Demonstrating GIL effects requires comparing CPU-bound vs I/O-bound thread timing, which is not meaningful in a browser sandbox. Run the examples locally with python3 -c "import threading; ...".

What does the GIL prevent in CPython?
For which type of workload does the GIL cause the most harm?
What Python module is the standard solution for true CPU parallelism across multiple cores?
How does asyncio achieve concurrency without threads?