◐ ai-teacher

🐍 Python for Data Engineers · Chapter 9

Concurrency: threads, processes, asyncio

Ask one question — is this job waiting, or is it computing? — and the right concurrency model falls out immediately.

Study this lesson free in the app →

The basics

Concurrency means making progress on several tasks at once — either by overlapping their waiting (threads, asyncio) or by running them on several CPU cores (processes).

The problem it solves: most data jobs spend their time waiting — for a partner API, Postgres, S3. A pull of 1,000 pages at 400 ms each is almost seven minutes of an idle CPU. Overlap the waits and it is under a minute. But throw threads at a job that is computing and it gets no faster, sometimes slower.

The mental model: a restaurant. Threads and async add waiters — one person serving many tables while the kitchen cooks. Processes add chefs — more hands doing the chopping. Ask one question first: is this job waiting, or computing?

Words you must know 5

I/O-bound
time goes to waiting on network or disk; threads or asyncio help
CPU-bound
time goes to computation; you need processes or C-level code (NumPy, PyArrow)
GIL (Global Interpreter Lock)
lets only one thread run Python bytecode at a time; released while waiting on I/O
Thread pool
a fixed set of worker threads you submit tasks to (ThreadPoolExecutor)
Event loop
asyncio's single thread that switches between coroutines at every await

Explained

Most data jobs are waiting: for an API, a database, a disk, S3. The CPU sits idle at 3% while the clock runs.

Waiting in parallel is nearly free. Computing in parallel is not — in Python it needs a different tool entirely, and picking the wrong one makes things slower.

One question decides it. Ask it out loud in the interview.

The restaurant

One waiter, one table at a time (sequential). Take the order, walk to the kitchen, stand there watching the food cook, carry it back, then greet the next table. Ninety percent of the shift is standing still.

One waiter, many tables (async / threads). Take an order, hand it to the kitchen, and while it cooks, go take three more orders. One person, no extra hands — just never standing idle. This is what you want when the kitchen is the slow part.

Many chefs (multiprocessing). If the bottleneck is actually the chopping, no amount of clever waiting helps. You need more people doing the work. More kitchens, more knives, more CPUs.

Threads and async add waiters. Multiprocessing adds chefs. Hiring the wrong one is why people say "I added threads and it got slower".

The decision, as a flowchart

Ninety percent of data-engineering concurrency is the second box. Most pipelines are I/O-bound and a thread pool is the whole answer.

The GIL, accurately

Python's Global Interpreter Lock allows only one thread to execute Python bytecode at a time in a single interpreter.

What that means in practice:

(Python 3.13 ships an experimental free-threaded build without the GIL. Mention it as a direction, not as something to depend on.)

Thread pool: the 80% answer

from concurrent.futures import ThreadPoolExecutor, as_completed

def fetch_city(city, day):
    return api.get(f"/rides?city={city}&date={day}")   # waits on the network

def pull_all(cities, day, workers=10):
    results, failures = [], []

    with ThreadPoolExecutor(max_workers=workers) as pool:
        futures = {pool.submit(fetch_city, c, day): c for c in cities}

        for fut in as_completed(futures):
            city = futures[fut]
            try:
                results.append(fut.result())      # re-raises inside the worker
            except Exception as err:
                failures.append((city, repr(err)))   # one city failing ≠ job dead

    return results, failures

Two details that separate this from tutorial code: the {future: city} map so you know which item failed, and catching per-future so one bad city does not kill the run.

Step through 3 workers on 6 cities

Key insight: as_completed gives you results in finish order. If you need input order, use pool.map instead.

Predict which one wins

Two jobs, each on a 4-core machine:

Job A — download 200 files from S3 (each ~300 ms of network wait) Job B — parse and hash 200 already-downloaded files (each ~300 ms of CPU)

You run both with ThreadPoolExecutor(max_workers=8).

Processes and asyncio, briefly

# CPU-bound: one interpreter per core, no shared GIL
from concurrent.futures import ProcessPoolExecutor

with ProcessPoolExecutor() as pool:
    checksums = list(pool.map(hash_file, paths))
