AI EngineeringZero to ProductionHome·About·What’s new·Contact
Appendix · Advanced AI Engineering · Part 2

Memory Optimization & Concurrency / the GIL

Two production realities that bite AI systems: memory (embedding matrices and long conversations are big) and concurrency (an agent serving many users, or fanning out dozens of API calls). This part explains how Python uses memory and how to shrink it, then demystifies the Global Interpreter Lock — the single most misunderstood thing in Python — so you pick threads, processes, or async correctly instead of by superstition.

⏱️ ~2 hours🎯 Advanced → Expert🔒 the GIL, explainedrunnable

Learning objectives

  • Understand per-object memory overhead and measure it.
  • Cut memory with __slots__, generators, and the right container.
  • Use buffers (memoryview, bytearray, array, struct) for zero-copy work.
  • Reason about reference counting, the cyclic GC, and weakref.
  • Explain what the GIL does and does not prevent.
  • Choose threading vs multiprocessing vs asyncio by workload type.

Why this matters for agents motivation

Here's the failure that ends most people's first "real" AI deployment. Your prototype was flawless — it embedded a few hundred documents, answered questions in a blink, handled you and a colleague clicking around. So you point it at the full corpus and open it to the team, and within an hour it's either dead (the process got killed with a cryptic MemoryError or vanished silently to an OOM reaper) or crawling (users wait thirty seconds because everyone is queued behind one request at a time). Nothing in your logic was wrong. What changed was scale — and scale is exactly where the two invisible costs in this chapter, memory and concurrency, stop being invisible.

Think of it like moving from cooking dinner for yourself to running a restaurant kitchen. At home you never think about counter space or whether you can chop and simmer at the same time — there's plenty of room and only one order. A restaurant on a Friday night is a different universe: you run out of counter space (memory) and you have forty tickets that all need to happen at once (concurrency). The recipes didn't change; the constraints did. Every AI system makes this same jump, and the engineers who anticipate it ship things that stay up.

The common mistake is to treat both as someone else's problem — "the cloud has infinite RAM," "I'll just add more servers." That's the expensive path, and it papers over bugs rather than fixing them. A million 1024-dimension float32 embeddings is genuinely 4 GB; if you store it as a Python list of lists you'll pay several times that and OOM on a box that should have fit it. An agent that makes one 2-second API call per user handles thirty users a minute if it works sequentially — or hundreds, on the same machine, if you let those waits overlap. This part teaches you to see those costs before production does, so you tune the code instead of the invoice.

A RAG index of a million 1024-dim float32 embeddings is 4 GB — store it wrong and you OOM. An agent serving 100 concurrent users making 2-second API calls needs concurrency or it handles one user at a time. Get memory and concurrency right and the same laptop or box handles 10× the load; get them wrong and you scale by throwing money at servers.

1 · How Python objects use memory intermediate

Quick gut-check: how many bytes do you think the number 0 takes in Python? If you said "8, it's a 64-bit integer" — reasonable guess, and wrong by a factor of three and a half. It's about 28 bytes. That gap is the single most important thing to internalize in this section, because when you multiply it across a million rows it turns a "surely this fits in RAM" into an out-of-memory crash.

Here's the why, in plain terms. In a language like C, an int is just the raw 8 bytes of the number sitting in memory — nothing else. Python trades that leanness for enormous flexibility: every value, even a lowly integer, is a full-blown object, and every object drags a little backpack of bookkeeping around with it. Inside that backpack are a reference count (how many things currently point at me, so Python knows when to free me), a type pointer (am I an int? a str? a Model?), and for ordinary class instances a whole per-object __dict__ to hold attributes. The number is in there somewhere, but it's wearing a heavy coat.

A useful analogy: a raw value is a bare book, but a Python object is that book fully catalogued in a library — barcode, shelf location, checkout card, dust jacket. One book, you don't notice the overhead. A million books and the catalogue cards alone fill a room. The flexibility is real and worth it (this is why you can put anything in a list and ask any object its type at runtime), but it is never free, and the cost hides until scale reveals it.

The mistake this leads to is guessing instead of measuring. Engineers eyeball "a list of a million ints, that's 8 MB, fine" and are shocked when it's 30–40 MB, because a list holds a million 8-byte pointers and the million int objects those pointers reach. The discipline that saves you is simple: don't reason about Python memory from first principles, weigh it. Python ships two scales — sys.getsizeof for the size of a single object, and tracemalloc for tracking where allocations actually pile up across your whole program. The lab below puts a few "small" values on the getsizeof scale so the overhead stops being abstract.

Every Python object carries overhead: a reference count, a type pointer, and (for normal classes) a per-instance __dict__. A "small" object is rarely small. Measure, don't guess — with sys.getsizeof and tracemalloc.

same number, very different footprint — Python wraps every value in an object C int value · 8 bytes a bare number Python int object · ~28 bytes refcount type pointer the value bookkeeping the number carries everywhere a normal class instance adds even more object header refcount + type __dict__ a whole hash table A "small" value is never just its bytes. The number 0 is ~28 bytes because it is a full object — refcount, type pointer, value — and an ordinary instance drags a per-object __dict__ on top, which is why a million of anything is heavier than you'd guess.
🗺️ How to read this diagram

Read it top to bottom as three tiers of the same idea: the bare number, the number wrapped as a Python object, and a full class instance.

  • The top-left green box is a C int: just 8 raw bytes of value, nothing else. That's the baseline to compare against.
  • The middle row breaks a Python int into its parts — the amber refcount, the purple type pointer, and the green value. Together they're ~28 bytes: the number is in there, but wearing a heavy coat of bookkeeping.
  • The bottom row is the payoff element: an ordinary class instance is the object header plus a red __dict__ hash table (the arrow shows it bolted on). That extra table is exactly what __slots__ removes in the next section.

In short: raw value → object header → header + __dict__; each tier is heavier, and the cost only shows up when you multiply by millions.

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 — measure the overhead
pythonimport sys

print(sys.getsizeof(0))          # ~28 bytes for a single int!
print(sys.getsizeof(""))         # ~49 bytes empty str
print(sys.getsizeof([]))         # ~56 bytes empty list (+ 8/elem pointer)
print(sys.getsizeof({}))         # ~64 bytes empty dict

# A million ints in a list: pointers + the int objects themselves
# -> use array/numpy for homogeneous numeric data (see below & A4)
▶ How this works

This measures how many bytes each tiny Python object actually takes. The surprise: nothing is free. Even the number 0 or an empty list is a full object carrying bookkeeping — a reference count, a type pointer, and length info — so "small" values are surprisingly heavy.

  1. sys.getsizeof(x) returns the size of one object in bytes. It's the tool you use to measure instead of guessing.
  2. The comments show the results on a typical machine: an int is ~28 bytes, an empty string ~49, an empty list ~56, an empty dict ~64. Compare that to a language where an int is 8 bytes — Python trades size for flexibility.
  3. The last comment is the real lesson: a list of a million ints stores a million 8-byte pointers plus the million int objects they point to. That's why homogeneous numeric data should live in an array or numpy, not a list.

