Concurrency: threads, processes and asyncio
What the GIL really blocks, ThreadPoolExecutor for IO, ProcessPoolExecutor for CPU, async and await, asyncio.gather, and timeouts.
Threads, processes and the GIL
The global interpreter lock lets only one thread execute Python bytecode at a time, so threads cannot speed up pure-Python computation. They are still excellent for IO, because the lock is released while a thread waits on a socket or a disk.
from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor
def fetch(url): # dominated by network waiting
return requests.get(url, timeout=10).text
with ThreadPoolExecutor(max_workers=8) as pool:
pages = list(pool.map(fetch, urls)) # 8 requests in flight at once
def crunch(chunk): # dominated by CPU work
return sum(i * i for i in chunk)
if __name__ == "__main__": # required: processes re-import this module
with ProcessPoolExecutor() as pool:
totals = list(pool.map(crunch, chunks)) # real parallelism
# submit returns futures when you want to react as they finish
with ThreadPoolExecutor(max_workers=4) as pool:
futures = {pool.submit(fetch, u): u for u in urls}
for fut in as_completed(futures):
try:
print(futures[fut], len(fut.result()))
except Exception as err:
print(futures[fut], "failed:", err)| Workload | Best tool | Why |
|---|---|---|
| Many HTTP or database calls | Threads or asyncio | Time is spent waiting, not computing |
| Reading and writing local files | Threads | The interpreter releases the lock during IO |
| Number crunching and parsing | Processes | The GIL blocks simultaneous bytecode |
| Thousands of idle connections | asyncio | No thread per connection |
| C extensions that release the lock | Threads | NumPy and friends already parallelise internally |
ProcessPoolExecutor pickles the arguments and the return value, so functions must be importable at module level and data must be serialisable. It also re-imports your module in each worker — guard the entry point with if __name__ == "__main__": or the pool recurses.async, await and gather
An async def function returns a coroutine. Nothing runs until you await it or hand it to the event loop. One thread drives every coroutine, switching only where you await, so no locks are needed between tasks.
import asyncio, aiohttp
async def get_text(session, url):
async with session.get(url) as resp:
resp.raise_for_status()
return await resp.text()
async def main(urls, limit=20):
sem = asyncio.Semaphore(limit) # cap concurrency
async with aiohttp.ClientSession() as session:
async def one(url):
async with sem:
return await get_text(session, url)
return await asyncio.gather(*(one(u) for u in urls), return_exceptions=True)
results = asyncio.run(main(urls)) # creates the loop, runs, closes it
for r in results:
if isinstance(r, Exception):
print("failed:", r)
# sequential versus concurrent
async def slow(): # 3 await points, one after another
a = await fetch(a_url)
b = await fetch(b_url)
return a, b
async def fast():
return await asyncio.gather(fetch(a_url), fetch(b_url))asyncio.run()owns the event loop: call it once at the top level, never inside an already-running loop.gatherwithreturn_exceptions=Truebehaves likeallSettled: you get partial results plus the errors.asyncio.create_task(coro)starts a coroutine in the background; keep a reference or it may be garbage collected mid-flight.- Never call blocking code (
requests,time.sleep, heavy parsing) directly inside a coroutine — it freezes every other task.
Timeouts, limits and running blocking code
import asyncio, requests
async def fetch_with_timeout(url):
try:
async with asyncio.timeout(5): # Python 3.11+
return await get_text(session, url)
except TimeoutError:
return None
# library-level timeout when you cannot cancel the whole block
await asyncio.wait_for(get_text(session, url), timeout=5)
# hand blocking work to a worker thread so the loop keeps turning
text = await asyncio.to_thread(requests.get, url, timeout=10)
# first result wins, cancel the rest
done, pending = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
for task in pending:
task.cancel()
# blocking cancellation must be awaited
for task in pending:
try:
await task
except asyncio.CancelledError:
passThreads for one process, processes for CPU, asyncio for very high connection counts. Most real systems combine them: an async web server that pushes blocking library calls into a thread pool and CPU work into a process pool.
- Always set a timeout on any network call; without one a stuck peer holds a worker forever.
- Bound concurrency with a semaphore or a pool size — thousands of simultaneous tasks usually hurts the remote service and your own memory.
- Cancellation is cooperative: a coroutine only stops at an
await, so keep awaits frequent inside long loops. - Measure before optimising. For small batches, sequential code is simpler and often fast enough.
FAQ
Does asyncio make my code faster?
Which should I choose first?
Related
Command-line tools: argparse and logging Iterators, generators and itertools
Last refreshed 2026-09-18.