Threads, Processes or Async

One lock, three answers: async for waiting, processes for computing, threads for blocking code you cannot change.

Syntaxresult = 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

WorkToolWhy
Many network or disk waitsasyncioThousands of cheap tasks, one thread, no lock contention
CPU-bound: parsing, hashing, compressionProcessPoolExecutorSeparate interpreters, real cores; pickling cost on both ends
A blocking library with no async APIasyncio.to_thread / ThreadPoolExecutorThe 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 thing

to_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

Try it yourself
Loading editor…
Press Run to execute the code.

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.

Example · python
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.

Example · python
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.

Example · python
# 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

  • Be the first to comment on this lesson.