Skip to content
v0.1.1latest release34 skills2 categories4 sub-categories79 tested scripts157 reference docsContact us

GIL Fundamentals

Reference for python-gil-bottleneck-analyzer · 229 lines

Programming / Pythonv1.0.0Updated OCT 21 20267 references2 scripts

What the Global Interpreter Lock actually serialises, how threads take turns, which code releases it, and the myths that lead to wrong fixes. Everything here describes the default CPython build unless a section says "free-threaded"; that build is covered in free-threading-and-subinterpreters.md.

Contents

  1. What the GIL protects
  2. How threads take turns: the switch interval
  3. Who releases the GIL
  4. Library-by-library table
  5. Checking a library yourself
  6. The GIL is not a lock for your code
  7. Convoy effect: CPU threads hurt I/O threads
  8. Version history
  9. Myths and corrections

1. What the GIL protects

CPython's memory management (reference counts, the allocator, many internal caches) is not thread-safe on its own. The GIL is one mutex per interpreter that a thread must hold to touch Python objects or run bytecode. Consequences:

  • Only one thread executes Python bytecode at any instant, regardless of core count.
  • C code called from Python runs with the GIL unless it explicitly releases it (Py_BEGIN_ALLOW_THREADS ... Py_END_ALLOW_THREADS in C, with nogil: in Cython, allow_threads/detach in PyO3, nogil=True in numba).
  • While a thread is blocked in a system call that released the GIL (socket read, sleep, file I/O), other threads run Python freely.
  • Each process has its own interpreter and GIL, so processes run Python in parallel.
  • Since 3.12 each sub-interpreter may have its own GIL (PEP 684), and 3.14 exposes that in the stdlib (concurrent.interpreters, InterpreterPoolExecutor).

Mental model: the GIL makes threads concurrent (interleaved) for Python code and parallel only for the parts that release it.

2. How threads take turns: the switch interval

Since Python 3.2 a thread that wants the GIL waits on a condition variable with a timeout equal to the switch interval. If the holder has not released it by then, the waiter sets a "drop request" flag; the holder checks the flag between bytecodes (at "eval breaker" points), releases the GIL and the waiter takes it.

python
import sys

print(sys.getswitchinterval())    # 0.005 seconds by default
sys.setswitchinterval(0.001)      # more responsive I/O threads, more hand-off overhead
sys.setswitchinterval(0.005)      # restore the default

What tuning it does and does not do:

ChangeEffect
Lower (e.g. 1 ms)I/O and UI threads get the GIL back sooner; CPU throughput drops slightly from more hand-offs
Higher (e.g. 50 ms)Fewer switches for pure CPU threads; latency for other threads rises
Any valueNever makes two Python threads run bytecode at the same time

The hand-off is not free: the dropping thread signals, the OS wakes the waiter, caches are cold. Two CPU-bound threads on a GIL build typically finish slightly slower than running the same work serially. That is why gil_probe.py often reports 0.9x for "threads" on pure-Python work.

Long-running C calls that hold the GIL (a huge re.sub, sorted on a million items, json.loads of a 100 MB string) do not check the drop request until they return, so they can starve other threads for the whole call regardless of the switch interval.

3. Who releases the GIL

Released (other threads run while this happens):

  • Blocking I/O in the stdlib: socket send/recv/connect/accept, select/poll/epoll, file read/write on io.FileIO and buffered files, os.read/os.write, subprocess waits, time.sleep, DNS (getaddrinfo).
  • Waiting on synchronisation: threading.Lock.acquire, Event.wait, queue.Queue.get, Future.result.
  • Hashing buffers larger than 2047 bytes in hashlib (OpenSSL-backed and, in recent versions, the built-in implementations). Hashing many tiny strings holds the GIL for the Python loop around them.
  • zlib, bz2, lzma compression and decompression.
  • sqlite3 while the SQLite engine executes a statement.
  • Most numpy computation on numeric dtypes (ufunc inner loops, reductions, copies, sorting, BLAS/LAPACK).
  • Most torch / onnxruntime / TensorFlow operator kernels, CUDA synchronisation, and model inference calls.
  • Rust extensions that call allow_threads (detach in newer PyO3), e.g. HF tokenizers batch encoding, polars query execution.
  • Pillow and OpenCV for many decode/resize operations.

