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 pageshowhide
explore
- The GIL and Threading20 questions
- What the GIL Is and Why It Exists4 questions
- The threading Module API4 questions
- Locks, Conditions, and Events4 questions
- Thread Safety and Races in Python4 questions
- Queues and Backpressure4 questions
- Multiprocessing20 questions
- Process and Pool4 questions
- Start Methods: spawn, fork, forkserver4 questions
- Sharing State Between Processes4 questions
- Pickling Constraints and Payload Cost4 questions
- Worker Crashes and Recovery4 questions
- Asyncio Core34 questions
- The Event Loop4 questions
- Coroutines and await Semantics4 questions
- Tasks, Futures, and gather4 questions
- Cancellation and Timeouts4 questions
- TaskGroup and Structured Concurrency3 questions
- Fire-and-Forget Task Pitfalls3 questions
- Diagnosing a Stalled Loop4 questions
- Streams and Protocols4 questions
- Graceful Shutdown4 questions
- Async Language Constructs12 questions
- Async Iteration and Async Generators4 questions
- Async Context Managers4 questions
- Asyncio Synchronization and Queues4 questions
- Executors and Sync/Async Interop18 questions
- concurrent.futures Executors4 questions
- Mixing Blocking and Async Code5 questions
- Picking an Execution Model5 questions
- Pool Sizing and Oversubscription4 questions
- Ambient State Propagation12 questions
- ContextVar Set and Reset4 questions
- Thread Locals and Inheritance4 questions
- Task Context Copying4 questions
- True Parallel Execution11 questions
- The Free-Threaded Build4 questions
- Atomicity Without a Lock3 questions
- Interpreter Pools and Queues4 questions
questions
127 · 7 sectionsWhat is CPython's GIL, and what does it do to CPU-bound threads?
basics
~20 sThe 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.
What do the block and timeout arguments of queue.Queue.get() and put() control?
basics
~20 sBy 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.
What is the difference between threading.Lock and threading.RLock in Python?
basics
~20 sthreading.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.
What is the difference between Thread.start() and Thread.run() on a threading.Thread?
basics
~20 sThread.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.
Why can two Python threads running `counter += 1` lose increments despite the GIL?
basics
~20 scounter += 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.
Why does multiprocessing.Pool refuse a lambda as its worker function?
basics
~20 sA 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.
What do multiprocessing.Process.start() and join() do?
basics
~20 sstart() 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.
Why does multiprocessing code need an `if __name__ == "__main__":` guard under spawn?
basics
~20 sUnder 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.
Why does asyncio.create_task() require you to keep a reference to the Task it returns?
basics
~20 sThe 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.
When asyncio.wait_for() times out, what happens to the wrapped coroutine?
basics
~10 sasyncio.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.
What does calling an `async def` function without `await` actually return?
basics
~20 sCalling 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.
What does asyncio.run() do with the event loop, and why prefer it to a hand-made loop?
basics
~20 sasyncio.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.
What does asyncio.run() do with tasks still pending when your main coroutine returns?
basics
~20 sasyncio.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.
What does Python's `async with` statement do that a plain `with` cannot, and which methods does it call?
basics
~10 sasync 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.
Why use asyncio.Lock instead of threading.Lock inside a coroutine?
basics
~20 sasyncio.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.
What must an object implement for Python's `async for` to iterate it?
basics
~10 sIts 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.
Why can't `__init__` await, and where must an async resource wrapper open its connection?
basics
~20 sConstruction 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.
How does asyncio.Semaphore bound a fan-out of hundreds of coroutines?
basics
~20 sasyncio.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.
Why does running a CPU-bound Python loop on threading.Thread workers not make it faster?
basics
~20 sIn 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.
Why does an exception inside a ThreadPoolExecutor job stay hidden until Future.result()?
basics
~10 sThe 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.
What does asyncio.to_thread() do, and when should a coroutine use it?
basics
~20 sasyncio.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.
How do you measure whether a Python workload is CPU-bound, I/O-bound or connection-bound?
basics
~10 sTime 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.
How do Executor.map and concurrent.futures.as_completed differ in ordering and error timing?
basics
~20 sExecutor.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.
Why does contextvars.ContextVar.set() return a Token, and what do you do with it?
basics
~20 sset() 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.
Why does contextvars.ContextVar.set inside an asyncio task not affect the caller?
basics
~20 sasyncio.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.
Why does a value set on a threading.local() inside one ThreadPoolExecutor task reappear in a later task?
basics
~20 sBecause 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.
What does contextvars.ContextVar.get() do if the variable was never set in the current context?
basics
~20 sContextVar.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.
Why does a module-level global lose the right value when two asyncio tasks interleave?
basics
~20 sOne 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.
Is `list.append` on a shared list still safe without a lock in free-threaded CPython 3.14?
basics
~10 sYes. 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.
Why does `totals[key] = totals[key] + 1` race across threads when each dict operation is indivisible?
basics
~20 sThat 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.
How does `concurrent.futures.InterpreterPoolExecutor` differ from `ThreadPoolExecutor` for CPU-bound work?
basics
~20 sBoth 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.
Your service runs python3.14t but sys._is_gil_enabled() returns True — why?
basics
~20 sEither 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.
How do you check whether CPython is running the free-threaded build?
basics
~10 sCall 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.