skip to content

Concurrency and Async

Running work at once in Python: the GIL, threads, processes, asyncio, and the builds that finally drop the lock. Interviewers open with the GIL, then make you defend one model for a real workload.

part ofPythonoverview, primer and where to startread it →
on this pageshow

explore

questions

127 · 7 sections

What is CPython's GIL, and what does it do to CPU-bound threads?

level: juniorimportance: must knowfreq 88%
basics
~20 s

The GIL is a single interpreter-wide mutex that only one thread at a time may hold to execute Python bytecode. CPU-bound threads therefore take turns instead of running in parallel, so extra threads add no speedup.

open as a page

What do the block and timeout arguments of queue.Queue.get() and put() control?

level: juniorimportance: must knowfreq 60%
basics
~20 s

By default both wait forever: get waits for an item, put waits for room on a bounded queue. Passing block=False raises queue.Empty or queue.Full at once; passing a timeout waits that many seconds and then raises the same exception.

open as a page

What is the difference between threading.Lock and threading.RLock in Python?

level: juniorimportance: must knowfreq 70%
basics
~20 s

threading.Lock is not reentrant: a thread that already holds it blocks forever if it acquires again. threading.RLock records an owner thread and a recursion count, so the same thread may re-enter and must release once per acquire.

open as a page

What is the difference between Thread.start() and Thread.run() on a threading.Thread?

level: juniorimportance: must knowfreq 70%
basics
~20 s

Thread.start() spawns a new operating-system thread and returns immediately; Thread.run() merely executes the work on the calling thread, with no concurrency at all. Each Thread object may be started once — a second start() raises RuntimeError.

open as a page

Why can two Python threads running `counter += 1` lose increments despite the GIL?

level: juniorimportance: must knowfreq 75%
basics
~20 s

counter += 1 is three steps — read the value, add one, store it back — and another thread can run between them. Both threads then read the same value and one increment is silently lost.

open as a page

Why doesn't a global list mutated inside a multiprocessing.Process appear changed in the parent?

level: juniorimportance: must knowfreq 70%
basics
~20 s

Each multiprocessing.Process is a separate OS process with its own memory, so the child mutates its own copy and nothing flows back. Sharing needs an explicit channel: a Queue, a Pipe, Value/Array, a shared memory block or a Manager proxy.

open as a page

Why does multiprocessing.Pool refuse a lambda as its worker function?

level: juniorimportance: must knowfreq 65%
basics
~20 s

A pool sends the function to its worker processes by pickling it, and pickle stores a function only as a module-plus-qualified-name reference. A lambda has no importable name, so the send fails. Use a module-level function instead.

open as a page

What do multiprocessing.Process.start() and join() do?

level: juniorimportance: must knowfreq 65%
basics
~20 s

start() launches a new operating-system process that runs the target callable; join() blocks the caller until that child has exited and reaps it. Calling run() instead executes the work in the current process and spawns nothing.

open as a page

Why does multiprocessing code need an `if __name__ == "__main__":` guard under spawn?

level: juniorimportance: must knowfreq 70%
basics
~20 s

Under the spawn start method the child launches a fresh interpreter and re-imports your main module to reach the target function. Without the guard, the module-level code that launched the process runs again in the child, so CPython raises RuntimeError.

open as a page

Why does multiprocessing.Queue deadlock when you join the child before draining it?

level: middleimportance: must knowfreq 55%
basics
~20 s

A multiprocessing.Queue is a bounded OS pipe fed by a background thread in the writing process. If the pipe fills, that thread blocks until the reader drains it, and the child cannot exit — so the parent's join() waits forever. Drain first, then join.

open as a page

Why does asyncio.create_task() require you to keep a reference to the Task it returns?

level: juniorimportance: must knowfreq 55%
basics
~20 s

The event loop registers running tasks only in a weak set, so the Task object create_task() hands back may be the only strong reference. Drop it and the task can be garbage-collected while still suspended, and the work never finishes.

open as a page

When asyncio.wait_for() times out, what happens to the wrapped coroutine?

level: juniorimportance: must knowfreq 65%
basics
~10 s