Held (threads serialise):

  • All Python bytecode: loops, comprehensions, function calls, attribute access, f-strings.
  • Built-ins that operate on Python objects: sorted, sum, min, max, len, str methods, dict/list/set operations, isinstance.
  • re (the stdlib engine does not release the GIL), json and orjson (they build Python objects), pickle, marshal, csv, struct, ast, compile/exec.
  • numpy with dtype=object, conversions between Python lists and arrays (np.array(list_of_lists), .tolist()), and small-array operations dominated by Python-level overhead.
  • pydantic-core validation, most C helpers that create many Python objects.

4. Library-by-library table

Library / callGILNotes
requests, httpx (sync)released while waiting on the socketthreads fine; asyncio for thousands of calls
httpx.AsyncClient, aiohttpn/a (event loop)one thread; CPU in callbacks blocks the loop
hashlib.sha256(big_bytes)released (> 2047 bytes)threads scale for big buffers
zlib.compress, gzipreleased in the C partgzip.open line iteration is Python and holds it
re.sub, re.findallheldthird-party regex can release with concurrent=True
json.loads, orjson.loads, msgspec.json.decodeheldfaster libraries help; threads do not
pickle.dumps / loadsheldthe hidden cost of process pools
numpy ufuncs, @, np.dot, np.sort on float arraysreleasedBLAS spawns its own threads too
numpy object arrays, np.vectorizeheldnp.vectorize is a Python loop
pandas column arithmeticreleased inside numpyapply(axis=1), iterrows hold it
polars expressionsreleased, parallel internallydon't wrap in more threads
torch ops, model(x)released inside kernelsintra-op threads: torch.set_num_threads
HF tokenizers encode_batchreleased, rayon-parallelTOKENIZERS_PARALLELISM controls it
HF slow (Python) tokenizershelduse the fast tokenizer or processes
sentence-transformers.encodemostly released (torch)batch size matters more than threads
lxml parsepartly released for parsing from files/byteselement-tree walking holds it
PIL.Image.resize, decodereleased in most operationsgood thread-pool candidate
sqlite3 executereleased while the engine runsone connection per thread
time.sleep, select, socketreleasedthe classic I/O-bound case

Treat this as a starting hypothesis. Library versions change; verify (next section).

5. Checking a library yourself

Three quick ways, from cheapest to most precise:

  1. Probe it: wrap one call in a function and run python scripts/gil_probe.py --target mymod:fn. If threads give ≈ Nx with CPU/wall ≈ N, the call releases the GIL.
  2. Sample it: py-spy record --gil -o gil.svg -- python bench.py records only samples where a thread holds the GIL. If the call disappears from the --gil flame graph but dominates the normal one, it releases it.
  3. Read the source: search for Py_BEGIN_ALLOW_THREADS (C), with nogil (Cython), allow_threads or detach (PyO3), nogil=True (numba).

A self-contained check with threads (prints the speedup; > 1.5x on 4 threads means the GIL is released):

python
import hashlib
import time
from concurrent.futures import ThreadPoolExecutor

BUF = bytes(range(256)) * 4096          # 1 MiB


def hash_many(_):
    for _ in range(40):
        hashlib.sha256(BUF).digest()


def timed(fn):
    t0 = time.perf_counter()
    fn()
    return time.perf_counter() - t0


serial = timed(lambda: [hash_many(i) for i in range(4)])
with ThreadPoolExecutor(4) as ex:
    threaded = timed(lambda: list(ex.map(hash_many, range(4))))
print(f"thread speedup: {serial / threaded:.1f}x")

6. The GIL is not a lock for your code

