Concurrency vs Parallelism in Python
Think of concurrency as juggling multiple tasks with one hand (single-core, switching between tasks), and parallelism as juggling multiple tasks with multiple hands (multi-core, truly simultaneous execution). asyncio is for the former, multiprocessing for the latter.
The Setup
A developer configures an asynchronous web scraper using asyncio to fetch thousands of web pages while concurrently parsing complex HTML trees, but notices the overall throughput is identical to a single-threaded loop.
What Does This Print?
import asyncio
import time
async def cpu_bound_parse(page_id):
# Simulating parsing CPU-heavy operations
end = time.perf_counter() + 0.1
while time.perf_counter() < end:
pass # Tight loop representing parsing
return f"Parsed {page_id}"
async def main():
start = time.perf_counter()
# Attempting concurrent execution of CPU-bound tasks using asyncio
tasks = [cpu_bound_parse(i) for i in range(10)]
results = await asyncio.gather(*tasks)
print(f"Completed in {time.perf_counter() - start:.2f}s")
asyncio.run(main())
The Output
The code runs entirely sequentially. Even though asyncio.gather was used to manage multiple coroutines concurrently, the execution is single-threaded. Because the cpu_bound_parse function contains a busy loop that does not yield control back to the event loop (using await), each coroutine runs to completion before the next one starts. Concurrency allows tasks to interleave, but it cannot parallelize execution across CPU cores when tasks block the thread.
Why Python Does This
Python's asyncio framework implements cooperative multitasking. It runs on a single thread utilizing an event loop. When a coroutine executes, it monopolizes the CPU until it explicitly yields control back to the loop via an await statement on an awaitable object (like an I/O read, a socket write, or asyncio.sleep). If a coroutine performs CPU-bound computation or synchronous I/O, it denies other tasks execution time. True parallelism requires preemptive multitasking across multiple OS processes, which maps tasks to separate physical CPU cores, entirely bypassing CPython's single-thread event loop execution model.
The Fix
import asyncio
import time
from concurrent.futures import ProcessPoolExecutor
def cpu_bound_parse(page_id):
# Must be a regular def function to be run in a process pool
end = time.perf_counter() + 0.1
while time.perf_counter() < end:
pass
return f"Parsed {page_id}"
async def main():
start = time.perf_counter()
loop = asyncio.get_running_loop()
# Run CPU-bound tasks in parallel processes using ProcessPoolExecutor
with ProcessPoolExecutor() as pool:
tasks = [
loop.run_in_executor(pool, cpu_bound_parse, i)
for i in range(10)
]
results = await asyncio.gather(*tasks)
print(f"Completed in {time.perf_counter() - start:.2f}s")
if __name__ == "__main__":
asyncio.run(main())
asyncio is designed for I/O-bound tasks where functions await external operations, allowing the event loop to switch to other tasks. For CPU-bound tasks, multiprocessing spawns separate processes, each with its own interpreter, enabling true parallel execution across multiple cores.
How This Fails in Real Systems
An analytics platform used asyncio to aggregate metrics from external APIs and then calculate statistical regressions. The developers assumed asyncio.gather would parallelize the calculation step. Under high load, the web service timed out on API health checks because the single-threaded event loop was completely blocked performing regression math. This was discovered during a load test prior to a major release, and fixed by offloading the regression calculations to a process pool.
Key Takeaway
asyncio for CPU-bound operations, expecting performance gains that only multiprocessing can provide.