Python / Maintainable systems / Lesson 16
Async, threads, processes, and the GIL
Choose concurrency by workload, manage task lifetimes, and distinguish CPython build behavior from universal language guarantees.
On this page
Lesson goals
- Concurrency is a scheduling choice
- Shared state still needs a design
Concurrency is a scheduling choice
Concurrency allows activities to make overlapping progress; parallelism executes work simultaneously on multiple processing resources. Asyncio normally schedules cooperative tasks on one event-loop thread. An await may suspend a task while another ready task progresses, but CPU-heavy code without suspension blocks the loop. Calling an async function creates a coroutine object rather than immediately completing its body. asyncio.run owns a top-level loop in an ordinary script; code already running inside a loop should await instead of nesting another run call. Choose async when the work can await nonblocking operations, not merely because a function is slow.
Bound task lifetime and cancellation
Python 3.11 introduced TaskGroup, which scopes related tasks and waits for them at exit. A task failure can cancel siblings, and grouped failures are represented with exception groups according to the documented rules. Cleanup should use finally, and code catching CancelledError should usually re-raise after cleanup so structured concurrency works correctly. Timeouts and cancellation express a request to stop at cooperative points rather than forcibly undoing completed side effects. Bound concurrency with a semaphore or a worker queue when processing many jobs. Creating one task per unbounded input can exhaust memory even if each task spends most of its time waiting.
A deterministic async example
The first example creates two related tasks in a TaskGroup and prints their results only after the group exits. sleep(0) provides a cooperative suspension without external network requests or a meaningful timing claim. Result reporting follows the explicit task list rather than relying on scheduling-dependent print order inside workers. This keeps the output deterministic while demonstrating concurrent task lifetime. It does not claim that squaring two integers is faster asynchronously. For real workloads, identify the awaited resource, ensure its client is async-compatible, and measure throughput and latency under bounded concurrency instead of inferring performance from the presence of async keywords.
import asyncio
async def square(value):
await asyncio.sleep(0)
return value * value
async def main():
async with asyncio.TaskGroup() as group:
tasks = [group.create_task(square(value)) for value in [2, 3]]
print([task.result() for task in tasks])
asyncio.run(main())
[4, 9]
The group waits for both tasks. Reading results afterward in input order avoids dependence on which task happened to resume first.
Threads, processes, and build-specific GIL facts
On a conventional GIL-enabled CPython build, the GIL limits simultaneous execution of Python bytecode by threads in one interpreter; many blocking I/O operations and some native extensions release it. Threads can therefore help I/O-bound work, while separate processes are often suitable for CPU-bound Python work after serialization and startup costs are considered. This is not a rule that Python threads can never run CPU work in parallel. CPython 3.13 introduced optional free-threaded builds, and 3.14 also supports them. Some extensions may re-enable the GIL. Check the actual interpreter build and extension compatibility rather than assuming a version number alone guarantees GIL-free execution.
Shared state still needs a design
The GIL is not a substitute for application synchronization: a read-modify-write sequence can span operations and violate invariants. Free-threaded execution makes explicit synchronization even more important; do not rely on internal container locks as a transaction contract. Prefer independent inputs and returned results, using locks or queues when sharing is required. The second example demonstrates a small thread pool with independent pure calls and ordered map results. Process pools add importability and pickling constraints, and entry-point guards matter on spawn-based platforms. No process example here assumes one operating system’s start method. Measure coordination overhead before increasing worker count, and always retrieve failures from futures.
from concurrent.futures import ThreadPoolExecutor
def square(value):
return value * value
with ThreadPoolExecutor(max_workers=2) as executor:
results = list(executor.map(square, [2, 3, 4]))
print(results)
[4, 9, 16]
Independent tasks need no shared mutable accumulator. map yields in input order, and leaving the executor context waits for submitted work. This example makes no speedup claim.
Common errors and fixes
Other async tasks freeze during one operation
- Why it happens
- Blocking or CPU-heavy synchronous work occupies the loop thread.
- How to fix it
- Use an async-compatible operation or move suitable work to a bounded executor.
- Prevent it next time
- Identify blocking boundaries before adopting asyncio.
A coroutine was never awaited
- Why it happens
- Calling the async function created an object without scheduling or awaiting it.
- How to fix it
- Await it or create it within an owned task scope.
- Prevent it next time
- Make every task’s owner and completion path explicit.
Shared totals lose updates despite having a GIL
- Why it happens
- The application assumes a multi-step invariant is automatically atomic.
- How to fix it
- Use explicit synchronization or aggregate independent results.
- Prevent it next time
- Test shared-state design on supported builds without relying on accidental scheduling.
Check your understanding
1. Choose a model for many independent blocking file-like operations and for pure Python CPU calculations.
Show worked solution
A bounded thread pool can overlap blocking operations. For CPU-bound code on a GIL-enabled CPython build, consider processes after measuring transfer and startup costs. On an optional free-threaded build, threads may offer parallel CPU execution if dependencies support it. Benchmark the actual workload and interpreter.
2. Why should cancellation handlers usually re-raise CancelledError after cleanup?
Show worked solution
Structured concurrency and timeout mechanisms rely on cancellation propagation to track task completion correctly. Swallowing cancellation can keep work alive or confuse the scope’s lifecycle. Put release logic in finally, and suppress cancellation only under a deliberately documented advanced policy.
Key takeaways
- Match concurrency to waiting, CPU work, and overhead.
- Task ownership and cancellation are part of correctness.
- GIL behavior depends on CPython build and extensions.