02 Β· CS Fundamentals
Concurrency & parallelism β the event loop you already live in
You run FastAPI in production β that means you ship an event loop, awaits, and (hopefully) no race conditions to 2M+ requests a month. This page gives you the vocabulary and mental models to explain what you already do, and two toys to make races and executor choice stick.
01Concurrency β parallelism
Concurrency = dealing with many things at once β structuring a program so multiple tasks are in flight, interleaved. One barista juggling 5 orders. Parallelism = doing many things at once β literally simultaneous execution on multiple cores. Five baristas.
Concurrency (1 core, interleaved): Parallelism (2 cores, simultaneous):
Task A: ββββββββββββ Core 1: ββββββββ (Task A)
Task B: ββββββββββββ Core 2: ββββββββ (Task B)
time ββββββββββββββΊ time ββββββββββΊ
Rob Pike's line: "Concurrency is about structure, parallelism is about execution." An asyncio server on one core is highly concurrent, zero parallel. A NumPy matrix multiply on 8 cores is parallel with no concurrency structure. Your prod stack does both: N uvicorn worker processes (parallelism), each running an event loop juggling hundreds of requests (concurrency).
02Race conditions
A race condition: correctness depends on the timing/interleaving of concurrent operations. The canonical bug:
counter += 1 # looks atomic. Is actually: LOAD counter β ADD 1 β STORE
Thread A Thread B counter
LOAD (reads 5) 5
LOAD (reads 5) 5
ADD β 6 5
ADD β 6 5
STORE 6 6
STORE 6 6 β two increments, +1. Lost update.
The bad interleaving might hit 1-in-a-million runs β which at 2M req/month means it's guaranteed in production and unreproducible on your laptop. That's what makes races evil: they're timing-dependent Heisenbugs.
The general term is a critical section: a chunk of code that must not be interleaved with itself. Fixes: make it atomic (hardware atomics, INCR in Redis, UPDATE ... SET x = x + 1 in Postgres) or mutually exclude (locks).
SELECT ... FOR UPDATE, unique constraints, or optimistic versioning. Same concept, bigger machine.03Locks, mutexes, semaphores
MUTual EXclusion. One holder at a time; others block. Protects a critical section.
A counter you acquire (decrement, block at 0) and release (increment). A mutex is a semaphore with count 1. Count N = "at most N concurrent users" β a bounded resource, not exclusion.
Same thread can re-acquire without deadlocking itself.
"Sleep until someone signals that a predicate might now be true" β the engine inside producer-consumer (Β§8).
You use the semaphore shape constantly: a DB connection pool of size 20 is semantically a semaphore(20). asyncio.Semaphore(10) to cap concurrent calls to a rate-limited LLM API is the textbook use.
Honorable mentions: RWLock (many readers OR one writer), spinlock (busy-wait instead of sleeping β kernels use them for tiny sections), atomic ops / CAS (lock-free single-variable updates). Under the hood on Linux, a contended lock parks the thread via the futex syscall; an uncontended acquire is just an atomic compare-and-swap β cheap.
SET key val NX PX 30000 as a distributed lock (plus its caveats β expiry vs crash, fencing tokens); Kafka's consumer-group protocol giving each partition to exactly one consumer β mutual exclusion by assignment rather than locking.04Deadlock
Two or more tasks each holding a resource the other needs. Nobody moves, forever.
Thread A: holds L1 ββ wants L2 βββ
βΌ
Thread B: holds L2 ββ wants L1 βββ (cycle β deadlock)
The four Coffman conditions (ALL required β memorize)
Resources can't be shared β only one holder at a time.
You hold one resource while waiting for another.
Resources can't be forcibly taken away from a holder.
A cycle exists in the who-waits-for-whom graph.
Prevention = break any one condition
- Break circular wait (the workhorse): impose a global lock ordering β always acquire L1 before L2, everywhere. No ordering violation β no cycle β no deadlock.
- Break hold and wait: acquire everything atomically up front, or release what you hold before waiting (
try_lock, back off, retry). - Break no preemption: timeouts.
lock.acquire(timeout=5),statement_timeout/lock_timeoutin Postgres. - Detection + recovery: Postgres does this for you β it walks the waits-for graph, and on a cycle kills one transaction with
deadlock detected. Classic prod cause: two transactions updating the same rows in opposite orders. Fix: touch rows in a consistent order (e.g., always by ascending id) β lock ordering, in SQL clothing.
05async/await and the event loop β deep, because FastAPI
Instead of one thread per request (blocked threads just burning stack + context switches while waiting on I/O), keep one thread and a loop:
βββββββββββββββββββββββββββββββ
β EVENT LOOP β
β ready queue: [t3, t7, ...] β
ββββββββ¬βββββββββββββ²ββββββββββ
run task until it β β I/O ready? put its
awaits (yields) βΌ β waiting task back on queue
ββββββββββββββββ β
β current task β β
ββββββββ¬ββββββββ β
await db.fetch() β
βΌ β
ββββββββββββββββββββββββββββββ
β epoll_wait(all sockets) β β the OS multiplexer
ββββββββββββββββββββββββββββββ
One iteration of the loop:
- Pop a ready task, run it. It executes real Python until it hits an
awaiton something not yet finished. - The coroutine suspends β its state (locals, instruction pointer) is saved in the coroutine object itself. Coroutines are resumable stack frames; no OS involvement.
- The loop registers "wake this task when FD X is readable / timer fires / that future resolves."
- Nothing ready to run? Call
epoll_wait()β sleep in the kernel until any watched socket has data. - I/O readiness arrives β the corresponding tasks go back on the ready queue. Repeat.
Key properties to say out loud:
- Cooperative scheduling. Tasks are never preempted; they run until they voluntarily
await. awaitis the yield point.async deffunctions return coroutine objects; the loop drives them via the generator protocol (send/throwβ async/await is literally built on generator machinery).- Switching cost is ~a function call β nanoseconds. That's why one process can hold 10k+ concurrent requests.
requests.get(), time.sleep(5), a synchronous DB driver, or a heavy CPU loop inside async def freezes the entire worker β every in-flight request stalls. This is the #1 FastAPI production footgun.One-thread concurrency still races (subtle, impressive point)
No parallelism β no races. Interleaving happens at every await:
balance = await get_balance(user) # task A reads 100... suspends
# β another request for the same user runs here, also reads 100
await set_balance(user, balance - 10) # both write 90. Lost update.
Anything spanning an await over shared state is a critical section β asyncio.Lock, or push atomicity to the DB. Between awaits, though, code is atomic w.r.t. other coroutines β a plain counter += 1 with no await inside is safe in asyncio (unlike threads).
06FastAPI specifics β the def vs async def rule
async def endpoint β runs on the event loop. Must use async libraries end-to-end (asyncpg / SQLAlchemy-async, httpx.AsyncClient, aioredisβ¦). Block in here and you stall the worker.
plain def endpoint β FastAPI/Starlette runs it in a threadpool (default ~40 threads via AnyIO). Blocking is safe; concurrency caps at pool size.
The wrong combo β async def + blocking calls inside β is the worst of both worlds: no threadpool rescue, dead loop. Escape hatches: await run_in_threadpool(fn) / loop.run_in_executor for blocking I/O, ProcessPoolExecutor for CPU-bound.
Tools worth naming: asyncio.gather() (fan out DB + cache + external API concurrently instead of sequential awaits β easy latency win), asyncio.Semaphore (cap concurrency), asyncio.wait_for (timeouts), TaskGroups (structured concurrency, 3.11+). Also uvloop: a libuv-based drop-in event loop (same engine family as Node's) that uvicorn uses for a meaningful speedup.
07Threads vs processes vs coroutines in Python
| Threads | Processes | Coroutines (asyncio) | |
|---|---|---|---|
| Scheduled by | OS, preemptive | OS, preemptive | Event loop, cooperative |
| Parallel CPU? | No (GIL) | Yes | No (single thread) |
| Memory | Shared | Isolated (IPC/pickle to share) | Shared |
| Cost per unit | ~MB stack, Β΅s switch | Heavy (fork/spawn) | ~KB object, ns switch |
| Practical scale | 100s | ~core count | 10,000s |
| Races? | Yes, anywhere | Only via external state | Only across await points |
| Best for | Blocking I/O libs w/o async support | CPU-bound work | High-volume network I/O |
run_in_executor). CPU-bound β processes. In production you compose them: uvicorn workers (processes) Γ event loop per worker (coroutines) Γ executor threadpool (threads) β parallelism, concurrency, and an escape hatch, respectively.08Classic interview scenarios
8a Β· Thread-safe counter
import threading
class Counter:
def __init__(self):
self._value = 0
self._lock = threading.Lock()
def increment(self):
with self._lock: # context manager: releases even on exception
self._value += 1 # read-modify-write, now atomic
Talking points: why += isn't atomic (3 bytecode ops, the GIL can switch between them); with guarantees release; alternatives β the itertools.count trick, atomics, or sharding the counter per-thread and summing to cut contention. Distributed version: Redis INCR or Postgres UPDATE ... SET n = n + 1 β atomic at the data store, no app lock at all.
8b Β· Producerβconsumer
Producers generate work, consumers process it, a bounded queue decouples them and provides backpressure (a full queue blocks producers β the system self-regulates instead of OOMing).
producers βββΊ [ bounded queue (size N) ] βββΊ consumers
block when full β² β² block when empty
βββ backpressure β
import threading, queue
q = queue.Queue(maxsize=100) # thread-safe, condition-vars inside
def producer():
for item in source():
q.put(item) # blocks when full β backpressure
def consumer():
while True:
item = q.get() # blocks when empty
process(item)
q.task_done()
If asked to build it without queue.Queue, that's a condition-variable exercise:
class BoundedQueue:
def __init__(self, cap):
self.buf, self.cap = [], cap
self.lock = threading.Lock()
self.not_full = threading.Condition(self.lock)
self.not_empty = threading.Condition(self.lock)
def put(self, item):
with self.not_full:
while len(self.buf) >= self.cap: # WHILE, not if β spurious wakeups
self.not_full.wait()
self.buf.append(item)
self.not_empty.notify()
def get(self):
with self.not_empty:
while not self.buf:
self.not_empty.wait()
item = self.buf.pop(0)
self.not_full.notify()
return item
while, not if, around wait() β waking up doesn't guarantee the predicate holds (spurious wakeups, another consumer got there first). Re-check.task_done becomes offset commits; at-least-once delivery means consumers must be idempotent. Saying "asyncio.Queue inside a process, Kafka between services β same pattern, different blast radius" is a strong senior-flavored line.8c Β· Quick-fire scenarios to have an answer for
asyncio.gather(*calls) β latency = slowest call, not the sum. Add return_exceptions=True or wrap with wait_for for timeouts.
sem = asyncio.Semaphore(10); async with sem: around each outbound call.
Never inline β loop.run_in_executor(process_pool, fn, arg), or better, enqueue it (Kafka/task queue) and return 202.
Postgres detects and aborts one txn; the real fix is consistent row-update ordering + short transactions + retry on 40P01.
09Interview questions you should be able to answer
Q1. Concurrency vs parallelism?
Concurrency = structuring many tasks in flight (interleaving, possibly one core); parallelism = simultaneous execution on multiple cores. Async server = concurrency; multiprocessing = parallelism. Prod services want concurrency because requests are mostly I/O waits; we get parallelism by running multiple worker processes.
Q2. What's a race condition? Give an example.
Correctness depending on interleaving of concurrent operations. counter += 1 from two threads = load/add/store interleaved = lost update. Timing-dependent, so rare locally and inevitable at production volume. Fix: atomic ops, locks, or push the read-modify-write into the DB/Redis where it's atomic.
Q3. Mutex vs semaphore?
Mutex = exclusion, one holder. Semaphore = counter allowing up to N holders β models bounded resources. A connection pool or "max 10 concurrent LLM calls" is a semaphore; protecting a shared dict is a mutex.
Q4. Deadlock: conditions and prevention?
All four Coffman conditions: mutual exclusion, hold-and-wait, no preemption, circular wait. Break one: global lock ordering (kills the cycle β the usual fix), acquire-all-up-front, timeouts, or detect-and-abort like Postgres's deadlock detector. In SQL: update rows in consistent order.
Q5. How does the async/await event loop work?
Single thread + ready queue + OS I/O multiplexer (epoll). Run a task until it awaits pending I/O; its state is saved in the coroutine object; the loop registers a wake-up for the FD/timer, runs other ready tasks, and sleeps in epoll_wait when nothing's ready. Cooperative: tasks yield only at await, so one blocking call stalls the whole loop. Switches are user-space function calls β nanoseconds β hence tens of thousands of concurrent connections per process.
Q6. def vs async def in FastAPI?
async def runs on the event loop β must be non-blocking end-to-end. Plain def gets shoved into a threadpool (~40 threads) so blocking is tolerated but concurrency is capped. The bug pattern is blocking inside async def β that freezes every request on the worker. Escape hatches: run_in_threadpool / executor.
Q7. Can single-threaded async code have race conditions?
Yes β interleaving happens at every await. Read-then-await-then-write on shared state is a lost update waiting to happen. Use asyncio.Lock or make the operation atomic at the datastore. Between awaits, code IS atomic relative to other coroutines.
Q8. Threads vs processes vs coroutines in Python β when each?
Coroutines: I/O-bound at scale with async libs (ns switches, 10k+ tasks). Threads: I/O-bound with blocking-only libraries (GIL releases on I/O). Processes: CPU-bound (own GIL each). Production composes all three: worker processes Γ event loops Γ executors.
Q9. Implement a thread-safe counter / why isn't += atomic?
+= compiles to load, add, store bytecodes; the GIL can hand off between them. Wrap in threading.Lock via with. Scale-out version: Redis INCR / SQL SET n = n + 1 β atomicity at the store.
Q10. Explain producer-consumer and where you've used it.
Producers and consumers decoupled by a bounded queue; full blocks producers (backpressure), empty blocks consumers; implemented with two condition variables (while around wait() β spurious wakeups). In-process: queue.Queue/asyncio.Queue. Between services: Kafka β partitions for parallel consumption, one consumer per partition (exclusion by assignment), offsets as acks, lag as the backpressure signal, idempotent consumers for at-least-once.
Q11. Why does adding threads sometimes make a Python program slower?
CPU-bound threads fight over the GIL β serialized execution plus handoff overhead and cache churn. Also generally: contention on shared locks + context-switch cost can outweigh gains (Amdahl's law caps speedup at the serial fraction anyway). Measure; move CPU work to processes.
10Self-test
Recite the four Coffman conditions and the workhorse prevention.
Mutual exclusion, hold-and-wait, no preemption, circular wait β all four required. Workhorse fix: global lock ordering (breaks circular wait). Also: acquire-all-up-front, timeouts, detect-and-abort (Postgres).
Why does counter += 1 lose updates, in three words per step?
LOAD the value, ADD one, STORE it back. A switch between LOAD and STORE lets another worker's whole increment happen in the gap β the resuming worker's STORE overwrites it.
What happens if you call requests.get() inside an async def endpoint?
The coroutine never yields, so the event loop can't run anything else β every in-flight request on that worker stalls until the call returns. Fix: httpx.AsyncClient, or await run_in_threadpool(...).
Why while and not if around Condition.wait()?
Wakeup doesn't guarantee the predicate: spurious wakeups exist, and another woken thread may have consumed the item first. Always re-check the condition in a loop.
State the executor decision rule from memory.
I/O-bound + async libraries β asyncio. I/O-bound + blocking-only libraries β threads / run_in_executor. CPU-bound β processes. Prod composes all three: worker processes Γ event loop Γ threadpool.
How is a DB connection pool a semaphore? How is Kafka a mutex?
Pool of 20 = semaphore(20): acquire a connection (block at zero), release when done β a bounded resource. Kafka's consumer group assigns each partition to exactly one consumer β mutual exclusion by assignment, no lock object at all.
Give the asyncio lost-update example β no threads involved.
balance = await get_balance(u) suspends; another request for the same user reads the same balance; both write back the same decremented value. Anything spanning an await over shared state is a critical section β asyncio.Lock or DB-level atomicity.