Concurrency: threads vs asyncio
Concurrency in Python — the honest cheat sheet
Python has three concurrency stories. You pick based on what your code is waiting for.
The three modes
I/O-bound — waiting for network, disk, database. Use asyncio (or threads). CPU-bound — number crunching, image processing. Use multiprocessing, never threads. Truly parallel + shared state — rare in modern Python. Usually a design smell; refactor to message-passing.
Why threads are limited (the GIL)
Python's Global Interpreter Lock lets only one thread execute Python bytecode at a time. Threads still shine for I/O because the OS releases the GIL during blocking calls (socket.recv, time.sleep). But two CPU-heavy threads won't run in parallel — they'll serialize.
asyncio in 60 seconds
async def f():— declares a coroutine (doesn't run yet).await something— pauses this coroutine, lets others run.asyncio.run(main())— event loop entrypoint.asyncio.gather(a, b, c)— run coroutines concurrently, collect results.- Everything you await must be awaitable — regular
requests.getwon't work; useaiohttporhttpx.AsyncClient.
Rules of thumb
- New code → asyncio. It scales to thousands of concurrent connections without a thread per task.
- Legacy sync libs →
concurrent.futures.ThreadPoolExecutor, wrap the blocking call. - CPU-bound →
multiprocessing.Poolorconcurrent.futures.ProcessPoolExecutor. - Never mix casually —
asyncio+threadingneedsrun_in_executorto bridge.
Common trap
Calling a sync function inside async def blocks the whole event loop. If your API handler calls time.sleep(5) (or a sync DB driver), every other request stalls for 5s. Use asyncio.sleep and async drivers everywhere, or offload with await asyncio.to_thread(sync_fn).
Try it
- Change the asyncio version to sleep 1s each but pass
asyncio.sleep(0.5)for one of them. Which finishes first? - Wrap a synchronous
time.sleep(2)inasyncio.to_threadand gather it with the others.