placeholderUnderstanding Python's GIL: The Problems It Solves and the Limits It Imposes

Understanding Python's GIL: The Problems It Solves and the Limits It Imposes

The GIL is not just why Python threads can't use multiple cores. It is CPython's engineering trade-off among refcounting, the object model, and C extensions.

Because of the GIL, Python’s multithreading cannot make effective use of multiple CPU cores. But if you treat the GIL as nothing more than a performance liability, it is hard to understand why CPython has kept it for so long, or why “removing the GIL” touches the whole interpreter and its extension ecosystem.

This article is for people who already write Python and know what threads and locks are. It focuses on: what problem the GIL was meant to solve, why “just give every object its own lock” sounds reasonable but is often worse, and why the GIL makes C extensions easier to integrate.

What the GIL Is

The GIL (Global Interpreter Lock) is exactly what the name says: a global lock over the interpreter. At any given moment, only one thread can hold the GIL; only the thread holding the GIL may execute Python bytecode[1] and directly manipulate interpreter state[2].

Why the GIL Exists

Simplifying Memory Management

CPython’s memory management has long relied on reference counting. Every object header carries a counter, ob_refcnt, recording how many places currently hold a reference to the object.

  • When a new variable points to it: Py_INCREF bumps the count by +1
  • When a reference goes away (variable destroyed, reassigned, etc.): Py_DECREF decrements it by -1
  • When the count reaches 0: the object is reclaimed immediately (memory freed)
import sysa = []print(sys.getrefcount(a))  # usually one higher than you expect, because the getrefcount call itself holds a reference

This scheme is relatively simple to implement. But in standard CPython, Py_INCREF and Py_DECREF are plain C macros that just execute op->ob_refcnt++ or --. Increment and decrement in C are not atomic, so under concurrent modification in a multithreaded environment the count can drift, with two possible outcomes:

  • Count too high: the object can never be freed, leaking memory.
  • Count too low: the object may be freed while still in use, leading to dangling pointers or even segfaults.

If concurrent refcount updates are unsafe, why not give each object its own lock, or make Py_INCREF atomic?

Intuitively that seems more sophisticated than one big global lock: the locks are finer-grained, so concurrency looks higher. But inside a runtime like CPython, where almost everything is an object, it is not that simple.

Start with one key fact: refcount operations are extremely frequent, and their critical sections are extremely short.

Plenty of ordinary code triggers INCREF or DECREF under the hood:

  • Variable assignment
  • Function arguments and return values
  • Container reads and writes
  • Creation and destruction of temporary objects
  • Saving and cleaning up objects during exception propagation

In other words, per-object locking (or any finer-grained locking scheme) has to reckon with at least the following.

1. High lock overhead

A refcount update is essentially just changing an integer in the object header, but acquiring and releasing a per-object lock requires:

  • Atomicity: operations protected by the lock must be atomic, i.e., not interruptible by another thread
  • Memory Visibility: one thread’s changes to an object must become visible to other threads in time
  • Cache Coherence: on multi-core CPUs each core may have its own cache, and per-object locks must keep the object state consistent across cores
  • Deadlock avoidance and reentrancy
2. Object bloat and worse cache hit rates

Putting a lock on every object bloats the object header, which hits the huge number of small objects: small ints, strings, tuples.

Consequences:

  • Higher memory usage
  • Fewer hot objects fit in CPU cache
  • Slower hot paths
3. Multi-object operations get complicated

Python operations usually touch several objects, e.g., assignment or list.append(x).

Problems:

  • Multi-object locking needs a strict ordering, or deadlocks come easily
  • Many code paths would need rewriting
  • C extensions would also have to follow the locking protocol

Per-object locks are not just slow; they significantly increase implementation complexity.

CPython chose one global lock instead, which brings:

  • Refcount updates are naturally serialized, no per-object locks needed
  • Many interpreter-internal structures can assume “only one thread is mutating right now”
  • Lock acquire/release cost is amortized over larger execution slices instead of once per object operation

Why the GIL Makes C Extensions Easier to Integrate

This part is often dismissed in one sentence, but it is actually one of the key reasons the GIL has survived so long.

CPython’s C API has long been built on one important constraint:

As long as extension code holds the GIL, it may treat most interpreter-internal state as “exclusively owned by the current thread.”

