Python Threading Model
Python’s
threadingmodule gives you genuine, preemptively-scheduled operating-system threads —pthread_createon Unix,CreateThread-family primitives on Windows — wrapped in a friendly object-oriented API. Everythreading.Threadyou start is a real kernel thread that the OS scheduler can place on any core. The catch, in the default build of CPython, is the Global Interpreter Lock (GIL): a single mutex that a thread must hold to execute Python bytecode or touch anyPyObject. So although you have N real threads, only one of them runs Python bytecode at any instant. This is the defining tension of the model — threads buy you concurrency (overlapping work, especially work that spends its time blocked in the kernel) but, in the GIL-enabled build, not CPU parallelism for pure-Python computation (threading docs; c-api threads.rst). The opt-in free-threaded build — experimental in 3.13 under PEP 703, “officially supported but still optional” in 3.14 under PEP 779 — removes the GIL and changes this calculus entirely.
Mental Model — Real Threads, One Token
The single most useful mental image is a room full of workers (your OS threads) and exactly one talking stick (the GIL). Anyone can sit at their desk and wait, sleep, or block on a syscall without the stick — but to speak Python (execute a bytecode, increment a reference count, allocate an object) a worker must be holding the stick. The OS scheduler decides who is “awake,” but the GIL decides who actually gets to run interpreter code. When a worker hits a blocking operation — reading a socket, sleeping, waiting on a lock — it puts the stick down so someone else can talk while it waits. That single discipline explains the entire performance profile of Python threads: throughput scales beautifully when the work is dominated by waiting (I/O-bound), and not at all when the work is dominated by computing in Python (CPU-bound).
flowchart TD subgraph OS["Operating system — all threads are real & schedulable"] T1["Thread 1 (pthread)"] T2["Thread 2 (pthread)"] T3["Thread 3 (pthread)"] end GIL{{"GIL — one holder at a time"}} T1 -->|holds GIL: runs bytecode| GIL T2 -.->|blocked on socket.recv — GIL released| WAIT2["kernel wait queue"] T3 -.->|waiting to acquire GIL| GIL GIL --> BC["ceval loop executes one bytecode at a time"] WAIT2 -->|I/O completes| T2
Diagram: three real OS threads exist simultaneously, but only the GIL holder (Thread 1) executes Python bytecode. Thread 2 is parked in the kernel on a blocking read with the GIL released; Thread 3 is ready and merely waiting for the GIL. Insight: parallelism in the default build happens only while threads are outside the interpreter — in the kernel or in GIL-releasing C code — never while two threads are both running pure Python.
How threading Maps onto the OS
The threading module is a thin, ergonomic layer over the low-level _thread extension module (historically thread in Python 2). _thread exposes the raw primitives: start_new_thread(), a primitive lock (allocated by allocate_lock()), and identity helpers like get_ident() and get_native_id(). threading.Thread builds the object model, lifecycle, naming, and the richer synchronization classes on top.
When you call Thread.start(), the chain that fires is entirely concrete. The CPython C source Modules/_threadmodule.c constructs a bootstate heap structure carrying the target callable, its arguments, and a freshly created thread state: boot->tstate = _PyThreadState_New(interp, _PyThreadState_WHENCE_THREADING). It then asks the OS for a real thread with PyThread_start_joinable_thread(thread_run, boot, &ident, &os_handle), which on Unix bottoms out in pthread_create (_threadmodule.c). The new kernel thread begins life in the C function thread_run(), which calls _PyThreadState_Bind(tstate) and then PyEval_AcquireThread(tstate) — the latter attaches the thread’s thread state and acquires the GIL before a single line of your Python runs. (The deep mechanics of that attach/acquire step live in the sibling note Thread State and the GIL Handshake.) Only after the GIL is in hand does thread_run invoke your target. This is why a freshly started thread does not begin executing Python instantly: it must first win the GIL from whoever currently holds it.
Thread.join() blocks the calling thread until the target thread terminates; crucially, join itself releases the GIL while it waits, so a joining thread does not freeze the whole interpreter. Thread.ident is the Python-level identifier (may be recycled after the thread dies), while Thread.native_id is the OS-assigned thread ID — the same number you would see in top -H or /proc/<pid>/task/, which confirms these are real kernel threads, not green threads (threading docs).
Where the GIL Is Released — Concurrency for I/O
The reason threads are useful at all in the default build is that the GIL is not held continuously. It is dropped in two situations.
First, the interpreter voluntarily hands it off between bytecode instructions to emulate fair concurrency. A running thread checks, at safe points in the evaluation loop, whether another thread has been waiting too long and, if so, drops the GIL to let it run. The “too long” threshold is the switch interval, 5 milliseconds by default in CPython 3.14 — the constant DEFAULT_INTERVAL is 5000 microseconds in Python/ceval_gil.c — and is tunable via sys.setswitchinterval(). The full hand-off protocol (the gil_drop_request flag, take_gil/drop_gil, the eval breaker) is covered in GIL Mechanics and the Switch Interval and The Global Interpreter Lock; this note only needs the consequence: pure-Python threads take turns, they do not run together.
Second — and this is what makes threading worthwhile — the GIL is released around blocking operations and around long-running C code that does not touch Python objects. When a thread calls time.sleep(), socket.recv(), file.read(), or blocks acquiring a threading.Lock, CPython detaches the thread state (releasing the GIL) for the duration of the wait, lets other threads run, and reacquires it on return. At the C level this is the Py_BEGIN_ALLOW_THREADS / Py_END_ALLOW_THREADS pattern — see Thread State and the GIL Handshake for the line-by-line expansion. The documentation gives concrete examples: the standard zlib and hashlib modules release the GIL while compressing or hashing, so multiple threads can compress or hash in genuine parallel on multiple cores (threads.rst). The lock-acquire path itself shows the discipline directly: lock_PyThread_acquire_lock in _threadmodule.c calls _PyMutex_LockTimed(..., _PY_LOCK_DETACH), where the _PY_LOCK_DETACH flag means “release the GIL while blocked on this mutex” (_threadmodule.c).
The practical upshot: a program issuing 100 concurrent HTTP requests across 100 threads will see all 100 sockets in flight at once, because each thread spends almost all its time blocked with the GIL released. A program computing 100 SHA-256 digests of in-memory data will also parallelize, because hashlib releases the GIL. But a program running 100 threads each summing a Python list in a for loop will run no faster than one thread — it may run slower, because of GIL contention and hand-off overhead.
Synchronization Primitives
Because threads share one address space, every mutable object is shared, and you need synchronization to coordinate access. The threading module supplies the standard toolkit (threading docs).
Lock is a primitive, non-reentrant mutex: acquire(blocking=True, timeout=-1) returns True on success, release() raises RuntimeError if called on an unlocked lock, and locked() reports state. A Lock has no concept of an owner — any thread may release it — and a thread that acquires it twice without releasing deadlocks itself.
RLock (reentrant lock) is owned by the thread that acquires it and may be acquired multiple times by that same thread; it is released only when an equal number of release() calls have been made. (RLock.locked() was added in 3.14.) Use RLock when one method holding the lock may call another method that also wants it.
Condition couples a lock with a wait/notify mechanism for the producer-consumer pattern: a thread acquires the condition, checks a predicate, and wait()s (atomically releasing the lock and blocking) until another thread calls notify(n=1) or notify_all(). The robust idiom is wait_for(predicate, timeout=None), which loops on wait() until the predicate holds, defending against spurious wakeups.
Event is the simplest signal: a boolean flag with set(), clear(), is_set(), and wait(timeout=None). It is the recommended way to ask a thread to stop gracefully — the thread loops while not stop_event.is_set().
Semaphore(value=1) maintains a counter that acquire() decrements (blocking at zero) and release(n=1) increments — ideal for bounding concurrency, e.g. capping a connection pool at five in-flight users. BoundedSemaphore additionally raises ValueError if you release() more times than you acquire()d, catching the classic “leaked a release” bug. Barrier(parties) makes a fixed number of threads rendezvous: each calls wait() and all are released together once parties have arrived.
All of Lock, RLock, Condition, Semaphore, and BoundedSemaphore are context managers, so the correct usage is almost always a with block, which guarantees release even on exception:
import threading
lock = threading.Lock()
shared_total = 0
def add(n):
global shared_total
with lock: # acquire(); released automatically on block exit
shared_total += n # read-modify-write protected against interleavingWithout the lock, shared_total += n is not atomic even under the GIL: it compiles to a LOAD, an in-place add, and a STORE, and the GIL can be handed off between them, so two threads can read the same old value and one increment is lost. (This is precisely the lost-update hazard the C-API docs cite to justify the GIL’s existence — threads.rst.)
Thread-Local Storage and Per-Thread Exception Handling
When you want each thread to have its own independent copy of some state without locking, use threading.local. An instance of local is a single object whose attributes resolve to different values per thread: mydata.number = 42 in one thread is invisible to and independent of mydata.number in another (threading docs). This is the Python-level counterpart of the C-level thread-local PyThreadState pointer described in Thread State and the GIL Handshake — both express “data that is private to a thread of control.” It is the idiomatic way to give, say, each worker thread its own database connection or request context without passing it through every call. A subtlety the docs flag: a subclass’s __init__ runs once per thread the first time that thread touches the instance, and __slots__ declared on a subclass are not thread-local — they are shared — because slots live on the type, not the per-thread dict.
Equally important is what happens when a thread’s target raises an exception that it never catches. Unlike the main thread, a worker thread cannot propagate the exception to your top-level code — there is no caller to receive it. Instead CPython routes it to threading.excepthook(args, /), added in Python 3.8, whose args is a namedtuple-like object with fields exc_type, exc_value, exc_traceback, and thread (threading docs). The default behavior silently ignores SystemExit and otherwise prints the traceback to sys.stderr — which is why an unhandled error in a thread prints a traceback but does not crash the process. You can override threading.excepthook to centralize thread error reporting (e.g. ship it to a logging service), restoring the original later from threading.__excepthook__ (saved since 3.10). The docs warn that retaining exc_value in a custom hook can create a reference cycle, and retaining thread can resurrect a finalizing thread — clear them when done.
Daemon Threads
A thread’s daemon flag, set before start() (and inherited from the creating thread), controls shutdown behavior: the Python program exits when only daemon threads remain. Non-daemon threads keep the process alive until they finish; daemon threads do not. The trade-off is brutal and explicit in the docs: “Daemon threads are abruptly stopped at shutdown. Their resources (such as open files, database transactions, etc.) may not be released properly.” (threading docs). So a background metrics-flusher might be a daemon (you don’t care if it dies mid-flush at exit), but a thread writing to a file should be non-daemon plus an Event-based graceful-stop signal. A new 3.14-era sharp edge: attempting to join() a still-running daemon thread very late in interpreter finalization raises PythonFinalizationError.
Failure Modes and Common Misunderstandings
The number-one misconception is “threads make CPU-bound Python faster.” They do not, in the default build — the GIL serializes bytecode. The fix is processes (multiprocessing and Process-Based Parallelism), GIL-releasing C extensions (NumPy, hashlib), or the free-threaded build.
A second trap is assuming individual operations are atomic. Some are (a single dict[key] = value on a built-in dict, a list.append) because they complete within one C-level bytecode that doesn’t yield. But compound operations (+=, “check then act”, if k not in d: d[k] = ...) are not, and the GIL does not save you. Always lock shared mutable state.
Deadlock is the third: thread A holds lock 1 and wants lock 2 while thread B holds lock 2 and wants lock 1. The standard defenses — acquire locks in a global order, use timeouts, prefer higher-level constructs like Queue (which is internally synchronized) — all apply to Python exactly as to any threaded language.
A subtler failure is the busy CPU-bound thread starving I/O threads. Before the modern eval-breaker design, a tight numeric loop could hold the GIL for long stretches and delay responsive threads; the 5 ms switch interval and the demand-driven gil_drop_request mechanism mitigate but do not eliminate this. If a latency-sensitive thread shares a process with a CPU-bound one, contention is real — measure it.
Alternatives and When to Choose Them
Threads are the right tool for I/O-bound concurrency with blocking libraries and moderate fan-out. When you have thousands of concurrent I/O operations, the per-thread memory cost (each OS thread reserves a stack, typically ~8 MB of virtual address space) and context-switch overhead favor asyncio, which multiplexes thousands of coroutines on one thread cooperatively — no GIL contention because there is only one thread. For CPU-bound parallelism, multiprocessing sidesteps the GIL by running separate interpreters in separate processes (at the cost of IPC and pickling), and the per-interpreter-GIL subinterpreters (PEP 734) offer an in-process middle ground. The concurrent.futures module gives a unified Executor API over both threads and processes.
The biggest shift is the free-threaded build. PEP 703 first shipped the GIL-less build experimentally in Python 3.13 (phase I). PEP 779 (“Criteria for supported status for free-threaded Python,” Final) then moved it to phase II in 3.14: “officially supported but still optional” — a distinct build you choose at compile time (or via a t-suffixed installer), not the default, with phase III (making it the default) reserved for a future decision. In that build the GIL is absent, so the same threading code can run pure-Python on all cores in parallel — the “talking stick” is gone. The price is that thread-safety of your own data structures now matters where the GIL previously masked races, and single-threaded performance carries a small overhead from the lock-free reference-counting machinery. Code written against threading does not change; its parallelism characteristics do.
Production Notes
Real systems lean on threads precisely where the GIL is not in the way. Web servers like Gunicorn’s gthread worker handle many simultaneous connections per worker because request handling is dominated by socket and database I/O with the GIL released. Database drivers, the requests/urllib3 stack, and subprocess all release the GIL during their blocking calls. The pattern to internalize: profile first, and if your threads spend their time in recv, read, sleep, or a C library that releases the GIL, threading scales; if they spend their time in Python bytecode, it does not, and you reach for processes or the free-threaded build.
See Also
- Thread State and the GIL Handshake — the C-level
PyThreadState, attach/detach, and thePy_BEGIN_ALLOW_THREADSmacros behind every GIL release described here (sibling) - The Global Interpreter Lock — why the GIL exists and what it protects
- GIL Mechanics and the Switch Interval — the 5 ms switch interval and
take_gil/drop_gilinternals - Free-Threaded CPython — the build that removes the GIL (PEP 703 / PEP 779)
- The eval Breaker — the safe-point mechanism that drives GIL hand-off
- multiprocessing and Process-Based Parallelism — true CPU parallelism via separate processes
- The asyncio Event Loop — single-threaded cooperative concurrency
- Subinterpreters — per-interpreter GIL, in-process
- Python Internals MOC — §9 Concurrency and Parallelism