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 shipped the GIL-free build as an experiment (PEP 703, python3.13t). Python 3.14 promoted it to an officially supported build (PEP 779): still a separate interpreter, python3.14t, and still not the default — but no longer experimental. In that build, threads genuinely run Python bytecode on several cores at once.

The trade-off is real: reference counting uses atomic operations, so single-threaded code runs roughly 5–10% slower, and C extensions that quietly relied on the GIL have to be audited and rebuilt. Check which build you are on with sys._is_gil_enabled().

Python 3.14 also adds concurrent.interpreters (PEP 734) — multiple interpreters inside one process, each with its own GIL, plus an InterpreterPoolExecutor that plugs into the concurrent.futures API. It sits between threads (shared memory, one GIL) and processes (no shared memory, slow to start), which makes it the closest thing Python has to Node’s worker_threads.

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?