Skip to main content

Python Global Interpreter Lock (GIL)

Follow CPython thread states, execution frames and shared objects through a GIL handoff. Inspect the source behind waiting, release, native work and application locks.

When one Python thread stops and another runs, what changes inside the interpreter? Follow the frames, references and lock fields through the handoff.

CPython 3.13.13 · GIL enabled

Inside one CPython interpreter

Step 1 / 36

What belongs to a thread, and what is shared?

One interpreter, one GIL

I0 → G0 ↔ T_A / T_B → F_A / F_B → L1

A: executing Python

B: waiting for the GIL

Common interpreter I0 · GIL G0 · object L1

locked = 1
last_holder → T_A
switch_number = 7
interval = 5000 µs
mutex: free
cond waiters: { B }
switch_cond: { ∅ }

Unordered waiting sets. switch_mutex is released while waiting.

Two OS threads belong to interpreter I0. Both use interp → I0 and the same interp->ceval.gil → G0. Only A currently executes Python.

Find the saved execution state that belongs to each thread.

Runnable program and evidence

The complete program below runs locally in CPython, not in this browser. Bytecode was captured with adaptive=False on GIL-enabled CPython 3.13.13. The socket harness verifies results without recording internal scheduling. The native variant’s digest and the barrier/lock outputs are reproducible; the diagram’s interleaving is authored.

gil_worker.py
import json
import socket
from threading import Thread

def worker(shared, sock):
    local = shared
    total = 0
    for value in range(3):
        total += value
    data = sock.recv(1)
    local.append((total, data))

shared = []
pairs = [socket.socketpair() for _ in range(2)]
threads = [Thread(target=worker, args=(shared, receiver))
           for receiver, sender in pairs]
try:
    for thread in threads:
        thread.start()
    for (_, sender), data in zip(pairs, [b"A", b"B"]):
        sender.sendall(data)
    for thread in threads:
        thread.join()
    print(json.dumps(sorted([total, data.hex()] for total, data in shared)))
finally:
    for pair in pairs:
        for sock in pair:
            sock.close()

Captured output: [[3,"41"],[3,"42"]]

Source-backed teaching model. IDs, scheduling order and internal field values are authored; bytecode and program outputs are captured separately. R and S are symbolic. DROP abbreviates _PY_GIL_DROP_REQUEST_BIT in eval_breaker. Selected references and operand slots are shown; other fields and the loop iterator are omitted.

What the GIL protects

In a GIL-enabled CPython interpreter, the Global Interpreter Lock allows only one thread at a time to execute Python bytecode. It protects interpreter state and operations on Python objects. It does not prevent other threads from existing, waiting for I/O, or running native code that releases the lock.

The scope matters: the detailed explorer follows GIL-enabled CPython 3.13.13, with two threads inside one interpreter. The final chapter compares other boundaries without pretending to trace their private internals. Free-threaded builds and isolated interpreters change that scope, as described below.

Read the structures before the handoff

A PyThreadState connects an OS thread to its interpreter and current execution frame. The two threads have separate _PyInterpreterFrame objects, so their instruction positions, local references and value stacks remain distinct. Both frames can still refer to the same list. Passing the GIL to another thread does not move the first thread’s frame or copy its objects.

Inside the frame, localsplus holds local references and stack entries. In the captured loop, CPython combines the reads of total and value into LOAD_FAST_LOAD_FAST. That single instruction pushes both operand references. BINARY_OP consumes them and produces a result reference; STORE_FAST binds the result to total. The loop iterator remains below the operand slots shown in the explorer. A Python source line, one bytecode instruction and one scheduling event are different units.

The GIL state is shared. Its short-lived mutex protects inspection and updates of fields such as locked and switch_number; it is not held throughout Python execution. A condition wait releases that mutex while the thread sleeps and reacquires it before returning. The diagram’s waiting sets show membership, without promising an order.

Why protect object operations?

CPython uses reference counting for much of its object lifetime management. Consider two names that refer to one list:

python
items = []
other_name = items

Creating and removing references changes bookkeeping associated with the object. An unsynchronized read–add–write update could lose an increment if two threads read the same old count. That could make object lifetime tracking incorrect. Reference counting is one motivation for the lock; the GIL also protects many other interpreter invariants.

The traditional GIL design lets CPython protect much of this state with a single broad lock. Free-threaded CPython uses different techniques, including changes to reference counting and internal locks. Removing the GIL requires replacing its protections, not simply deleting an acquire call.

For the wider object and interpreter architecture, see CPython internals and Python memory management.

How threads get a turn

A thread needs the GIL before it can execute Python code in that interpreter. It can release the lock when a blocking operation waits, when a native operation deliberately releases it, or at interpreter checks that allow another thread to run.

The switching interval is a scheduling target

Modern CPython does not switch after a fixed number of bytecodes. sys.getswitchinterval() reports the interpreter's ideal thread switching interval in seconds. It is a scheduling target, not a deadline: long-running internal operations can delay a handoff, and the operating system decides which thread runs next. The explorer shows possible ownership changes without claiming equal turns or exact durations.

In the contention chapter, B’s timed wait returns while A still owns the GIL and the switch count has not changed. B sets _PY_GIL_DROP_REQUEST_BIT in A’s eval_breaker. A must reach a suitable evaluator checkpoint, such as the loop backedge, before it handles that request. The request itself changes no ownership.

