Concurrency (asyncio/threading/multiprocessing)

15 questions found

What is the fundamental difference between concurrency and parallelism, and how does this map to Python's threading versus multiprocessing modules?

Beginner
Concurrency means multiple tasks make PROGRESS over overlapping time periods (not necessarily simultaneously); parallelism means tasks run LITERALLY AT THE SAME INSTANT on different CPU cores. Python's threading module provides concurrency (constrained by the GIL for CPU-bound work), while multiprocessing achieves TRUE parallelism by using separate OS processes, each with its own Python interpreter and GIL.
# threading: concurrent, but GIL-limited for CPU-bound work
import threading
threading.Thread(target=cpu_bound_task).start()

# multiprocessing: true parallelism, separate processes
import multiprocessing
multiprocessing.Process(target=cpu_bound_task).start()
Real-world example Choosing multiprocessing for a CPU-heavy image-processing batch job to actually utilize multiple CPU cores.

Common follow-ups: Why doesn't the GIL limitation apply to multiprocessing the same way it does to threading?

The Global Interpreter Lock (GIL)

How do you create and run a basic thread using the threading module?

Beginner
Create a threading.Thread object, passing the target function (and optional args), then call .start() to begin execution concurrently, and .join() to wait for it to finish before continuing.
import threading

def greet(name):
    print(f"Hello, {name}!")

thread = threading.Thread(target=greet, args=("Sam",))
thread.start()
thread.join()  # waits for the thread to finish
Real-world example Running a background task (like a periodic health check) concurrently with the main program's execution.

Common follow-ups: What happens to the main program if it exits before a non-daemon thread finishes?

Functions & Scope

What is the basic pattern for running an async function using asyncio.run()?

Beginner
Define your entry-point coroutine with 'async def', then call asyncio.run(coroutine()) from regular (synchronous) code — this creates a new event loop, runs the coroutine to completion, and closes the loop, serving as the standard top-level entry point for an asyncio program.
import asyncio

async def main():
    print("Start")
    await asyncio.sleep(1)
    print("End")

asyncio.run(main())
Real-world example Running an async web scraper or API client script as the top-level entry point of a standalone Python program.

Common follow-ups: Why should asyncio.run() typically only be called ONCE, at the very top level of a program?

Async Generators & Async Context Managers

How does the Global Interpreter Lock (GIL) affect CPU-bound versus I/O-bound multithreaded programs differently?

Intermediate
The GIL ensures only ONE thread executes Python bytecode at any given instant, so CPU-bound multithreaded code gets NO real speedup (and can even be slightly slower due to lock overhead); I/O-bound code, however, RELEASES the GIL during blocking operations (like network calls or file reads), so multiple threads CAN make real concurrent progress waiting on I/O.
# CPU-bound: threading gives little to no benefit due to the GIL
def cpu_work(): sum(i * i for i in range(10_000_000))

# I/O-bound: threading helps, since the GIL is released during network waits
def io_work(): requests.get("https://example.com")  # GIL released while waiting
Real-world example Choosing threading for a multi-URL web scraper (I/O-bound) but multiprocessing for a CPU-heavy data transformation job.

Common follow-ups: How does asyncio compare to threading specifically for I/O-bound concurrency, in terms of overhead?

The Global Interpreter Lock (GIL)

How do you run multiple coroutines concurrently using asyncio.gather()?

Intermediate
asyncio.gather(*coroutines) schedules all given coroutines to run CONCURRENTLY on the event loop and returns a single awaitable that resolves once ALL of them complete, collecting their results in a list matching the input order — much faster than awaiting each one sequentially when they're independent.
async def fetch(url):
    await asyncio.sleep(1)  # simulated network call
    return f"data from {url}"

async def main():
    results = await asyncio.gather(fetch("a"), fetch("b"), fetch("c"))
    print(results)  # all three run concurrently, total time ~1s, not 3s
Real-world example Fetching data from three independent API endpoints concurrently instead of sequentially, cutting total wait time significantly.

Common follow-ups: What happens to the other still-running coroutines if ONE of the gathered coroutines raises an exception?

Async Generators & Async Context Managers

How do you protect shared mutable state from race conditions when using multiple threads, using threading.Lock?