What the output means: Four numbers roughly like 28, 49, 56, 64. Exact values vary by Python version and OS, but the order of magnitude — tens of bytes for "empty" — is the point.

Try this: Run sys.getsizeof([1, 2, 3]) and then sys.getsizeof([1, 2, 3, 4]). Each extra element adds ~8 bytes — that's the pointer per slot the last comment warns about.

2 · __slots__ — drop the per-instance dict advanced

You just learned that a plain instance carries a whole hash table for its attributes. Now imagine you have five million of them. That's not a hypothetical — it's a graph with five million nodes, a parsed log with five million events, a corpus chunked into five million records. Suddenly the invisible __dict__ tax on each object is the difference between fitting in memory and not. __slots__ is the one-line declaration that turns that tax off.

Why does it work? By default, when you write self.text = "hi", Python stashes that attribute in a per-instance __dict__ — a full hash table living inside every single object. Hash tables are wonderful when you don't know the keys ahead of time (you can bolt on any attribute you like at any moment), but they're bulky: buckets, spare capacity, hashing machinery. The catch is that for a class like a data record, you do know the attributes in advance — they're always text and vec, never anything else. Declaring __slots__ = ("text", "vec") hands Python that promise, and in exchange Python stores the values in a tight fixed-size array baked directly into the object, with no dict at all.

Picture the difference as filing cabinets versus a fitted tray. A __dict__ is a filing cabinet with room for folders you might add later — roomy, general, heavy. __slots__ is a custom foam tray cut to hold exactly two tools: no wasted space, and you grab each tool faster because its position is fixed. That "grab faster" is a real bonus — slotted attribute access is a bit quicker too, since it's a direct array offset instead of a hash lookup.

The mistake people make is reaching for __slots__ everywhere, as a reflex "optimization," and then getting bitten. The whole deal is that you gave up flexibility: a slotted object cannot grow a new attribute you didn't declare (obj.extra = 5 raises AttributeError), and slots interact awkwardly with multiple inheritance. So the rule is narrow and specific — use it for the classes you stamp out in bulk (nodes, cache entries, records, events), and don't bother for the handful of config or singleton objects where the memory is irrelevant and the freedom is handy. The lab measures the dict you're deleting, so the payoff is concrete rather than a claim.

By default each instance has a __dict__ (a whole hash table) to hold attributes — flexible but heavy. Declaring __slots__ tells Python the fixed attribute set, so it stores them in a compact array instead: big memory savings and faster access when you have many instances (nodes, records, events).

same two attributes, two very different layouts without __slots__ instance __dict__ (hash table) text vec empty spare roomy buckets + hashing with __slots__ instance (no dict) slot 0 · text slot 1 · vec tight fixed array Slots swap a roomy hash table for a fitted tray. A normal instance points at a __dict__ with spare buckets you might one day fill; __slots__ bakes exactly the declared attributes into a fixed array — no dict, less memory, faster access — at the cost of never being able to add an undeclared attribute.
🗺️ How to read this diagram

Compare the two halves left and right: the same class holding text and vec, laid out the default way versus the slotted way.

  • On the left, the instance is small but points (blue arrow) at a separate red __dict__ — a hash table. Two of its slots hold your attributes; the dashed "empty/spare" boxes are the extra capacity a hash table always keeps around, which is wasted memory.
  • On the right, there is no dict at all: the green instance holds a two-slot fixed array with text and vec sitting directly inside it.
  • The payoff is the missing box — the whole red table on the left simply disappears on the right. Multiply that saved table across millions of objects and that's the win; the trade-off is the right layout has no room to grow a new attribute.

In short: without slots = instance + a roomy separate hash table; with slots = attributes packed into the object itself, dict deleted.

Try it — 10× lighter objects
pythonclass ChunkA:                     # normal: has __dict__
    def __init__(self, text, vec):
        self.text, self.vec = text, vec

class ChunkB:
    __slots__ = ("text", "vec")       # no per-instance dict
    def __init__(self, text, vec):
        self.text, self.vec = text, vec

import sys
a, b = ChunkA("x", 1), ChunkB("x", 1)
print(sys.getsizeof(a.__dict__))     # ~100+ bytes just for the dict
# ChunkB has no __dict__ at all -> across millions of chunks, huge savings
▶ How this works

Two classes that look identical, but store their attributes differently. By default every object gets a hidden __dict__ — a full hash table — to hold its attributes. That's flexible but heavy. __slots__ tells Python "these are the only attributes," so it uses a compact fixed array instead and skips the dict entirely.

  1. ChunkA is a normal class: each instance carries its own __dict__.
  2. ChunkB declares __slots__ = ("text", "vec"). Same two attributes, but now there is no per-instance dict — the values sit in a small slot array baked into the object.
  3. sys.getsizeof(a.__dict__) measures just the dictionary that ChunkA instances carry (~100+ bytes each). ChunkB has no such dict, so that cost disappears entirely.

What the output means: A number in the ~100+ range — the size of one instance's __dict__. Multiply that by millions of chunks and you see why __slots__ is a big win at scale.

Try this: Add a line a.extra = 5 — it works, because ChunkA has a dict. Then try b.extra = 5 on the slotted object: you get an AttributeError. That's the trade-off — smaller and faster, but the attribute set is locked.

When to use __slots__Use it for classes you instantiate in bulk (graph nodes, cache entries, parsed records, event objects). Skip it for singletons/config objects where flexibility matters. The trade-off: slotted classes can't gain arbitrary new attributes and need care with multiple inheritance. Dataclasses support it: @dataclass(slots=True).

3 · Buffers & zero-copy — memoryview, bytearray, array, struct expert essential

Every time you slice a big blob of bytes, Python quietly makes a full copy of that slice — and if you do it in a loop, you can copy gigabytes without writing a single line that says "copy." That's the hidden cost this section kills. When you're shuffling model weights, embedding blobs, or network frames — payloads measured in megabytes — the copying can easily cost more than the actual work you meant to do.

The idea that fixes it is copy versus view, and it's worth slowing down on because it underpins how numpy, PyTorch, and every fast numeric library work. A copy duplicates the data into fresh memory; a view is a window that points into the original memory and reads/writes straight through it. Think of a copy as photocopying ten pages of a book to mark them up, versus a view as laying a transparent sticky-flag over those same pages — the flag adds nothing, and anything you write on it is really written on the page underneath. A memoryview is that sticky flag: it exposes another object's buffer with zero copying, in O(1) time and 0 extra bytes.

