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, and asyncio. 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. The concurrent.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 an await 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()