Intermediate
Wrap the critical section (code that reads-then-writes shared state) in 'with lock:' — this ensures only ONE thread can execute that block at a time, preventing the classic race condition where two threads interleave their read/modify/write operations and lose an update.
import threading

counter = 0
lock = threading.Lock()

def increment():
    global counter
    with lock:
        counter += 1  # protected: no race condition between threads

threads = [threading.Thread(target=increment) for _ in range(1000)]
for t in threads: t.start()
for t in threads: t.join()
print(counter)  # reliably 1000, not less due to lost updates
Real-world example Safely incrementing a shared counter or updating a shared cache from multiple worker threads without data corruption.

Common follow-ups: What would happen to the final counter value if the lock were removed entirely, and why?

Memory Management & Garbage Collection

How does multiprocessing.Pool let you parallelize a CPU-bound function across multiple worker processes?

Intermediate
Pool.map() (or apply_async() for more control) distributes calls of a function across a pool of worker PROCESSES (each with its own Python interpreter, bypassing the GIL entirely), running them in true parallel on separate CPU cores, and collects the results back in the main process.
from multiprocessing import Pool

def square(n):
    return n * n

if __name__ == "__main__":
    with Pool(processes=4) as pool:
        results = pool.map(square, range(10))
    print(results)  # [0, 1, 4, 9, ..., 81], computed across 4 parallel processes
Real-world example Parallelizing a CPU-intensive batch computation (like image resizing or numerical simulation) across all available CPU cores.

Common follow-ups: Why must code using multiprocessing.Pool typically be guarded by 'if __name__ == "__main__":' on Windows?

The Global Interpreter Lock (GIL)

How does asyncio's event loop schedule and switch between multiple coroutines, and what makes this cooperative rather than preemptive?

Advanced
The event loop maintains a queue of ready-to-run tasks; a coroutine voluntarily YIELDS control back to the loop only at an 'await' point (specifically when awaiting something not yet complete) — this is COOPERATIVE multitasking, since a coroutine that never awaits can block the entire event loop, unlike preemptive OS thread scheduling which can interrupt a thread at any instruction.
async def hog_the_loop():
    total = 0
    for i in range(100_000_000):  # no await inside -- blocks the ENTIRE event loop
        total += i
    return total
# Every other coroutine is frozen until this finishes, since it never yields control
Real-world example Understanding why a synchronous, CPU-heavy computation accidentally placed inside an async function can freeze an entire asyncio application.

Common follow-ups: How would you fix a CPU-bound blocking operation inside an async function without freezing the whole event loop?

Async Generators & Async Context Managers

How do you safely run a blocking, synchronous function from within an asyncio coroutine without freezing the event loop?

Advanced
Use loop.run_in_executor() (or the higher-level asyncio.to_thread() in modern Python) to offload the blocking call to a separate thread pool, awaiting its completion WITHOUT blocking the event loop itself — appropriate for blocking I/O calls or moderate CPU work that can't be rewritten as native async code.
import asyncio

def blocking_io():
    import time; time.sleep(2)  # simulates a blocking call
    return "done"

async def main():
    result = await asyncio.to_thread(blocking_io)  # runs in a thread, doesn't block the event loop
    print(result)
Real-world example Calling a legacy synchronous database driver or third-party library from inside an otherwise fully async application.

Common follow-ups: When would you use a ProcessPoolExecutor instead of a ThreadPoolExecutor for offloading work from asyncio?

Async Generators & Async Context Managers

How do you share data between processes using multiprocessing, given that separate processes don't share memory the way threads do?

Advanced
Use multiprocessing's dedicated inter-process communication mechanisms: a Queue or Pipe for passing data/messages between processes, or shared memory constructs like Value/Array (or multiprocessing.shared_memory for larger data) for genuinely SHARED, synchronized state — you can't just share a regular Python object directly, since each process has its own independent memory space.
from multiprocessing import Process, Queue

def worker(q):
    q.put("result from worker")

if __name__ == "__main__":
    q = Queue()
    p = Process(target=worker, args=(q,))
    p.start()
    p.join()
    print(q.get())  # "result from worker" -- data explicitly passed via the Queue
Real-world example Collecting results from multiple parallel worker processes back into the main process for aggregation.

Common follow-ups: Why is a regular Python list or dict NOT automatically shared between multiprocessing.Process instances the way it would be between threads?

Data Types & Structures

Showing 1–10 of 15