Alongside memoryview, this section brings in three companions that all attack the same "objects are heavy, copies are expensive" problem from different angles. bytearray is a mutable block of raw bytes you can write into (plain bytes is read-only). array stores a run of homogeneous numbers as raw values with no per-element object overhead — a million floats in ~4 MB instead of the ~30+ MB a list of float objects would cost (this is exactly the object-overhead tax from §1, paid once instead of a million times). And struct packs and unpacks fixed binary layouts, which is how you read a wire-protocol header or a binary file format byte-for-byte.

The mistake to watch for is the flip side of the superpower: because a memoryview shares memory with its parent, writing through the view mutates the original — surprising if you thought you were working on a private copy — and a view keeps the underlying buffer alive, so you can accidentally pin a huge object in memory just by holding a tiny window into it. That's a fair trade once you know it's happening. The lab makes the sharing visible: it writes one byte through a slice and shows the giant buffer changed underneath.

Slicing bytes or a list copies data (D1). For large binary/numeric payloads — model weights, embedding blobs, network frames — copies dominate. A memoryview exposes another object's buffer without copying; array stores homogeneous numbers compactly; struct packs/unpacks binary formats.

both take a slice of the same 10 MB buffer — only one copies original buffer (bytearray) bytes slice = COPY new bytes region copy data · O(n) time + bytes writing here leaves the original untouched memoryview = WINDOW view into the buffer no copy · O(1) time + 0 bytes writing here changes the original Copy versus view is the whole idea. Slicing bytes duplicates the region into fresh memory; a memoryview slice is a window pointing straight into the original buffer — zero copy, and writes through it mutate the source.
🗺️ How to read this diagram

Start at the blue buffer on top, then follow the two arrows down to the two ways of slicing it.

  • The left, solid-arrow path is a bytes slice: it lands in a new red region — a full duplicate. It costs time and memory proportional to the slice size, and it's detached, so writing into it never touches the top buffer.
  • The right, dashed-arrow path is a memoryview: the green box is not new memory at all, it's a window onto the original. It costs O(1) time and 0 extra bytes.
  • The payoff — and the trap — is the last line under each box: because the view shares storage, writing through it changes the original buffer. That's the superpower (zero-copy edits) and the footgun (accidental mutation) in one.

In short: a bytes slice photocopies the pages; a memoryview lays a transparent flag over them — write on the flag and you've written on the page underneath.

Try it — zero-copy slicing & compact numeric storage
pythonimport array

# memoryview: slice a big buffer with NO copy
data = bytearray(10 * 1024 * 1024)   # 10 MB
view = memoryview(data)
chunk = view[1024:2048]              # a window, not a copy — O(1), 0 bytes extra
chunk[0] = 255                        # writes straight through to `data`

# array: a million floats WITHOUT per-element object overhead
scores = array.array("f", [0.0] * 1_000_000)   # ~4 MB, vs ~30+ MB in a list

# struct: pack/unpack a binary record (e.g. a wire protocol header)
import struct
packed = struct.pack(">IHf", 42, 7, 3.14)   # big-endian: uint32, uint16, float
n, k, x = struct.unpack(">IHf", packed)
▶ How this works

The core idea here is copy vs. view. Slicing a normal bytes or list makes a brand-new copy of the data — fine for small things, disastrous for a 10 MB blob you slice often. A memoryview gives you a window onto the same underlying bytes, so no data is duplicated.

  1. bytearray(10 * 1024 * 1024) allocates a 10 MB editable buffer. memoryview(data) wraps it without copying anything.
  2. view[1024:2048] is the zero-copy slice — it points into the same memory. It costs O(1) time and 0 extra bytes, unlike data[1024:2048] which would copy 1 KB. Proof that they share memory: chunk[0] = 255 writes straight through and changes data too.
  3. array.array("f", ...) stores a million floats as raw 4-byte numbers (~4 MB) instead of a million float objects (~30+ MB in a list) — the "f" means "float".
  4. struct.pack(">IHf", ...) packs values into a compact binary record and unpack reads them back — the format string ">IHf" means big-endian uint32, uint16, float. This is how wire protocols and file headers are read.

What the output means: No printed output — the payoff is invisible: chunk is created with zero copying, and after chunk[0] = 255 the first byte of the big data buffer is now 255. The struct round-trip gives back 42, 7, 3.14.

Try this: After chunk[0] = 255, print data[1024] — it's 255. You mutated the big buffer through the small window, proving the slice never copied.

This is what numpy does for youFor real numeric work you'll use numpy (A4), which is array + memoryview ideas industrialized: contiguous typed buffers, zero-copy views/slices, and vectorized C loops. Knowing the primitives explains why numpy is fast and when a numpy slice shares memory with its parent.

4 · Garbage collection & weakref advanced

Your agent has been running fine for three days, and then it dies at 3 a.m. with the RAM graph a perfect diagonal line climbing to the ceiling. No crash bug, no bad request — just memory that went up and never came back down. That's a leak, and understanding how Python reclaims memory is what lets you find it. The good news is Python usually cleans up after you automatically; the trap is the handful of cases where it can't, and those cases are exactly what long-running agents hit.

Python's main cleanup mechanism is delightfully simple: reference counting. Every object keeps a tally of how many things currently point at it. Assign it somewhere, the count goes up; the variable goes out of scope or you del it, the count goes down. The instant that tally hits zero — nobody is holding this anymore — the object is freed immediately. It's like a coat-check that throws away a coat the moment the last claim ticket for it is handed back. This is why memory in Python typically frees itself the second you stop using something, with no pause and no separate "collector" running.

But counting alone has one blind spot, and it's the classic one: reference cycles. Suppose object A holds a reference to B, and B holds one right back to A. Even after you let go of both, each still has a count of 1 — because they're pointing at each other — so neither ever reaches zero, and neither is ever freed. Two friends each refusing to leave the party until the other does. To handle this, CPython runs a second, periodic cyclic garbage collector that hunts down these islands of mutually-referencing junk and reclaims them. You can nudge it with gc.collect(), but mostly it just works in the background.

The third tool, weakref, is the subtle one, and it's the fix for the most common agent leak. A normal reference is a strong claim — "keep this alive as long as I exist." Sometimes you want the opposite: to refer to an object without being the reason it stays in memory. That's a weak reference. The textbook case is a cache: you want to remember a Model object if it's still in use elsewhere, but you emphatically do not want your cache to be the thing keeping every model that ever passed through alive forever. Hold cache values weakly and they evaporate the moment their last real user lets go — no leak. The mistake this section is really warning against is the opposite: an ever-growing global list or dict that you append to and never trim. Reference counting can't save you there, because you are still holding a live strong reference to every item — the leak is working as coded. The lab shows a weakref cache emptying itself, and the callout names the global-collection leak so you'll recognize it in the wild.

CPython frees objects mainly by reference counting — when the last reference goes away, the object is freed immediately. A separate cyclic garbage collector handles reference cycles (A→B→A) that counting alone can't. A weak reference lets you refer to an object without keeping it alive — essential for caches that shouldn't cause leaks.

