AI EngineeringZero to ProductionHome·About·What’s new·Contact
Appendix · Python for AI Agents · Part 7

CPython internals & the GIL

P1–P6 taught you to use Python. This part opens the hood. You will see that in CPython every value is a heap-allocated object with a type and a reference count, that your functions compile to a stack-based bytecode you can print with dis, that a name is just a label bound to an object, and that memory is reclaimed by reference counting plus a cyclic collector for the cycles refcounting cannot see. Then the headline act: the Global Interpreter Lock — what it protects, why CPU-bound threads do not speed up while processes do. Every claim here is checked against real CPython output, and the CPython-specific parts are flagged as such: the language spec does not mandate refcounting or a GIL — PyPy and others differ.

⏱️ ~2.5 hours🎯 Advanced → Industry🧠 language internalsrunnable CPython 3.13

Learning objectives

  • Explain the CPython object model: every value is a typed, refcounted PyObject on the heap.
  • Read Python bytecode with dis.dis and describe the stack-based evaluation loop.
  • Distinguish names from objects, and is/id (identity) from == (value).
  • Reason about reference counting, reference cycles, and the cyclic garbage collector (gc, weakref).
  • State what the GIL protects and demonstrate why CPU-bound threads don't speed up but processes do.
  • Separate CPython implementation details (refcount, GIL, interning) from the Python language spec.

1 · Everything is an object

Start with a moment of confusion you may have already had. You came from a language where int is a primitive — a naked machine word, cheap, no frills. Then in Python you type (42).bit_length() and it works. A plain number has methods? You attach an attribute to a function and it sticks. You pass a class into another function as if it were data. Nothing in your old mental model explains any of this, and the temptation is to file it under "Python is weird." It isn't weird — it's consistent, and this section is the one idea that makes all of it click.

Here's the analogy that unlocks it. Imagine a warehouse where every single thing — the products, the shelves, the price tags, even the catalogue that lists the products — is stored in an identical kind of labelled box. Same box, same label format, whether it holds a screw or the master inventory. That uniformity is the whole trick: one forklift, one labelling scheme, one set of rules handles everything. CPython is that warehouse. An int, a string, a function, a class, a module — all live in the same kind of box (a heap object) with the same standard header on the front. There is no secret second category of "primitives" that plays by different rules underneath.

Concretely: in CPython there are no primitives hiding underneath — an int, a function, a class, a module are all objects: a chunk of heap memory carrying (at least) a type pointer (what am I?) and a reference count (how many names point at me?). The C struct behind them is PyObject; every concrete type embeds it as its first field, which is exactly why the same machinery can inspect any value at all. Because the type itself is an object (of type type — yes, types have a type), you can pass classes and functions around like any other value — the foundation for decorators, registries, and the object model in P8.

The common mistake this section inoculates you against is reasoning about performance and memory as if Python had C's primitives. A million-integer Python list is not a million machine words packed tight; it's a million pointers to a million boxed objects, each with a header — which is precisely why NumPy exists (it stores raw machine numbers, skipping the boxes) and why "everything is an object" is the first thing you must internalise before the refcounting and GIL sections make sense. Uniformity buys you enormous flexibility; you pay for it in indirection and memory. Keep that trade in mind — it is the thread running through this entire lesson.

different values · one kind of box (a heap PyObject) int 42 type → int refcount function greet type → function refcount class / type type → type refcount same PyObject header+ machinery no hidden "primitives" underneath — one forklift handles all One box format for everything. An int, a function, and a class are all heap objects wearing the identical header — a type pointer ("what am I?") and a reference count ("how many names point at me?") — which is why the same tools inspect any value.
🗺️ How to read this diagram

Read the three boxes across the top as three very different Python values, then follow the arrows down to what they have in common.

  • The three top boxes — an int, a function, a class — look like separate categories, but each carries the same two inner fields: a type pointer and a reference count.
  • The arrows converge on one green box: every value shares the identical PyObject header and machinery. There is no second, faster category of "primitives" hiding below.
  • The payoff is that green box — because all values are the same kind of box, you can attach an attribute to a function, call a method on 42, and pass a class around like data.

In short: Uniformity is the trick — one box format, one header, one set of rules for every value in the language.

Example code for learning — review, test, and adapt it before running against real or production systems. Commands can create, change, or delete resources. See the Terms & Disclaimer.
Try it
objects.py# Everything is an object: a value, a type, and (in CPython) a refcount.
import sys

x = 42
print("value :", x)
print("type  :", type(x))                 # <class 'int'>
print("id    :", type(id(x)))             # id() -> an int (the address in CPython)
print("is int an object? ", isinstance(int, object))   # True: the TYPE is an object
print("type of int       ", type(int))                 # <class 'type'>

# A function is an object too — you can attach attributes to it.
def greet(name):
    return f"hi {name}"

greet.category = "io"                       # functions carry a __dict__
print("function attr:", greet.category)
print("function type:", type(greet))        # <class 'function'>

