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

asynciothreadingmultiprocessing
Runs onOne thread, switching at awaitSeveral threads in one processSeveral processes
Best forMany concurrent I/O tasks, network codeI/O-bound work, blocking librariesCPU-bound work
Uses multiple cores?NoNot for Python code under the GILYes
MemoryShared, one threadSharedSeparate; data is pickled between processes
Main riskOne blocking call freezes everythingShared-state bugsStartup cost, pickling limits
High-level APIasyncio.run, TaskGroup, to_threadThreadPoolExecutorPool, 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_workers is min(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:

MethodWhat it doesDefault
spawnStarts a fresh interpreterWindows and macOS
forkCopies the parent with os.fork()No longer a default on any platform since 3.14
forkserverAsks a server process to start childrenOther 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_thread and 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?

  1. Is the program waiting on the network or disk? Use asyncio if your libraries support it, otherwise a ThreadPoolExecutor.
  2. Is it burning CPU in Python code? Use a ProcessPoolExecutor.
  3. 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.
  4. 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.