asyncio.wait_for cancels the wrapped awaitable when the deadline passes, waits for that cancellation to finish unwinding, then raises TimeoutError to the caller. The inner work is stopped, not left running in the background.

open as a page

What does calling an `async def` function without `await` actually return?

level: juniorimportance: must knowfreq 85%
basics
~20 s

Calling an async def function returns a coroutine object and runs none of its body. The body executes only when something drives it: an await, or asyncio.run. If nothing ever does, Python emits a RuntimeWarning saying the coroutine was never awaited.

open as a page

What does asyncio.run() do with the event loop, and why prefer it to a hand-made loop?

level: juniorimportance: must knowfreq 78%
basics
~20 s

asyncio.run() creates a brand-new event loop, drives the coroutine you hand it to completion, then cancels leftover tasks, shuts down async generators and the default executor, and closes the loop. It is an async program's single entry point.

open as a page

What does asyncio.run() do with tasks still pending when your main coroutine returns?

level: juniorimportance: must knowfreq 70%
basics
~20 s

asyncio.run() does not wait for them. Once the coroutine you passed it returns, it cancels every task still pending, awaits them all, finalizes async generators and the default thread executor, then closes the event loop.

open as a page

What does Python's `async with` statement do that a plain `with` cannot, and which methods does it call?

level: juniorimportance: must knowfreq 68%
basics
~10 s

async with awaits aenter before the block and awaits aexit after it, so setup and teardown can perform I/O. A plain with calls enter and exit synchronously and cannot await anything.

open as a page

Why use asyncio.Lock instead of threading.Lock inside a coroutine?

level: juniorimportance: must knowfreq 58%
basics
~20 s

asyncio.Lock suspends only the awaiting task, so the event loop keeps running everything else. threading.Lock blocks the entire thread the loop runs on, freezing every task on that loop — including the one that would release the lock.

open as a page

What must an object implement for Python's `async for` to iterate it?

level: juniorimportance: must knowfreq 60%
basics
~10 s

Its type must define __aiter__, an ordinary method returning an async iterator, and that iterator's type must define __anext__, a coroutine that resolves to the next item or raises StopAsyncIteration when the stream ends.

open as a page

Why can't `__init__` await, and where must an async resource wrapper open its connection?

level: middleimportance: must knowfreq 55%
basics
~20 s

Construction is synchronous: __init__ must return None, so an async def __init__ returns a coroutine and raises TypeError. Put the awaited connect in __aenter__ and the awaited close in __aexit__, and use the object under async with.

open as a page

How does asyncio.Semaphore bound a fan-out of hundreds of coroutines?

level: middleimportance: must knowfreq 55%
basics
~20 s

asyncio.Semaphore(n) holds n permits. async with sem: takes one and suspends the task when none are left, so at most n coroutines are inside the guarded block at once; waiters are released in order as permits come back.

open as a page

Why does running a CPU-bound Python loop on threading.Thread workers not make it faster?

level: juniorimportance: must knowfreq 80%
basics
~20 s

In a standard CPython build the global interpreter lock lets only one thread execute Python bytecode at a time, so CPU-bound threads take turns rather than running in parallel. Move that work to separate processes instead.

open as a page

Why does an exception inside a ThreadPoolExecutor job stay hidden until Future.result()?

level: juniorimportance: must knowfreq 72%
basics
~10 s

The executor catches whatever the submitted callable raises and stores it on that job's Future instead of letting it propagate. Nothing surfaces until you call Future.result(), which re-raises it, or Future.exception(), which returns it.

open as a page

What does asyncio.to_thread() do, and when should a coroutine use it?

level: juniorimportance: must knowfreq 70%
basics
~20 s

asyncio.to_thread() runs a blocking function on a worker thread and returns an awaitable for its result, so the event loop keeps serving other tasks meanwhile. Await it for any synchronous call that would otherwise freeze the loop.

open as a page

How do you measure whether a Python workload is CPU-bound, I/O-bound or connection-bound?

level: middleimportance: must knowfreq 60%
basics
~10 s

