Course outline

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.

About 5 min readContent reviewed
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.

python A deterministic async example — worked example
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())
Expected output
[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.

python Shared state still needs a design — worked example
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)
Expected output
[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.

Official references