The GIL and free-threaded Python

Python · Expert · 16 min

Deciding how to parallelise Python, and preparing code for a GIL-free future, depends on these details.

The global interpreter lock is a mutex in CPython that a thread must hold to execute bytecode. It exists mainly because reference counting and much of the interpreter's internal state aren't thread-safe; one big lock made the single-threaded case fast and C extensions simple to write.

How it behaves:

  • a running thread is asked to drop the GIL after a switch interval (sys.getswitchinterval(), 5 ms by default) so others can run
  • blocking I/O and time.sleep release it, which is why threads help with I/O-bound code
  • C extensions can release it around long computations — NumPy, hashlib and compression do — so some "CPU" work does parallelise across threads

What it does not do is make your code thread-safe. A switch can happen between any two bytecodes, so x += 1, check-then-act sequences and multi-step updates race. Individual operations on built-ins such as list.append happen to be atomic, but don't design around that.

Python 3.13 ships an experimental free-threaded build (PEP 703), available as an optional python3.13t executable in the official Windows and macOS installers, or built from source with --disable-gil. It replaces the GIL with biased reference counting, per-object locks and deferred reclamation, so threads run Python code on multiple cores. In 3.13 it is explicitly experimental: single-threaded code runs noticeably slower (the specialising interpreter is disabled in that build), and importing an extension module that hasn't declared free-threading support re-enables the GIL with a warning. You can force the choice with PYTHON_GIL=0 or -X gil=0, and check at runtime with sys._is_gil_enabled().

Code that is correctly synchronised with locks and queues runs unchanged on either build; code that leaned on the GIL for accidental safety is what breaks.

Example

import sys
import sysconfig
import threading
import time

free_threaded_build = bool(sysconfig.get_config_var("Py_GIL_DISABLED"))
gil_on = getattr(sys, "_is_gil_enabled", lambda: True)()
print(f"free-threaded build: {free_threaded_build}, GIL enabled: {gil_on}")
print("switch interval:", sys.getswitchinterval())   # 0.005

def spin(n=3_000_000):
    total = 0
    for i in range(n):
        total += i
    return total

for workers in (1, 4):
    threads = [threading.Thread(target=spin) for _ in range(workers)]
    start = time.perf_counter()
    for t in threads: t.start()
    for t in threads: t.join()
    print(workers, "threads:", round(time.perf_counter() - start, 2), "s")
# With the GIL, 4 threads take ~4x as long as 1 (no parallelism).
# On python3.13t with the GIL off, they take about as long as 1.

Remember

  • The GIL lets one thread run bytecode at a time; I/O and some C code release it
  • The GIL never made compound operations like x += 1 atomic
  • 3.13's free-threaded build (python3.13t) is experimental and opt-in

Watch out for

  • Assuming code is thread-safe because of the GIL.

Check yourself

  1. In Python 3.13's free-threaded build, what happens when you import a C extension not marked as free-threading safe?
  2. Which workload usually speeds up with threads even on the regular (GIL) build?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: docs.python.org/3/howto/free-threading-python.html