Concurrency advice often begins with a noun: use threads, use asyncio, use processes. Production systems begin with a verb: wait for a socket, compute a transform, acquire a lock, accept capacity, or cross a service boundary. The mechanism should follow the wait.

A thread can block without freezing sibling threads. An asyncio task can suspend cheaply when an async operation cooperates with its event loop. A process can execute Python independently on another core, but ordinary Python objects do not cross into it directly. Each model moves a bottleneck and creates a different ownership boundary.

This tutorial classifies those boundaries through experiments. It does not repeat the GIL's implementation story from part one. Instead, it asks which resource prevents progress, who owns mutable state, how overload is represented, and what failure or cancellation can actually stop.

Version note. Every Python block was run on GIL-enabled CPython 3.14.7. Timing values are assertions with generous bounds, not benchmarks. asyncio and concurrent.futures behavior cited here is Python 3.14 behavior. Process startup details vary by platform and release; Python 3.14 changed the default POSIX process start method away from fork.

Experiment 1: sequential waits add

Start with the baseline people skip:

from time import perf_counter, sleep

started = perf_counter()
for _ in range(3):
    sleep(0.05)
elapsed = perf_counter() - started

assert elapsed >= 0.14
print(round(elapsed, 3))

The process is mostly not computing. It asks the operating system to wake the only active thread later. Three independent waits become one 150-millisecond critical path because the program serializes them.

sleep() is a model, not evidence that a database behaves like a timer. Real waits may contend for a connection pool, disk, remote quota, or lock. Before changing architecture, trace enough of the request to name the blocked resource. "I/O-bound" is too broad: one slow upstream with a concurrency limit of two cannot usefully absorb 500 simultaneous requests.

Python guarantee. time.sleep() suspends the calling thread for at least approximately the requested duration; it may run longer because of scheduling. Python does not guarantee precise wake-up latency.

Experiment 2: threads overlap blocking calls

Threads fit synchronous call stacks whose expensive operations release the interpreter or block in the operating system:

from concurrent.futures import ThreadPoolExecutor
from time import perf_counter, sleep

def fetch(item):
    sleep(0.05)
    return item * 10

started = perf_counter()
with ThreadPoolExecutor(max_workers=3) as pool:
    values = list(pool.map(fetch, range(3)))
elapsed = perf_counter() - started

assert values == [0, 10, 20]
assert elapsed < 0.14
print(round(elapsed, 3))

The code preserves an ordinary function interface. That makes threads practical around mature blocking drivers, filesystem operations, and libraries with no async API. Threads also share objects, so passing work is cheap. Sharing is simultaneously their largest design cost: a dictionary, client, or cache may require locks or strict single-owner rules.

ThreadPoolExecutor.map() returns results in input order. Completion order can differ. If the first input stalls, consuming the mapped iterator can wait even while later results are ready. Use submitted futures and as_completed() when completion order is the desired protocol.

Thread cancellation is limited. Cancelling a queued future can prevent it from starting; cancelling a running future does not interrupt arbitrary Python or a blocking C call. Timeouts on the caller do not necessarily stop the work. Libraries need their own request deadlines, and shutdown policy must account for operations still running.

Experiment 3: tasks overlap cooperative waits

Asyncio tasks run coroutines on an event loop, normally in one thread:

import asyncio
from time import perf_counter

async def fetch(item):
    await asyncio.sleep(0.05)
    return item * 10

async def main():
    started = perf_counter()
    values = await asyncio.gather(*(fetch(i) for i in range(3)))
    elapsed = perf_counter() - started
    assert values == [0, 10, 20]
    assert elapsed < 0.14
    print(round(elapsed, 3))

asyncio.run(main())

At await asyncio.sleep, a task explicitly gives the loop a chance to run another ready task. Tasks are lighter than operating-system threads and are a strong fit for high fan-out when the whole dependency chain offers async operations.

