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)
WorkloadBest toolWhy
Many HTTP or database callsThreads or asyncioTime is spent waiting, not computing
Reading and writing local filesThreadsThe interpreter releases the lock during IO
Number crunching and parsingProcessesThe GIL blocks simultaneous bytecode
Thousands of idle connectionsasyncioNo thread per connection
C extensions that release the lockThreadsNumPy 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.
  • gather with return_exceptions=True behaves like allSettled: 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:
        pass

Threads 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?
It raises throughput for IO-bound work by overlapping waits. It does not speed up computation: a CPU-bound task inside a coroutine blocks the loop exactly as it would block a thread. Use processes for that.
Which should I choose first?
ThreadPoolExecutor. It requires almost no restructuring, works with any existing library, and is enough for hundreds of concurrent IO operations. Move to asyncio when you need thousands of connections or already use async libraries.

Command-line tools: argparse and logging Iterators, generators and itertools

Last refreshed 2026-09-18.