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 target I/O-bound work. The difference is who controls the switch — and that single difference cascades into very different mental models for writing safe concurrent code.

The core distinction

With threads, the OS scheduler decides when to pause one thread and resume another. Your code doesn’t get a say. Any shared variable can be mid-update when the switch happens, so you need locks.

With asyncio, your code decides when to yield control — at every await. Between await points, you are the only thing running. No locks needed for local state, because nothing can interrupt you.

asyncio — cooperative, single-threaded

An async def function is a coroutine. When it hits await, it suspends and hands control back to the event loop, which picks another coroutine to run. No OS scheduler, no GIL contention, no locks needed for coroutine-local state.

The practical win: a single event loop can juggle tens of thousands of connections at very low overhead — no thread stacks, no kernel context switches.

import asyncio

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

async def main():
    import aiohttp
    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 context-switch a thread at any point. This makes threads work transparently with blocking libraries (plain requests, synchronous database drivers), but shared mutable state requires explicit synchronisation.

import threading
import requests

results = []
lock = threading.Lock()

def fetch(url):
    r = requests.get(url)
    with lock:
        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 is ~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

You can bridge the two worlds: loop.run_in_executor offloads a blocking call into a thread pool from async code.

Learn it properly Asyncio

Keep practising

All Python questions

Explore further

Skip to content