Lesson 10

The GIL and Thread State

What the global interpreter lock actually protects, the per-thread state that goes with it, how to release it correctly around real work, and how to call into Python from a thread Python did not create.

Learning objective

You can state precisely what you may and may not do while the interpreter is released, write a compute or I/O routine that releases it correctly, enter Python from a foreign thread, and recognize the two deadlock patterns that C extensions cause.

What the GIL is

The global interpreter lock is a single mutex, per interpreter, that a thread must hold in order to execute Python bytecode or call almost any C API function. It exists because the object model from Lessons 01–04 is not thread-safe: ob_refcnt is an ordinary integer, container mutations are multi-step, and the cycle collector needs a consistent view of the heap. Rather than lock every object, CPython historically locked the whole interpreter.

Consequences you already know from Python: threads give you concurrency for I/O but not parallelism for computation. The consequence that matters here is different: holding the GIL is a precondition of the C API, and it is a precondition you can temporarily give up.

Terminology, updated

Current CPython documentation talks about a thread state being attached or detached rather than about holding or releasing the GIL. The reason is that on free-threaded builds there is no GIL, but attaching and detaching still matter — the runtime still needs to be able to stop all threads for a garbage collection. Reading "detach the thread state" wherever you used to read "release the GIL" will keep you correct on both builds.

PyThreadState and PyInterpreterState

Two structs sit between your C code and the runtime:

PyThreadState
Everything that is per-thread: the current frame (Lesson 07), the exception state (Lesson 05), the recursion depth, the profiling and tracing hooks, per-thread caches. Each OS thread that runs Python has one, reachable via PyThreadState_Get() — which issues a fatal error if there is none — or PyThreadState_GetUnchecked(), which returns NULL instead.
PyInterpreterState
Everything that is per-interpreter and shared by its threads: the module table, sys.modules, builtins, the import machinery, the GIL itself. A process normally has one; subinterpreters (PEP 684, PEP 734) create more, and since 3.12 each can have its own GIL.

A thread is "attached" when its thread state is installed as the current one and, on a GIL build, it holds the GIL. Almost every C API function has that as an unwritten precondition.

Releasing the interpreter around real work

If your C function is going to spend a long time doing something that does not touch Python objects — a numeric kernel, a compression pass, a blocking read(), a call into a library that sleeps — you should detach so other Python threads can run. The macro pair does it:

Py_BEGIN_ALLOW_THREADS
    /* no Python here */
    result = crunch(data, n);
Py_END_ALLOW_THREADS

which expands to exactly:

{
    PyThreadState *_save;
    _save = PyEval_SaveThread();     /* detach; releases the GIL */
    result = crunch(data, n);
    PyEval_RestoreThread(_save);     /* re-attach; blocks until available */
}

Note the braces. The pair opens and closes a C block, so both halves must be in the same scope — you cannot return or goto out of the middle without restoring first.

The rules while detached — memorize these

The correct shape for a compute kernel therefore looks like this:

static PyObject *
sum_buffer(PyObject *module, PyObject *arg)
{
    Py_buffer view;
    if (PyObject_GetBuffer(arg, &view, PyBUF_C_CONTIGUOUS) < 0) return NULL;

    if (view.itemsize != sizeof(double)) {
        PyBuffer_Release(&view);
        return PyErr_Format(PyExc_TypeError, "expected a buffer of doubles");
    }

    const double *p = (const double *)view.buf;   /* pinned by the view */
    Py_ssize_t n = view.len / (Py_ssize_t)sizeof(double);
    double total = 0.0;
    int overflow = 0;                              /* error flag in plain C */

    Py_BEGIN_ALLOW_THREADS
    for (Py_ssize_t i = 0; i < n; i++) {
        total += p[i];
    }
    if (!isfinite(total)) overflow = 1;
    Py_END_ALLOW_THREADS

    PyBuffer_Release(&view);
    if (overflow) {
        PyErr_SetString(PyExc_OverflowError, "sum is not finite");
        return NULL;
    }
    return PyFloat_FromDouble(total);
}

The buffer pins the memory; the error is a C flag; the exception is raised only after re-attaching.

When it is worth it

Detaching and re-attaching costs on the order of a hundred nanoseconds, plus the risk that re-attaching blocks. Releasing around a loop body that takes 20 ns is strictly worse than not releasing. The rule of thumb:

Entering Python from a foreign thread

A C library that calls your callback from its own thread pool presents the reverse problem: a thread with no Python thread state at all. Touching the C API from it is undefined behaviour. The established API is:

static void
library_callback(void *userdata)
{
    PyGILState_STATE gstate = PyGILState_Ensure();   /* attach, creating state if needed */

    PyObject *res = PyObject_CallNoArgs((PyObject *)userdata);
    if (res == NULL) {
        PyErr_FormatUnraisable("Exception ignored in library callback");
    }
    Py_XDECREF(res);

    PyGILState_Release(gstate);                       /* restore previous state exactly */
}

PyGILState_Ensure is reentrant: if the thread already had a state, it just attaches, and PyGILState_Release returns things to exactly how they were. That is why the opaque gstate handle exists and why it must be passed back unmodified.

Two caveats the documentation is emphatic about:

PyGILState_Check() returns whether the current thread is attached. It is useful in assertions, with the caveat that it always returns 1 once any subinterpreter has been created.

Thread-local storage in C

If your extension needs per-thread C data, use CPython's portable TSS API rather than raw pthread_key_t or __declspec(thread):

static Py_tss_t my_key = Py_tss_NEEDS_INIT;

/* once, in module exec */
if (PyThread_tss_create(&my_key) != 0) { PyErr_NoMemory(); return -1; }

/* per thread */
void *v = PyThread_tss_get(&my_key);
if (v == NULL) {
    v = make_per_thread_thing();
    if (PyThread_tss_set(&my_key, v) != 0) { ... }
}

Note that the key itself is a C global, which is acceptable because it contains no Python objects — but whatever you store through it must not be a PyObject * shared across interpreters.

The two deadlocks

1. Lock-ordering inversion with your own mutex

/* Thread A: holds the GIL, wants my_mutex.
   Thread B: holds my_mutex, is inside a Python callback and wants the GIL.
   Neither can proceed. */
pthread_mutex_lock(&my_mutex);      /* WRONG: acquired while attached */
... may call back into Python ...
pthread_mutex_unlock(&my_mutex);

The fix is to detach before blocking on any lock that another thread might hold while it needs the interpreter:

Py_BEGIN_ALLOW_THREADS
pthread_mutex_lock(&my_mutex);
Py_END_ALLOW_THREADS
... work, re-attached ...
pthread_mutex_unlock(&my_mutex);

Python 3.13 added PyMutex, which does this for you: PyMutex_Lock automatically detaches the thread state while it waits. Prefer it for any lock inside an extension. It is one byte, must be zero-initialized (static PyMutex m = {0};), and must never be copied or moved.

2. Forking with the GIL held

fork() in a multithreaded process gives the child only the calling thread, but all locks retain whatever state they had. If another thread held the GIL at fork time, the child deadlocks on its first Python operation. CPython installs pthread_atfork handlers to reacquire and reinitialize its own locks, but any lock your extension holds is your problem. The practical rules: use multiprocessing with the spawn start method where you can, and register os.register_at_fork handlers for any lock your extension owns.

Subinterpreters

Since PEP 684, each subinterpreter can have its own GIL, which makes real parallelism possible within one process without free-threading — provided every extension in play supports it. That support is exactly the Py_mod_multiple_interpreters slot from Lesson 08 plus the discipline of having no PyObject * in C globals. PEP 734 put a Python-level API on top (concurrent.interpreters in 3.14), which is why the question "does this extension work under subinterpreters?" is now something users actually ask.

Hands-on
import sys, time, threading, math, zlib

print(sys.getswitchinterval())      # how long a thread may hold the GIL, in seconds

def busy(n):
    s = 0.0
    for i in range(n): s += math.sqrt(i)
    return s

def timed(fn, *a):
    t = time.perf_counter(); fn(*a); return time.perf_counter() - t

# Pure-Python compute: two threads take about as long as two sequential runs.
print("1 thread ", timed(busy, 3_000_000))
ts = [threading.Thread(target=busy, args=(3_000_000,)) for _ in range(2)]
t = time.perf_counter()
[x.start() for x in ts]; [x.join() for x in ts]
print("2 threads", time.perf_counter() - t)

# zlib releases the GIL around compression -- compare the same experiment.
data = b"x" * 20_000_000
print("1 thread ", timed(zlib.compress, data, 6))
ts = [threading.Thread(target=zlib.compress, args=(data, 6)) for _ in range(2)]
t = time.perf_counter()
[x.start() for x in ts]; [x.join() for x in ts]
print("2 threads", time.perf_counter() - t)

The second experiment scales and the first does not, and the only difference is a Py_BEGIN_ALLOW_THREADS inside zlib. That is the whole value proposition of this lesson.

Further reading