00Overview 01Python semantics 02Python runtime 03NumPy 04pandas 05Operating systems 06Networking 07Resume project 0812-interview plan
02 · Python runtime

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.

Mechanism

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

  1. 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.
  2. 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.
  3. 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.

Say it like this 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 precise distinction

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.

Widget

Lock timeline simulator

Four workers, and a choice of workload and execution model. Green means holding the lock and running bytecode. Grey means runnable but blocked on the lock. Violet means parked in a syscall, holding nothing. Watch which configurations compress the total and which do not.

Workload
Model
Wall time
Versus one thread
Peak parallel bytecode

Mechanism

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.

Bedrock The lock exists to make non-atomic reference counting safe. That is why it protects exactly the operations that touch Python objects, and why anything that can promise not to touch Python objects, whether a syscall or a NumPy kernel, is allowed to run without it.
Mechanism

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.

The failure mode

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.
Rough sketch

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.

The one qualification worth knowing

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.

One line each

Not needed, but do not look blank

bytecodePython compiles source to a stack-based instruction set that the evaluation loop executes. dis.dis(fn) shows it. You do not need to read it, only to know that this is the thing the lock is serialising.
CPython internalsThe reference implementation, written in C. Alternatives exist: PyPy uses a tracing JIT, and there is now an experimental JIT in CPython itself as of 3.13.
free-threaded buildOptional build without the interpreter lock. Experimental in 3.13, officially supported in 3.14 under PEP 779, still not the default and still gated on extension support.
sys.setswitchintervalTunes how long a thread holds the lock before releasing it, 5 ms by default. Lowering it improves latency fairness and raises overhead.
subinterpretersPEP 734 exposed multiple interpreters in one process from 3.13, each with its own lock. A middle ground between threads and processes, without the pickling cost of a pipe.