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

Why concurrency pays for services: your requests are ~99% waiting β€” on Postgres, Redis, OpenSearch, an LLM API. Concurrency overlaps the waits. Parallelism only pays when you're compute-bound.

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

Races aren't just a threads thing. Two pods doing read-modify-write against the same Postgres row race exactly the same way β€” the fix moves to the database: atomic UPDATEs, SELECT ... FOR UPDATE, unique constraints, or optimistic versioning. Same concept, bigger machine.

🏁 Race condition simulator

Two workers each run counter += 1 500 times. Expected final value: 1000. Toggle the mutex, run it, and see how many increments survive.

counter = 0   idle

Press run. Watch whether all 1000 increments make it.

03Locks, mutexes, semaphores

Mutex (lock)

MUTual EXclusion. One holder at a time; others block. Protects a critical section.

Semaphore

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.

RLock (reentrant)

Same thread can re-acquire without deadlocking itself.

Condition variable

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

Distributed cousins to name-drop: Postgres row locks and advisory locks; Redis 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)

1 Β· Mutual exclusion

Resources can't be shared β€” only one holder at a time.

2 Β· Hold and wait

You hold one resource while waiting for another.

3 Β· No preemption

Resources can't be forcibly taken away from a holder.

4 Β· Circular wait

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_timeout in 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.
Related failure modes: livelock β€” everyone actively retrying and re-colliding (fix with jittered backoff, which is exactly why retry libraries jitter) β€” and starvation β€” someone never gets the resource (fairness policies fix it).

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:

  1. Pop a ready task, run it. It executes real Python until it hits an await on something not yet finished.
  2. The coroutine suspends β€” its state (locals, instruction pointer) is saved in the coroutine object itself. Coroutines are resumable stack frames; no OS involvement.
  3. The loop registers "wake this task when FD X is readable / timer fires / that future resolves."
  4. Nothing ready to run? Call epoll_wait() β€” sleep in the kernel until any watched socket has data.
  5. 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.
  • await is the yield point. async def functions 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.
Corollary β€” anything blocking blocks everything. A sync 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.

🧭 Pick the executor

For each workload in a FastAPI service, click where it belongs: event loop, thread pool, or process pool.

10k idle websockets

unanswered

CPU-heavy image resize

unanswered

await db.fetch(...)

unanswered

blocking requests.get in async def

unanswered

07Threads vs processes vs coroutines in Python

ThreadsProcessesCoroutines (asyncio)
Scheduled byOS, preemptiveOS, preemptiveEvent loop, cooperative
Parallel CPU?No (GIL)YesNo (single thread)
MemorySharedIsolated (IPC/pickle to share)Shared
Cost per unit~MB stack, Β΅s switchHeavy (fork/spawn)~KB object, ns switch
Practical scale100s~core count10,000s
Races?Yes, anywhereOnly via external stateOnly across await points
Best forBlocking I/O libs w/o async supportCPU-bound workHigh-volume network I/O
Decision rule (recite this): I/O-bound + async libraries exist β†’ asyncio. I/O-bound + only blocking libraries β†’ threads (or 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
The one detail interviewers probe: while, not if, around wait() β€” waking up doesn't guarantee the predicate holds (spurious wakeups, another consumer got there first). Re-check.
Your ace: Kafka is producer-consumer as infrastructure. Producers β†’ topic (the queue, partitioned and durable) β†’ consumer group (competing consumers; each partition owned by exactly one consumer). Bounded-buffer backpressure becomes retention + consumer lag monitoring; 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

Call 5 APIs, need all results

asyncio.gather(*calls) β€” latency = slowest call, not the sum. Add return_exceptions=True or wrap with wait_for for timeouts.

Rate-limit to 10 concurrent

sem = asyncio.Semaphore(10); async with sem: around each outbound call.

CPU-heavy job hits async endpoint

Never inline β€” loop.run_in_executor(process_pool, fn, arg), or better, enqueue it (Kafka/task queue) and return 202.

Deadlock in prod DB

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.

← Operating systems Networking β†’