That final qualification matters. Calling a blocking database driver inside async def blocks the event-loop thread. Syntax does not make an operation asynchronous. asyncio.to_thread() can bridge isolated blocking calls, but it adds a thread pool rather than converting the driver. A service split between async and blocking libraries must budget both forms of concurrency.

CPython 3.14 detail. The default asyncio event-loop implementation and selector are platform-specific CPython library choices. Python documents task and event-loop semantics, not one universal kernel mechanism.

Experiment 4: CPU work needs a different baseline

A process pool gives workers independent interpreters and memory spaces:

from concurrent.futures import ProcessPoolExecutor

def checksum(limit):
    return sum((number * number) % 97 for number in range(limit))

def main():
    inputs = [40_000, 50_000, 60_000]
    expected = [checksum(value) for value in inputs]
    with ProcessPoolExecutor(max_workers=2) as pool:
        actual = list(pool.map(checksum, inputs))
    assert actual == expected
    print(actual)

if __name__ == '__main__':
    main()

This experiment checks semantics, not speed. Tiny computations are usually slower in a pool because worker startup, scheduling, serialization, and result transfer dominate. Processes become candidates when tasks are independent, sufficiently coarse, and spend meaningful time executing Python CPU work.

The __main__ guard makes worker import safe. Submitted callables and arguments must be serializable according to the executor's process model. Lambdas, nested functions, open sockets, locks, and many extension objects are unsuitable boundaries. Part five develops that boundary in detail.

Processes isolate accidental mutation and failures better than threads, but "separate memory" does not mean infinite isolation. Workers compete for CPU, memory bandwidth, files, databases, and downstream services. A process pool around an already parallel native library can oversubscribe every core.

Experiment 5: a blocking call freezes the loop

One blocking task delays an unrelated coroutine:

import asyncio
import time

async def blocker():
    time.sleep(0.06)

async def observer():
    started = time.perf_counter()
    await asyncio.sleep(0.01)
    return time.perf_counter() - started

async def main():
    delay, _ = await asyncio.gather(observer(), blocker())
    assert delay >= 0.05
    print(round(delay, 3))

asyncio.run(main())

The observer requested ten milliseconds but could not resume while time.sleep occupied the loop thread. In a server, the same mistake inflates tail latency for unrelated requests. Event-loop lag is therefore an operational signal, not merely a debugging curiosity.

The repair is to use a native async operation, move bounded blocking work through to_thread, or redesign the boundary. Do not merely sprinkle await: only awaiting an object whose implementation suspends allows peers to progress.

Experiment 6: capacity is part of correctness

Unlimited concurrency turns latency into resource exhaustion. A semaphore states a local capacity limit:

Pyodide / WebAssembly
import asyncio

active = 0
peak = 0

async def operation(limit):
    global active, peak
    async with limit:
        active += 1
        peak = max(peak, active)
        await asyncio.sleep(0.01)
        active -= 1

async def main():
    limit = asyncio.Semaphore(3)
    await asyncio.gather(*(operation(limit) for _ in range(20)))
    assert active == 0
    assert peak == 3
    print("[result] peak concurrent operations:", peak)

asyncio.run(main())

A semaphore bounds active operations but not necessarily queued tasks. Creating a million tasks that wait on a semaphore still retains a million coroutine frames, arguments, and contexts. For streams, prefer a bounded asyncio.Queue: producers then wait when consumers fall behind, carrying backpressure to the source.

Thread and process executors also have queues. max_workers limits active workers, not all submitted futures. Feed pools incrementally or place a bounded queue before them. Python 3.14's Executor.map has a buffersize parameter that limits submitted results awaiting consumption; older versions eagerly collect iterables, so this is version-specific behavior.

Experiment 7: ownership beats incidental safety

Message passing can keep mutation under one task:

import asyncio

async def counter(inbox):
    total = 0
    while (amount := await inbox.get()) is not None:
        total += amount
    return total

async def main():
    inbox = asyncio.Queue()
    owner = asyncio.create_task(counter(inbox))
    for amount in [2, 3, -1, 5]:
        await inbox.put(amount)
    await inbox.put(None)
    assert await owner == 9

