2. Concurrency & Parallelism
Pick the right model (threads, processes, async), keep the event loop responsive, and control concurrency.
Question: What is the Python Global Interpreter Lock (GIL), and how does it affect concurrency?
Answer: The Global Interpreter Lock (GIL) is a mutex in CPython that allows only one thread to execute Python bytecode at a time within a single process. This means that multi-threaded Python code does not achieve true parallelism for CPU-bound tasks, even on multi-core hardware.
Explanation: The GIL exists primarily to simplify memory management in CPython (by making object access thread-safe) and to make it easier to write C extensions.
For I/O-bound tasks (e.g., network requests, database queries), the GIL is released by the waiting thread, allowing other threads to run. This makes
threading
effective for I/O-bound concurrency.For CPU-bound tasks (e.g., complex calculations), the GIL is a bottleneck. For these scenarios, the
multiprocessing
module is the correct choice, as it sidesteps the GIL by giving each process its own interpreter and memory.
Question: Compare and contrast
threading
,multiprocessing
, andasyncio
. When should you use each one?
Answer: threading
is for I/O-bound tasks where you manage multiple operations that spend time waiting. multiprocessing
is for CPU-bound tasks where you need to perform parallel computations on multi-core hardware. asyncio
is for high-throughput I/O-bound tasks in a single thread, using an event loop and cooperative multitasking.
Explanation:
threading
: Uses OS threads. Best for I/O-bound tasks because the GIL is released during blocking I/O calls. Memory is shared, requiring locks for synchronization. Theconcurrent.futures.ThreadPoolExecutor
provides a convenient high-level interface.from concurrent.futures import ThreadPoolExecutor def fetch_url(url): # I/O-bound operation pass with ThreadPoolExecutor(max_workers=8) as exe: exe.map(fetch_url, ["url1", "url2"])
multiprocessing
: Bypasses the GIL by creating separate processes, each with its own interpreter and memory. Ideal for CPU-bound work.from multiprocessing import Pool def fib(n: int) -> int: return n if n < 2 else fib(n-1) + fib(n-2) if __name__ == "__main__": with Pool() as p: print(p.map(fib, [30, 31, 32]))
asyncio
: Single-threaded concurrency.async/await
syntax allows the event loop to switch to another task when anawait
is encountered. Highly efficient for thousands of concurrent I/O operations.import asyncio, aiohttp async def fetch(session: aiohttp.ClientSession, url: str) -> str: async with session.get(url, timeout=10) as r: r.raise_for_status() return await r.text() async def main(urls: list[str]): async with aiohttp.ClientSession() as s: return await asyncio.gather(*(fetch(s, u) for u in urls))
Question: What is
asyncio.TaskGroup
(3.11+) and why use it?
Answer: It provides structured concurrency ensuring child tasks finish or cancel together, with aggregated errors.
import asyncio
async def main(urls: list[str]):
async with asyncio.TaskGroup() as tg:
tasks = [tg.create_task(fetch(u)) for u in urls]
Question: How do you run blocking code in an async app without freezing the loop?
Answer: Offload to a thread with asyncio.to_thread
(or a process for CPU-bound work).
import asyncio
def blocking_io(path: str) -> str:
return open(path).read()
data = await asyncio.to_thread(blocking_io, "file.txt")
Question: What is thread safety, and how can you achieve it in Python?
Answer: Thread safety is a property of code that ensures it can be executed by multiple threads concurrently without causing race conditions or data corruption. The most common way to achieve it is by using synchronization primitives like threading.Lock
.
Explanation: When multiple threads access and modify shared data, their operations can interleave in unexpected ways, leading to incorrect results. A Lock
is a simple mutex that ensures only one thread can execute a critical section of code at a time. A thread will acquire the lock before entering the critical section and release it upon exit, preventing other threads from entering until it's done. Using locks is essential for protecting shared mutable state.
import threading
class Counter:
def __init__(self):
self.value = 0
self._lock = threading.Lock()
def increment(self):
with self._lock:
# This is the critical section
self.value += 1
Question: How do you run CPU-bound tasks in an
asyncio
application without blocking the event loop?
Answer: Offload CPU-bound work to an executor via loop.run_in_executor
; prefer a ProcessPoolExecutor
to bypass the GIL for pure Python CPU tasks.
Explanation: The event loop must stay responsive; CPU-bound code will starve it. Threads still contend on the GIL for Python code, whereas processes run in parallel.
import asyncio
from concurrent.futures import ProcessPoolExecutor
def cpu_heavy(n: int) -> int:
return n if n < 2 else cpu_heavy(n-1) + cpu_heavy(n-2)
async def main():
loop = asyncio.get_running_loop()
with ProcessPoolExecutor() as pool:
result = await loop.run_in_executor(pool, cpu_heavy, 32)
print(result)
Question: What’s the status of "no GIL" (free-threaded) CPython?
Answer: As of 2025, a free-threaded (PEP 703) build is experimental and opt-in; it’s not the default interpreter. Most guidance about the GIL still applies in production.
Explanation: Library adoption is ongoing. For CPU-bound parallelism today, processes remain the safe default.
Question: How does cancellation work in
asyncio
, and how can you shield critical sections?
Answer: Cancelling a task raises asyncio.CancelledError
at the next await
. Use asyncio.shield
to protect awaited operations from external cancellation; always clean up in finally
.
Explanation: Handle cancellations promptly to keep the loop responsive. In Python 3.11+, TaskGroup
provides structured concurrency and predictable propagation.
import asyncio
async def critical_write():
await asyncio.sleep(0)
return "ok"
async def handler():
try:
return await asyncio.shield(critical_write())
except asyncio.CancelledError:
# cleanup if needed
raise
Question: How do you apply backpressure in async code to avoid overload?
Answer: Limit concurrency with asyncio.Semaphore
and constrain buffers with asyncio.Queue(maxsize=N)
so producers block when consumers lag.
Explanation: Throttling prevents memory blow-ups and protects downstream dependencies.
import asyncio
async def bounded_map(coros, limit: int = 50):
sem = asyncio.Semaphore(limit)
async def run(c):
async with sem:
return await c
return await asyncio.gather(*(run(c) for c in coros))
Question: Show a producer–consumer pattern with
asyncio.Queue
.
Answer: Use a bounded queue and sentinel(s) to signal completion; call task_done()
and join()
.
Explanation: Producers await queue.put(item)
; consumers item = await queue.get()
.
import asyncio
async def producer(q: asyncio.Queue[int], n: int):
for i in range(n):
await q.put(i)
await q.put(None)
async def consumer(q: asyncio.Queue[int]):
while True:
item = await q.get()
if item is None:
q.task_done()
break
# process item
q.task_done()