# 'everything is an object' means even modules and classes are values you can pass.
print("module is object:", isinstance(sys, object))
value : 42
type  : <class 'int'>
id    : <class 'int'>
is int an object?  True
type of int        <class 'type'>
function attr: io
function type: <class 'function'>
module is object: True
Spec vs implementation“Everything is an object” is part of the language model. That objects carry a refcount and that id() returns a memory address are CPython implementation facts — the spec only promises id() is a unique, constant integer for the object's lifetime.

2 · The interpreter loop & bytecode

Here's a belief worth killing early: "Python is interpreted, so it reads my source line by line as it runs." Almost everyone starts there, and it's wrong in a way that quietly blocks understanding. When you run a Python file, your source code is compiled first — into an intermediate instruction set — and it's that compiled form, not your text, that actually executes. Realising this is the difference between "Python is a black box that runs my strings" and "Python is a small, knowable machine I can watch operate."

The analogy: think of a recipe. Your Python source is the recipe written in prose — "cream the butter and sugar, then fold in the flour." A CPU can't follow prose; it needs a numbered checklist of tiny mechanical steps: pick up bowl, add butter, add sugar, stir 40 times. CPython's compiler is the sous-chef who reads your prose once up front and rewrites it as that checklist. The checklist is called bytecode, and the machine that mindlessly executes it one step at a time is the evaluation loop. The beautiful part: this machine is a stack machine — it has no complicated registers, just one pile (a stack) where it puts operands, applies an operation, and leaves the result. Add two numbers? Push both onto the pile, run "add", and the pile now holds the sum. Every Python expression you have ever written reduces to that.

Concretely: CPython does not execute your source directly. It compiles each function to bytecode — a sequence of instructions for that stack machine — stored on the function's __code__ object (specifically its co_code). The evaluation loop (historically the giant switch in ceval.c) fetches one instruction at a time and mutates a value stack. The tool that lets you see the checklist your prose became is dis.dis — it prints exactly what the compiler produced, turning the black box into glass.

Why any of this matters to you, and the trap to avoid. Reading bytecode is how you settle "which of these two ways is faster?" arguments with evidence instead of folklore — you disassemble both and count the work. It also demystifies whole features: a list comprehension, a generator, an f-string all become obvious once you see their opcodes. But here is the mistake to sidestep from day one: never depend on the exact opcodes. Bytecode is a private implementation detail, not a public contract; the instructions you see below are specific to a CPython version and the compiler rewrites them freely between releases. Use dis as an X-ray to understand what runs — never as an interface to build on.

Try it
disassemble.pyimport dis

def add(a, b):
    total = a + b
    return total

dis.dis(add)
  2           RESUME                   0

  3           LOAD_FAST_LOAD_FAST      1 (a, b)
              BINARY_OP                0 (+)
              STORE_FAST               2 (total)

  4           LOAD_FAST                2 (total)
              RETURN_VALUE

Read it top to bottom: LOAD_FAST_LOAD_FAST pushes the two locals a and b onto the stack (a 3.13 fused opcode), BINARY_OP 0 (+) pops both and pushes their sum, STORE_FAST pops it into total, then the last two lines reload total and RETURN_VALUE pops it as the result. That is the whole machine: push operands, apply an op, store or return.

source .py def add compile() AST → code bytecode co_code eval loop stack machine result value
🗺️ How to read this diagram

Read it strictly left to right, following the arrows — each box is a stage your code passes through, and each arrow means "feeds into the next stage."

  • The first box is your source .py (def add) — the prose you actually wrote.
  • The arrows carry it through compile() (which turns the source into an AST, then a code object) and into the bytecode box stored on co_code; those middle stages happen once, up front, before anything runs.
  • The payoff is the last two boxes: the eval loop, a stack machine, fetches that bytecode one instruction at a time and produces the final result value.

In short: Your source is compiled to bytecode first, then the stack-machine eval loop runs the bytecode — Python never executes your text directly.

Exact opcodes are version-specificBytecode is not a stable interface — LOAD_FAST_LOAD_FAST and the BINARY_OP arg-encoding are 3.11+/3.13 specifics. Never depend on exact opcodes across versions; use dis to understand, not to build on.

3 · Names vs objects

Almost every Python programmer has lost an afternoon to this exact bug. You copy a list — backup = config — tweak config, and then discover to your horror that backup changed too. You "backed nothing up." You stare at the code, re-read it five times, and it looks perfectly correct. It is correct — for what Python actually did, which is not what you thought it did. That gap is one of the most consequential misunderstandings in the language, and this section closes it for good.

The fix is a single change of picture. Stop imagining a variable as a box that holds a value. In C that's true — int x = 5 carves out a box and puts the bytes 5 inside. Python does not work that way. A Python variable is a name tag — a luggage label — tied to an object that floats out on the heap. a = [1, 2, 3] creates one list object somewhere in memory and ties the label a to it. When you then write b = a, you do not photocopy the suitcase; you tie a second label, b, onto the very same suitcase. Now two labels, one object. Mutate the object through either label — b.append(4) — and of course both labels see it, because there is only one suitcase. It was never copied.