# cost: arguments and results are pickled between processes,
# so passing a 2 GB DataFrame per task will eat the gains

# I/O-bound, async-native libraries: thousands of concurrent waits, one thread
import asyncio, httpx

async def fetch_all(urls):
    async with httpx.AsyncClient(timeout=30) as client:
        sem = asyncio.Semaphore(20)              # your own rate limit

        async def one(u):
            async with sem:
                r = await client.get(u)
                return r.json()

        return await asyncio.gather(*(one(u) for u in urls),
                                    return_exceptions=True)

asyncio scales to thousands of concurrent requests where threads would collapse — but every library in the path must be async. One blocking call inside a coroutine freezes the entire event loop.

Words to know

I/O-boundLimited by waiting on network, disk or database, not by CPU.
CPU-boundLimited by computation — parsing, hashing, compressing.
GILGlobal Interpreter Lock — only one thread executes Python bytecode per interpreter at a time.
ThreadPoolExecutorA pool of threads for concurrent I/O-bound work, sharing memory.
ProcessPoolExecutorA pool of separate processes for true CPU parallelism, at the cost of pickling.
asyncioSingle-threaded cooperative concurrency built on an event loop and await points.
as_completedYields futures in completion order rather than submission order.
SemaphoreA counter limiting how many tasks run concurrently — your own rate limiter.
Race conditionA bug where the result depends on the unpredictable interleaving of concurrent operations.

In two minutes

Test yourself

1. Your job downloads 500 files and is slow. Which tool?

  1. ProcessPoolExecutor
  2. ThreadPoolExecutor
  3. A faster for-loop
  4. More RAM
Show the answer

ThreadPoolExecutor — Downloading is I/O-bound. The GIL is released during network waits, so threads wait in parallel — cheap and effective.

2. You add 8 threads to a CPU-heavy parsing job. What happens?

  1. It gets ~8x faster
  2. Little or no speedup, possibly slower from context switching
  3. It crashes
  4. Memory drops
Show the answer

Little or no speedup, possibly slower from context switching — The GIL allows one thread to execute Python bytecode at a time. CPU-bound work needs processes, or a C library that releases the GIL.

3. What does the GIL actually prevent?

  1. Any kind of parallelism
  2. Two threads executing Python bytecode simultaneously in one interpreter
  3. Threads from waiting on I/O together
  4. Processes from running in parallel
Show the answer

Two threads executing Python bytecode simultaneously in one interpreter — It is a bytecode lock. It is released during I/O and inside C extensions like NumPy and PyArrow, which is why those still scale.

4. You set max_workers=100 against a database with a 20-connection pool. Result?

  1. 5x faster than 20 workers
  2. 80 workers queue waiting for a connection — no gain, and it looks like a hang
  3. The pool grows automatically
  4. Connections are shared safely
Show the answer

80 workers queue waiting for a connection — no gain, and it looks like a hang — The real constraint is the pool. Concurrency past the actual bottleneck adds queuing and confusion, not throughput.

5. Why does calling requests.get() inside an async coroutine break things?

  1. It raises a TypeError
  2. It blocks the event loop, freezing every other concurrent task
  3. It uses too much memory
  4. asyncio forbids HTTP
Show the answer

It blocks the event loop, freezing every other concurrent task — asyncio is cooperative and single-threaded. A blocking call never yields control, so all other tasks stop until it returns.

6. How does ProcessPoolExecutor get your arguments into the worker processes?

  1. Shared memory, with no copying
  2. It pickles them and sends them to the worker; results come back the same way
  3. Through the GIL
  4. It re-imports them from disk
Show the answer

It pickles them and sends them to the worker; results come back the same way — Processes do not share memory, so arguments and return values are serialised with pickle. That is why passing a 2 GB DataFrame per task can cost more than the parallelism saves — and why lambdas (unpicklable) fail as process-pool tasks.

There are 9 questions, 3 puzzles and 7 recall cards for this lesson in the app — the recall cards come back on later days, just before you would forget.

Learn it so it stays. Read it, test yourself, then recall it on two different days. The app schedules that for you, free.

Start this lesson →