Steven's Knowledge

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_VALUE

Name 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 += 1 is 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 reference

The 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 timings

Two 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 alive

The 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 conn

Caveat: 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.

On this page