{}The Interview
Handbook

Tracks / Python

Concurrency, the GIL & asyncio

senior 10 questions · 5 min read concurrencyasynciogil

Questions in this set 10
  1. 01What is the GIL and what does it actually prevent?
  2. 02Threads vs processes vs asyncio — pick one, and say why.
  3. 03Explain the asyncio event loop. What does await really do?
  4. 04What happens if you call blocking code inside a coroutine?
  5. 05asyncio.gather vs TaskGroup vs as_completed vs wait.
  6. 06How do you bound concurrency and apply timeouts?
  7. 07How does cancellation work, and how do you write cancellation-safe code?
  8. 08What is a race condition here if there is only one thread?
  9. 09multiprocessing: fork vs spawn, and why does my code break on macOS/Windows?
  10. 10When would you choose threads over asyncio even for I/O?
01

What is the GIL and what does it actually prevent?

The Global Interpreter Lock is one mutex per CPython interpreter that must be held to execute bytecode and touch object refcounts. Consequences:

  • CPU-bound Python code does not scale across threads. Two threads doing arithmetic take about as long as one, plus contention.
  • I/O-bound code scales fine with threads — the GIL is released around blocking syscalls (socket reads, disk, time.sleep).
  • C extensions can release it: NumPy, hashlib, compression and most DB drivers drop the GIL during heavy work, so they do parallelise.

Decision rule to state out loud: CPU-bound → processes (multiprocessing, ProcessPoolExecutor) or a native library; I/O-bound with thousands of concurrent waits → asyncio; I/O-bound with modest concurrency or blocking libraries → threads.

Follow-up: "Is the GIL going away?" Python 3.13 ships an experimental free-threaded build (PEP 703, --disable-gil), and 3.12 added per-interpreter GILs (PEP 684). Neither is the default yet, and free-threading currently costs single-thread performance and requires extensions to be rebuilt. Do not plan production around it today.

02

Threads vs processes vs asyncio — pick one, and say why.

parallel CPU memory switch cost failure blast radius best for
threads no (GIL) shared µs shared state corruption blocking I/O libraries, modest fan-out
processes yes copied ms isolated CPU-bound work, untrusted/crashy code
asyncio no shared ns one bad await stalls all 10k+ sockets, proxies, web servers

The cost nobody mentions: processes need picklable arguments and pay IPC serialisation, so passing a 500 MB DataFrame to four workers can be slower than doing it serially.

03

Explain the asyncio event loop. What does await really do?

A coroutine is a generator-like object. await suspends the coroutine, returning control to the loop, which resumes it when the awaited future is done. One thread, cooperative multitasking — nothing is preempted, so a coroutine that never awaits monopolises the loop.

python
import asyncio, time

async def main():
    # WRONG: sequential — 3 seconds
    a = await fetch(1); b = await fetch(2); c = await fetch(3)

    # RIGHT: concurrent — ~1 second
    a, b, c = await asyncio.gather(fetch(1), fetch(2), fetch(3))

    # 3.11+: structured concurrency — cancels siblings if one fails
    async with asyncio.TaskGroup() as tg:
        t1 = tg.create_task(fetch(1))
        t2 = tg.create_task(fetch(2))

await on its own is not concurrency. Concurrency starts when you create tasks (gather, TaskGroup, create_task). This is the single most common misunderstanding in async interviews.

04

What happens if you call blocking code inside a coroutine?

It blocks the entire event loop — every other request on that worker stalls, latency spikes across the board, and health checks start failing. This is the number one async production incident.

python
# BAD inside async def
row = session.query(User).get(1)    # blocking DB driver
resp = requests.get(url)            # blocking HTTP
time.sleep(1)                       # blocking sleep

# GOOD
row = await session.get(User, 1)    # async driver (asyncpg, aiosqlite, SQLAlchemy 2 async)
resp = await client.get(url)        # httpx.AsyncClient
await asyncio.sleep(1)
result = await asyncio.to_thread(cpu_or_blocking_fn, arg)   # offload to a thread

Detection: run with PYTHONASYNCIODEBUG=1 or loop.set_debug(True) and asyncio logs any callback taking >100ms.

05

asyncio.gather vs TaskGroup vs as_completed vs wait.

  • gather(*aws) — results in order. return_exceptions=False (default) re-raises the first error but leaves the other tasks running, which leaks work. return_exceptions=True gives you exceptions as values.
  • TaskGroup (3.11+) — the correct default. On any failure it cancels siblings and raises an ExceptionGroup. Nothing escapes the block.
  • as_completed(aws) — yields futures in completion order; use it to stream results as they land.
  • wait(tasks, return_when=FIRST_COMPLETED) — low-level; returns (done, pending) and you must clean up pending yourself.

Trap: a bare asyncio.create_task(coro) whose result you never keep can be garbage-collected mid-flight. Hold a reference: self._tasks.add(t); t.add_done_callback(self._tasks.discard).

06

How do you bound concurrency and apply timeouts?

python
sem = asyncio.Semaphore(20)                     # never fan out unbounded to a dependency

async def fetch_one(client, url):
    async with sem:
        async with asyncio.timeout(5):          # 3.11+; else asyncio.wait_for
            r = await client.get(url)
            return r.json()

async def fetch_all(urls):
    async with httpx.AsyncClient() as client:   # ONE client — connection pooling
        async with asyncio.TaskGroup() as tg:
            tasks = [tg.create_task(fetch_one(client, u)) for u in urls]
    return [t.result() for t in tasks]

Unbounded gather over 10,000 URLs will exhaust file descriptors and DDoS your own dependency. Saying "semaphore + timeout + one shared client" marks you as someone who has run async in production.

07

How does cancellation work, and how do you write cancellation-safe code?

Cancelling a task raises asyncio.CancelledError at its current suspension point. Since 3.8 it inherits from BaseException, so except Exception does not swallow it.

python
async def worker():
    try:
        await do_work()
    except asyncio.CancelledError:
        await rollback()          # cleanup
        raise                     # ALWAYS re-raise, or the task refuses to die
    finally:
        await conn.close()        # shielded cleanup: use asyncio.shield if it must complete
08

What is a race condition here if there is only one thread?

Cooperative scheduling still interleaves at every await. Check-then-act across an await is not atomic:

python
# BUG
if await store.get(key) is None:      # two coroutines can both see None here
    await store.set(key, value)

# FIX: an asyncio.Lock, or push atomicity down to the store (SETNX / INSERT ... ON CONFLICT)

The rule: anything between two awaits is atomic; anything spanning an await is not.

09

multiprocessing: fork vs spawn, and why does my code break on macOS/Windows?

fork (Linux default until 3.14) clones the process — fast, but copies locks, open sockets and threads in whatever state they were in, which deadlocks with threaded libraries. spawn (macOS/Windows default, and the safe choice everywhere) starts a fresh interpreter and re-imports your __main__, so module-level side effects run again and everything passed must be picklable. That is why the if __name__ == "__main__": guard is mandatory.

Practical rule: set multiprocessing.set_start_method("spawn") explicitly, keep worker arguments small and picklable, and never fork a process that already has threads or a DB connection pool.

10

When would you choose threads over asyncio even for I/O?

When the library you must use is blocking and has no async equivalent (many enterprise SDKs, boto3, some DB drivers), when concurrency is in the dozens rather than thousands, or when the team does not have async experience — an async codebase with one blocking call is worse than a thread pool. concurrent.futures.ThreadPoolExecutor with a bounded max_workers is an entirely respectable senior answer.