The precise statement: a variable in Python is a name (a binding in a namespace) that points at an object; it is not a box holding bytes. Assignment b = a copies the reference, not the object, so both names see the same mutable list. The other half of the rule is what happens on rebinding: a = a + [5] builds a brand-new list and re-ties the label a to that, while b's label stays on the original — so they diverge. This is the single distinction between mutating the object a name points at (everyone sees it) and rebinding the name to a different object (only that name moves).

The mistake, named plainly, and the cure. The trap is assuming = copies. It never does — it only ever binds a label. So the moment you actually want an independent copy, you must ask for one explicitly: b = a.copy() (or list(a), or copy.deepcopy for nested structures). Getting names-vs-objects right explains nearly every "why did my list change?" bug, the classic mutable-default-argument footgun, and — since a reference is exactly a "point at" — it is also the mental model you carry directly into reference counting two sections from now. Names point; objects live; copying is a thing you request, never a thing = gives you for free.

b = a → two labels, one object a b [1, 2, 3, 4] .append mutates it → both see it a = a + [5] → new object, a moves a b […, 5] [1,2,3,4] rebinding re-ties a label; the old object stays under b Assignment binds, it never copies. b = a ties a second label to the one list (mutate through either and both see it); a = a + [5] builds a fresh list and re-ties only a, so a and b diverge.
🗺️ How to read this diagram

Compare the two halves: the left shows a plain assignment, the right shows a rebind. Follow each label's arrow to the object it points at.

  • On the left, both labels a and b point at the same list box. A mutation like .append(4) changes that one box, so both labels observe the change — there was never a copy.
  • On the right, a = a + [5] builds a brand-new green list and re-ties only the a label to it; b's arrow still points at the original box.
  • The payoff is the split arrows on the right: mutating touches the shared object (everyone sees it), rebinding moves just one label (they diverge).

In short: = only ever moves a label — to get an independent object you must ask with a.copy().

Try it
names.py# A name is a label; the object lives on the heap. Assignment rebinds the label.
a = [1, 2, 3]
b = a                 # b labels the SAME list object (no copy)
b.append(4)
print("a:", a)        # a sees the change: [1, 2, 3, 4]
print("same object:", a is b, " id equal:", id(a) == id(b))

a = a + [5]           # + builds a NEW list; a now labels it, b still the old one
print("a:", a)        # [1, 2, 3, 4, 5]
print("b:", b)        # [1, 2, 3, 4]
print("still same?", a is b)   # False
a: [1, 2, 3, 4]
same object: True  id equal: True
a: [1, 2, 3, 4, 5]
b: [1, 2, 3, 4]
still same? False

4 · Identity vs equality: is / id / ==

This is the bug that survives code review, passes every test, ships to production, and then fails only on Thursdays. Someone writes if status is "active":. It works. It works on their laptop, works in CI, works in the demo. Then in production, under real traffic, it starts returning False for statuses that are plainly "active" — intermittently, unreproducibly, maddeningly. The author swears the code hasn't changed. It hasn't. They just used the wrong one of two operators that usually agree and occasionally, catastrophically, don't.

The analogy that keeps them straight: think of two people. == asks "are these twins?" — do they look the same, same value, same everything a photo would capture. is asks a completely different question: "are these literally the same person?" — one body, standing in one place. Identical twins are equal (== True) but they are not the same person (is False). The trouble is that Python, to save memory, sometimes reuses one object for equal values — so now and then your "twins" really are one person, and is accidentally returns True, lulling you into thinking it always will. It won't.

