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.
What the GIL is
Section titled “What the GIL is”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/Node.js: worker_threads for CPU parallelism// Workers share no memory by default — communicate with postMessageimport { 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: threads exist but the GIL limits CPU parallelismimport 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 timet1 = 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 fasterWhen the GIL hurts and when it does not
Section titled “When the GIL hurts and when it does not”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:
| Workload | GIL effect | Recommended tool |
|---|---|---|
| CPU-bound Python loops | Blocks parallelism | multiprocessing or C extensions |
| Network I/O (requests, sockets) | GIL released during syscall | threading or asyncio |
| File I/O | GIL released during syscall | threading or asyncio |
| NumPy / pandas operations | GIL released in C layer | threading is fine |
| Subprocess calls | GIL released | threading 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.
Bypassing the GIL with multiprocessing
Section titled “Bypassing the GIL with multiprocessing”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: CPU parallelism via worker_threads poolimport { 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: multiprocessing bypasses the GIL entirelyfrom multiprocessing import Poolimport 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))Free-threaded Python is official (3.14)
Section titled “Free-threaded Python is official (3.14)”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; ...".