Backend / Async & concurrency / 07_asyncio_vs_threads.md

asyncio and threads

Updated 5 interview angles 9 min read source
On this page9
  1. The mechanism: who decides to switch
  2. The consequences
  3. The part people underrate: colour
  4. Bridging the two
  5. Where it breaks in production
  6. The trade not taken
  7. Summary of the trade
  8. Related
  9. Interview angle

asyncio and threads

Article.

Both give you concurrency, neither gives you parallelism for Python bytecode, and the choice between them is almost never about speed. It is about where the switch happens — and everything else, the memory numbers, the locking, the way failures behave, follows from that one difference.

Which model a given workload wants is CPU-bound versus I/O-bound. This note is the two of them side by side.

The mechanism: who decides to switch

python
# threads: the interpreter can switch between ANY two bytecodes
def worker():
    total = 0
    for i in items:          # a switch can happen here,
        total += weigh(i)    # or here, or mid-expression
    return total

# asyncio: control moves only where you wrote `await`
async def worker():
    total = 0
    for i in items:
        total += await weigh(i)   # ← the only switch point
    return total

Threads are preemptive: the scheduler takes the thread away whether it is ready or not. Coroutines are cooperative: a task holds the thread until it yields, and nothing can take it.

“Any two bytecodes” is the right safety rule, but on the GIL build CPython actually hands over only at a blocking call, or after another thread has waited the 5 ms switch interval, at the next safe point in the loop. That is why a race can pass every test on the GIL build and fail constantly on the free-threaded one (The GIL, and free-threaded Python).

That is the entire difference. The rest of this note is consequences.

The consequences

Memory, and therefore scale

A thread carries an OS stack: megabytes of reserved address space, of which it touches tens of kilobytes. A coroutine is an object on the heap of about 200 bytes plus what its frame holds. Measured on CPython 3.14.5 (Windows):

Threads asyncio tasks
10,000 idle +341 MB resident +8 MB
10,000 1 s waits 2.55 s wall 1.10 s wall
one switch 28.9 us (ping-pong) 2.7 us (sleep(0))

Ten thousand threads will run, but they cost hundreds of megabytes, take seconds just to start, and make scheduling the bottleneck. Ten thousand coroutines is a moderately busy web server. This is why asyncio exists: the win is memory and start-up first, switch latency second.

One blocking call

python
async def handler():
    rows = db.query(sql)         # synchronous driver: blocks the loop
    return render(rows)

Under threads, that call blocks one thread and the other requests continue. Under asyncio it blocks every task in the process, because nothing can preempt it, and the symptom is that latency rises across endpoints that have nothing to do with this one.

It is the most common asyncio production incident, and the fix is run_in_executor and asyncio.to_thread — push the blocking call to a thread and await that.

Where races live

Both models have race conditions. They differ in where.

python
# Threads: this can be interrupted mid-operation.
counter += 1                 # read, add, write - three steps

# asyncio: this cannot. Nothing runs between the read and the write
# unless there is an `await` between them.
counter += 1

With threads any two bytecodes can be interrupted, so shared mutable state needs a lock by default. With asyncio the switch points are await statements, which are visible in the source — so you can read a function and see whether it is atomic.

That does not mean asyncio is race-free. An await in the middle of a read-modify-write is exactly as broken as a thread interrupt, and it is easier to miss because it looks like ordinary sequential code. asyncio.Lock exists for that case; see Synchronization primitives.

Failure behaviour

An exception in a thread kills that thread and goes to threading.excepthook (3.8), which prints it; the program carries on missing a worker. An exception in a coroutine propagates through await like an ordinary call stack, which is better; a task that fails and is never awaited keeps its exception until it is garbage-collected, when the loop’s exception handler logs “Task exception was never retrieved”, which is worse.

Structured concurrency fixes the second half by making failures propagate to the group — see TaskGroup and structured concurrency.

Cancellation and timeouts

A task can be cancelled at any await: task.cancel() or asyncio.timeout() raises CancelledError there, and finally blocks run. A thread cannot be stopped from outside at all. Future.cancel() only removes work that has not started, and a timeout around a thread only stops waiting:

python
async with asyncio.timeout(0.2):
    await asyncio.to_thread(blocking_call)   # TimeoutError after 0.2 s...
# ...while blocking_call keeps running in its thread, holding a pool slot

So timeouts and cancellation favour asyncio strongly: a slow dependency behind async I/O can be abandoned cleanly, while one behind a thread pool keeps consuming threads until it returns.

Debugging a stuck process

Threads are inspected with OS-level tools: py-spy dump --pid prints every thread’s stack, and faulthandler.dump_traceback() does it from inside. Tasks are invisible to those tools, which only see the loop thread. Since 3.14, python -m asyncio ps <pid> and pstree <pid> list a running process’s tasks and who awaits whom, and asyncio.print_call_graph() prints it from inside.

On the free-threaded build

On 3.14t threads also run CPU work in parallel, while an event loop still runs on one thread. That shifts the CPU-bound case towards threads; it does not change the I/O case, where asyncio’s memory and cancellation advantages remain.

The part people underrate: colour

Choosing asyncio is a choice about your whole dependency tree, not about one function.

python
def get_user(uid): ...          # callable from anywhere
async def get_user(uid): ...    # callable only from async code

