Python Concurrency: asyncio, Threading, Multiprocessing, and Free-Threaded CPython (2026)
Practical guidance on asyncio fundamentals and task groups, threading vs multiprocessing, the GIL, the status of free-threaded CPython (3.13/3.14), and a decision framework for choosing async, threads, or processes.
Introduction / Overview
Python offers three distinct concurrency models - asyncio (single-threaded cooperative concurrency), threading (OS threads, historically constrained by the GIL), and multiprocessing (separate processes, true parallelism) - plus, as of Python 3.13/3.14, an experimental-to-supported free-threaded build that removes the Global Interpreter Lock entirely. Choosing correctly among these is one of the highest-leverage architecture decisions in a Python codebase, because retrofitting the wrong model is expensive.
As of 2026, PEP 703's free-threaded CPython has progressed from experimental (3.13) to officially supported via PEP 779 (3.14), with the specializing adaptive interpreter now enabled in free-threaded builds and single-threaded overhead down to roughly 5-10%. It remains an opt-in build (python3.14t), not yet the default interpreter distributed by most package managers.
When to use this guidance: Designing a new service under concurrent load, diagnosing why a "concurrent" implementation isn't actually parallel, or deciding whether free-threaded CPython is viable for a given workload.
Core Concepts
- The GIL (Global Interpreter Lock): In standard (non-free-threaded) CPython, only one thread executes Python bytecode at a time. Threads still provide concurrency for I/O-bound work (the GIL is released during blocking I/O) but not CPU-bound parallelism.
asynciois single-threaded cooperative concurrency: Coroutines voluntarily yield control atawaitpoints. There is no preemption - a coroutine that never awaits blocks the entire event loop.- Threads share memory, processes do not:
threadinggives you shared address space (fast communication, but requires explicit synchronization);multiprocessinggives you isolated memory (safe by construction, but requires serialization to communicate). - Free-threaded CPython removes the GIL, not the need for synchronization: Without the GIL, true multi-core parallelism is possible even for pure-Python CPU-bound code, but data races on shared mutable state become possible in ways the GIL previously masked - locks, atomics, or immutable data structures are still required for correctness.
- I/O-bound vs CPU-bound is the primary decision axis:
asyncio/threads suit I/O-bound work (network calls, disk, waiting on external systems);multiprocessingor free-threaded parallelism suit CPU-bound work (parsing, numeric computation, image processing).
Best Practices
1. Default to asyncio for I/O-Bound Concurrency at Scale
Rationale: A single event loop can manage tens of thousands of concurrent connections with far less memory overhead per unit of concurrency than an equivalent number of OS threads.
import asyncio
import httpx
async def fetch_all(urls: list[str]) -> list[httpx.Response]:
async with httpx.AsyncClient(timeout=10.0) as client:
async with asyncio.TaskGroup() as tg:
tasks = [tg.create_task(client.get(url)) for url in urls]
return [task.result() for task in tasks]
results = asyncio.run(fetch_all(["https://api.example.com/a", "https://api.example.com/b"]))
Why this works well in production: TaskGroup (Python 3.11+) supersedes asyncio.gather for most use cases - it cancels sibling tasks automatically if one fails and raises an ExceptionGroup with all failures, not just the first one.
2. Use TaskGroup and except* for Structured Concurrency
Rationale: Structured concurrency guarantees that no task outlives the scope that created it, eliminating an entire class of "fire and forget" bugs where a background task's exception is silently dropped.
async def process_batch(items: list[Item]) -> None:
try:
async with asyncio.TaskGroup() as tg:
for item in items:
tg.create_task(process_one(item))
except* ValidationError as eg:
for exc in eg.exceptions:
logger.warning("Skipping invalid item: %s", exc)
except* ConnectionError as eg:
raise ServiceUnavailableError("Downstream unavailable") from eg.exceptions[0]
3. Use threading for I/O-Bound Work That Cannot Be Made Async
Rationale: Some libraries (legacy database drivers, certain C extensions) are blocking and have no async equivalent. threading - or asyncio.to_thread to bridge into an async application - is the correct tool, not multiprocessing, since the work is I/O-bound and shares data cheaply.
import asyncio
async def query_legacy_driver(sql: str) -> list[dict]:
return await asyncio.to_thread(blocking_legacy_query, sql)
Production tip: asyncio.tothread runs the function in the default ThreadPoolExecutor; size that executor deliberately (loop.setdefaultexecutor) rather than accepting the default of min(32, os.cpucount() + 4) without consideration.
4. Use multiprocessing (or concurrent.futures.ProcessPoolExecutor) for CPU-Bound Work
Rationale: On standard (GIL) CPython, CPU-bound Python code cannot use more than one core within a single process regardless of thread count. Separate processes bypass the GIL entirely by using separate interpreters.
from concurrent.futures import ProcessPoolExecutor
import asyncio
def cpu_heavy_transform(payload: bytes) -> bytes:
... # pure computation, no shared state
async def process_uploads(payloads: list[bytes]) -> list[bytes]:
loop = asyncio.get_running_loop()
with ProcessPoolExecutor(max_workers=4) as pool:
futures = [loop.run_in_executor(pool, cpu_heavy_transform, p) for p in payloads]
return await asyncio.gather(*futures)
Why this works well in production: Combining an async web layer with a ProcessPoolExecutor for CPU-bound work keeps the event loop responsive while genuinely parallelizing the expensive part.
5. Evaluate Free-Threaded CPython (3.13t/3.14t) for Genuinely CPU-Bound, Shared-Memory Workloads
Rationale: Free-threaded builds allow true multi-core parallelism with threading for pure-Python CPU-bound code, avoiding the serialization overhead of multiprocessing for large shared datasets (e.g., large in-memory arrays passed between workers).
# Requires a free-threaded build: python3.14t
import sys
import threading
assert not sys._is_gil_enabled() # Confirms GIL is disabled at runtime
def worker(shared_counter: list[int], lock: threading.Lock) -> None:
for _ in range(100_000):
with lock:
shared_counter[0] += 1
Adoption guidance for 2026: Treat free-threaded CPython as production-viable only after verifying that every C-extension dependency in your stack (NumPy, Pydantic's Rust core, database drivers) has confirmed free-threaded compatibility; the ecosystem transition is ongoing and per-package, not automatic.
Common Pitfalls & Anti-Patterns
Pitfall: Using multiprocessing for I/O-Bound Work
Problem: Spawning processes to wait on network calls wastes memory (each process has its own interpreter and copy of loaded modules) and adds serialization overhead for no parallelism benefit, since I/O-bound work was never CPU-constrained.
Recommended approach: Use asyncio or threading for I/O-bound work; reserve multiprocessing for CPU-bound computation (Best Practice #4).
Pitfall: Blocking the Event Loop with Synchronous Code
Problem: A single synchronous, CPU-heavy call (hashlib.sha256(huge_file), a synchronous requests.get) inside an async def function blocks the entire event loop, stalling every other concurrent request.
Recommended approach: Offload blocking calls with asyncio.to_thread (I/O-bound) or a ProcessPoolExecutor (CPU-bound); never call blocking library functions directly from a coroutine.
Pitfall: Assuming the GIL Makes All Operations Thread-Safe
Problem: The GIL prevents two threads from executing Python bytecode simultaneously, but compound operations (counter += 1, dict get-then-set) are not atomic at the bytecode level - race conditions still occur without explicit locking.
Recommended approach: Use threading.Lock, queue.Queue, or higher-level primitives for any shared mutable state, regardless of GIL presence - and treat this as mandatory, not optional, on free-threaded builds where the GIL's incidental protection is entirely absent.
Pitfall: Unbounded Task Creation Without Backpressure
Problem: Creating an asyncio task per incoming item without limiting concurrency (for item in millionsofitems: tg.create_task(handle(item))) can exhaust memory or overwhelm downstream services.
Recommended approach: Bound concurrency with asyncio.Semaphore or a bounded task queue pattern.
semaphore = asyncio.Semaphore(50)
async def bounded_fetch(client: httpx.AsyncClient, url: str) -> httpx.Response:
async with semaphore:
return await client.get(url)
Testing Strategies
- Test coroutines with
pytest-asyncio; useasyncio_mode = "auto"inpyproject.tomlto avoid repeating@pytest.mark.asyncioon every test. - Test
TaskGroup/except*error paths explicitly: assert that anExceptionGroupis raised and contains the expected exception types when a sibling task fails. - For thread/process-based code, test the pure worker function directly (no thread pool) for logic correctness, and add a small number of integration tests that verify actual concurrent execution (e.g., timing-based assertions that work happens in parallel, with generous tolerances to avoid CI flakiness).
- Use
hypothesis's stateful testing (RuleBasedStateMachine) to fuzz-test concurrent data structures for race conditions.
See Python Testing Guide for the broader pytest ecosystem.
Performance, Security & Scalability Considerations
- Context-switching overhead:
asynciocoroutines are far cheaper than OS threads (kilobytes vs megabytes of stack per unit, no kernel-level context switch), making it the right default for high-concurrency I/O. - Multiprocessing serialization cost: Data passed to/from worker processes is pickled; large objects (big NumPy arrays, large DataFrames) incur real serialization overhead - use shared memory (
multiprocessing.shared_memory) for large numeric data when the cost is measurable. - Free-threading single-thread overhead: Free-threaded builds carry an approximate 5-10% single-thread performance penalty relative to the standard GIL build, a cost that must be weighed against the parallelism gained.
- Denial-of-service via unbounded concurrency: An async server without connection/semaphore limits is vulnerable to resource exhaustion from a burst of legitimate or malicious concurrent requests; always cap in-flight work.
- Deadlocks: Acquiring multiple locks in inconsistent order across threads is the classic deadlock cause; always acquire locks in a single, globally consistent order, or prefer lock-free/queue-based designs.
Edge Cases & Advanced Usage
- Mixing async and sync codebases during migration: Use
asyncio.to_threadat the boundary to call into legacy synchronous code incrementally rather than rewriting an entire codebase to async at once. asyncio.Queueas a producer/consumer pattern: Preferred over manual locking for coordinating work between coroutines within a single event loop.- Cancellation semantics: A cancelled
asyncio.Taskraisesasyncio.CancelledErrorinside the coroutine at its nextawaitpoint; cleanup code must run in afinallyblock, and re-raisingCancelledError(not swallowing it) is required for correct shutdown behavior. - Free-threading and C extensions: Extensions built against the stable ABI without free-threading support will force the GIL back on at import time (or fail to import) in some transitional builds; check the
PyGILDISABLEDbuild compatibility of every native dependency before relying on true parallelism. multiprocessingstart methods:fork(Linux default, fast but can inherit unsafe state),spawn(safer, required on macOS/Windows, slower startup), andforkserver- explicitly choose the start method for cross-platform reproducibility rather than relying on the platform default.
When Not to Use / Alternatives
| Dimension | asyncio | threading | multiprocessing | Free-threaded CPython |
|---|---|---|---|---|
| I/O-bound concurrency at scale | Excellent | Good (higher memory overhead) | Poor (heavy overhead) | N/A (no I/O advantage) |
| CPU-bound parallelism | None (single-threaded) | None (GIL-limited) | Excellent (true parallelism) | Excellent (true parallelism) |
| Shared mutable state simplicity | Simple (single-threaded) | Requires locks | None needed (isolated memory) | Requires locks (no GIL safety net) |
| Ecosystem/library maturity (2026) | Very mature | Very mature | Very mature | Rapidly maturing, opt-in build |
| Startup/communication overhead | Negligible | Low | High (process spawn, pickling) | Negligible (shared memory) |
Choose multiprocessing over free-threaded CPython when strict memory isolation between workers is a correctness or security requirement (e.g., untrusted plugin code), not just a performance concern.
Choose plain synchronous code when concurrency adds no measurable throughput benefit - a low-traffic internal tool does not need asyncio's added complexity.
Related Documentation
- Python Language Overview & Ecosystem
- Python General Best Practices
- Python Performance Guide
- Python Testing Guide
- FastAPI Best Practices - async request handling patterns
- Django Best Practices - async views vs sync
- Unit Testing Principles
- Clean Code Principles
Glossary
- GIL (Global Interpreter Lock): A mutex in standard CPython builds ensuring only one thread executes Python bytecode at a time.
- Free-threaded CPython: A CPython build (PEP 703, officially supported per PEP 779 since 3.14) that removes the GIL, enabling true multi-core parallelism for pure-Python threads.
TaskGroup: A Python 3.11+asyncioconstruct providing structured concurrency - all child tasks are awaited or cancelled together, with combined error reporting viaExceptionGroup.- **
ExceptionGroup/except***: A mechanism (PEP 654) for handling multiple exceptions raised concurrently by sibling tasks. - Structured concurrency: A concurrency discipline where a task's lifetime is strictly bound to the scope that created it, preventing orphaned background tasks.
- Start method (multiprocessing): The mechanism (
fork,spawn,forkserver) by which a new child process is created.