Concurrency Patterns: Threads vs Processes vs Async
Pick any Python project doing real work, and within ten lines you will find a thread, a process, or an async def. The wrong choice is rarely obvious at the time, but it shows up later as memory pressure, stalled event loops, or a CPU-bound task that refuses to scale. This guide compares the three Python concurrency patterns side by side and walks through the decision points that actually matter.
Why does Python ship three concurrency models?
Python ships three concurrency patterns, and the fact that they coexist is not an accident. They each solve a different problem, and choosing the wrong one costs you either speed, memory, or both.
The core question is: what is the interpreter waiting on? If the answer is “the network,” “the disk,” or “the database,” the GIL is not in your way and threads or asyncio will do the job. If the answer is “the CPU,” the GIL serializes pure-Python bytecode and you need separate processes (or free-threaded CPython, which we’ll get to).
Two quick definitions. I/O-bound work releases the GIL while it waits: socket reads, file writes, time.sleep, anything that calls into the operating system. CPU-bound work holds the GIL: number crunching, JSON parsing of large strings, image processing in pure Python. C extensions like NumPy and hashlib release the GIL during their heavy lifting, which is why NumPy code often scales with threads even though the GIL is on.
TL;DR
- CPU-bound, parallelizable →
ProcessPoolExecutorormultiprocessing. Bypasses the GIL by giving each worker its own interpreter. - CPU-bound under free-threaded 3.13t/3.14t →
ThreadPoolExecutor. The GIL is gone; threads scale across cores. - I/O-bound, dozens of tasks →
ThreadPoolExecutor. Simplest model, plays well with existing sync libraries. - I/O-bound, hundreds to thousands of tasks →
asynciowithTaskGroup. Lower memory per task, no thread overhead. - Mixed workloads → pick the model for the dominant layer, bridge inward with
asyncio.to_threadandloop.run_in_executor. - Free-threaded CPython (3.13t/3.14t) is production-ready for some workloads but costs single-thread speed and requires C extensions that opt in.
Which concurrency pattern fits which workload?
| Workload | Best fit | Why |
|---|---|---|
| CPU-bound, parallelizable | ProcessPoolExecutor or multiprocessing | Bypasses GIL; each worker has its own interpreter. |
| CPU-bound under free-threaded 3.13t/3.14t | ThreadPoolExecutor | GIL is gone; threads scale across cores. |
| I/O-bound, low-to-moderate concurrency (≤ dozens) | ThreadPoolExecutor | Simplest model; works with sync libraries. |
| I/O-bound, high fanout (hundreds–thousands) | asyncio + TaskGroup | Lower memory per task, no thread overhead. |
| Subprocess supervision | asyncio.create_subprocess_exec | Deterministic lifecycle. |
| Calling sync code from async | asyncio.to_thread | Bridges the worlds. |
The one-liner version: CPU-bound → processes. I/O-bound with high concurrency → asyncio. I/O-bound with sync libraries → threads. Need both → mix them and bridge at the seam.
For the deeper treatment of each model, see the threading guide, the multiprocessing guide, and the asyncio basics guide. For the canonical reference, the concurrent.futures docs and the asyncio task docs are the source of truth.
Threads: still the right call for I/O
Threads shine when the work is dominated by waiting. The thread blocks on a syscall, the GIL is released, another thread runs, and the OS hands you parallelism essentially for free.
from concurrent.futures import ThreadPoolExecutor
import urllib.request
URLS = [
"https://docs.python.org/3/",
"https://docs.python.org/3/library/asyncio.html",
"https://docs.python.org/3/library/concurrent.futures.html",
]
def fetch(url: str) -> tuple[str, int]:
with urllib.request.urlopen(url, timeout=10) as r:
return url, r.status
with ThreadPoolExecutor(max_workers=8) as pool:
results = list(pool.map(fetch, URLS))
for url, status in results:
print(status, url)
# output (order matches input because of map):
# 200 https://docs.python.org/3/
# 200 https://docs.python.org/3/library/asyncio.html
# 200 https://docs.python.org/3/library/concurrent.futures.html
A few details that matter:
max_workers=Nonedefaults tomin(32, os.cpu_count() + 4). For I/O-bound work, set it to the concurrency you actually expect; for CPU-bound work, leave it at the default.- The
withblock callsshutdown(wait=True)on exit, which joins all in-flight tasks. If you want fire-and-forget background work, passdaemon=Trueto the threads you spawn manually. - Don’t call
future.result()on a sibling future from inside a worker task. If the pool is too small to schedule the sibling, you deadlock. The officialconcurrent.futuresdocs call this out explicitly.
For a full walkthrough of thread lifecycles, locks, and events, the threading tutorial goes deeper.
Processes: CPU-bound work that needs to scale
When the bottleneck is the CPU, threads will not help you on a stock CPython build. Every bytecode instruction needs the GIL, and only one thread holds it at a time. The escape hatch is a separate process with its own interpreter and its own GIL. Arguments and return values cross the boundary pickled, which is the main thing to watch out for.
from concurrent.futures import ProcessPoolExecutor
import math
PRIMES = [
112272535095293,
112582705942171,
112272535095293,
115280095190773,
115797848077099,
1099726899285419,
]
def is_prime(n: int) -> bool:
if n < 2:
return False
if n % 2 == 0:
return n == 2
limit = int(math.isqrt(n))
for i in range(3, limit + 1, 2):
if n % i == 0:
return False
return True
if __name__ == "__main__":
with ProcessPoolExecutor() as pool:
results = list(pool.map(is_prime, PRIMES))
for n, prime in zip(PRIMES, results):
print(n, "prime" if prime else "composite")
# output:
# 112272535095293 prime
# 112582705942171 prime
# 112272535095293 prime
# 115280095190773 prime
# 115797848077099 prime
# 1099726899285419 prime
Three footguns with ProcessPoolExecutor:
- The
if __name__ == "__main__":guard is not optional on macOS and Windows. The default start method there isspawn, which re-imports the module in the child. If the module-level code submits work to the pool at import time, you recurse forever. Linux defaults tofork, which “works” but can deadlock if the parent holds a thread or a lock. - Pickling has real cost. Don’t pass a 2 GB DataFrame or an open socket. Use
initializerto set up heavy state once per worker, and pass only small, pickleable payloads per task. - Shared state needs explicit plumbing.
multiprocessing.Value,multiprocessing.Array, andmultiprocessing.Managerproxies exist for this. For large read-only data, look atmultiprocessing.shared_memoryto avoid copies.
The multiprocessing.Pool patterns guide covers the lower-level API, and the GIL explainer is worth reading if you want to understand why this is the rule.
Asyncio: high-fanout I/O with structured concurrency
Threads work great for tens or hundreds of concurrent I/O tasks. Beyond that, you start paying for stack space, kernel scheduling, and lock contention. asyncio swaps threads for coroutines on a single OS thread. The trade is cooperative: your code has to await regularly for the event loop to make progress. In return, you can run thousands of in-flight tasks for a fraction of the memory.
import asyncio
import aiohttp
URLS = [
"https://docs.python.org/3/library/asyncio-task.html",
"https://docs.python.org/3/library/concurrent.futures.html",
"https://docs.python.org/3/library/multiprocessing.html",
]
async def fetch(session: aiohttp.ClientSession, url: str) -> tuple[str, int]:
async with session.get(url, timeout=10) as r:
return url, r.status
async def main() -> None:
async with aiohttp.ClientSession() as session:
async with asyncio.TaskGroup() as tg:
tasks = [tg.create_task(fetch(session, u)) for u in URLS]
for url, status in tasks: # tasks are results in 3.12+, not coroutines
print(status, url)
asyncio.run(main())
# output:
# 200 https://docs.python.org/3/library/asyncio-task.html
# 200 https://docs.python.org/3/library/concurrent.futures.html
# 200 https://docs.python.org/3/library/multiprocessing.html
TaskGroup (added in 3.11) is the modern answer to asyncio.gather. The interesting bit is what happens on failure: if one task raises, the group cancels the rest, awaits them, and then raises an ExceptionGroup containing every failure. With gather, you get whichever exception fires first, and the siblings keep running unless you remember to set return_exceptions=True. Task groups make “all of these, or none of these” the default behavior.
The async patterns tutorial and the structured concurrency tutorial go into timeouts, shielding, and cancellation in more depth.
How do you bridge threads, processes, and asyncio?
Real systems mix the models. A web service has thousands of in-flight requests (asyncio) but a few of them need to do CPU work — image resizing, PDF rendering, a heavy regex. The asyncio event loop cannot block on that, so you hand the work to a thread or process pool.
The cheap bridge is asyncio.to_thread (added in 3.9):
import asyncio
import requests # sync library
async def fetch(label: str, url: str) -> tuple[str, int]:
def _get() -> int:
r = requests.get(url, timeout=10)
return r.status_code
status = await asyncio.to_thread(_get)
return label, status
async def main() -> None:
async with asyncio.TaskGroup() as tg:
t1 = tg.create_task(fetch("docs", "https://docs.python.org/3/"))
t2 = tg.create_task(fetch("pep", "https://peps.python.org/"))
print(t1.result(), t2.result())
asyncio.run(main())
# output:
# ('docs', 200) ('pep', 200)
asyncio.to_thread runs the callable in the default ThreadPoolExecutor that asyncio keeps around for exactly this purpose. If the sync work is genuinely CPU-bound rather than I/O-bound, swap in a process pool to bypass the GIL entirely:
loop = asyncio.get_running_loop()
with ProcessPoolExecutor() as pool:
result = await loop.run_in_executor(pool, heavy_cpu_function, arg)
The pattern at the outermost layer of your program should be the one that matches what the OS is waiting on most often. Bridge inward with to_thread and run_in_executor rather than building a Frankenstein where threads, processes, and tasks all share state.
Free-threaded CPython (3.13t/3.14t)
A quieter but important change: starting in 3.13, CPython ships an experimental free-threaded build (python3.13t, python3.14t) where the GIL is removed. Under that build, the same threading code that just serialized can now scale across cores.
# Same code as the threading example — under 3.13t/3.14t, threads scale across cores
from concurrent.futures import ThreadPoolExecutor
def busy(n: int) -> int:
s = 0
for i in range(10_000_000):
s += i % (n + 1)
return s
with ThreadPoolExecutor(max_workers=8) as pool:
print(sum(pool.map(busy, [97] * 8)))
# Under 3.13t/3.14t: threads actually run in parallel
# Under 3.12 with the GIL: serialized, no speedup
Two caveats that keep free-threading from being a free win:
- Single-threaded code gets slower. The interpreter no longer assumes the GIL protects it, so reference counting and some internal data structures pay a small tax. Benchmarks vary by workload, but expect a noticeable single-thread regression.
- C extensions have to opt in. If a C extension was not written to be safe without the GIL, importing it under a free-threaded build can crash or corrupt state. NumPy, Cython, and the major C-backed packages are catching up, but you need to test.
For now, treat free-threaded CPython as “available for specific workloads where you have measured the trade-off,” not as a default replacement. The GIL explainer covers the background.
What mistakes bite most often?
- Using
requestsinside an async function withoutto_thread.requestsis synchronous; calling it from coroutine code blocks the event loop until the response arrives. Usehttpxwith its async client, or wrap withasyncio.to_thread. - Calling
asyncio.runfrom inside a running loop. It raisesRuntimeError. The right way to call sync code from async isto_thread; the right way to call async code from sync isasyncio.runat the top level only. - Assuming
asyncio.Lockworks across threads. It does not. It is task-safe (cooperative), not thread-safe. For threads, usethreading.Lock. If you think you need both at once, redesign so threads and tasks don’t share state. - Picking threads for CPU work “just to try.” They will not get faster. You will get the same throughput as a single thread, plus thread overhead, plus harder-to-debug code. Reach for
ProcessPoolExecutor. - Returning huge objects from
ProcessPoolExecutortasks. They get pickled, sent over a pipe, and unpickled. A 1 GB numpy array is going to hurt. Either reduce at the source, or usemultiprocessing.shared_memory. - Mixing
asyncio.gatherwith baretry/exceptaround the call. A single exception cancels the rest of the group, but the raisedCancelledErroris raised before the rest of the failures are surfaced. WithTaskGroupyou get all of them in oneExceptionGroup; withgather, you get whichever was scheduled first.
How do you pick the right concurrency patterns?
A short checklist for the next time you reach for concurrency in Python:
- Is the work I/O-bound or CPU-bound? Identify which it is before you write the import line. If you are not sure, profile.
- How many concurrent tasks do you expect? Dozens → threads is fine. Hundreds to thousands → asyncio. Both: pick the model for the dominant workload and bridge the rest.
- Does the third-party library you need support async?
httpxdoes,requestsdoes not. Mixing them silently is one of the most common performance bugs in async code. - Is the data you need to share huge? If yes, processes need
shared_memoryor a different architecture. Threads share memory but pay the GIL tax. Free-threaded builds share memory without the tax but cost single-thread speed. - What is the lifecycle?
asynciogives you structured concurrency for free withTaskGroup. Threads and processes give you the raw building blocks, and you wire the lifecycle yourself.
Python concurrency patterns are not a single decision you make once. They are a per-boundary choice, and the right answer is usually “mix, and bridge at the seam.”
Frequently asked questions
Should I always use asyncio for I/O-bound work?
No. For dozens of concurrent I/O tasks, threads are simpler and play well with existing sync libraries. Reach for asyncio when you need hundreds or thousands of in-flight tasks, or when you want structured concurrency via TaskGroup.
Can threads speed up CPU-bound code on regular CPython?
No, not in any meaningful way. The GIL serializes pure-Python bytecode. Use ProcessPoolExecutor for CPU work on stock CPython, or try a free-threaded build (3.13t/3.14t) if you can measure the trade-off for your workload.
Is free-threaded CPython ready for production?
For some workloads, yes. Single-threaded performance regresses noticeably, and C extensions must be written to be safe without the GIL. Test before you deploy, and keep the GIL build as your default until you have evidence.
Why does my async code stall when I call requests?
requests is synchronous. Calling it from coroutine code blocks the event loop for the duration of the HTTP round trip. Use httpx with its async client, or wrap the call in asyncio.to_thread.
What is the difference between asyncio.gather and asyncio.TaskGroup?
gather (legacy) returns the first exception and leaves siblings running unless you pass return_exceptions=True. TaskGroup (3.11+) cancels siblings, awaits them, and raises an ExceptionGroup containing every failure. Prefer TaskGroup for new code.