asyncio vs threading vs multiprocessing: Which to Use
asyncio, threading or multiprocessing? Match the tool to the job: I/O-bound or CPU-bound work, the GIL, the executors, and what changed in Python 3.14.
Use asyncio or threads when your program mostly waits (network, disk, databases), and processes when it computes. Waiting can overlap on one core; computing needs more cores, and in standard CPython that means separate processes.
The decision in one table
| asyncio | threading | multiprocessing | |
|---|---|---|---|
| Runs on | One thread, switching at await | Several threads in one process | Several processes |
| Best for | Many concurrent I/O tasks, network code | I/O-bound work, blocking libraries | CPU-bound work |
| Uses multiple cores? | No | Not for Python code under the GIL | Yes |
| Memory | Shared, one thread | Shared | Separate; data is pickled between processes |
| Main risk | One blocking call freezes everything | Shared-state bugs | Startup cost, pickling limits |
| High-level API | asyncio.run, TaskGroup, to_thread | ThreadPoolExecutor | Pool, ProcessPoolExecutor |
The GIL in one paragraph
In standard CPython, the Global Interpreter Lock means only one thread executes Python code at a time. Threads still help when they spend their time waiting, because a waiting thread lets another run. They do not speed up pure-Python computation. As of Python 3.13, free-threaded builds can disable the GIL, but that is not the default; see free-threaded Python explained.
asyncio: many waits on one thread
The Python documentation describes asyncio as a library for writing concurrent code with async/await, and as often a perfect fit for I/O-bound and high-level structured network code.
import asyncio
async def main():
print("Hello ...")
await asyncio.sleep(1)
print("... World!")
asyncio.run(main())
Tasks take turns, switching only at await. That makes it cheap to run thousands of connections, and it is why async web frameworks use it. The cost is that every library in the path must be async-aware: one blocking call, such as time.sleep() or a synchronous HTTP request, freezes every other task.
For blocking code you cannot rewrite, run it in a thread without stopping the event loop:
result = await asyncio.to_thread(blocking_function, arg)
See Python async/await explained for the basics.
threading: simple overlap of blocking I/O
The threading documentation says threads are particularly useful when tasks are I/O bound, such as file operations or network requests, where much of the time is spent waiting. Its example starts one thread per link so the waits overlap.
For most work, use the higher-level ThreadPoolExecutor instead of creating threads yourself:
from concurrent.futures import ThreadPoolExecutor
with ThreadPoolExecutor(max_workers=5) as executor:
results = list(executor.map(fetch, urls))
Things to know from the concurrent.futures documentation:
- The default
max_workersismin(32, (os.process_cpu_count() or 1) + 4)(3.13 and later), which keeps at least five workers for I/O-bound tasks. - A callable that waits on another future in the same executor can deadlock, for example with
max_workers=1. - All queued work is joined before the interpreter exits, so the docs recommend not using it for long-running tasks.
Threads share memory, which is convenient and dangerous: anything two threads modify needs a lock or a thread-safe structure such as queue.Queue.
multiprocessing: real parallelism for CPU-bound work
The multiprocessing module starts separate processes with an API similar to threading. Because it uses subprocesses, it sidesteps the GIL and can use multiple processors. The Pool example from the docs:
from multiprocessing import Pool
def f(x):
return x * x
if __name__ == '__main__':
with Pool(5) as p:
print(p.map(f, [1, 2, 3]))
The executor version is usually nicer to write, and the same code can switch between threads and processes by changing one class:
from concurrent.futures import ProcessPoolExecutor
def main():
with ProcessPoolExecutor() as executor:
results = list(executor.map(is_prime, numbers))
if __name__ == '__main__':
main()
Rules that catch people out:
- Guard the entry point with
if __name__ == '__main__':. The docs require that the__main__module be importable by the children. - It does not work in the interactive interpreter. A function defined in a REPL, or a lambda, should not be expected to work.
- Only picklable objects can be sent to workers and returned.
- Processes cost more to start than threads, and each has its own memory, so passing large objects around is expensive.
- Never call executor methods from inside a worker submitted to a
ProcessPoolExecutor; the docs warn that it deadlocks.
What changed in Python 3.14
The multiprocessing start method decides how child processes are created:
| Method | What it does | Default |
|---|---|---|
spawn | Starts a fresh interpreter | Windows and macOS |
fork | Copies the parent with os.fork() | No longer a default on any platform since 3.14 |
forkserver | Asks a server process to start children | Other POSIX platforms such as Linux, since 3.14 |
The documentation notes that safely forking a multithreaded process is problematic, which is why fork stopped being the default. If your code depends on forked children inheriting the parent’s state, request it explicitly with multiprocessing.get_context("fork"), or better, pass what the child needs as arguments. The Python 3.12 end of life guide covers the other changes to expect when upgrading.
Combining them
Real programs often mix models:
- An async web app that offloads blocking calls with
asyncio.to_threadand heavy computation to a process pool. - A process pool where each worker uses threads or asyncio internally for its own I/O.
- A task queue such as Celery, RQ or Dramatiq when work should outlive the request; see Celery vs RQ vs Dramatiq.
Which should you pick?
- Is the program waiting on the network or disk? Use asyncio if your libraries support it, otherwise a
ThreadPoolExecutor. - Is it burning CPU in Python code? Use a
ProcessPoolExecutor. - Is the heavy work inside NumPy, a database or another C library? Those often release the GIL while they run, so threads may help. Measure before deciding.
- Not sure? Start with an executor, since you can switch between thread and process pools by changing the class, and profile with your real data.
Frequently Asked Questions
What is the difference between asyncio, threading and multiprocessing?
asyncio runs many tasks on one thread by switching at await points. threading runs several threads inside one process, which share memory. multiprocessing runs separate processes, each with its own interpreter and memory, so it can use several CPU cores.
What is the difference between Python async and threading?
async code (asyncio) runs many tasks on a single thread and switches between them only at await points, so your code has to be written with async and await. threading runs several operating system threads inside one process, which share memory and can run ordinary blocking code. Both overlap time spent waiting on the network or disk, and neither makes CPU-bound Python faster under the GIL.
Which should I use for I/O-bound work?
asyncio or threading. Both overlap time spent waiting on the network or disk. The Python documentation says asyncio is a good fit for I/O-bound and structured network code, and that threads are particularly useful when tasks are I/O bound.
Which should I use for CPU-bound work?
multiprocessing, or concurrent.futures.ProcessPoolExecutor. Under the GIL only one thread runs Python code at a time, so threads do not speed up CPU-bound Python. The threading documentation recommends processes for using multiple cores.
What is the GIL?
The Global Interpreter Lock, which in a standard CPython build lets only one thread execute Python code at a time. Free-threaded builds, available since Python 3.13, can disable it, but they are not the default.
Does asyncio use multiple CPU cores?
No. asyncio runs on a single thread, so it overlaps waiting but not computation. To keep the event loop responsive around blocking or CPU-heavy code, hand that work to a thread with asyncio.to_thread or to a process pool.
What changed for multiprocessing in Python 3.14?
The default start method on Linux and other POSIX systems other than macOS changed from fork to forkserver, and fork is no longer the default on any platform. Code that relied on forked children inheriting the parent’s state needs an explicit get_context(‘fork’) or a redesign.