What is the difference between asyncio, threading, and multiprocessing in Python?
Python offers three concurrency models, each suited to a different problem class. Choosing wrong is a common source of performance bugs.
**asyncio** is cooperative, single-threaded concurrency built on an event loop and coroutines. A coroutine yields control at `await` expressions, allowing the event loop to run other coroutines while it waits. Because everything runs on one thread, there is no lock contention or thread-safety concern for pure Python data structures. asyncio is ideal for I/O-bound workloads with many concurrent operations—HTTP clients, database drivers, WebSocket servers. The concurrency limit scales to thousands of coroutines with minimal overhead. The constraint: all code in the event loop must be non-blocking. A single blocking call (e.g., `time.sleep`, a synchronous file read) freezes the entire event loop. Use `asyncio.to_thread()` to offload blocking calls.
**threading** creates OS-level threads managed by the Python runtime. Threads share memory space, making data sharing easy but requiring locks for correctness. Python's GIL (Global Interpreter Lock) allows only one thread to execute Python bytecode at a time, so threading does not achieve CPU parallelism for Python code. Threads release the GIL during I/O and when calling C extensions, so threading does give concurrency benefits for I/O-bound workloads. It's simpler to retrofit into existing synchronous code than asyncio (just wrap blocking calls in threads). Overhead: each thread has an OS-level stack (~1–8 MB), so thousands of threads are impractical.
**multiprocessing** spawns separate OS processes, each with its own Python interpreter and GIL. This achieves true CPU parallelism—work is distributed across all available cores. The cost: inter-process communication (IPC) requires serialization (pickle), making it expensive to share large data. Process startup is heavy (~100ms). It's the correct tool for CPU-bound tasks: image processing, data transformation, ML preprocessing. `ProcessPoolExecutor` from `concurrent.futures` provides a clean high-level API.
Decision heuristic: I/O-bound with async-compatible libraries → asyncio. I/O-bound with legacy synchronous code → threading. CPU-bound → multiprocessing. For mixed workloads, combine: use asyncio for I/O and `loop.run_in_executor()` with a `ProcessPoolExecutor` for CPU-intensive tasks.
| Aspect | asyncio | threading | multiprocessing |
|---|---|---|---|
| Execution model | Single thread, event loop | OS threads, shared memory | Separate processes |
| GIL impact | N/A (one thread) | Limits CPU parallelism | Bypasses GIL completely |
| I/O-bound performance | Excellent | Good | Overhead per process |
| CPU-bound performance | Poor (blocks event loop) | Poor (GIL) | Excellent |
| Memory overhead | Very low (coroutines) | ~1-8 MB per thread | High (full process copy) |
| Data sharing | Shared (same thread) | Shared (needs locks) | Requires IPC/pickle |
| Best use case | High-concurrency async I/O | Legacy sync I/O workloads | CPU-intensive computation |
Correctly identifies asyncio as event-loop/coroutine-based, explains GIL limitation on threading, states multiprocessing bypasses GIL for CPU parallelism, gives an appropriate use case for each.
Explains cooperative vs. preemptive scheduling, quantifies overhead differences, explains asyncio.to_thread() for blocking calls in event loops, describes run_in_executor() for mixing models, and gives a nuanced recommendation for mixed workloads.
Reading the answer is step one. Explaining it unprompted — under interview pressure — is what actually matters. Get AI-graded feedback on your answer with follow-up probes on your weak points.
Get Graded — Free Assessment