Release, wake-up and acquisition are separate events. After A releases, locked is zero and both ownership flags are clear, while last_holder can still point to A. Signaling cond can wake a waiter, but does not grant the GIL. When B successfully acquires it, last_holder changes and switch_number increases. Reacquiring by the same holder would not increment that count. The switch_cond path gives another holder an opportunity; it does not establish a deterministic round-robin schedule.

These are three different states:

  • Ready for the GIL: the thread has Python work but cannot execute it yet.
  • Waiting for I/O: the thread is blocked on something outside Python, such as socket data.
  • Running native work without the GIL: the thread is computing, but its hot loop is outside Python execution.

This is why “CPU-bound” needs a qualification. A pure-Python loop competes for the interpreter lock; a native compression or numerical operation may release it. Check the specific operation rather than assuming that every C extension releases the GIL.

Choose by the hot path

Start with what the program spends time doing. Then choose a concurrency model that can overlap that work.

Where time goesStarting pointWhat can overlapMain trade-off
Pure-Python computationProcessPoolExecutorPython work in separate processesWorker startup, serialization, and memory
Blocking network or database callsThreadPoolExecutorTime spent waiting outside the GILConnection limits and shared-state coordination
Many I/O tasks with async APIsasyncioCooperative waiting on the event loopBlocking Python work stalls the loop
Native operations that release the GILThe library's own APIs; test threads if appropriateNative computation and other threadsRelease behavior and internal thread pools vary
Isolated Python tasks on Python 3.14+InterpreterPoolExecutorPython work in separate interpretersIsolation, data transfer, and extension compatibility

asyncio does not remove the GIL or make a Python loop parallel. It lets a thread switch between tasks at suspension points such as an actual I/O wait. An await expression is not a guarantee that a task yields.

Native libraries can also create their own worker threads. Adding a Python thread pool on top may oversubscribe the machine. Benchmark the actual library, input sizes, and worker configuration.

Shared state still needs a protocol

The shared-update chapter separates two kinds of protection. The GIL protects interpreter execution. An application lock protects the logical operation that must happen together, such as reading a balance and writing its updated value. This is a separate runnable variant: M0 represents the module namespace, whose balance binding changes. Its panels retain a history of each worker’s read. They do not present a completed worker’s frame as a live current_frame.

The barrier example intentionally splits the update. Both threads read 0 before either writes, so both later write 1. It demonstrates a race even though their Python execution takes turns. A short balance += 1 loop is a poor demonstration because whether it visibly loses updates can depend on the Python version and the surrounding code.

In the corrected example, with lock: surrounds the entire read and write. Other useful protocols include a synchronized queue, ownership by one worker, and database transactions. Choose the protocol for the invariant you need to preserve; individual container operations do not make a multi-step transaction safe.

Compare equal work before blaming the GIL

Keep the inputs and total work fixed. Comparing one loop against four full loops asks the threaded version to do four times as much computation.

This script runs the same two chunks sequentially and with two threads. It prints measurements from your machine; the lesson does not prescribe a speedup or a slowdown.

python
from concurrent.futures import ThreadPoolExecutor
from time import perf_counter


def score(bounds):
    start, stop = bounds
    total = 0
    for value in range(start, stop):
        total += value * value
    return total


if __name__ == "__main__":
    chunks = [(0, 1_000_000), (1_000_000, 2_000_000)]

    start = perf_counter()
    sequential = list(map(score, chunks))
    print("sequential:", perf_counter() - start)

    start = perf_counter()
    with ThreadPoolExecutor(max_workers=2) as pool:
        threaded = list(pool.map(score, chunks))
    print("two threads:", perf_counter() - start)
    assert threaded == sequential

For a process comparison, save the script as a file and replace ThreadPoolExecutor with ProcessPoolExecutor in both the import and pool construction. Keep the main guard. This end-to-end comparison includes pool startup and shutdown; a persistent service should also measure a warm pool.

Repeat measurements and vary chunk sizes. Profile the worker code to establish whether its hot path is Python, native computation, I/O, or another application lock. A single core's worth of CPU use is a clue, not proof of a GIL bottleneck. Available cores, CPU quotas, serialization, and other contention also affect throughput.

Free-threaded Python and isolated interpreters

The detailed walkthrough describes GIL-enabled execution. Two supported approaches can give threads different interpreter-level constraints.

Free-threaded builds

Python 3.13 introduced an optional free-threaded build. Python 3.14 made free-threading officially supported under PEP 779; it remains optional. With the GIL disabled, threads can execute Python in parallel on available cores. This does not guarantee that an existing program becomes faster or thread-safe.

On CPython 3.13+, check the build and the running state separately:

python
import sys
import sysconfig

print("free-threaded build:",
      sysconfig.get_config_var("Py_GIL_DISABLED") == 1)
print("GIL currently enabled:", sys._is_gil_enabled())

A free-threaded build can still enable the GIL

A free-threaded build can run with its GIL enabled. Importing an extension that has not declared free-threading support may enable it, so check the runtime state after loading the packages your program uses. Application synchronization still matters in either mode.

Isolated interpreters

Python 3.14's InterpreterPoolExecutor runs each worker in a separate interpreter, each with its own GIL. That permits multi-core Python execution even in a GIL-enabled build.

The workers have isolated runtime state. Ordinary mutable Python objects are not shared directly between interpreters, and inputs and results cross an explicit communication boundary. This is a different model from several threads sharing one interpreter, and from separate operating-system processes.

Further Reading

If you found this explanation helpful, consider sharing it with others.

Mastodon