This means a C extension working with a PyObject* can usually do the following directly, without adding another layer of per-object locks:

  • Read and write reference counts
  • Access the internals of lists, dicts, tuples, and other objects
  • Create new objects, raise exceptions, call Python APIs
  • Use the many legacy C API macros and fast paths

This dramatically lowers the mental burden on extension authors. For many extensions, the rule almost boils down to one sentence:

Before touching a Python object, make sure you hold the GIL.

This default assumption matters a lot, because many C extension authors are not concurrent-runtime experts. They may be great at numerical computing, image processing, database drivers, or system interfaces, but they do not want to re-derive object lifetimes and lock ordering on every Python C API call.

Without the GIL, under a truly fine-grained concurrency model, C extensions could no longer assume “once I enter the Python API, I’m in an approximately single-threaded world.”

That immediately raises a string of extra requirements:

  • Before every operation on a Python object, consider whether another thread may be modifying it concurrently
  • Legacy interfaces like borrowed references, container iterators, and cached pointers become more fragile
  • If an extension keeps raw pointers into a Python object’s internals, their lifetimes must be re-examined
  • Many old extensions would need added locks, changed data structures, and adjusted API usage

In other words, removing the GIL is not just “the interpreter tweaks itself”; the entire CPython C API contract has to be tightened or rewritten.

Concurrency in C Extensions

The GIL does not force C extensions to run serially the whole time. What it really provides is a simple boundary:

  • Hold the GIL while operating on Python objects
  • Voluntarily release the GIL during pure C computation or blocking I/O

That is why many extensions can integrate safely while still running in parallel on the hot paths. The typical pattern looks like this:

Py_BEGIN_ALLOW_THREADSdo_blocking_io_or_native_compute();Py_END_ALLOW_THREADS

The benefit of this model is safe by default, opt out as needed.

Extension authors do not have to write everything as fine-grained thread-safe code from day one; they only need to explicitly release the GIL during the phases that never touch Python objects.

How the GIL Affects Python Multithreading

CPU-bound Python threads cannot truly run in parallel

If two threads are both running pure-Python CPU-bound code, they take turns holding the GIL instead of executing bytecode simultaneously on two cores.

That is why so many people find that “adding threads to Python didn’t make it any faster.”

I/O-bound workloads are still a good fit for threads

If threads spend most of their time waiting on the network, disk, or other blocking I/O, threads are still valuable, because the waiting phases usually give up the GIL. What multithreading buys you here is not single-core compute, but the overlap of waiting time.

Native extensions can sidestep the limit

If the expensive part lives in a native extension written in C, C++, Rust, or Fortran, and that code releases the GIL during computation, then multiple threads really can run in parallel underneath.

CPU-bound code often has a C extension implementation available, so in practice you frequently do not need to worry about the GIL at all.

Warning

The GIL does not mean “Python multithreaded code is automatically thread-safe.” The GIL protects interpreter-internal state, not your business variables.

Read-modify-write logic like remain_count -= 1 can still interleave across threads and produce wrong results.

How to Work Around the GIL

Three common approaches:

  • Multiprocessing: multiprocessing gives each process its own interpreter instance and its own GIL, well suited to CPU-bound tasks.
  • Native extensions: move hot paths into C, C++, Rust, Cython, or any implementation that can release the GIL.
  • Switch implementations, or watch the free-threaded track: PEP 703 is pushing a no-GIL / free-threaded CPython, but that is not as simple as “delete one lock”; it is a systematic overhaul of the object model, the refcounting strategy, and C extension compatibility.

Summary

Overall, the global lock is an engineering compromise among performance, memory, and complexity: it sacrifices parallelism for CPU-bound multithreading in exchange for lower interpreter implementation complexity, cheaper single-threaded paths, and ecosystem compatibility.

Read More

Inside The Python Virtual Machine | 深入理解 Python 虚拟机


  1. Python bytecode is a low-level, platform-independent intermediate code produced by compiling Python source (.py), living in .pyc files or in memory. It is executed on a virtual machine by the CPython interpreter and speeds up program loading. Developers can disassemble bytecode with the built-in dis module to study how a program runs. ↩︎

  2. The collection of all runtime data structures and execution context maintained by the Python interpreter (usually CPython) while running. It includes the object system (ints, strings, etc.), memory management (refcounting and GC), the call stack and frames, thread and interpreter state, bytecode execution state (instructions and the operand stack), the Global Interpreter Lock (GIL), modules and namespaces, and exception handling. See the PyInterpreterState struct in the Python 3.7 source. ↩︎