The GIL guarantees that the interpreter's internals stay consistent; it does not make your multi-step operations atomic. A thread can be switched out between any two bytecodes.

python
import dis


def bump(counter):
    counter["n"] += 1


dis.dis(bump)   # BINARY_SUBSCR / BINARY_OP / STORE_SUBSCR: a switch can happen between them
OperationSafe without a lock on the GIL build?
lst.append(x), dict[k] = v, deque.popleft() (single C call)yes, in practice (do not rely on it across implementations)
x += 1 on a shared int / dict valueno (read-modify-write)
if k not in d: d[k] = make()no (check-then-act)
iterating a dict while another thread insertsno (RuntimeError: dictionary changed size during iteration)
lazy singletons (if _client is None: _client = Client())no (two clients get built)

Use threading.Lock, queue.Queue, or per-thread state. Code that is "correct because of the GIL" is the code that breaks first on the free-threaded build.

7. Convoy effect: CPU threads hurt I/O threads

When one thread is CPU-bound and another does I/O, every time the I/O thread's syscall completes it must re-acquire the GIL, and may wait up to one switch interval for the CPU thread to drop it. With many short I/O operations (a socket read per message), throughput collapses: each read pays up to 5 ms of waiting.

Symptoms: an asyncio or threaded server whose p99 latency jumps when a background CPU task runs, py-spy --native stacks ending in take_gil / PyEval_RestoreThread, profile_classify.py verdict GIL-CONTENDED.

Fixes, best first: move the CPU work to a process (run_in_executor(process_pool, ...)); make the CPU work release the GIL (numpy, numba nogil=True); lower the switch interval as a stop-gap; on 3.14t, test the free-threaded build.

8. Version history

VersionChange relevant to the GIL
3.2"New GIL": time-based switching (sys.setswitchinterval), replaces the bytecode-count sys.setcheckinterval (removed in 3.9)
3.7GIL always created at startup (earlier versions created it lazily when the first thread started)
3.8ThreadPoolExecutor default workers min(32, os.cpu_count() + 4)
3.9sys.setcheckinterval/getcheckinterval removed
3.12PEP 684 per-interpreter GIL (C API); PEP 683 immortal objects; PEP 669 sys.monitoring; -X perf for Linux perf
3.13PEP 703 free-threaded build (experimental, python3.13t), sys._is_gil_enabled(), PYTHON_GIL/-X gil; os.process_cpu_count(); PYTHON_CPU_COUNT/-X cpu_count; -X perf_jit
3.14PEP 779: free-threaded build officially supported (still optional); PEP 734 concurrent.interpreters; concurrent.futures.InterpreterPoolExecutor; forkserver default start method on Linux/POSIX except macOS

If a detail matters for a release decision, check the "What's New" page for the exact version you run.

9. Myths and corrections

MythReality
"Python can't use multiple cores"Processes, GIL-releasing native code, sub-interpreters and the free-threaded build all can
"Threads are useless in Python"They are excellent for I/O and for native work that releases the GIL
"asyncio gets around the GIL"asyncio is one thread; it overlaps waiting, never computation
"numpy is always parallel"Only BLAS-backed ops (matmul, dot, linalg) use several cores by default; ufuncs are single-threaded but release the GIL, so you can thread them
"The GIL makes my code thread-safe"Only single C-level operations are atomic; compound updates race
"Lowering the switch interval makes threads faster"It shifts latency vs throughput; never adds parallelism
"Processes are always faster for CPU work"Not when per-item work is < ~1 ms or arguments are large: pickling and IPC dominate
"Free-threaded Python is a drop-in speedup"Single-threaded code is slower on it; it only pays when you run CPU work in threads and every extension supports it
"C extensions release the GIL"Only where the author wrote it; re, json, pickle do not
More in Python

python-async-deadlock-debugger