Precisely: == asks "equal value?" and calls __eq__. is asks "the same object?" and compares id() (the object's identity — in CPython, its address). They usually agree — but not always, because CPython caches small integers (−5…256) and interns some strings, so equal values sometimes share one object and sometimes don't. A literal "active" written in your source may be interned to the same object as another literal "active"; but a status built at runtime — sliced from a request, decoded from JSON, concatenated — is a fresh, separate object with an equal value. Same value, different identity: == True, is False. That is the whole Thursday bug.

The rule, and it has exactly one exception worth memorising. Use == for values — always, every time you're comparing what something is worth. Reserve is for singletons: None, True, False, and sentinel objects, where there is by design only ever one object in the whole program, so identity and equality genuinely coincide (which is why if x is None: is not just allowed but preferred). The caching of small ints and interning of literals are implementation details you must never build on — treat any is comparison against a number or string as a latent bug, no matter how reliably it seems to work today.

runtime-built: two objects, equal value c d 1000 1000 c is d → False c == d → True cached / interned: one shared object m n 100 m is n → True 100 cached — never rely on it == asks "equal value?"; is asks "same object?" Two runtime-built 1000s are separate objects — equal but not identical — while a cached small int like 100 is one shared object, so is is True by accident.
🗺️ How to read this diagram

Follow each name's arrow to the object it points at, then read the two coloured verdict boxes below each side.

  • On the left, c and d were built at runtime, so they point at two separate boxes that happen to hold the same value: is is red (False — different objects) but == is green (True — equal value).
  • On the right, m and n both point at the one cached 100 object, so here is is green (True) — but only because CPython caches small ints, an implementation detail.
  • The payoff is the contrast: the same-looking value gives opposite is answers depending on whether one object is shared — which is exactly the Thursday bug.

In short: Compare worth with ==; reserve is for singletons like None, never for numbers or strings.

Try it
identity.py# is  -> same object (identity, compares id()).   ==  -> equal value.
c = int("1000")       # built at runtime
d = int("1000")       # a DIFFERENT object with the same value
print("c == d:", c == d)        # True  (equal value)
print("c is d:", c is d)        # False (two separate objects)

# CPython caches the small integers -5..256, so these ARE the same object:
m = 100
n = 100
print("100 is 100:", m is n)    # True — cached (an implementation detail!)

# Strings: identical literals are interned by the compiler...
s1 = "hello"
s2 = "hello"
print("literal intern:", s1 is s2)          # True

# ...but strings BUILT at runtime are not automatically interned:
r1 = "".join(["h", "e", "l", "l", "o"])
r2 = "hel" + "lo"
print("runtime is:", r1 is r2, " runtime ==:", r1 == r2)   # False True
c == d: True
c is d: False
100 is 100: True
literal intern: True
runtime is: False  runtime ==: True
Never use `is` to compare valuesx is 1000 may be True or False depending on how the int was produced and which literals the compiler folded — an implementation detail. Comparing values with is is a real, hard-to-find bug. Use ==.

5 · Reference counting

Every language that hides memory from you has to answer one question: when is it safe to throw an object away? Get it wrong in one direction and you free something still in use — a crash, or worse, a security hole. Get it wrong the other way and you never free anything — the process bloats until it dies. In C you answer this by hand and pay for every mistake. Python answers it for you, silently, thousands of times a second, and this section is how. Once you see the mechanism you'll stop treating memory as magic and start reasoning about exactly when your objects die.

The analogy is a library book with a checkout card. Every time someone borrows the book, the librarian adds a tally mark to the card; every time someone returns it, she crosses one off. The rule is dead simple: the moment the tally hits zero — nobody has it checked out — the book can go back on the shelf (or be pulped). CPython does exactly this with every object. The "tally" is the reference count: how many names, list slots, and attributes currently point at the object. Bind a new name to it, the count goes up; let a name go out of scope or del it, the count goes down. When it reaches zero, the object is freed immediately — not eventually, not at some sweep later, but right then. That immediacy is a genuine feature: memory is reclaimed the instant it's provably dead.

The precise statement: CPython's primary memory manager is reference counting. Each object tracks how many references point at it; when that count drops to zero the object is freed at once. The tool for watching it is sys.getrefcount — with one quirk that trips up everyone who first meets it. To ask an object its count, you must pass that object as an argument to getrefcount, and passing it creates one more reference that exists only while the call runs. So the number you get back is always one higher than the references your own code created. See 2? That means one — your name — plus the temporary argument. It is not a bug; it's the observer effect, baked in.

The mistake to guard against is over-trusting this determinism across the whole Python world. On CPython, prompt freeing at refcount-zero is real, and it's tempting to lean on it — to assume a file closes or a lock releases the instant its object's last name disappears. But reference counting is a CPython choice, not a language guarantee: other implementations (PyPy, Jython) use tracing collectors with no per-object count, where an object may linger long after your last reference drops. So use refcounting to understand when things die, but never rely on __del__ firing at a precise moment for cleanup — reach for with / context managers, which promise deterministic release everywhere. And hold onto this section: the one case where refcounting quietly fails — the reference cycle — is the entire reason the next section exists.

the object's tally as names bind and drop data = […]1 name alias = data2 names del aliasback to 1 no name leftfreed refcount 1 refcount 2 refcount 1 refcount 0 every bind +1, every drop −1 — at 0 the object is freed at once The library-card tally, drawn. Each name that points at the object adds one; each name that goes away subtracts one; the instant the count hits zero (the red box) CPython frees the object immediately.
🗺️ How to read this diagram

Read it left-to-right as a timeline: the top row is what your code does, the bottom row is the object's reference count right after.

  • The top blue boxes are the steps in order — bind data, add the alias, del the alias, drop the last name. Each arrow moves you one step forward in time.
  • The bottom row shows the running count: it rises to 2 when a second name binds and falls back as names disappear — the exact numbers sys.getrefcount reports (minus the temporary argument).
  • The payoff is the red box at the far right: when the count reaches 0, no name points at the object, and it is reclaimed on the spot.

In short: Binding a name is +1, dropping one is −1, and reaching 0 frees the object immediately — that is CPython's primary memory manager.

Try it
refcount.pyimport sys

data = ["a", "b", "c"]
# getrefcount reports one MORE than you might expect: the argument passed to
# getrefcount is itself a temporary reference while the call runs.
print("refs to data:", sys.getrefcount(data))   # 2  (the name + the temp arg)

alias = data                                     # a second name -> +1
print("after alias  :", sys.getrefcount(data))   # 3

del alias                                        # drop that reference -> -1
print("after del     :", sys.getrefcount(data))  # 2
refs to data: 2
after alias  : 3
after del     : 2
Refcounting is a CPython choiceThe language does not require reference counting. PyPy and Jython use tracing garbage collectors with no per-object count, so sys.getrefcount is CPython-only and __del__ timing differs. Do not rely on prompt, deterministic destruction across implementations — use with/context managers for cleanup.

6 · Cycles & the garbage collector

Picture a slow memory leak in a long-running service — an LLM gateway, say — that only shows up after hours of uptime. Memory climbs, gently, relentlessly, and none of your usual tools point at anything. You checked: there are no obvious lingering references, objects go out of scope like they should, refcounting is doing its job. And yet RAM keeps rising until the box tips over at 3 a.m. This is the failure mode the previous section's library-card scheme cannot catch on its own, and understanding why is the difference between a two-minute fix and a week of confusion.

The trap is a reference cycle. Two objects that point at each other — a parent holding its child, the child holding a back-reference to its parent — keep each other's tallies above zero forever, even after every outside name to them is gone. Picture two drowning swimmers each gripping the other to stay afloat: from the outside no one is holding either of them up, yet neither sinks, because each is counting on the other. The library card never reaches zero. Refcounting, which frees an object only at zero, is structurally blind to this — the counts are "correct," they're just permanently stuck at one apiece. Cycles are not exotic, either: doubly linked lists, trees with parent pointers, and caches that store objects which reference their own context all create them routinely.

So CPython bolts on a second, independent mechanism: a cyclic garbage collector (the gc module). Rather than counting references, it periodically walks the tracked objects, works out which groups are unreachable from the outside — including our two drowning swimmers — and frees them as a batch. This is why it's "garbage collection" in the classic sense: a deferred sweep, not the instant, per-object freeing of refcounting. The two systems divide the labour: refcounting handles the overwhelming common case promptly and cheaply, and the cyclic collector mops up the cycles refcounting can't, at the cost of running only now and then. To prove all this to yourself, the lab uses a weakref — a reference that deliberately does not add a tally mark, so it lets you peek at whether an object is still alive without being the very thing keeping it alive.

Where this bites in practice, and the fix. The mistake is assuming "Python has GC, so I never think about memory" — true right up until a cycle full of heavy objects (large tensors, cached responses, request contexts) sits around between collection passes and your footprint balloons. The cure once you spot it is to break the cycle: make one direction a weakref (typically the back-reference, child→parent), so refcounting can reclaim promptly and the cyclic collector never has to. This is exactly the diagnosis you'll be asked to make in the industry exercise at the end — a service leaking memory because cached objects reference their own context and back again.

after del a, b — outside names gone a b each refcount = 1 never hits 0 → refcounting is blind gc.collect() sweeps the unreachable group cyclic GC walkstracked objects unreachable cyclefreed weakref() now returns None The cycle refcounting can't break. Two objects pointing at each other each keep the other's count at 1 even after every outside name is gone — so refcounting never frees them; only the periodic cyclic collector can.
🗺️ How to read this diagram

Read left-to-right: the left half is the stuck state after your names are gone, the right half is the collector cleaning it up.

  • On the left, boxes a and b point at each other (the two curved arrows). Even with no outside name left, each still holds the other, so each count stays at 1 — never zero.
  • The red label is the trap: refcounting frees only at zero, so it is structurally blind to this pair — the two drowning swimmers holding each other up.
  • On the right, gc.collect() walks the tracked objects, finds the group is unreachable from outside, and frees it as a batch (the red box) — after which the weakref returns None.

In short: Mutual references keep counts above zero forever; only the deferred cyclic collector can reclaim them — or break the cycle yourself with a weakref.

Try it
cycles.pyimport gc, weakref

class Node:
    def __init__(self, name):
        self.name = name
        self.ref = None

gc.collect()          # start clean
gc.disable()          # turn OFF the cyclic collector to see refcounting alone

a = Node("a"); b = Node("b")
a.ref = b; b.ref = a   # a <-> b : a reference CYCLE
watch = weakref.ref(a) # a weak ref does NOT keep the object alive

del a, b               # drop our names, but a and b still reference each other
print("alive after del?      ", watch() is not None)  # True: refcount never hit 0

collected = gc.collect()   # the cyclic collector finds and frees the cycle
print("objects reclaimed >=2:", collected >= 2)
print("alive after gc.collect?", watch() is not None) # False
gc.enable()
alive after del?       True
objects reclaimed >=2: True
alive after gc.collect? False
ReclaimsReference countingCyclic GC (gc)
Acyclic garbageYes, immediately at refcount 0Not needed
Reference cyclesNo — counts never reach 0Yes, on collection
CostTiny, constant per ref changePeriodic scan of tracked objects
DeterminismPrompt (freed at once)Deferred to a collection pass

7 · The GIL: threads vs processes

This is the one that ends up in a postmortem. A team has a CPU-bound job — a heavy numeric loop in pure Python — chewing through work on a shiny 8-core machine. The obvious move: spread it across 8 threads, expect roughly 8× the throughput. They ship it. The result? No speedup. None. Sometimes it's slightly slower than one thread. Eight cores sitting there, seven of them idle, and the code that "should" parallelise flatly refuses to. Nobody made a mistake in the threading code; they made a mistake about what a Python thread is. That mistake is so common, and so expensive, that it earns the headline slot of this lesson.

The culprit is the Global Interpreter Lock — the GIL. The analogy that makes it stick: imagine a workshop with eight skilled carpenters (your threads) and exactly one set of tools, chained to a single bench, with a rule that only the person holding the tool-key may work. You can hire all eight carpenters you like; only one can actually build at any instant. The others stand around waiting for the key. That single key is the GIL: a lock that lets only one thread execute Python bytecode at a time inside one interpreter. More threads doesn't mean more building — it means more people queuing for the same key, which is why you sometimes go backwards (the overhead of passing the key around).

Why would a language do something so apparently self-defeating? Because of the refcount from section 5. Every object's tally is bumped and dropped constantly, and if two threads did that to the same object simultaneously the count would corrupt — freeing live objects or leaking dead ones. Making every refcount update individually thread-safe would be slow for the 99% single-threaded case. The GIL is the pragmatic bargain: one coarse lock makes all those refcount updates safe cheaply, and single-threaded code (almost everyone's) pays nothing. The bill comes due only when you want true multi-core parallelism for pure-Python CPU work — which you don't get.

