One lock,
and its consequences.
This is where a quant dev screen actually probes, because it is the difference between someone who writes Python and someone who knows what the machine underneath is doing with it. Everything on this page comes from one design decision and the memory model that forced it.
Why threads do not speed up a numeric loop
CPython has one lock per interpreter that a thread must hold to execute Python bytecode. You do not need the acronym to explain any of this, and describing the mechanism is more convincing than naming it.
The three rules the lock imposes
- You must hold the lock to run bytecode. Not to exist, not to be scheduled by the OS, but to execute a single Python instruction.
-
A running thread releases it periodically.
Every 5 milliseconds by default, tunable via
sys.setswitchinterval. The releasing thread signals a waiting one, then immediately tries to reacquire. - A thread releases it around any call that blocks. Every read, write, socket operation, sleep and lock acquire in the standard library drops the lock before entering the syscall and takes it back afterwards.
Rules one and two give you the bad case. Rule three gives you the good one.
Case A: a tight numeric loop, and why two threads is not faster
def work(n):
total = 0
for i in range(n):
total += i * i
return total
Every one of those instructions is bytecode. There is no syscall, no I/O, nothing that would ever hand the lock away voluntarily. So the only releases are the involuntary ones every 5 ms, and each release is immediately followed by another thread grabbing the lock and running its own bytecode.
The result is interleaving, not overlap. Two threads take turns on one core's worth of Python execution. Total wall time is the same as running them one after the other, plus overhead from the handoffs and from the cache disruption of switching working sets. Threads here are typically a few percent slower than a single thread, not faster.
Both threads are runnable, but only one can hold the interpreter lock at a time, so they interleave on a single core's worth of bytecode rather than running in parallel. The work is CPU bound and never blocks, so the lock is never voluntarily released and there is no overlap to gain.
Case B: a thread waiting on a socket, and why that one does help
def fetch(sock):
data = sock.recv(4096) # lock released before the syscall,
return parse(data) # reacquired after it returns
The interesting fact about recv is that almost
all of its elapsed time is spent doing nothing. The thread
is parked in the kernel waiting for a packet, consuming no
CPU. Since the lock is released for that entire window,
other threads run Python freely the whole time.
Ten threads each waiting 100 ms on a network round trip finish in roughly 100 ms total rather than a second, because the waits overlap. Nothing about the lock prevents that, since waiting is not executing.
The lock serialises Python execution, not time spent. Anything that spends its time outside the interpreter, whether that is waiting on a kernel or crunching numbers in C, overlaps freely. That single sentence covers both the limitation and every escape hatch below.
Lock timeline simulator
Why the lock is there at all
This is the follow-up question, and answering it well requires connecting the lock to the memory model rather than treating it as an arbitrary historical wart.
Every object carries a counter
CPython frees memory by reference counting. Each object header holds an integer counting how many references point at it. Binding a name increments it, a name going out of scope decrements it, and when it reaches zero the object is freed on the spot.
x = [1, 2] # the list's refcount is 1
y = x # incremented to 2
del x # back to 1
del y # 0, freed immediately
Those counters are ordinary non-atomic C integers, and they are touched constantly. Assigning a variable, passing an argument, appending to a list and returning a value all adjust refcounts. It is the single hottest operation in the interpreter.
Now put two threads on it
count += 1 in machine code is three steps:
load, add, store. Two threads interleaving those steps can
both read 5, both write 6, and lose an increment. An object
with a refcount one lower than the truth gets freed while
something still points at it, and the next use of that
pointer reads freed memory. That is a use-after-free, which
means silent corruption or a crash, in a language that
promises neither is possible.
So the counters must be protected. There are exactly two ways to do it, and the trade between them is the whole story:
| Approach | What you get | What you pay |
|---|---|---|
| One coarse lock over the interpreter | Refcounts and every other piece of interpreter state are safe for free. Single-threaded code pays nothing. C extensions need no thread awareness at all. | Only one thread runs bytecode at a time. |
| Atomic refcounts, plus fine-grained locks | Genuine multi-core Python. | Every increment becomes an atomic operation, which forces cache-line coherence traffic between cores on the hottest instruction in the interpreter. Historically this made single-threaded code substantially slower, and it breaks the C extension ecosystem's assumptions. |
CPython chose the first for decades because most programs are single threaded and the second option taxed all of them to benefit a few. That calculus is what has finally shifted, which is what the free-threaded build is about.
Because the lock would cost more than the work it protects. Incrementing an integer is one or two nanoseconds. Acquiring and releasing even an uncontended lock is meaningfully more, and you would do it on literally every name binding. You would also need a lock per object, which is memory on every object, and you would open the door to deadlock between objects that reference each other.
Because it is one instruction that has to coordinate across cores. An atomic read-modify-write needs exclusive ownership of the cache line holding that counter, so the core must invalidate every other core's copy of it and wait for the acknowledgements. If several cores keep touching the same object, its cache line ping-pongs between them and each access costs tens or hundreds of cycles instead of one.
This matters enormously in Python because everyone
touches the same few objects: None,
True, small integers, the common type
objects. Those are the hottest refcounts in the
process, and they would be the most contended cache
lines in the machine.
Because the code running while the lock is released is C code that touches no Python objects. The thread has already copied out the file descriptor and the buffer pointer it needs, and it will not touch a refcount again until it has reacquired the lock. So the invariant the lock protects is never violated during the window.
That is also the rule a C extension author has to follow to release the lock: it may do anything at all except touch Python objects while the lock is not held.
Getting real parallelism anyway
Four routes out, each defined by how it satisfies the rule from the bedrock box above. Naming the right one for a given workload is the actual test.
1. multiprocessing: a separate interpreter per core
Each process gets its own interpreter, its own lock, and its own memory. Nothing is shared so nothing needs coordinating, and you get genuine multi-core CPU parallelism.
The cost is the boundary. Arguments and results are pickled, written through a pipe and unpickled, so anything you send is serialised and copied twice. Startup is milliseconds per process. And large data becomes the dominant cost: sending a 1 GB array to a worker and getting one back can easily exceed the compute you were trying to parallelise.
from concurrent.futures import ProcessPoolExecutor
with ProcessPoolExecutor() as ex:
results = list(ex.map(heavy_pure_function, chunks))
The shape that works is coarse chunks, small payloads and a
pure function. If you must move big arrays,
multiprocessing.shared_memory lets workers map
the same buffer instead of copying it, and on Linux the
default fork start method shares read-only
pages with the parent through copy-on-write. Note that since
Python 3.14 the default start method on Linux is
forkserver rather than fork,
because forking a threaded process was a long-standing
source of deadlocks.
2. NumPy and C extensions: release the lock and go
When you call arr.sum(), NumPy releases the
interpreter lock, runs a C loop over the buffer, and
reacquires it before returning. During that entire window no
Python objects are touched, so the rule holds and other
threads run freely.
This is the most important one for this desk, because it means a thread pool over NumPy or pandas work often does scale, even though a thread pool over pure Python arithmetic does not. It is also why linear algebra scales without you doing anything: the underlying BLAS library runs its own OpenMP threads underneath, entirely outside Python's view.
3. asyncio: concurrency for waiting, on one thread
An event loop on a single thread interleaves thousands of
coroutines. Each await on I/O yields control
back to the loop, which runs something else until the kernel
says that socket is ready.
This does not add CPU. It removes the per-connection thread,
so you handle tens of thousands of concurrent sockets with a
few kilobytes of state each instead of a megabyte of stack
each. Compared to threads it also makes the switch points
explicit: control changes hands only at an
await, which removes most data races by
construction.
One blocking call inside a coroutine stalls the entire
loop and every other connection with it. A synchronous
requests.get, a heavy
json.loads, or a NumPy call that takes 200 ms
all freeze everything. Push blocking work out with
loop.run_in_executor, and use
asyncio.to_thread for the common case.
4. Free-threaded builds
Worth knowing the status rather than the internals. Python 3.13 shipped an optional build with the lock removed, marked experimental. Python 3.14, released in October 2025, promoted that build to officially supported under PEP 779, with single-threaded performance now within roughly 5 to 10 percent of the standard build. It is still a separate, optional build rather than the default, and extension modules must be rebuilt and audited for it, so ecosystem coverage remains the real constraint. Knowing that it exists, that it is supported as of 3.14, and that it is not yet the default is the right amount of detail.
| Your workload | Reach for | Because |
|---|---|---|
| Pure Python number crunching | multiprocessing, or rewrite it with NumPy | Bytecode is exactly what the lock serialises. Vectorising is usually the bigger win and needs no processes. |
| Array or matrix work | Threads are fine | The lock is released for the duration of the C kernel. |
| A handful of network calls | Threads | Simple, and the waits overlap. No need to make the code async. |
| Thousands of concurrent sockets | asyncio | Per-connection cost drops from a thread stack to a small object. |
| Reading many files | Threads | File I/O releases the lock too. Note the disk may be your limit, not the CPU. |
Reference counting, cycles, and where the memory goes
You do not need CPython's allocator internals. You do need to be able to say, roughly and correctly, what a million small objects cost against one array, because that number is the argument for every NumPy and pandas choice on the following pages.
Two collectors, doing different jobs
Reference counting handles almost everything. It is immediate, so memory is reclaimed the instant the last reference goes away and destructors run deterministically. It is also incremental, so there are no collection pauses. Its cost is spread thinly across every single operation, and it cannot handle one case.
Cycles. If a references
b and b references a,
and you drop both external names, each still has a refcount
of 1 from the other. Neither ever reaches zero. That memory
is unreachable and unfreeable by counting alone.
a, b = Node(), Node()
a.peer = b
b.peer = a
del a, b # refcounts drop to 1, not 0. leaked, until the cycle collector runs.
So there is a second, generational cycle collector that periodically walks container objects looking for groups whose only remaining references come from inside the group. It has three generations. New objects start in generation 0, which is scanned often and is cheap because it is small. Objects that survive a scan are promoted to older generations, which are scanned progressively more rarely. The premise is that most objects die young, so the frequent scans should look at the young ones.
It only tracks containers, since only a container can
participate in a cycle. Ints, floats and strings are never
scanned. You can call gc.disable() in a
latency-sensitive loop that provably creates no cycles,
which is a real technique in trading systems, and it is a
good thing to be able to mention.
A million integers, two ways
This is the calculation to be able to do out loud.
list(range(1_000_000)) |
np.arange(1_000_000) |
|
|---|---|---|
| Per element | 28 bytes for the int object, which is a 16-byte header plus the digit storage, plus 8 bytes for the pointer in the list | 8 bytes, and only the value |
| Total | roughly 36 MB | 8 MB, of which 8,000,000 bytes are the data and about a hundred are the array object |
| Layout | An array of pointers into scattered heap allocations | One contiguous block |
| To read element i | Load the pointer, then follow it to wherever the object landed, most likely a cache miss | Compute base + i*8 and read, and the prefetcher already fetched it |
Roughly 4 to 5 times the memory, but the layout difference matters more than the size difference. Every element of the list is a separate heap object that could be anywhere, so iterating the list is a pointer chase through scattered memory. Iterating the array walks one contiguous block, which is the access pattern hardware prefetchers are built for.
list(range(1000)) is cheaper than the
arithmetic suggests, because CPython caches the integers
from −5 to 256 as singletons, so a list of small
numbers holds a million pointers to a few shared objects.
That saves the object allocations but not the pointer
array, and it stops helping the moment your values exceed
256, which for real data they always do.
Two related facts to have ready. A Python object's memory is
not just its data: every object carries a refcount and a
type pointer, which is the 16-byte header, and every
instance of a normal class carries a
__dict__ on top of that. And CPython does not
reliably return freed memory to the operating system, since
its small-object allocator keeps arenas for reuse, so
resident memory tends to stay at the high-water mark. That
is why a process that briefly built a huge list stays large
afterwards, and why the fix is usually to not build it.
Not needed, but do not look blank
dis.dis(fn) shows it. You do not need to read
it, only to know that this is the thing the lock is
serialising.