Runtime & Internals
How CPython actually runs your code -- bytecode, the GIL and multiprocessing, reference counting, the cyclic GC, GC tuning, and weak references
Runtime & Internals
To reason about performance and surprising behaviour, you need a model of what the interpreter actually does. This page covers CPython's bytecode and name resolution, the GIL and how to work around it, the two-part garbage collector (reference counting plus the cyclic collector), GC tuning at scale, and weak references.
CPython Internals and Bytecode
To reason about performance and surprising behaviour, you need a model of what the interpreter actually does. CPython compiles source to bytecode, then a stack-based evaluation loop executes it. dis lets you see it:
import dis
def f(x):
return x * 2 + 1
dis.dis(f)
# LOAD_FAST 0 (x) # push x onto the stack
# LOAD_CONST 1 (2)
# BINARY_OP 5 (*) # pop two, push product
# LOAD_CONST 2 (1)
# BINARY_OP 0 (+)
# RETURN_VALUEName resolution follows LEGB: Local → Enclosing → Global → Built-in. The trap is that assigning to a name anywhere in a function makes it local for the entire function -- so reading it before the assignment raises UnboundLocalError, not a fallback to the global:
count = 0
def increment():
count += 1 # UnboundLocalError: 'count' is local (because it's assigned)
return count
# Fix: declare `global count` (module state) or `nonlocal` (enclosing scope).LOAD_FAST (locals, indexed by integer) is meaningfully faster than LOAD_GLOBAL (dict lookup). This is why the old micro-optimisation of binding local_len = len inside a hot loop exists -- though in practice you should profile before caring.
Two interpreter facts worth holding precisely:
- Reference counting is the primary GC. Every object has a refcount; it's freed the instant the count hits zero (deterministic), and the cyclic collector only mops up cycles.
sys.getrefcount(obj)reads it (the value is one higher than expected because the argument itself is a reference). - The GIL protects interpreter state, not your data. It guarantees individual bytecode ops are atomic, but
count += 1is three bytecodes (load, add, store) -- so it is not thread-safe. You still need locks for compound operations across threads.
import sys
x = []
sys.getrefcount(x) # 2: the name `x` plus the argument referenceThe GIL and Multiprocessing
The Global Interpreter Lock (GIL) prevents true parallel execution of Python bytecode. For CPU-bound work, use multiprocessing:
from concurrent.futures import ProcessPoolExecutor, ThreadPoolExecutor
import multiprocessing as mp
# CPU-bound: use processes (bypasses GIL)
def process_image(path: str) -> str:
# heavy computation
return f"processed:{path}"
with ProcessPoolExecutor(max_workers=mp.cpu_count()) as pool:
results = list(pool.map(process_image, image_paths))
# I/O-bound: threads are fine (GIL is released during I/O)
def download(url: str) -> bytes:
return httpx.get(url).content
with ThreadPoolExecutor(max_workers=20) as pool:
results = list(pool.map(download, urls))Note: Python 3.13 introduced a free-threaded build (no-GIL mode) as an experimental feature. It is not yet production-ready, but signals the direction Python is heading.
Memory, the GC, and Reference Cycles
CPython frees most objects immediately via reference counting; a separate cyclic garbage collector exists only to clean up reference cycles. Knowing this explains real production bugs.
import sys, gc
# Reference counting frees this the instant the name goes out of scope
data = [0] * 1_000_000
print(sys.getsizeof(data)) # bytes for the list object itself
# A cycle: refcounting alone can never free these -- the GC must
a = {}; b = {}
a["b"] = b; b["a"] = a # cycle; collected only by gc, not refcount
# __del__ on objects in a cycle historically blocked collection (fixed in 3.4+,
# but __del__ timing is still non-deterministic -- never rely on it for cleanup)The expert takeaways: never use __del__ for resource cleanup (use context managers); be aware that holding references in a module-level cache or a closure keeps objects alive indefinitely (a common memory "leak"); and __slots__ removes the per-instance __dict__, cutting memory dramatically for objects you create in the millions.
Garbage Collection Tuning
Most code never touches gc, and that is correct. But at scale -- large long-lived object graphs, latency-sensitive services -- the cyclic collector's pauses become measurable, and knowing the knobs pays off.
import gc
# The cyclic GC is generational: new objects in gen 0, survivors promoted up.
# Collections of gen 0 are frequent and cheap; gen 2 (full) scans are rare and costly.
gc.collect() # force a full collection (e.g. after bulk loading)
gc.get_stats() # per-generation collection counts and timingsTwo patterns experts use:
gc.freeze()before forking. Move everything currently alive into a permanent generation the collector won't scan. In a pre-fork server (Gunicorn, Celery) this keeps the large, read-only parent heap out of GC, and -- because the pages are never written by the collector -- preserves copy-on-write memory sharing across workers instead of each fork dirtying them.
import gc
warm_up_caches_and_imports()
gc.freeze() # parent's objects become permanent; CoW stays shared
# ... then fork workers- Disabling the cyclic GC for short-lived, latency-critical batch work. If you build a huge graph, use it, and exit, refcounting still frees everything acyclic; turning off the cyclic collector removes its pauses. Re-enable it after, and never disable it in a long-running service that creates cycles, or those cycles leak:
gc.disable()
process_one_request() # no mid-request GC pauses
gc.enable()The honest default remains: profile, confirm GC is actually your latency source (via gc.callbacks or gc.get_stats()), and only then tune. Reaching for gc knobs without evidence is the same premature-optimisation trap as reaching for C.
Weak References and Caches
Normal references keep objects alive. A weak reference points at an object without preventing its collection -- essential for caches, registries, and observer patterns that must not be the reason an object lives forever.
import weakref
class Image:
pass
img = Image()
ref = weakref.ref(img) # does not increment the refcount
ref() # -> the Image, or None if it's been collected
del img
ref() # -> None: the object is gone, ref didn't keep it aliveThe common production bug this prevents: a module-level dict cache keyed by object holds strong references, so nothing it has ever seen can be freed -- an unbounded "leak" that looks like a slow memory climb. WeakValueDictionary lets entries disappear when no one else holds the value:
_cache: weakref.WeakValueDictionary[str, Connection] = weakref.WeakValueDictionary()
def get_connection(key: str) -> Connection:
conn = _cache.get(key)
if conn is None:
conn = _cache[key] = Connection(key) # auto-evicted when callers drop it
return connCaveat: not everything is weak-referenceable -- list, dict, int, and tuple are not, and a class needs __weakref__ (which __slots__ removes unless you add "__weakref__" to the slots). For bounded caches with a clear policy, prefer functools.lru_cache(maxsize=...) or a real cache like cachetools over hand-rolled dicts.