datarekha
Python Hard Asked at StripeAsked at CloudflareAsked at DiscordAsked at Netflix

How does asyncio differ from threading, and when would you choose one over the other?

The short answer

asyncio is cooperative, single-threaded concurrency: coroutines yield control explicitly at await points, so there is no GIL contention and no shared-state races. Threads are preemptive OS-level concurrency: the scheduler can switch at any bytecode boundary, which requires explicit locking. Choose asyncio for high-fan-out I/O (thousands of connections); choose threads when you need to call blocking APIs you cannot rewrite.

How to think about it

Both asyncio and threading exist to handle I/O-bound work, and they often hit similar throughput. The real difference is who controls the switch — and that one difference cascades into two very different mental models for writing safe concurrent code.

With threads, the OS scheduler decides when to pause one thread and resume another, and your code gets no say. The switch can land in the middle of updating a shared variable, so you need locks. With asyncio, your code decides when to yield — at every await. Between two await points nothing else runs, so local state needs no locking, because nothing can interrupt you mid-update.

asyncio (cooperative)
  coroutine A ──await──▶ event loop picks coroutine B
  coroutine B ──await──▶ event loop picks coroutine A
  one thread, you control the handoff

threading (preemptive)
  thread A ───────────▶ OS interrupts ──▶ thread B
  thread B ───────────▶ OS interrupts ──▶ thread A
  multiple threads, the scheduler controls the handoff

asyncio — cooperative, single-threaded

An async def is a coroutine. When it hits await it suspends and hands control back to the event loop, which runs another coroutine. No OS scheduler, no GIL contention, no locks for coroutine-local state. The payoff is scale: one event loop can juggle tens of thousands of connections at tiny overhead — no thread stacks, no kernel context switches.

import asyncio
import aiohttp

async def fetch(session, url):
    async with session.get(url) as r:
        return await r.text()

async def main():
    async with aiohttp.ClientSession() as session:
        tasks = [fetch(session, "https://example.com") for _ in range(100)]
        pages = await asyncio.gather(*tasks)

asyncio.run(main())

threading — preemptive, multi-threaded

The OS can switch a thread at any point, which is exactly what lets threads work transparently with blocking libraries you can’t rewrite — plain requests, a synchronous database driver. The cost of that convenience is that any shared mutable state needs explicit synchronisation:

import threading
import requests

results, lock = [], threading.Lock()

def fetch(url):
    r = requests.get(url)
    with lock:                 # the switch can land anywhere — guard shared state
        results.append(r.text)

threads = [threading.Thread(target=fetch, args=("https://example.com",)) for _ in range(10)]
for t in threads: t.start()
for t in threads: t.join()

When to choose which

Factorasynciothreading
concurrency countthousandshundreds (stack RAM ~8 MB/thread)
library ecosystemneeds async-native libsworks with any blocking lib
shared-state safetyimplicit (single thread)requires locks
blocking CPU workloop.run_in_executornatural

The two worlds bridge cleanly: from async code, loop.run_in_executor offloads a blocking call into a thread pool, so one stubborn synchronous API doesn’t force you off asyncio.

Learn it properly Asyncio

Keep practising

All Python questions

Explore further

Skip to content