Python Concurrency: Threads vs. asyncio vs. Multiprocessing¶
Choosing a concurrency model is an engineering decision, not a syntax preference. The right choice depends on where time is spent, how work is coordinated, and what isolation or parallelism the workload needs.
1. Concurrency vs. parallelism¶
- Concurrency means multiple tasks can make progress during overlapping periods. Execution may be interleaved.
- Parallelism means multiple tasks execute at the same instant, typically on different CPU cores.
A program can be concurrent without executing Python code on multiple cores.
2. Compare the models¶
| Model | Execution unit | Good fit | Main costs |
|---|---|---|---|
| threading | OS threads in one process | Blocking I/O and synchronous libraries | Shared-state synchronization and thread overhead |
| asyncio | Tasks scheduled by an event loop | Many I/O operations using async-compatible libraries | Cooperative scheduling; blocking calls stall the loop |
| multiprocessing | Separate OS processes | CPU-heavy Python work and process isolation | Startup, memory, and inter-process communication |
A useful starting rule:
- Use asyncio when the call path is async-compatible and involves substantial I/O waiting.
- Use threads to overlap blocking I/O or integrate synchronous libraries.
- Use processes for CPU-heavy Python work or stronger process isolation.
Measure the real workload before assuming one model is faster.
3. Threads: overlapping blocking I/O¶
Threads share one process's memory. While one thread waits for a network response or file operation, another thread can make progress.
In the standard GIL-enabled CPython build, the Global Interpreter Lock generally prevents multiple threads from executing Python bytecode simultaneously. This limits CPU-bound scaling with threads, but does not make threads useless: they remain useful for I/O-bound work.
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
def read_file(path: Path) -> tuple[str, int]:
data = path.read_bytes()
return path.name, len(data)
def main() -> None:
paths = list(Path("data").glob("*.bin"))
with ThreadPoolExecutor(max_workers=8) as executor:
for name, size in executor.map(read_file, paths):
print(f"{name}: {size} bytes")
if __name__ == "__main__":
main()
The worker count should respect downstream limits. Too many workers can overload a database, remote API, file descriptor limit, or connection pool.
Interview questions¶
Do Python threads run in parallel? It depends on the Python implementation and build. In standard GIL-enabled CPython, only one thread at a time generally executes Python bytecode. Threads can overlap I/O waits. CPython also has optional free-threaded builds beginning with Python 3.13; these are not the default deployment.
What is a race condition? A race condition occurs when correctness depends on the timing or interleaving of concurrent operations. Protect shared mutable state with appropriate synchronization, or avoid sharing mutable state.
Lock vs. RLock? A Lock is a basic mutual-exclusion primitive. An RLock can be acquired multiple times by the same thread; the owning thread must release it the same number of times.
4. asyncio: cooperative I/O concurrency¶
asyncio runs coroutines on an event loop. A coroutine gives control back when it awaits an operation that cannot complete immediately, allowing another task to run.
import asyncio
async def fetch_record(record_id: int) -> dict[str, int]:
# Simulate an asynchronous I/O wait.
await asyncio.sleep(0.2)
return {"id": record_id}
async def main() -> None:
results = await asyncio.gather(
fetch_record(101),
fetch_record(102),
fetch_record(103),
)
print(results)
if __name__ == "__main__":
asyncio.run(main())
The waits overlap. This does not make CPU-bound Python code execute simultaneously across multiple cores.
The blocking-call trap¶
Calling a blocking function directly inside an async function blocks the event-loop thread. Other tasks on that loop cannot make progress until it returns. Avoid calling time.sleep() in an async request handler; use await asyncio.sleep() for an asynchronous delay. For unavoidable blocking I/O, consider asyncio.to_thread().
Does async def run a function in the background? No. Calling an async function creates a coroutine object. It runs when awaited or scheduled as a task.
What if a coroutine performs CPU-heavy work without yielding? It monopolizes the event-loop thread until it yields or returns. Other tasks on that loop are delayed.
Is asyncio always faster than threads? No. It can reduce per-task overhead for many concurrent I/O operations, but it requires async-compatible dependencies and careful handling of blocking work.
5. Multiprocessing: separate processes¶
Multiprocessing uses separate processes. In CPython, this can run Python code on multiple CPU cores without threads contending for one process's GIL. Processes have separate address spaces, improving isolation but making data exchange more expensive.
from concurrent.futures import ProcessPoolExecutor
def sum_of_squares(limit: int) -> int:
return sum(number * number for number in range(limit))
def main() -> None:
limits = [1_000_000, 1_200_000, 1_400_000, 1_600_000]
with ProcessPoolExecutor() as executor:
results = list(executor.map(sum_of_squares, limits))
print(results)
if __name__ == "__main__":
main()
The main-module guard is important for portable process startup, particularly with start methods that import the main module in child processes.
Trade-offs include serialization and transfer costs, memory overhead, process startup, coordination, and explicit shared-state mechanisms.
6. Backend engineering scenarios¶
| Scenario | Starting point | Reason |
|---|---|---|
| Many HTTP calls using an async client | asyncio | Async network waits can overlap |
| Blocking SDK integration | Threads | Keeps blocking work off the event-loop thread |
| CPU-heavy image transformation in Python | Processes | Can use multiple CPU cores |
| Synchronous database driver | Threads or synchronous execution | The driver blocks its caller |
| Many long-lived network connections | asyncio or async framework | Efficient I/O multiplexing when the stack supports it |
These are starting points, not universal rules. A database connection pool may impose a lower concurrency limit than the application worker pool.
7. Production concerns¶
- Bound concurrency. Unbounded task or thread creation can exhaust memory, sockets, file descriptors, or downstream capacity.
- Set timeouts. A task waiting forever can consume capacity and delay shutdown.
- Propagate cancellation. Async tasks and worker jobs need clear cancellation and cleanup behavior.
- Handle exceptions. Collect and report worker failures; do not silently discard them.
- Protect shared state. Threads can expose race conditions; processes need explicit coordination.
- Respect downstream limits. More concurrent requests can reduce reliability rather than improve throughput.
- Measure end-to-end behavior. Track latency, throughput, CPU, memory, queue depth, and error rates.
8. Interview questions for revision¶
A service makes 500 independent HTTP requests. Which model would you evaluate first? If the HTTP client and call path support async I/O, evaluate asyncio first. Add a concurrency limit to protect the remote service and local resources. Compare it with a bounded thread pool if dependencies are synchronous.
Why might adding threads not speed up CPU-bound Python code? In standard GIL-enabled CPython, threads contend for the GIL when executing Python bytecode. Scheduling and synchronization add overhead. Consider processes, native code that releases the GIL, or a compatible free-threaded build after measuring.
Can an asyncio application use threads? Yes. Async applications can offload blocking functions to threads, for example with asyncio.to_thread(). This keeps blocking calls from stalling the event loop.
Why is a process pool not always best? Processes add startup, memory, serialization, and coordination costs. For small tasks or I/O-heavy work, those costs may exceed the benefit.
What is the risk of synchronous code in an async request handler? It blocks the event-loop thread. Other tasks assigned to that loop cannot progress until the call returns, causing broad latency spikes under load.
9. Summary¶
- Threads: overlap blocking I/O and integrate synchronous libraries.
- asyncio: handle high-concurrency I/O when dependencies are async-compatible.
- Processes: handle CPU-heavy Python work or provide process isolation.
- Bound concurrency, set timeouts, handle cancellation and failures, and measure real workloads.
- Treat the GIL as a CPython implementation concern, not a universal statement about every Python implementation or build.