how CPython decides an object is garbage refcount hits 0 → freed count 1 → 0 freed immediately a cycle counting can't free A B each still counts 1 → neither freed cyclic GC reclaims it weak reference points at obj but count += 0 → won't keep it alive (caches) Three cleanup rules in one picture. Reference counting frees an object the instant its count hits zero; a mutual A↔B cycle keeps both counts at 1 so only the cyclic GC can reclaim it; a weak reference points at an object without bumping the count, which is why a weak-valued cache never causes a leak.
🗺️ How to read this diagram

Read the three panels left to right — they are the three mechanisms Python uses to reclaim memory, from simplest to trickiest.

  • Left: the common case. When an object's reference count drops to 0, the green box shows it's freed immediately — no collector, no pause.
  • Middle: the blind spot. The two red boxes A and B point at each other (the two brand arrows), so even after you let go of both, each count stays at 1 and neither ever hits zero. The dashed arrow down to the amber box is the cyclic GC — the separate pass that finds and frees these islands.
  • Right: the fix for cache leaks. A purple weak reference points at an object but adds 0 to its count, so it can never be the reason the object stays alive — exactly what you want in a cache.

In short: count-to-zero frees most things instantly; cycles need the GC; a weakref lets you refer without owning.

Try it — weakref caches & breaking cycles
pythonimport weakref, gc

class Model:
    def __init__(self, name): self.name = name

# A cache that does NOT keep models alive on its own
cache = weakref.WeakValueDictionary()
m = Model("opus")
cache["opus"] = m
del m                     # last strong ref gone -> entry auto-disappears
print(list(cache))        # [] — no leak

# Cycles need the GC (or manual breaking):
gc.collect()              # force a cyclic-GC pass; returns objects collected
▶ How this works

This shows how Python decides an object is garbage. Every object counts how many references point to it; when that count hits zero, it's freed immediately. A weak reference is the exception — it lets you point at an object without bumping that count, so it won't keep the object alive. That's exactly what a cache wants: cache a thing, but don't be the reason it can never be freed.

  1. weakref.WeakValueDictionary() is a dict whose values are held weakly. Putting m in it does not, by itself, keep the model alive.
  2. del m removes the last strong reference. Now the only thing pointing at the model is the weak cache entry — which doesn't count — so the object is freed and the entry vanishes on its own.
  3. print(list(cache)) shows []: the cache emptied itself, so it caused no memory leak.
  4. gc.collect() handles the harder case — reference cycles (A points to B, B points back to A). Their counts never reach zero on their own, so the cyclic garbage collector must find and free them.

What the output means: [] — an empty list, meaning the cached model disappeared the moment its last real reference was deleted. gc.collect() returns a count of objects it reclaimed.

Try this: Comment out del m and re-run: now list(cache) prints ['opus'], because m is still a live strong reference keeping the model — and its cache entry — alive.

The common leak in long-running agentsA global list/dict that you append to forever (unbounded history, growing cache) is the #1 memory leak — reference counting can't help because you're still holding the reference. Bound them (deque(maxlen=...), an LRU cache from D2) or use weakref. Watch for it in agents that run for days.

5 · The GIL — what it actually is expert intermediate

Ask ten Python developers what the GIL does and you'll get "it makes Python slow" or "it means threads don't work" — and both are wrong enough to make you write bad code. This is the single most misunderstood mechanism in the language, and getting it precisely right is what lets you choose threads, processes, or async on purpose instead of by folklore. So let's define it exactly, then dismantle the myth.

The Global Interpreter Lock is a single lock (a mutex) inside a CPython process, and the rule it enforces is narrow: only one thread may be executing Python bytecode at any given instant. A thread must hold this one lock to run your Python code, and there's only one lock, so no matter how many threads you spawn, exactly one is running Python at a time. Picture an open-plan kitchen with a dozen cooks but only one chef's knife — everyone can be busy prepping, plating, or waiting on the oven, but only one person can actually be cutting at once, and they have to pass the knife around.

Why on earth would the language do this? Because of §4's reference counting. Every object's refcount is being incremented and decremented constantly, and if two threads bumped the same count at the same time, the counts would corrupt and Python would free objects that are still in use — instant crashes. The GIL is the cheap, brute-force fix: make bytecode execution single-threaded and the counts can never race. It bought CPython simplicity and speed for the overwhelmingly common single-threaded case, at the price of true multi-core Python threading.

