Threads, Processes or Async
One lock, three answers: async for waiting, processes for computing, threads for blocking code you cannot change.
result = await asyncio.to_thread(blocking_fn, *args, **kwargs)CPython executes bytecode under a global interpreter lock: one thread runs Python at a time. The lock is released around blocking IO and inside C code that opts out, so threads overlap waiting and not computing. Two threads hashing passwords take as long as one doing both; two threads reading sockets genuinely overlap. Python 3.14 ships an officially supported free-threaded build (PEP 779) with no GIL, but it is a separate interpreter build and C extensions must support it, so the default image you deploy still has the lock.
The decision
| Work | Tool | Why |
|---|---|---|
| Many network or disk waits | asyncio | Thousands of cheap tasks, one thread, no lock contention |
| CPU-bound: parsing, hashing, compression | ProcessPoolExecutor | Separate interpreters, real cores; pickling cost on both ends |
| A blocking library with no async API | asyncio.to_thread / ThreadPoolExecutor | The lock is released while it waits on IO |
to_thread
Inside async code, the shorthand and the longhand it replaces:
result = await asyncio.to_thread(blocking_fn, arg) # 3.9+
loop = asyncio.get_running_loop()
result = await loop.run_in_executor(
None, functools.partial(blocking_fn, arg)) # the same thingto_thread also copies the current contextvars context into the thread. Use run_in_executor with an explicit executor when you want your own pool — including a ProcessPoolExecutor for CPU work driven from async code. 3.14 adds concurrent.futures.InterpreterPoolExecutor, which runs tasks in separate interpreters in the same process.
Example
When to use it
- Choosing between asyncio and multiprocessing for a batch job before writing it.
- Calling a synchronous vendor SDK from inside an async web service.
- Explaining why adding threads did not speed up a CSV parser.
- Keeping an event loop responsive while a legacy database driver does its work.
More examples
CPU-bound work from an async service
The one correct way to do heavy computation in an async server: hand it to processes and await the future. Build the pool once at startup, because process spawn costs tens of milliseconds, and remember the arguments cross a pickle boundary — a 50MB payload is copied twice, which can cost more than the work itself.
import asyncio
import functools
from concurrent.futures import ProcessPoolExecutor
POOL = None
def render_thumbnail(data, width):
"""Pure CPU. Runs in a child process, so args and return value are pickled."""
...
return data[:width]
async def lifespan_startup():
global POOL
POOL = ProcessPoolExecutor(max_workers=4) # built once, not per request
async def handle(data):
loop = asyncio.get_running_loop()
# partial() because run_in_executor passes positional args only
return await loop.run_in_executor(POOL, functools.partial(render_thumbnail, data, 128))A blocking DB driver, bounded to its own pool
A dedicated executor keeps slow database calls out of the default pool that to_thread shares with everything else, and the thread_name_prefix makes them identifiable in a stack dump. Size the semaphore to the pool so backpressure appears in your code rather than as unexplained latency inside the executor queue.
import asyncio
from concurrent.futures import ThreadPoolExecutor
import psycopg2 # synchronous driver
class Db:
def __init__(self, dsn, workers=8):
self._pool = ThreadPoolExecutor(max_workers=workers, thread_name_prefix="db")
self._sem = asyncio.Semaphore(workers) # match the pool, so queueing is visible
self._dsn = dsn
def _query(self, sql, params):
with psycopg2.connect(self._dsn) as conn, conn.cursor() as cur:
cur.execute(sql, params)
return cur.fetchall()
async def query(self, sql, params=()):
loop = asyncio.get_running_loop()
async with self._sem:
return await loop.run_in_executor(self._pool, self._query, sql, params)Which one is this? A profile first
The decision table is only usable once you know which row you are in, and intuition is usually wrong. Debug mode is the cheapest way to find the specific coroutine that holds the loop: anything logged as a slow callback is doing CPU work or blocking IO where it should be awaiting.
# Decide from measurement, not from how slow it feels.
# python -X importtime app.py # startup cost
# python -m cProfile -s cumtime app.py # where wall-clock time goes
#
# cumtime concentrated in socket.recv / ssl.read / cursor.execute -> waiting -> async
# cumtime concentrated in your own functions -> computing -> processes
# A third-party sync client in the hot path -> threads, for now
import asyncio
async def probe():
loop = asyncio.get_running_loop()
# debug mode logs any callback slower than loop.slow_callback_duration (0.1s)
loop.set_debug(True)
await suspicious_handler()
asyncio.run(probe(), debug=True)
Discussion