asyncio.run(main())

Only counter owns total. Producers communicate values rather than coordinating reads and writes. The sentinel is a protocol, so choose it carefully when None could be valid data. A typed message or closing abstraction can make production protocols clearer.

This pattern works across mechanisms. A thread can own mutable state behind queue.Queue; a process can own state behind a pipe; an async task can own it behind asyncio.Queue. Queues do not remove failure handling. Producers need to know if the consumer died, shutdown must define what happens to buffered work, and bounded queues need a policy for callers unwilling to wait.

Choose by the wait and boundary

Use threads when the stack is synchronous, waits happen in blocking APIs, object sharing is genuinely useful, and the libraries document thread safety. Use asyncio when you control an async-native stack, need many in-flight waits, and can propagate deadlines and cancellation through coroutine APIs. Use processes for coarse independent CPU work, stronger memory separation, or libraries whose state should not be shared.

Hybrid designs are normal. An async server may use a small thread pool for a blocking SDK and a process service for expensive transforms. The danger is unaccounted multiplication: server workers times event-loop tasks times thread workers times database connections. Write the complete concurrency budget down.

Ask these questions before selecting an API:

  • What resource is usually blocking progress?
  • Can the operation actually be interrupted, or only abandoned by its caller?
  • Who owns each mutable object?
  • Where is admission controlled, and is the waiting queue bounded?
  • What crosses the boundary: references, messages, or serialized values?
  • How are partial failure and shutdown observed?
  • Does task granularity amortize scheduling and transfer costs?

Observe the queue, not just the workers

Concurrency dashboards often graph active workers and miss the waiting work that determines user latency. Record admission delay separately from execution time. A task that spends 900 milliseconds queued and 100 milliseconds executing is not a 100-millisecond operation, even though worker instrumentation may report it that way. Track queue depth, oldest-item age, rejection count, active operations, and completion rate at every bounded boundary.

Little's Law provides a useful consistency check for a stable system: average work in progress equals throughput multiplied by average time in the system. It does not size a pool by itself, especially under bursty or unstable load, but it can reveal impossible dashboards. If reported throughput and latency imply 200 in-flight requests while instrumentation shows five, a queue or layer is missing from observation.

Overload policy belongs at admission. Waiting forever is a policy, usually an accidental one. Depending on the operation, reject with a retry signal, shed low-priority work, coalesce duplicate refreshes, or enqueue durably. A semaphore deep inside a request may protect a dependency while still tying up every upstream request slot. Push backpressure far enough toward the producer that the whole process remains responsive.

Finally, include shutdown in load tests. Stop admission, drain bounded queues for a declared interval, cancel cooperative tasks, and close executors. Verify that a deployment does not accept work it cannot finish or wait indefinitely for blocking calls. The correct mechanism is the one whose overload and shutdown behavior remain understandable, not merely the one with the best steady-state throughput.

Exercises

  1. Replace Experiment 2's sleep with a local blocking socket server. Record throughput and peak server concurrency as worker count changes.
  2. Add an accidental time.sleep to an async test and implement an event-loop-lag assertion that detects it.
  3. Turn Experiment 6 into a bounded producer-consumer pipeline. Prove the queue never exceeds its configured capacity.
  4. Benchmark Experiment 4 over increasing input sizes. Include startup and pool shutdown, and find the local crossover point.
  5. Draw the concurrency budget for one of your services, including deployment processes, threads, tasks, connection pools, and upstream limits.

Keep this model

Threads, tasks, and processes are not faster versions of one another. Threads preserve synchronous stacks and overlap blocking calls while sharing memory. Tasks cooperate at await points and make huge numbers of waits affordable, but one blocking call can stall the loop. Processes buy independent execution and memory at the price of startup and serialization.

Choose after locating the wait. Then design ownership, backpressure, cancellation, and observability around the boundary. A mechanism that overlaps work but admits unbounded demand or cannot shut down is not a complete concurrency design.

Primary sources