Now the myth, and the nuance that demolishes it. The knife is only held while a cook is actually cutting — the moment a thread stops running Python bytecode, it hands the GIL back. And threads stop running Python constantly: whenever they wait on the network or disk (an I/O syscall isn't Python bytecode), and whenever they drop into a C extension that explicitly releases the lock — numpy's math loops, compression, crypto, and the like. So the honest statement is not "threads don't work." It's: threads give you no speedup for CPU-bound pure-Python code, but they absolutely help when your threads spend their time waiting or inside C. That single distinction — is my bottleneck waiting, or is it pure-Python computing? — decides everything in the next three sections, and the table plus the labs make it concrete. (One caveat worth knowing: Python 3.13+ ships an experimental free-threaded build that removes the GIL entirely, but it's opt-in and not yet the default, so assume the GIL is present unless you've deliberately turned it off.)

The Global Interpreter Lock is a mutex that lets only one thread execute Python bytecode at a time in a CPython process. It exists to make memory management (reference counting) safe without per-object locks. The consequence everyone half-remembers: threads don't give you CPU parallelism for pure-Python code. But the crucial nuance: the GIL is released during I/O (network, disk) and inside many C extensions (numpy, compression, crypto).

one process · three threads · exactly one GIL to pass around Thread 1 wants to run Thread 2 wants to run Thread 3 wants to run the GIL one lock runs Python bytecode only the holder GIL is RELEASED on I/O waits and in C ext (numpy) → others run One knife, passed around. All three threads want to run, but only the one holding the single GIL executes Python bytecode — so pure-Python threads take turns, never parallel. The escape hatch: the GIL is released during I/O waits and inside C extensions, so other threads genuinely run then.
🗺️ How to read this diagram

Read left to right: three threads on the left all competing, the single lock in the middle, and the one privilege it grants on the right.

  • The three left boxes are threads in one process, all wanting to run Python. Only Thread 2's arrow is solid — it currently holds the lock; the other two are dashed, meaning "waiting, blocked."
  • The amber box is the GIL: a single lock. The solid arrow out of it reaches the green box — running Python bytecode — which only the lock's holder may do. That's why pure-Python threads never run truly in parallel.
  • The payoff is the line under the green box: the GIL is released during I/O waits and inside C extensions like numpy — so at those moments a waiting thread grabs the knife and runs. That single fact is why threads still help I/O-bound work (§6) but not CPU-bound Python (§7).

In short: many threads, one lock, one runner — except while waiting on I/O or inside C, when the lock is handed off and others proceed.

WorkloadDo threads help?Why
Waiting on API/network (I/O-bound)YesGIL released while waiting → real overlap
Reading/writing filesYesGIL released during the syscall
numpy / C-extension mathYesthe C code releases the GIL
Pure-Python number crunchingNoGIL held → threads run one-at-a-time
The mental modelThe GIL hurts only CPU-bound pure-Python code. Most agent work is I/O-bound (waiting on the LLM API), so threads and async both give big wins there. For CPU-bound Python, use processes. (Note: Python 3.13+ ships an experimental free-threaded build that removes the GIL — but assume the GIL exists unless you've explicitly opted out.)

6 · Threads — for I/O-bound fan-out advanced

Your agent needs to fetch ten documents, and each fetch takes one second. Done one after another, that's ten seconds your user stares at a spinner. Done right, it's one second. The trick isn't a faster network — it's noticing that for nine of those ten seconds, your program was doing nothing but waiting. Threads let those waits happen on top of each other, and for the I/O-bound work that dominates agent code, that's the highest-leverage change you can make.

This is where §5's nuance pays off directly. A network call spends almost all its time blocked on a syscall — not running Python bytecode — so the GIL is released the whole time it waits. That means a second thread can fire off its request while the first is still waiting for a reply, and a third while those two wait, and so on. It's a restaurant server taking a second table's order while the first table's food cooks: the cooking (the network round-trip) overlaps, and total wall-clock time collapses toward the length of the single slowest wait rather than the sum of all of them.

The clean way to express this is a thread pool — you don't hand-manage threads, you hand jobs to a pool and collect the results. concurrent.futures.ThreadPoolExecutor is that interface: you submit work and get back futures (placeholders for results that aren't ready yet), or map a function over inputs, and the pool runs up to max_workers of them concurrently. The with block tidies the threads away when you're done. The common mistake beginners make here is expecting this same pattern to speed up a heavy math loop — it won't, because that's pure-Python CPU work where the GIL is held the entire time and the threads just take turns. Threads are for waiting, not computing; keep that line bright. The lab shows ten one-second fake fetches finishing in about one second, and invites you to throttle max_workers to watch the overlap shrink.

Since the GIL releases during network waits, threads are a simple way to make many API calls concurrently. ThreadPoolExecutor is the clean interface.

four 1-second fetches — the waits either queue or overlap Sequential fetch 1 fetch 2 fetch 3 fetch 4 ≈ 4s Thread pool (GIL released while waiting) fetch 1 fetch 2 fetch 3 fetch 4 all four wait at the same time ≈ 1s Threads collapse waiting, not computing. Run sequentially, four one-second fetches queue up to ≈4s; in a thread pool the GIL is released during each wait, so all four wait at once and the batch finishes in ≈1s — the length of the single slowest wait.
🗺️ How to read this diagram

These are two timelines that both run left-to-right along the same time axis; compare how far right each finishes.

  • The top row is sequential: the four blue fetch bars sit end to end, each starting only after the previous finishes. The total stretches to the far right — about 4 seconds.
  • The bottom rows are the thread pool: the four green bars are stacked, all starting at the same moment. The dashed arrow marks the shared wait window — they overlap because the GIL is released while each thread waits on I/O.
  • The payoff is the two time labels on the right: the same work drops from ≈4s to ≈1s. Nothing got faster per-fetch; the idle waiting simply happens simultaneously.

In short: stack the waits instead of queuing them and total time falls to the slowest single wait — the entire reason threads win for I/O.

Try it — fan out I/O with a thread pool
pythonfrom concurrent.futures import ThreadPoolExecutor, as_completed
import time

def fetch(doc_id):
    time.sleep(1)              # stand-in for a network/API call (GIL released)
    return (doc_id, f"content-{doc_id}")

ids = range(10)
# Sequential: ~10s.  With 10 threads: ~1s — because they wait in parallel.
with ThreadPoolExecutor(max_workers=10) as ex:
    futures = [ex.submit(fetch, i) for i in ids]
    results = [f.result() for f in as_completed(futures)]
print(len(results))          # 10, in ~1 second
▶ How this works

This is the payoff of the GIL discussion: threads do help when the work is waiting (network, disk, an LLM API). While a thread waits, Python releases the GIL so other threads run — so ten one-second waits can overlap and finish in about one second instead of ten.

  1. fetch(doc_id) uses time.sleep(1) as a fake network call. Sleeping releases the GIL, just like a real I/O wait does — so multiple fetch calls can wait at the same time.
  2. ThreadPoolExecutor(max_workers=10) creates a pool of 10 worker threads. The with block cleans them up automatically when done.
  3. ex.submit(fetch, i) hands each job to the pool and returns a future — a placeholder for a result that isn't ready yet.
  4. as_completed(futures) yields each future as it finishes, and f.result() pulls out its return value. Because all ten waited in parallel, the whole batch takes ~1 second.

What the output means: 10 — all ten fetches completed. The headline is the time: ~1 second total instead of ~10, because the waits overlapped.

Try this: Change max_workers to 2 and re-run. Now only two can wait at once, so 10 jobs take ~5 seconds — you can watch the concurrency limit control the speed.

Threads share memory — so protect shared stateBecause threads share the same memory, two threads mutating the same list/dict can corrupt it or lose updates (a "race condition"). Guard shared mutable state with a threading.Lock, or better, have each thread return its result and combine afterwards (as above) so there's no shared mutation at all.

7 · Processes — for CPU-bound work advanced

So threads collapsed ten seconds of waiting into one — but what if your ten jobs aren't waiting at all? What if each one is a grinding pure-Python calculation with no network in sight? Then threads give you exactly nothing: the GIL hands the knife around, one cook cuts at a time, and your eight-core machine runs at the speed of one core. To actually use all those cores for Python computation, you need to stop sharing a GIL — and the way to do that is to stop sharing an interpreter.

That's what a process is. Each operating-system process gets its own private Python interpreter, its own memory, and — the whole point — its own separate GIL. Four processes on a four-core box genuinely run four Python computations at the same instant, because there are now four knives, one per kitchen. This is the only way to get true multi-core parallelism out of CPU-bound pure Python, and concurrent.futures.ProcessPoolExecutor gives you the same tidy submit/map interface you already learned for threads — you often change one word to switch models.

But separate memory is a double-edged sword, and this is where the mistakes cluster. Because processes share nothing, any input you send and any result you get back must be pickled (serialized to bytes), shipped across a pipe, and unpickled on the other side — a real cost that scales with your data. So processes pay off only when the compute per job dwarfs that copying overhead; hand a process pool a pile of tiny jobs and the pickling can cost more than you saved. The second classic trap is platform-specific: on macOS and Windows, new processes start by re-importing your script, so without the if __name__ == "__main__": guard, the pool tries to launch a pool that tries to launch a pool — an infinite fork bomb. The lab runs four genuinely heavy jobs across cores and then dares you to swap in ThreadPoolExecutor and watch it stay stubbornly slow — the GIL, demonstrated in your own terminal.

To actually parallelize CPU-bound Python, use separate processes — each has its own interpreter and its own GIL, so they run truly in parallel on multiple cores. The cost: data must be pickled and copied between processes, so it's worth it only when the compute per item dwarfs that overhead.

why processes parallelize CPU work and threads don't threads · one GIL one interpreter · one GIL T1 T2 T3 take turns → 1 core busy processes · own GIL each proc 1 own GIL core 1 proc 2 own GIL core 2 proc 3 own GIL core 3 all run at once crossing the boundary costs a pickle + copy of every input and result Separate interpreters mean separate GILs. Threads share one interpreter and one GIL, so CPU-bound Python serializes onto a single core; a process pool gives each worker its own interpreter, GIL and core — true multi-core parallelism — paid for by pickling every input and result across the process boundary.
🗺️ How to read this diagram

Compare the two sides: the same three jobs run as threads (left) versus as processes (right).

  • On the left, the three red thread boxes T1–T3 all live inside one interpreter with one GIL. The label says it: they take turns, so only one core is ever busy — no speedup for CPU work.
  • On the right, each green box is a whole process with its own GIL on its own core. There's no single lock to contend for, so all three genuinely run at the same instant — real multi-core parallelism.
  • The payoff carries a price, shown by the dashed arrow crossing between the two worlds: because processes share no memory, every input and result must be pickled and copied across the boundary. That overhead is why processes only pay off when each job does heavy compute.

In short: one GIL shared = take turns on one core; one GIL each = true parallel on many cores, minus the pickling toll.

Try it — real parallelism with a process pool
pythonfrom concurrent.futures import ProcessPoolExecutor

def heavy(n):                 # pure-Python CPU work — threads would NOT parallelize this
    return sum(i*i for i in range(n))

if __name__ == "__main__":      # required guard on Windows/macOS spawn
    with ProcessPoolExecutor() as ex:      # defaults to #CPU cores
        results = list(ex.map(heavy, [10_000_000] * 4))
    # 4 cores -> ~4x faster than sequential for this CPU-bound task
▶ How this works

When the work is CPU-bound pure Python (real number-crunching, no waiting), threads can't help — the GIL lets only one run at a time. The fix is separate processes: each has its own Python interpreter and its own GIL, so they truly run in parallel on different CPU cores.

  1. heavy(n) is deliberately CPU-heavy — it just adds up squares with no I/O. This is the exact case where threads would give no speedup at all.
  2. if __name__ == "__main__": is required when using processes on macOS and Windows. They start new processes by re-importing this file, and this guard stops that import from launching the pool again (which would loop forever).
  3. ProcessPoolExecutor() with no argument uses one worker per CPU core. ex.map(heavy, [10_000_000] * 4) runs four heavy jobs, spreading them across cores.
  4. The catch, noted in the section text: inputs and results must be pickled (serialized) and copied between processes. That overhead only pays off when each job does a lot of compute — as these do.

What the output means: A list of four identical big sums. On a 4-core machine it finishes in roughly a quarter of the sequential time — real parallelism, which threads could not deliver here.

Try this: Swap ProcessPoolExecutor for ThreadPoolExecutor and time both. The thread version stays as slow as running one after another — a hands-on demonstration of what the GIL blocks.

8 · Choosing a concurrency model intermediate

Here's the whole chapter compressed into one decision you'll make on real systems: given a workload, do you reach for asyncio, threads, or processes? Pick wrong and you either leave your cores idle or drown a simple task in overhead. The reassuring news is that the choice is almost mechanical once you ask the right question first — and that question is always the same.

Ask: is this job spending its time waiting, or computing? That one distinction, which you met with the GIL and saw proven in the last two labs, sorts nearly everything. If the bottleneck is waiting — on the LLM API, a database, a file, another service — you want a concurrency model that overlaps waits, and you never need real CPU parallelism because the CPU is idle anyway. If the bottleneck is computing in pure Python, you need genuine multi-core parallelism, which means processes (or pushing the math into a C library like numpy that drops the GIL for you).

Within the "waiting" branch there's a second, finer choice, and it's about scale, not capability. Threads are the low-ceremony option: they share memory, the code reads like ordinary blocking code, and they're perfect for moderate fan-out — a few dozen concurrent calls. But each OS thread costs real memory and scheduling overhead, so when you need thousands of simultaneous waits (a server juggling ten thousand open connections), asyncio wins decisively because it does it all in one thread with tiny per-task cost — at the price of "async all the way down" plumbing (that's chapter A3). The common mistake is treating these as rival religions to argue about; they're tools matched to a bottleneck. The frequent right answer in a real agent is to combine them — threads or async to fan out the I/O, a process pool for the occasional heavy compute — which is exactly what the practice-ladder solution below does. Use the table as your cheat sheet, but carry the question, not the grid.