The escape is to stop sharing one tool-set: run separate processes. Each process has its own interpreter, its own memory, and — crucially — its own GIL and its own key, so two processes genuinely run in parallel on two cores. That is the whole rule, and it's measurable in the lab below: two CPU-bound threads take about as long as doing the work twice in a row (≈1×), while two processes finish in roughly half the time (≈2× on two cores). The distinction that saves you from the postmortem: is your slow stage CPU-bound (crunching numbers) or I/O-bound (waiting on the network, disk, an API)? For CPU-bound work, reach for multiprocessing, or push the hot loop into a native library (NumPy, orjson) that releases the GIL while it computes in C. For I/O-bound work, threads and asyncio shine — because a thread drops the key while it waits, letting the others run. And note the honest caveat the notes below expand on: the GIL is a CPython trait, not a Python law — 3.13 ships an experimental free-threaded build (PEP 703) that removes it — but for standard CPython today, this rule holds.

2 threads · one interpreter, one GIL key thread 1 thread 2 1 GIL key only one runs → ≈1× the other waits for the key 2 processes · own interpreter + GIL process 1 process 2 own GIL own GIL both run → ≈2× on 2 cores real parallelism, separate memory One key vs two keys. Two threads share a single interpreter and its one GIL, so only one executes Python bytecode at a time (≈1×); two processes each own an interpreter and GIL, so they genuinely run in parallel (≈2× on two cores).
🗺️ How to read this diagram