Diagnose and fix hangs, deadlocks, livelocks and starvation in asyncio, threaded and multiprocessing Python: classify CPU spin vs blocked IO vs deadlock vs lost wakeup vs event loop blocked by sync code, collect evidence with faulthandler, py-spy dump, gdb py-bt, asyncio.all_tasks, python -m asyncio ps/pstree, debug mode and sys._current_frames, then fix lock-order cycles, non-reentrant asyncio.Lock re-acquisition, run_coroutine_threadsafe().result() on the loop thread, Queue.join without task_done, Condition lost wakeups, semaphore leaks, futures nobody resolves, nested event loops, thread pool exhaustion, multiprocessing Queue/fork deadlocks and shutdown hangs. Ships a static hang scanner and a watchdog that dumps every thread and asyncio task.

python-asyncio-pattern-advisor

Recommend, implement and review Python asyncio patterns: async vs threads vs processes (IO vs CPU-bound), coroutines vs tasks vs futures, TaskGroup, gather and return_exceptions, wait and as_completed, Semaphore and Queue worker pools with backpressure, asyncio.timeout vs wait_for, cancellation, shield and cleanup, token-bucket RPM/TPM rate limits for LLM APIs, retries with backoff and jitter, async generators and SSE token streaming, to_thread/run_in_executor, background task references, eager tasks, asyncio.run/Runner, uvloop, Jupyter, FastAPI async vs def routes, httpx.AsyncClient lifespan, async DB/vector-store clients, debug mode and pytest-asyncio.

python-beginner-concept-tutor

Teach Python concepts to beginners heading into AI engineering (data scientists, analysts, students, career switchers): plain-language definitions, analogies, minimal examples, line-by-line walkthroughs, common mistakes, check-your-understanding questions and mini exercises; Socratic hints before solutions; explaining tracebacks; mutable vs immutable, references and aliasing, is vs ==, integer division, floating point, slicing and off-by-one, indentation errors; a staged learning path from variables to calling an LLM API, embeddings and a tiny RAG. Ships a script that lists the concepts a snippet uses in learning order and traces execution step by step like Python Tutor.

python-comprehension-refactorer

Refactor Python loops into list, dict and set comprehensions and generator expressions, and unroll comprehensions that are too clever: filtering, mapping, nested-loop flattening, conditional expressions, walrus in comprehensions, dict inversion, grouping with defaultdict/Counter, lazy generators for sum/any/all/max, streaming large files, itertools (chain, islice, groupby, batched, pairwise, accumulate, product), scoping rules, side-effect anti-patterns and performance (when map/filter or numpy/pandas vectorization win). AI-engineering focus: building prompt lists, batching texts for embeddings, filtering retrieved chunks by score, id->embedding dicts, flattening tool-call results, lazy dataset streaming and token counting.

python-context-manager-writer

Write correct Python context managers and find resource-handling bugs — __enter__/__exit__ semantics and exception suppression, contextlib (contextmanager, asynccontextmanager, ExitStack, AsyncExitStack, closing, aclosing, suppress, redirect_stdout, nullcontext, chdir), async with, re-entrancy, leaked files/connections/clients/pools/locks, and AI-engineering recipes: torch.no_grad/inference_mode/autocast, eval()/train() switches, GPU cache cleanup, token and latency meters, tracing spans, temp env vars, rate-limit semaphores, httpx/aiohttp client lifecycle, FastAPI lifespan, DB/vector-store transactions, atomic checkpoint writes, seeds and agent sandboxes.

python-dataclass-converter

Convert boilerplate Python classes, dicts, tuples and JSON payloads into dataclasses, or into pydantic v2 BaseModel, TypedDict, NamedTuple, attrs or msgspec when that fits better. Covers every @dataclass option (frozen, slots, kw_only, order, unsafe_hash, match_args, weakref_slot), field() (default_factory, init=False, repr, compare, hash, metadata), __post_init__, InitVar, KW_ONLY, inheritance and default ordering, replace(), asdict deep-copy pitfalls, hashing/equality, serialization and validation. AI focus: LLM request/response and chat Message types, RAG chunks with embeddings (numpy eq gotcha), eval records, configs, typed OpenAI/Anthropic JSON.