Time one unit of work and compare CPU time to elapsed time: time.process_time over time.perf_counter near 100% means CPU-bound, near zero means it is waiting. Thousands of simultaneous idle waits means connection-bound.

open as a page

How do Executor.map and concurrent.futures.as_completed differ in ordering and error timing?

level: middleimportance: must knowfreq 58%
basics
~20 s

Executor.map yields results in input order, so one slow early job holds back everything behind it and its exception surfaces only when iteration reaches that position. as_completed yields Futures in completion order, so whatever finishes or fails first reaches you first.

open as a page

Why does contextvars.ContextVar.set() return a Token, and what do you do with it?

level: middleimportance: must knowfreq 55%
basics
~20 s

set() returns a Token recording what the variable held before that write, including the fact that it held nothing. Passing the token to var.reset(token) restores exactly that previous state. Each token may be reset once, so nested writes unwind last-in-first-out.

open as a page

Why does contextvars.ContextVar.set inside an asyncio task not affect the caller?

level: middleimportance: must knowfreq 50%
basics
~20 s

asyncio.create_task snapshots the context that is active at the moment of the call and runs the coroutine inside that copy. A ContextVar.set inside the task writes into the copy, so the creator's context is untouched. Propagation is one-way: parent to child, at creation time.

open as a page

Why does a value set on a threading.local() inside one ThreadPoolExecutor task reappear in a later task?

level: middleimportance: must knowfreq 46%
basics
~20 s

Because concurrent.futures.ThreadPoolExecutor reuses its worker threads. The slot is keyed by the thread, not by the task, and nothing clears it between work items, so the next task on that worker inherits whatever the previous one left.

open as a page

What does contextvars.ContextVar.get() do if the variable was never set in the current context?

level: juniorimportance: should knowfreq 32%
basics
~20 s

ContextVar.get() raises LookupError when nothing has been set and the variable was declared without a default. Pass default= when you create the variable, or hand get() a fallback argument, and the miss returns that value instead of raising.

open as a page

Why does a module-level global lose the right value when two asyncio tasks interleave?

level: juniorimportance: should knowfreq 42%
basics
~20 s

One module-level global is shared by every task in the process. A coroutine can be suspended at any await, so a second task overwrites the global before the first resumes and reads it back. A contextvars.ContextVar gives each task its own value instead.

open as a page

Is `list.append` on a shared list still safe without a lock in free-threaded CPython 3.14?

level: juniorimportance: must knowfreq 55%
basics
~10 s

Yes. The free-threaded build locks each container object internally, so a single list.append completes without corrupting the list or losing the item. Anything whose correctness spans two operations still races and still needs threading.Lock.

open as a page

Why does `totals[key] = totals[key] + 1` race across threads when each dict operation is indivisible?

level: middleimportance: must knowfreq 65%
basics
~20 s

That line is two dict operations, not one: a read, then a write, with a gap between them. Two threads can both read the same old value and both store it plus one, so an increment is lost. Lock both.

open as a page

How does `concurrent.futures.InterpreterPoolExecutor` differ from `ThreadPoolExecutor` for CPU-bound work?

level: middleimportance: must knowfreq 38%
basics
~20 s

Both run workers as OS threads in one process, but ThreadPoolExecutor workers share one interpreter and one GIL, so pure-Python CPU work serializes. Each InterpreterPoolExecutor worker gets its own interpreter and its own GIL, so CPU work runs genuinely in parallel.

open as a page

Your service runs python3.14t but sys._is_gil_enabled() returns True — why?

level: seniorimportance: must knowfreq 42%
basics
~20 s

Either the run asked for the lock with -X gil=1 or PYTHON_GIL=1, or a C extension without a free-threading declaration was imported and the interpreter turned the GIL back on for the whole process, warning as it did. Check the requested options first, then bisect the imports.

open as a page

How do you check whether CPython is running the free-threaded build?

level: juniorimportance: should knowfreq 28%
basics
~10 s

Call sys._is_gil_enabled(): it returns False only while the GIL is actually off. For the build itself, sysconfig.get_config_var('Py_GIL_DISABLED') is 1 on a free-threaded interpreter, and the binary is named python3.14t.

open as a page