one question sorts nearly every workload Does the job WAIT or COMPUTE? waits (I/O) computes (CPU) overlap waits · then by scale asyncio 1000s of calls threads moderate fan-out processes own GIL/core numpy / C releases GIL need true multi-core work Carry the question, not the grid. Ask only "does this job wait or compute?" — waiting wants a model that overlaps waits (asyncio at huge scale, threads for moderate fan-out); computing wants true parallelism (processes, or a numpy/C library that drops the GIL).
🗺️ How to read this diagram

Start at the amber box at the top and follow the branch that matches your workload down to a leaf.

  • The top amber box is the only question that matters: is this job spending its time waiting or computing? Every other choice hangs off this one.
  • The left "waits (I/O)" branch splits again by scale (dashed arrows): asyncio when you need thousands of simultaneous calls, threads for moderate fan-out with simpler code. Both just overlap idle waits.
  • The right "computes (CPU)" branch needs real multi-core parallelism: a process pool (its own GIL per core) or pushing the math into numpy/C, which releases the GIL for you.

In short: wait → asyncio or threads; compute → processes or numpy — and the branch is decided by that one wait-vs-compute question.

ModelBest forParallel CPU?Overhead
asyncio (A3)very high I/O concurrency (1000s of calls)nolowest (single thread)
threadsmoderate I/O fan-out, simple codenolow (shared memory)
processesCPU-bound Python (parsing, math w/o numpy)yeshigh (pickling, copies)
numpy/C ext (A4)numeric arraysyes (releases GIL)lib-level
🔗 Used in the courseFanning out many LLM calls (batch evals in Ch 5, multi-tool retrieval) is I/O-bound → threads or async win big. The Workflow-style parallel agent fan-out is the same idea. Heavy local embedding of a big corpus is CPU/GPU-bound → processes or a vectorized library (A4). Match the model to the bottleneck.

