--- title: "Python Concurrency: asyncio, Threading, Multiprocessing, and Free-Threaded CPython (2026)" description: "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." language: python framework: null category: language_concurrency tags: - concurrency - async - parallelism - thread-safety - asyncio - gil - free-threading keywords: - asyncio task groups python - threading vs multiprocessing python - python GIL free threaded cpython 3.13 3.14 - PEP 703 PEP 779 status - when to use async vs threads vs processes last_updated: 2026-07-07 difficulty: advanced version: "Python >= 3.12, free-threaded build officially supported since 3.14 (PEP 779)" related: - README.md - best_practices.md - performance.md - testing.md - project_structure.md - frameworks/fastapi/best_practices.md - frameworks/django/best_practices.md - ../../core/testing/unit_testing_principles.md - ../../core/principles/clean_code.md search_priority: high status: published --- # Python Concurrency: asyncio, Threading, Multiprocessing, and Free-Threaded CPython (2026) ## 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. - **`asyncio` is single-threaded cooperative concurrency**: Coroutines voluntarily yield control at `await` points. There is no preemption - a coroutine that never awaits blocks the entire event loop. - **Threads share memory, processes do not**: `threading` gives you shared address space (fast communication, but requires explicit synchronization); `multiprocessing` gives 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); `multiprocessing` or 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. ```python 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. ```python 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. ```python import asyncio async def query_legacy_driver(sql: str) -> list[dict]: return await asyncio.to_thread(blocking_legacy_query, sql) ``` **Production tip**: `asyncio.to_thread` runs the function in the default `ThreadPoolExecutor`; size that executor deliberately (`loop.set_default_executor`) rather than accepting the default of `min(32, os.cpu_count() + 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. ```python 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). ```python # 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 millions_of_items: 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. ```python 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`; use `asyncio_mode = "auto"` in `pyproject.toml` to avoid repeating `@pytest.mark.asyncio` on every test. - Test `TaskGroup`/`except*` error paths explicitly: assert that an `ExceptionGroup` is 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](testing.md) for the broader pytest ecosystem. ## Performance, Security & Scalability Considerations - **Context-switching overhead**: `asyncio` coroutines 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_thread` at the boundary to call into legacy synchronous code incrementally rather than rewriting an entire codebase to async at once. - **`asyncio.Queue` as a producer/consumer pattern**: Preferred over manual locking for coordinating work between coroutines within a single event loop. - **Cancellation semantics**: A cancelled `asyncio.Task` raises `asyncio.CancelledError` inside the coroutine at its next `await` point; cleanup code must run in a `finally` block, and re-raising `CancelledError` (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 `Py_GIL_DISABLED` build compatibility of every native dependency before relying on true parallelism. - **`multiprocessing` start methods**: `fork` (Linux default, fast but can inherit unsafe state), `spawn` (safer, required on macOS/Windows, slower startup), and `forkserver` - 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](README.md) - [Python General Best Practices](best_practices.md) - [Python Performance Guide](performance.md) - [Python Testing Guide](testing.md) - [FastAPI Best Practices](frameworks/fastapi/best_practices.md) - async request handling patterns - [Django Best Practices](frameworks/django/best_practices.md) - async views vs sync - [Unit Testing Principles](../../core/testing/unit_testing_principles.md) - [Clean Code Principles](../../core/principles/clean_code.md) ## 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+ `asyncio` construct providing structured concurrency - all child tasks are awaited or cancelled together, with combined error reporting via `ExceptionGroup`. - **`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.