Compare the two halves top-to-bottom: both start with two workers, but they converge on very different things below.

  • On the left, the two threads' arrows funnel into a single red GIL key box — one interpreter, one key, so only one thread can run Python bytecode at any instant while the other waits.
  • On the right, each process has its own green GIL box — no sharing, no queuing for a key.
  • The payoff is the coloured verdicts: shared key gives ≈1× (no speedup, the postmortem), separate keys give ≈2× on two cores (real parallelism).

In short: Threads share one GIL (no CPU-bound speedup); processes each get their own, so use multiprocessing for CPU-bound work — threads still win for I/O.

Try it
gil_demo.pyimport time, threading
from multiprocessing import Pool

def cpu_bound(n):
    total = 0
    for i in range(n):
        total += i * i
    return total

N = 20_000_000

# 1) sequential: do the work twice, one after the other
t0 = time.perf_counter()
cpu_bound(N); cpu_bound(N)
seq = time.perf_counter() - t0

# 2) two THREADS: they cannot run Python bytecode at the same time — the GIL
#    lets only one thread hold the interpreter at a time.
t0 = time.perf_counter()
ts = [threading.Thread(target=cpu_bound, args=(N,)) for _ in range(2)]
for t in ts: t.start()
for t in ts: t.join()
thr = time.perf_counter() - t0

# 3) two PROCESSES: each has its OWN interpreter and its OWN GIL -> real parallelism
if __name__ == "__main__":
    t0 = time.perf_counter()
    with Pool(2) as pool:
        pool.map(cpu_bound, [N, N])
    proc = time.perf_counter() - t0

    print(f"sequential   : {seq:.2f}s")
    print(f"2 threads    : {thr:.2f}s   speedup x{seq/thr:.2f}")
    print(f"2 processes  : {proc:.2f}s   speedup x{seq/proc:.2f}")
