Python asyncio vs multiprocessing: When to Use Each
python asyncio vs multiprocessing: Understand when Python asyncio fits I/O-bound concurrency and when multiprocessing fits CPU-bound parallelism, with practical code e...
The decision between python asyncio vs multiprocessing comes down to what is blocking your program. asyncio handles many tasks that wait on I/O — network responses, file reads, database queries — by interleaving them on a single thread. multiprocessing runs separate Python interpreters in parallel, which lets CPU-bound code actually use multiple cores.
The two tools solve different problems. Choosing the wrong one usually produces either a program that still blocks on CPU work or a program that pays process overhead for work that never needed it.
What the Choice Actually Depends On
The dominant question is whether your workload is I/O-bound or CPU-bound. I/O-bound work spends most of its time waiting for an external resource to respond. CPU-bound work spends most of its time executing Python bytecode. asyncio excels at the former because it schedules other coroutines while one waits. multiprocessing excels at the latter because each worker process runs its own interpreter on its own core.
A secondary question is whether your libraries support async interfaces. If every dependency exposes awaitable functions, asyncio is straightforward. If a library performs blocking I/O internally, calling it from a coroutine stalls the entire event loop, and you need a thread or process executor to escape that.
How asyncio Executes Work
asyncio runs an event loop on one thread. Your coroutines yield control at await points, and the loop schedules other coroutines while the awaited operation is in progress. This works well when the operation is handled by the operating system or a C library — socket reads, subprocess output, or file I/O through the appropriate async wrappers.
The key constraint is that asyncio does not make Python code run faster. A coroutine that performs a long loop without awaiting anything will block the entire event loop, including every other coroutine scheduled on it.
import asyncio async def wait_on_io(name: str, delay: float) -> str: await asyncio.sleep(delay) return f"{name} finished" async def main() -> None: tasks = [wait_on_io("a", 0.2), wait_on_io("b", 0.1)] results = await asyncio.gather(*tasks) print(results) asyncio.run(main())
asyncio.sleep is an awaitable that yields control, so both tasks progress concurrently. The total wall time is roughly the longest delay, not the sum of both delays. Replace sleep with a real database query or HTTP request and the same interleaving applies, provided you use an async driver that releases the event loop while waiting.
How multiprocessing Executes Work
multiprocessing starts child processes, each with its own Python interpreter and memory space. Because each process has its own GIL, CPU-bound Python code in different processes can run simultaneously on different cores.
from multiprocessing import Pool def dense_compute(n: int) -> int: total = 0 for i in range(n): total += i * i return total if __name__ == "__main__": with Pool(4) as pool: results = pool.map(dense_compute, [5_000_000] * 4) print(results)
The if __name__ == "__main__" guard is required on platforms that use the spawn start method, which includes Windows and macOS by default in modern Python. Without it, child processes attempt to re-import the module and can recursively start new processes.
Data passed to workers is pickled and sent through a pipe or queue. Return values are pickled and sent back. This serialization cost is real and grows with the size of the data, so multiprocessing is most effective when the input and output are small relative to the computation.
The GIL and Why It Drives the Decision
The global interpreter lock serializes bytecode execution within a single process. Threads in Python cannot run Python bytecode in parallel, which is why threading is not a general solution for CPU-bound work.
asyncio operates within this same constraint: one thread, one GIL, one coroutine executing at a time. It is a scheduling mechanism, not a parallelism mechanism.
multiprocessing bypasses the GIL by giving each worker its own interpreter. That is the only reason it helps CPU-bound Python code. If your work spends most of its time inside a C extension that releases the GIL — such as NumPy operations or compression libraries — a thread pool may already provide parallelism without process overhead. But for pure Python loops and computations, multiprocessing is the standard answer.
I/O-Bound Work: asyncio in Practice
The clearest win for asyncio is a program that makes many network requests or database calls. Each request spends most of its time waiting on the network, and the event loop uses that waiting time to start or advance other requests.
import asyncio async def fetch_one(url: str) -> tuple[str, int]: # Use an async HTTP client such as aiohttp or httpx in real code. await asyncio.sleep(0.05) return url, 200 async def fetch_many(urls: list[str]) -> list[tuple[str, int]]: return await asyncio.gather(*(fetch_one(url) for url in urls)) asyncio.run(fetch_many([f"https://example.com/{i}" for i in range(50)]))
The same program written with multiprocessing would create dozens of processes, each spending most of its time blocked on I/O. Process startup and pickling overhead would dominate, and the result would be slower and more memory-hungry than the single-threaded event loop.
The practical limit of asyncio is that every dependency must expose an async interface. If a library performs blocking I/O internally, calling it from a coroutine blocks the loop. Wrapping it in loop.run_in_executor moves the blocking call to a thread pool, but then you are paying thread overhead and should measure whether that is actually faster than a simpler threaded design.
CPU-Bound Work: multiprocessing in Practice
For pure Python computation, multiprocessing is the direct answer. The typical pattern is a Pool with a fixed number of workers, chosen to match the number of cores you want to consume.
from multiprocessing import Pool import os def transform_row(row: int) -> int: # Simulated CPU-heavy transformation. result = row for _ in range(1_000): result = (result * 31 + 7) % 1_000_000_007 return result if __name__ == "__main__": data = list(range(10_000)) workers = os.cpu_count() or 1 with Pool(workers) as pool: transformed = pool.map(transform_row, data)
pool.map preserves input order in the result list, which keeps the code predictable. pool.imap returns results as they arrive and can be useful when you want to process early results before the whole batch completes.
One common mistake is creating a new Pool inside a loop for each batch of work. Pool creation forks or spawns processes and is expensive. Create the pool once and reuse it, or use Pool as a context manager around the whole batch.
Memory, Startup, and Operational Overhead
multiprocessing pays a fixed cost per worker: interpreter startup, memory space, and pickling of arguments and results. Each worker is a full Python interpreter with its own memory space, so a pool of workers consumes substantially more memory than a single-threaded program.
asyncio has essentially no per-task overhead beyond coroutine objects. A program with tens of thousands of pending tasks is normal. The cost is that everything runs on one core, and a single blocking call stalls the entire loop.
This tradeoff matters in production. A web service handling many concurrent requests is usually I/O-bound and benefits from asyncio or from a threaded server. A batch job that crunches data across cores needs multiprocessing. Mixing the two is sometimes necessary, and the next section shows a safe way to do it.
Combining asyncio with Process Pools
A common production pattern is an asyncio application that offloads CPU-bound work to a process pool. This keeps the event loop responsive while still using multiple cores.
import asyncio from concurrent.futures import ProcessPoolExecutor def cpu_heavy(n: int) -> int: total = 0 for i in range(n): total += i * i return total async def main() -> None: loop = asyncio.get_running_loop() with ProcessPoolExecutor(max_workers=4) as pool: results = await asyncio.gather( *(loop.run_in_executor(pool, cpu_heavy, 2_000_000) for _ in range(8)) ) print(results) asyncio.run(main())
loop.run_in_executor submits the callable to the executor and returns an awaitable that completes when the worker finishes. The event loop stays free to handle other coroutines while the process pool runs the CPU-bound work.
The same executor can be shared across the whole application instead of being created per request. Creating a ProcessPoolExecutor on every request is expensive and can exhaust file descriptors under load. In a long-running service, create the executor once at startup and shut it down during application teardown.
Choosing by Scenario
Use asyncio when the work is dominated by waiting on I/O: HTTP APIs, database queries, message queues, or file streams, and when your libraries expose async interfaces. Use multiprocessing when the work is dominated by Python bytecode execution that must use multiple cores.
A mixed workload — many concurrent requests that each contain a CPU-heavy section — is best served by combining the two, with asyncio managing concurrency and a process pool handling the computation. The exact number of processes should be tuned against your workload and available memory, not set to os.cpu_count() blindly, because each worker competes for memory and the GIL is irrelevant across processes.
When the CPU-bound section is small relative to the I/O, the pickling and process startup cost can exceed the parallelism gain. Measure with your real data before committing to a process pool for every request.