AI EngineeringZero to ProductionHome·About·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

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

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.

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

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).

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

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.

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

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.

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

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).

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

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

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

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.

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

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