Exercises advanced

Practice
  1. Measure a list of 100k small objects vs the same with __slots__ using tracemalloc.
  2. Store 1M floats three ways (list, array, and — if installed — numpy) and compare memory.
  3. Use a ThreadPoolExecutor to fetch 20 mock URLs concurrently and collect results with as_completed.
  4. Show a race condition: 10 threads each incrementing a shared counter 100k times without a lock, then fix it with threading.Lock.
  5. Benchmark a CPU-bound function with threads vs processes and explain the difference via the GIL.

🎯 Interview practice interview

The interview questions this topic gets asked — worked, with code. For the full pattern catalog see A9 · Big Tech AI-engineering patterns.

"Do threads speed this up?" — the GIL question

Reason out loud: I/O-bound (network/disk) → threads help (GIL released while waiting). CPU-bound pure Python → threads don't; use processes.

Setup to run this snippet
class _Any:
    '''stands in for any undefined demo value; supports call/attr/index/
    iteration and basic arithmetic (as 0.7) so demo snippets run.'''
    def __call__(self, *a, **k): return _Any()
    def __getattr__(self, k): return _Any()
    def __getitem__(self, k): return _Any()
    def __iter__(self): return iter([])
    def __len__(self): return 0
    def __contains__(self, o): return True
    def __enter__(self, *a): return _Any()
    def __exit__(self, *a): return False
    def __float__(self): return 0.7
    def __int__(self): return 1
    def __lt__(self, o): return True
    def __gt__(self, o): return False
    def __le__(self, o): return True
    def __ge__(self, o): return False
    def __add__(self, o): return o
    def __radd__(self, o): return o
    def __bool__(self): return True
    def __repr__(self): return 'demo'
    def __str__(self): return 'demo'
urls = _Any()
pythonfrom concurrent.futures import ThreadPoolExecutor
# I/O-bound: 10 network calls overlap because the GIL is released on I/O
def fetch(url): ...            # network wait
with ThreadPoolExecutor(max_workers=10) as ex:
    results = list(ex.map(fetch, urls))   # ~1x latency, not 10x
▶ How this works

The interview-ready version of the whole lesson: threads help I/O, not CPU. This snippet is the I/O case — network calls that spend their time waiting — so a thread pool overlaps the waits and wins.

  1. fetch(url) stands for a network call (its body is elided with ...). The key fact is that during a network wait, CPython releases the GIL, letting other threads proceed.
  2. ThreadPoolExecutor(max_workers=10) gives ten workers; ex.map(fetch, urls) runs fetch once per URL and returns results in order.
  3. The comment ~1x latency, not 10x is the punchline: ten calls that each wait finish in about the time of one, because the waits happen simultaneously.

Try this: In an interview, say the rule out loud: I/O-bound → threads (or async) because the GIL is released while waiting; CPU-bound pure Python → processes because each gets its own GIL. That single sentence answers most GIL questions.

Shrink a program's memory (generators + __slots__)

Stream instead of materializing lists; drop per-instance __dict__ for bulk objects.

pythonclass Point:
    __slots__ = ("x", "y")    # no per-instance dict -> big savings at scale
    def __init__(self, x, y): self.x, self.y = x, y

def squares(n):
    for i in range(n):        # generator: O(1) memory, not O(n)
        yield i * i
▶ How this works

Two independent memory tricks in one snippet: use __slots__ to shrink each object, and use a generator to avoid building a giant list at all.

  1. class Point with __slots__ = ("x", "y") drops the per-instance __dict__ — a big saving when you create millions of points.
  2. squares(n) uses yield instead of return, which makes it a generator: it produces one value at a time on demand rather than building a full list of n numbers up front.
  3. So a normal function would need O(n) memory to hold every square at once; the generator needs only O(1) — it remembers just where it is and hands back the next value when asked.

What the output means: No output on its own — these are building blocks. The win is structural: constant memory for the sequence and a much smaller footprint per Point.

Try this: Compare sum(i*i for i in range(10_000_000)) (a generator, tiny memory) against sum([i*i for i in range(10_000_000)]) (a list, builds all 10M in memory first). Same answer, very different memory use.

Checkpoint advanced

  • Estimate object memory overhead and cut it with __slots__/array/generators.
  • Use memoryview for zero-copy slices and know when a slice copies.
  • Explain reference counting, cyclic GC, and a weakref cache.
  • State precisely what the GIL prevents, and choose threads/processes/async by bottleneck.

🪜 Practice ladder beginner → industry

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

Exercise 1 · Measure object memoryBeginner

Context: A normal instance stores its attributes in a per-object __dict__ that costs memory on top of the object header — the baseline you measure before optimizing.

Your task: Use sys.getsizeof to show that a plain instance carries a per-instance __dict__, printing the size of an instance's dict for a class with two attributes.

Requirements:

  • Define a small class with two instance attributes
  • Show the instance has a populated __dict__
  • Print sys.getsizeof of the instance's __dict__
  • Also show the object header size for comparison

💡 Hint: The attributes live in instance.__dict__; measuring that dict separately from the object shows where the memory actually goes.

Show solution

A normal instance stores attributes in a per-object __dict__, which costs memory on top of the object header.

import sys

class Point:
    def __init__(self, x, y):
        self.x = x; self.y = y

p = Point(1, 2)
print("instance dict:", p.__dict__)                 # {'x': 1, 'y': 2}
print("dict size (bytes):", sys.getsizeof(p.__dict__))
print("object header:", sys.getsizeof(p))
Exercise 2 · __slots__ vs dict, measuredIntermediate

Context: __slots__ drops the per-instance dict for a fixed attribute layout — a large saving when you have millions of small objects. Measuring it makes the trade-off concrete.

Your task: Define the same class with and without __slots__ and show the slotted instance has no __dict__.

Requirements:

  • The non-slotted instance has a __dict__ (True)
  • The slotted instance has no __dict__ (False)
  • Assigning an attribute not in __slots__ raises AttributeError
  • Both classes hold the same declared attributes

💡 Hint: Slots trade flexibility for memory — the tell is that a slotted object rejects new attributes and exposes no __dict__.

Show solution

__slots__ drops the per-instance dict, storing attributes in a fixed layout — big savings when you have millions of small objects.

class Dictful:
    def __init__(self, x, y):
        self.x = x; self.y = y

class Slotted:
    __slots__ = ("x", "y")
    def __init__(self, x, y):
        self.x = x; self.y = y

d = Dictful(1, 2)
s = Slotted(1, 2)
print(hasattr(d, "__dict__"))   # True
print(hasattr(s, "__dict__"))   # False
try:
    s.z = 3                       # slots forbid new attrs
except AttributeError as e:
    print("blocked:", e)
Exercise 3 · Zero-copy slicing with memoryviewAdvanced