An async function can only be awaited from another async function, so the requirement propagates up to the entry point and outward through every caller. A synchronous library — a database driver, an SDK, a parser — cannot be used directly, and you need an async equivalent or a thread to wrap it in.

Threads have no such constraint, which is why they remain the pragmatic answer for integrating with code you do not control: any library works in a thread pool, unchanged.

Bridging the two

  • Sync code inside async: asyncio.to_thread(fn, *args) runs it in the default executor and awaits the result (run_in_executor and asyncio.to_thread).
  • Async code inside sync: asyncio.run(coro) per call creates and closes a whole event loop each time, which is slow and breaks loop-bound clients. Keep one loop: asyncio.Runner for repeated calls from one thread, or a background loop thread fed with asyncio.run_coroutine_threadsafe(). Django’s asgiref packages both directions as sync_to_async and async_to_sync.

Where it breaks in production

An await inside a read-modify-write

The asyncio race looks like sequential code, which is why it ships:

python
async def deposit(amount):
    global balance
    current = balance
    await audit_log.write(amount)     # a switch point between read and write
    balance = current + amount
text
100 deposits of 10, await between read and write: 10
same, under asyncio.Lock:                         1000

Every task read the balance before any of them wrote it back, so 99 deposits vanished without an error. The fix is to keep the read and the write with no await between them, or hold an asyncio.Lock across the whole update; in a database, it is an atomic UPDATE ... SET balance = balance + %s.

asyncio.run per call, from sync code

Calling async code from a sync codebase with asyncio.run on each call creates and closes a loop every time. Anything bound to the first loop breaks on the second:

python
queue = asyncio.Queue()              # or a client with a connection pool

asyncio.run(use(queue))              # works
asyncio.run(use(queue))              # a new loop
text
first asyncio.run: x
second asyncio.run: RuntimeError: <Queue at 0x... maxsize=0 tasks=1>
  is bound to a different event loop

Async clients with connection pools (aiohttp, asyncpg, httpx’s async client) bind the same way, and fail as “Event loop is closed” or “attached to a different loop”. Keep one loop for the process: asyncio.Runner for repeated calls from one thread, or a background loop thread fed with run_coroutine_threadsafe.

The same dashboard, two different causes

“Latency is up” means different things in each model. With threads, look at pool queue time: requests waiting for a free thread while the threads themselves are blocked on I/O. With asyncio, look at event-loop lag: how late a scheduled callback runs, which grows when something blocks the loop. Measure the one that matches your model; a service with both has both.

The trade not taken

The tempting project is rewriting a working threaded service in asyncio “for performance”. Per request it is rarely faster: a sync Django or Flask service under gunicorn threads at a few hundred concurrent requests spends its time in the database either way.

What asyncio measurably buys is memory per connection (8 MB against 341 MB at 10,000) and cancellation, which matter at thousands of mostly idle connections: WebSockets, long polling, fan-out to slow APIs.

The rewrite costs the colour: every library, every caller, every test. So the default is threads, and asyncio where connection count or cancellation is the actual constraint, usually in a new service rather than a converted one.

Summary of the trade

asyncio Threads
Switch point await only any safe point
10,000 idle +8 MB +341 MB
Cancellation at any await not from outside
Blocking call stalls everything stalls one
Locking at await points by default
Library support needs async ones anything

Interview angle 5

  • “asyncio or threads?” - the real difference is who decides to switch. Threads are preemptive, so the interpreter can switch between any two bytecodes; coroutines are cooperative, so control moves only at an await. Every other difference follows from that.
  • “Why does asyncio scale to far more connections?” - memory and start-up first: 10,000 idle threads added 341 MB on 3.14.5, 10,000 idle tasks 8 MB, and a switch costs 2.7 us against a 28.9 us thread ping-pong.
    • Follow-up: “You put a 2-second timeout on a call made with to_thread. What happens when it fires?” - the await raises TimeoutError, but the thread keeps running until the call returns, holding a pool slot. Threads cannot be cancelled from outside; tasks can, at any await.
  • “A synchronous database call sneaks into a coroutine. What happens?” - it blocks every task in the process, because nothing can preempt it. Latency rises on unrelated endpoints, which makes it look like an infrastructure problem. Under threads it would have blocked one request.
    • Follow-up: “How do you fix it without rewriting the driver?” - run_in_executor or asyncio.to_thread, so the blocking call runs in a thread and the loop awaits its result.
  • “Does asyncio remove race conditions?” - no, it relocates them. The switch points are await statements, so you can read a function and see whether it is atomic - but an await in the middle of a read-modify-write is exactly as broken as a thread interrupt, and it looks like ordinary sequential code.
  • “What is the cost of choosing async that people underestimate?” - it propagates. An async function can only be awaited from async code, so the requirement spreads to every caller and up to the entry point, and any synchronous library needs an async equivalent or a thread to wrap it. Threads have no such constraint, which is why they stay the pragmatic choice for integrating code you do not control.
    • Follow-up: “Would you rewrite a threaded Flask service in asyncio to make it faster?” - not for speed: per request it spends its time in the database either way. Asyncio pays off at thousands of idle connections or where cancellation matters, and the rewrite costs every library and caller.