sequential   : 1.64s
2 threads    : 1.77s   speedup x0.93
2 processes  : 0.90s   speedup x1.82
Numbers are machine-dependent — the ratio is the pointThe exact seconds vary by CPU; what is reproducible is the shape: threads give ≈1x speedup (no gain, sometimes slightly worse from lock contention), processes give ≈2x on two cores. Threads still help for I/O-bound work (network, disk) because a thread releases the GIL while it waits — which is exactly why asyncio and threads win for API calls (P5 §5).
The GIL is CPython-specific and evolvingJython and IronPython have no GIL; PyPy has one. CPython 3.13 ships an experimental free-threaded build (PEP 703) that removes the GIL behind a build flag. For standard CPython today the rule holds: CPU-bound → multiprocessing; I/O-bound → threads or async.

✓ Checkpoint — you can move on when you can…

  • Explain why a = a + [5] leaves an aliased b unchanged but a.append(5) would not.
  • Predict is vs == for cached small ints, interned literals, and runtime-built strings.
  • Describe what reference counting frees immediately and what only the cyclic gc can reclaim.
  • State what the GIL protects and why CPU-bound threads don't scale but processes do.
  • Name three things in this lesson that are CPython implementation details, not language spec.
✓ Knowledge check

A colleague writes if status is 'active': and it works on their machine but fails intermittently in production. What is happening, and what is the fix?

Show answer
String literals are often interned, so at authoring time 'active' is 'active' can be True — but a status value built at runtime (read from a request, sliced, concatenated, or decoded) is a different object even when equal, so is returns False. Identity coincidence is an implementation detail you must never rely on. Fix: compare values with == (if status == 'active':); reserve is for singletons like None.
✓ Knowledge check

Your data pipeline is CPU-bound (heavy numeric loops in pure Python). You move it to a ThreadPoolExecutor with 8 workers on an 8-core box and see no speedup. Why, and what should you do?

Show answer
The GIL serializes Python bytecode: only one of the 8 threads runs the interpreter at a time, so CPU-bound pure-Python work does not parallelize with threads — you get ≈1x. Options, best first: (1) use multiprocessing/ProcessPoolExecutor so each worker has its own interpreter and GIL (real parallelism); (2) push the hot loop into a C/NumPy/native extension that releases the GIL while it computes; (3) on 3.13, experiment with the free-threaded build. Threads would only have helped if the work were I/O-bound.

🪜 Practice ladder beginner → industry

Six graded exercises, easy to real-world. Try each before opening its solution.

Exercise 1 · Prove names are referencesBeginner

Context: New Python programmers are surprised when mutating a list through one name changes it through another. Proving it to yourself cements the model.

Your task: Write a function that shows a list mutated via one name is visible via an alias, and that rebinding one name does not affect the other.

Requirements:

  • Create a, alias it to b, mutate through b, show a changed
  • Then rebind a with + and show b is unchanged
  • Print a is b before and after the rebind

💡 Hint: Mutation (.append) changes the shared object; + builds a new one.

Show solution
Solution
a = [1, 2, 3]
b = a
b.append(99)
print(a, a is b)        # [1, 2, 3, 99] True  -> same object
a = a + [0]             # NEW list bound to a
print(a)                # [1, 2, 3, 99, 0]
print(b)                # [1, 2, 3, 99]  -> b unchanged
print(a is b)           # False
[1, 2, 3, 99] True
[1, 2, 3, 99, 0]
[1, 2, 3, 99]
False
Exercise 2 · Disassemble a comprehensionIntermediate

Context: Reading bytecode turns “magic” into mechanism. A list comprehension compiles to its own code object with a loop — dis reveals it.

Your task: Disassemble a function containing a list comprehension and identify the instruction that appends each element.

Requirements:

  • Define def squares(n): return [i*i for i in range(n)]
  • Call dis.dis(squares)
  • In your answer, name the opcode that builds/extends the result list

💡 Hint: Look for LIST_APPEND in the nested comprehension code object.

Show solution
Solution
import dis
def squares(n):
    return [i*i for i in range(n)]
dis.dis(squares)
# The inner comprehension is its own code object; the element is built with
# BINARY_OP (i*i) and appended with LIST_APPEND, looped by FOR_ITER.

Key opcodes: FOR_ITER drives the loop, BINARY_OP 5 (*) squares each i, and LIST_APPEND pushes it onto the result list. Exact listing varies by CPython version — run it on yours to see the current form.

Exercise 3 · Watch a refcount rise and fallAdvanced

Context: Understanding when an object is freed is the difference between confident and superstitious resource handling.

Your task: Track an object's reference count as you add and drop references, accounting for the temporary reference getrefcount itself holds.

Requirements:

  • Create an object, print sys.getrefcount
  • Add an alias and a container reference; print again after each
  • Delete them one at a time; print after each and explain the +1 offset

💡 Hint: Every extra name or container that holds the object adds exactly one to the count.