Context: A memoryview exposes a buffer without copying, so a view slice shares storage — the basis for zero-copy processing of large byte buffers.

Your task: Show that slicing bytes copies but slicing a memoryview does not, by mutating a bytearray through a view.

Requirements:

  • Create a memoryview over a bytearray and slice it
  • Write through the view slice and show the original buffer changed (shared storage)
  • Show that a bytes slice is an independent copy (mutating it doesn't affect the original)
  • Confirm the view reports the bytes viewed with no copy

💡 Hint: Writing through the view mutates the backing buffer; slicing bytes instead hands you a detached copy.

Show solution

A memoryview exposes a buffer without copying; a view slice shares storage, so writes through it mutate the original.

buf = bytearray(b"HELLO world")
mv = memoryview(buf)

word = mv[6:11]          # view, NOT a copy
word[:] = b"WORLD"       # writes straight into buf
print(buf)               # bytearray(b'HELLO WORLD')

copy = bytes(buf[0:5])   # bytes slice IS a copy
print(copy)              # b'HELLO'
print(mv.nbytes, "bytes viewed with no copy")
Exercise 4 · weakref: cache that doesn't leakExpert

Context: A weak reference doesn't increment the refcount, so a weak-valued cache lets objects be collected once no strong refs remain — the fix for the classic cache memory leak.

Your task: Build a cache keyed on objects using weakref.WeakValueDictionary so cached values don't keep their objects alive, and demonstrate the entry vanishing after the object is dropped.

Requirements:

  • Store a value in a WeakValueDictionary and confirm it's present
  • Drop the only strong reference to the value
  • After collection, the cache entry is gone
  • Explain that the weak reference doesn't keep the object alive (avoids the leak)

💡 Hint: Once the last strong reference is dropped, the object is collectable and the weak cache entry disappears on its own.

Show solution

A weak reference does not increment the refcount, so a weak cache lets objects be collected once no strong refs remain — avoiding the classic cache memory leak.

import weakref, gc

class Doc:
    def __init__(self, name):
        self.name = name

cache = weakref.WeakValueDictionary()
d = Doc("a")
cache["a"] = d
print("a" in cache)     # True

del d                    # drop the only strong ref
gc.collect()
print("a" in cache)     # False -- entry auto-removed
Exercise 5 · GIL: threads for I/O, processes for CPUProfessional

Context: The GIL serializes Python bytecode, so threads give no speedup on CPU-bound pure-Python work while processes do — the single most consequential fact for choosing a concurrency model.

Your task: Demonstrate the GIL's effect: a CPU-bound function does not speed up with a ThreadPool but does with a ProcessPool, kept small and runnable.

Requirements:

  • Use a genuinely CPU-bound function (a compute loop, no I/O)
  • Run it across a ThreadPoolExecutor and time it
  • Run the same work across a ProcessPoolExecutor and time it
  • Guard the process pool under if __name__ == "__main__"
  • Note the threads give no CPU speedup while processes can (gap depends on cores)

💡 Hint: Threads share one interpreter under the GIL; each process gets its own, which is why only the process pool parallelizes the compute.

Show solution

The GIL serializes Python bytecode, so threads don't parallelize CPU-bound work; processes each get their own interpreter and do.

import time
from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor

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

N, TASKS = 2_000_000, 4

if __name__ == "__main__":       # required for ProcessPool
    t = time.perf_counter()
    with ThreadPoolExecutor(4) as ex:
        list(ex.map(work, [N]*TASKS))
    print("threads:  %.2fs" % (time.perf_counter()-t))

    t = time.perf_counter()
    with ProcessPoolExecutor(4) as ex:
        list(ex.map(work, [N]*TASKS))
    print("processes: %.2fs (faster on multi-core)" % (time.perf_counter()-t))

Note: on a single-core CI box the gap shrinks; the point is threads give no CPU speedup while processes can.

Exercise 6 · Choosing a concurrency model for an agent workerIndustry scenario

Context: A real agent worker mixes I/O-bound API calls and CPU-bound vector math, and there is no single ‘fast’ concurrency model — you match each stage to whether it waits or computes, with the GIL in mind.

Your task: Design the concurrency model for an agent service doing (a) many concurrent LLM API calls and (b) local embedding-vector math, justifying each choice and showing the dispatch skeleton.

Requirements:

  • Route I/O-bound API fan-out to async or a thread pool (GIL released during I/O waits)
  • Route CPU-bound math to a process pool or a native lib (numpy/BLAS releases the GIL in C)
  • Justify each choice against the GIL
  • Show a dispatch skeleton with network calls labelled (stubbed, no key needed)
  • State the lesson: match the model to wait-vs-compute; don't mix both in one pool

💡 Hint: Ask of each stage ‘does it wait or does it compute?’ — waiting wants threads/async, computing wants processes or a C library.

Show solution

Design: split by workload. I/O-bound fan-out (API calls) → async or a thread pool (GIL is released during I/O waits, so threads overlap). CPU-bound math → a process pool or a native lib (numpy/BLAS releases the GIL in C) so it actually parallelizes.

WorkloadModelWhy
LLM API callsasync / threadsBound on network waits; GIL released during I/O
Vector mathprocesses / numpyCPU-bound; escapes the GIL only via processes or C
from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor

def fetch(url):                     # I/O-bound (needs network to run)
    # response = requests.get(url); return response.text
    return f"stub:{url}"

def score(vec):                     # CPU-bound stand-in
    return sum(x*x for x in vec)

def handle(urls, vectors):
    with ThreadPoolExecutor(16) as io:      # overlap network waits
        docs = list(io.map(fetch, urls))
    with ProcessPoolExecutor(4) as cpu:     # real parallel math
        scores = list(cpu.map(score, vectors))
    return docs, scores

if __name__ == "__main__":
    d, s = handle(["a", "b"], [[1,2,3], [4,5,6]])
    print(d, s)                     # ['stub:a','stub:b'] [14, 77]

Lesson: there is no single ‘fast’ concurrency model — match it to whether each stage waits (I/O) or computes (CPU). Mixing the two in one pool wastes the cores or blocks the loop.

Knowledge check check yourself

✓ Knowledge check

The lesson says the GIL hurts only CPU-bound pure-Python code and lists network waits, file I/O, and numpy math as cases where threads still help. What single property explains all three exceptions?

Show answer
In each case the GIL is released while the work happens — during I/O syscalls the interpreter isn't executing bytecode, and C extensions like numpy release it around their compiled loops — so other threads run and you get real overlap.
✓ Knowledge check

Why does the lesson name an ever-growing global list/dict (unbounded history or cache) as the #1 memory leak in long-running agents, and what's the fix?

Show answer
Reference counting can't reclaim those objects because your global still holds a live strong reference to them, so they accumulate forever; bound the structure (e.g. deque(maxlen=...) or an LRU cache) or hold entries weakly with weakref.
© 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