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.
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.
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)
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.
sys.getsizeof(x)returns the size of one object in bytes. It's the tool you use to measure instead of guessing.- The comments show the results on a typical machine: an
intis ~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. - 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
arrayor 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).
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
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.
ChunkAis a normal class: each instance carries its own__dict__.ChunkBdeclares__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.sys.getsizeof(a.__dict__)measures just the dictionary thatChunkAinstances carry (~100+ bytes each).ChunkBhas 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.
@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.
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)
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.
bytearray(10 * 1024 * 1024)allocates a 10 MB editable buffer.memoryview(data)wraps it without copying anything.view[1024:2048]is the zero-copy slice — it points into the same memory. It costs O(1) time and 0 extra bytes, unlikedata[1024:2048]which would copy 1 KB. Proof that they share memory:chunk[0] = 255writes straight through and changesdatatoo.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".struct.pack(">IHf", ...)packs values into a compact binary record andunpackreads 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.
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.
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
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.
weakref.WeakValueDictionary()is a dict whose values are held weakly. Puttingmin it does not, by itself, keep the model alive.del mremoves 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.print(list(cache))shows[]: the cache emptied itself, so it caused no memory leak.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.
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).
| Workload | Do threads help? | Why |
|---|---|---|
| Waiting on API/network (I/O-bound) | Yes | GIL released while waiting → real overlap |
| Reading/writing files | Yes | GIL released during the syscall |
| numpy / C-extension math | Yes | the C code releases the GIL |
| Pure-Python number crunching | No | GIL held → threads run one-at-a-time |
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.
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
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.
fetch(doc_id)usestime.sleep(1)as a fake network call. Sleeping releases the GIL, just like a real I/O wait does — so multiplefetchcalls can wait at the same time.ThreadPoolExecutor(max_workers=10)creates a pool of 10 worker threads. Thewithblock cleans them up automatically when done.ex.submit(fetch, i)hands each job to the pool and returns afuture— a placeholder for a result that isn't ready yet.as_completed(futures)yields each future as it finishes, andf.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.
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.
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
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.
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.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).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.- 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
| Model | Best for | Parallel CPU? | Overhead |
|---|---|---|---|
| asyncio (A3) | very high I/O concurrency (1000s of calls) | no | lowest (single thread) |
| threads | moderate I/O fan-out, simple code | no | low (shared memory) |
| processes | CPU-bound Python (parsing, math w/o numpy) | yes | high (pickling, copies) |
| numpy/C ext (A4) | numeric arrays | yes (releases GIL) | lib-level |
Exercises advanced
- Measure a list of 100k small objects vs the same with
__slots__usingtracemalloc. - Store 1M floats three ways (list,
array, and — if installed — numpy) and compare memory. - Use a
ThreadPoolExecutorto fetch 20 mock URLs concurrently and collect results withas_completed. - Show a race condition: 10 threads each incrementing a shared counter 100k times without a lock, then fix it with
threading.Lock. - 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.
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
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.
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.ThreadPoolExecutor(max_workers=10)gives ten workers;ex.map(fetch, urls)runsfetchonce per URL and returns results in order.- The comment
~1x latency, not 10xis 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.
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
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.
class Pointwith__slots__ = ("x", "y")drops the per-instance__dict__— a big saving when you create millions of points.squares(n)usesyieldinstead ofreturn, which makes it a generator: it produces one value at a time on demand rather than building a full list ofnnumbers up front.- 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
memoryviewfor zero-copy slices and know when a slice copies. - Explain reference counting, cyclic GC, and a
weakrefcache. - 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.
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.getsizeofof 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))
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__raisesAttributeError - 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)
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
memoryviewover abytearrayand slice it - Write through the view slice and show the original buffer changed (shared storage)
- Show that a
bytesslice 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")
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
WeakValueDictionaryand 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
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.
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.
| Workload | Model | Why |
|---|---|---|
| LLM API calls | async / threads | Bound on network waits; GIL released during I/O |
| Vector math | processes / numpy | CPU-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
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
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?