Show solution
Solution
import sys
obj = object()
print(sys.getrefcount(obj))   # 2  (name obj + temp arg to getrefcount)
box = [obj]
print(sys.getrefcount(obj))   # 3  (list holds one)
alias = obj
print(sys.getrefcount(obj))   # 4
del alias
print(sys.getrefcount(obj))   # 3
box.clear()
print(sys.getrefcount(obj))   # 2
2
3
4
3
2
Why +1getrefcount receives the object as an argument, so while it runs there is one extra reference. Subtract 1 to get the count your code created.
Exercise 4 · Force and observe a cycle collectionExpert

Context: Long-running services leak when cycles pile up between collections. Reproducing and observing a cycle is the first step to diagnosing that.

Your task: Build a reference cycle, disable gc to show refcounting alone cannot free it, then collect it and confirm reclamation with a weakref.

Requirements:

  • Two objects referring to each other; a weakref to one
  • gc.disable(), del both names, show the weakref is still alive
  • gc.collect(), show the weakref is now dead and report objects collected

💡 Hint: A weakref's callable returns None once the referent is gone.

Show solution
Solution
import gc, weakref
class N:
    def __init__(self): self.other = None
gc.collect(); gc.disable()
x = N(); y = N()
x.other = y; y.other = x          # cycle
w = weakref.ref(x)
del x, y
print('alive:', w() is not None)  # True: refcount stuck at 1 each
n = gc.collect()
print('collected>=2:', n >= 2)    # True
print('alive:', w() is not None)  # False
gc.enable()
alive: True
collected>=2: True
alive: False
Exercise 5 · Measure the GIL: threads vs processesProfessional

Context: Before choosing a concurrency model for a CPU-bound stage, you must be able to show the GIL's effect, not just assert it.

Your task: Benchmark the same CPU-bound function run sequentially, across two threads, and across two processes, and report the speedups.

Requirements:

  • A pure-Python CPU-bound function (a big arithmetic loop)
  • Time: sequential ×2, two threading.Threads, two-worker Pool
  • Print each time and the speedup vs sequential; guard the process code with __main__

💡 Hint: Threads should give ≈1x; processes ≈number-of-cores (up to 2 here).

Show solution
Solution
import time, threading
from multiprocessing import Pool

def work(n):
    s = 0
    for i in range(n): s += i * i
    return s

N = 20_000_000
if __name__ == '__main__':
    t = time.perf_counter(); work(N); work(N)
    seq = time.perf_counter() - t
    t = time.perf_counter()
    th = [threading.Thread(target=work, args=(N,)) for _ in range(2)]
    [x.start() for x in th]; [x.join() for x in th]
    thr = time.perf_counter() - t
    t = time.perf_counter()
    with Pool(2) as p: p.map(work, [N, N])
    pr = time.perf_counter() - t
    print(f'seq {seq:.2f}s  thread x{seq/thr:.2f}  proc x{seq/pr:.2f}')
seq 1.64s  thread x0.93  proc x1.82
Interpretation, not exact secondsAbsolute times depend on your CPU. The durable result is thread ≈ x1 (no parallel speedup) and process ≈ x(cores). If you see thread > x1, the work wasn't purely CPU-bound.
Exercise 6 · Diagnose a latency regression from the object modelIndustry scenario

Context: A production LLM gateway shows rising p99 latency and memory after a refactor that added a shared in-process cache holding response objects that also reference their request context. You are on call.

Your task: Explain, using the internals from this lesson, the two most likely root causes and how you would confirm and fix each.

Requirements:

  • Reference the GIL for the latency symptom under CPU-bound post-processing
  • Reference cycles + deferred gc for the memory growth
  • Give a concrete confirmation step and fix for each

💡 Hint: One symptom is about parallelism under a lock; the other is about what refcounting can't free.

Show solution

Root cause 1 — latency under the GIL. If the refactor added CPU-heavy pure-Python post-processing (token counting, JSON re-serialization) on the request threads, the GIL serializes it: adding worker threads won't help and p99 climbs under load. Confirm: profile with py-spy/cProfile; check whether wall-time scales with concurrency. Fix: move the CPU stage to a ProcessPoolExecutor, or replace the hot loop with a native library (NumPy/orjson) that releases the GIL.

Root cause 2 — cycles + deferred collection. Cached response objects that reference their request context (and vice versa) form cycles; refcounting alone never frees them, so memory grows until the periodic cyclic gc runs — and if objects have __del__, older CPythons could even refuse to collect them. Confirm: gc.set_debug(gc.DEBUG_SAVEALL) or objgraph to find the cycle; watch gc.get_count(). Fix: break the cycle with a weakref from response→context, or drop the back-reference so refcounting reclaims promptly. Bound the cache (e.g. lru_cache(maxsize=...)) so it can't grow without limit.

© 2026 studybydoing.in · AI Engineering: Zero to Production · All rights reserved. · About · Privacy Policy · Terms · Contact
Educational content, provided as-is and without warranty. Code samples are examples — review, test, and adapt them before using in production. See the Terms of Use & Disclaimer. Use at your own risk.
© studybydoing.in