Language and Runtime Concurrency Models: Python

Python offers three concurrency toolkits that look alike but solve different problems: threads for overlapping blocking I/O (and for native code that releases the GIL), asyncio for very many concurrent I/O operations on one thread, and processes for CPU-bou…

Learning Objectives

Use threading locks, events, conditions and semaphores; queue.Queue; concurrent.futures; asyncio tasks, the event loop and TaskGroup; multiprocessing and process pools.

Explain the GIL, its implementation/version caveats, and free-threaded CPython's status.

Handle cancellation and timeouts; profile and debug concurrent Python.

Plain-Language Explanation

Python offers three concurrency toolkits that look alike but solve different problems: threads for overlapping blocking I/O (and for native code that releases the GIL), asyncio for very many concurrent I/O operations on one thread, and processes for CPU-bound parallel work. Knowing which one fits is most of the battle.

Precise Technical Explanation

threading: locks, events, conditions and semaphores Lock (non-reentrant), RLock (reentrant), Event (manual-reset flag), Condition (monitor with wait_for(predicate)), Semaphore/BoundedSemaphore, Barrier, Timer, threading.local, threading.excepthook, daemon threads. All support with.

Thread-safe FIFO (also LifoQueue, PriorityQueue, SimpleQueue), with maxsize, blocking put/get with timeouts, task_done/join, and shutdown() (Python 3.13+).

A uniform executor API: ThreadPoolExecutor/ProcessPoolExecutor (and InterpreterPoolExecutor in 3.14), submit, map, as_completed, wait, Future.cancel/result/exception/add_done_callback, shutdown(wait, cancel_futures).

asyncio tasks and event loop asyncio.run, create_task, gather, wait, as_completed, Queue, Lock/Event/Semaphore/Condition (not thread-safe!), to_thread, run_coroutine_threadsafe, streams, subprocesses.

asyncio.TaskGroup where supported Python 3.11+ (Module 13). For 3.8–3.10, use anyio/Trio or manual cancellation.

Multiprocessing and process pools Module 16: Process, Pool, ProcessPoolExecutor, Queue, Pipe, Manager, shared_memory, start methods.

GIL concept and implementation/version caveats The global interpreter lock in CPython's standard build allows one thread at a time to execute Python bytecode; it is released during blocking I/O, time.sleep, and by many C extensions during heavy computation. It is an implementation detail: PyPy has a GIL too, GraalPy and Jython historically did not; the GIL never made *compound* Python operations atomic — races still happen (Module 3). The interpreter switches threads every sys.getswitchinterval() (5 ms by default).

Free-threaded CPython builds and version-specific status PEP 703: CPython 3.13 offers an *experimental* free-threaded build (python3.13t, --disable-gil); 3.14 makes it officially supported (PEP 779) but optional; the default build keeps the GIL. Check with sys._is_gil_enabled() (3.13+). C extensions must declare compatibility; single-threaded performance is somewhat lower.

Cancellation and timeout behavior Threads: cooperative (Event), no forced cancel. Futures: cancel() only succeeds before start. asyncio: Task.cancel() raises CancelledError at the next await; asyncio.timeout/wait_for cancel on timeout; CancelledError derives from BaseException (3.8+) so except Exception does not swallow it.

Profiling and debugging faulthandler.dump_traceback(all_threads=True) (thread dump, shown below), sys._current_frames(), py-spy dump/top (sampling, no code changes), asyncio debug mode and asyncio.all_tasks(), cProfile (per thread), threading.settrace, tracemalloc.

Key Terms

CPython lock allowing one thread to run bytecode at a time CPython built without the GIL (3.13t+) Wait in a predicate loop concurrent.futures pool abstraction

Run a blocking function in a thread from asyncio

Diagram or Execution Timeline

playground

Syntax and API Overview

join() / wait() gather / TaskGroup join() / pool map

Complete Code Example

The specification's thread example (CPython 3.13.1), run three times:

The order of the worker messages *can* vary (these runs may or may not show it); "all workers finished" is always last because of join(). join() waits for completion — it does not make shared mutable state safe.

Monitors, events, semaphores, the GIL status and a live thread dump (CPython 3.13.1):

from threading import Thread

def worker(number):
    print(f"worker {number} running")

threads = [Thread(target=worker, args=(n,)) for n in range(3)]

for thread in threads:
    thread.start()

for thread in threads:
    thread.join()

print("all workers finished")
--- run 1 ---
worker 0 running
worker 1 running
worker 2 running
all workers finished
--- run 2 ---
worker 0 running
worker 1 running
worker 2 running
all workers finished
--- run 3 ---
worker 0 running
worker 1 running
worker 2 running
all workers finished
import faulthandler
import sys
import tempfile
import threading
import time


class BoundedCounter:                                  # a monitor: data + lock + condition
    def __init__(self, limit):
        self.value, self.limit = 0, limit
        self.cond = threading.Condition()

    def increment(self):
        with self.cond:
            self.cond.wait_for(lambda: self.value < self.limit)   # predicate loop built in
            self.value += 1
            self.cond.notify_all()

    def decrement(self):
        with self.cond:
            self.value -= 1
            self.cond.notify_all()


c = BoundedCounter(limit=2)
peak = 0
peak_lock = threading.Lock()
def user():
    global peak
    c.increment()
    with peak_lock:
        peak = max(peak, c.value)
    time.sleep(0.02)
    c.decrement()
ts = [threading.Thread(target=user) for _ in range(6)]
for t in ts: t.start()
for t in ts: t.join()
print("monitor with limit 2: peak value", peak)

ready = threading.Event()
started = []
def waiter(i):
    ready.wait()                                       # all waiters released at once
    started.append(i)
ws = [threading.Thread(target=waiter, args=(i,)) for i in range(3)]
for w in ws
…
monitor with limit 2: peak value 2
Event released 3 waiters
GIL enabled in this build: True | switch interval: 0.005 s
thread dump excerpt:
  Thread 0x00005a64 (most recent call first):
  File "D:\EngineerHub-tools\python313-pkg\tools\Lib\threading.py", line 359 in wait
  File "D:\EngineerHub-tools\python313-pkg\tools\Lib\threading.py", line 659 in wait

Line-by-Line Explanation

Condition.wait_for re-checks the predicate after every wake-up; the counter never exceeds 2.

Event.set releases every waiter (manual reset).

sys._is_gil_enabled() is True on this standard build; on a free-threaded build it returns False.

faulthandler.dump_traceback(all_threads=True) prints every thread's current stack — the stuck worker is visible waiting in threading.py.

Output

Shown above (the thread-dump addresses/IDs vary per run).

Interleaving or State Trace

Three users wanting the bounded counter (limit 2): users 1 and 2 increment (value 2); user 3 waits in wait_for; user 1 decrements and notify_alls; user 3 wakes, re-checks (value 1 < 2), increments.

Correctness and Safety Analysis

Every shared variable has one guard: value → the condition's lock; peak → peak_lock; started → appended by threads, read after joins (list append is atomic in CPython, but relying on that is fragile — prefer a lock or a queue).

Common Mistakes and Misconceptions

"The GIL makes my code thread-safe." Using asyncio.Lock from threads (it is not thread-safe) or threading.Lock inside coroutines (it blocks the loop).

Calling blocking libraries (requests) inside async def.

Forgetting the __main__ guard with multiprocessing.

Failure Modes and Debugging Steps

Hangs: faulthandler.dump_traceback_later(timeout) or py-spy dump --pid; asyncio stalls: debug mode + all_tasks(); races: stress tests, since there is no ThreadSanitizer for pure Python (free-threaded builds can be tested with TSan-instrumented CPython).

Performance and Scalability Implications

Threads: good for blocking I/O, poor for pure-Python CPU work (GIL build). asyncio: best for many concurrent sockets. Processes: CPU parallelism with serialization costs. Free-threaded builds change the threads row — measure on the build you deploy.

Security Considerations

Never unpickle untrusted data (multiprocessing uses pickle). threading.local/contextvars must not leak per-request data between requests in pooled workers.

Platform and Runtime Caveats

TaskGroup, asyncio.timeout, except*: 3.11+. queue.Queue.shutdown, sys._is_gil_enabled, free-threaded build: 3.13+. InterpreterPoolExecutor: 3.14.

Windows: spawn only; asyncio defaults to the proactor loop.

Real-World Applications

Django/Flask (threads or processes per request via Gunicorn), FastAPI (asyncio), Celery workers (processes), PyTorch DataLoader (processes), crawlers (asyncio + aiohttp).

Best Practices

Threads for blocking I/O, asyncio for many sockets, processes for CPU.

Use concurrent.futures and TaskGroup rather than raw primitives.

Keep locks of one kind per world (threading vs asyncio vs multiprocessing).

Dump stacks when hung; test on the interpreter build you ship.

Exercises

E1 · Beginner. Which toolkit fits: (a) 10,000 websocket clients; (b) resizing 5,000 images with Pillow; (c) calling a blocking SDK 20 times concurrently from a script?

Answer: (a) asyncio; (b) processes (or threads if the operation releases the GIL — Pillow often does for some operations; measure); (c) ThreadPoolExecutor.

E2 · Intermediate. How do you call a blocking function from asyncio without stalling the loop, and how do you call a coroutine from a plain thread?

Answer: await asyncio.to_thread(func, *args) (or loop.run_in_executor); from a thread: asyncio.run_coroutine_threadsafe(coro, loop).result().

E3 · Advanced challenge. Why might a program that is correct on the GIL build fail on a free-threaded build?

Answer: It may rely on operations being effectively atomic under the GIL (e.g. unsynchronised updates to shared dicts or counters, or C extensions assuming the GIL protects their state). Without the GIL, true parallel execution exposes those races; containers have per-object locks in the free-threaded build, but compound operations still race.

Quiz

? What does the GIL guarantee?
+ Only one thread executes Python bytecode at a time in the standard CPython build. :: It is an interpreter lock.
- That += on shared integers is atomic in all Python implementations. :: Compound operations can interleave.
- That threads run in the order they were started. :: Scheduling is not ordered.
- That asyncio code runs in parallel. :: asyncio is single-threaded.

? Which lock belongs in an async def coroutine?
+ asyncio.Lock :: It suspends the coroutine instead of blocking the loop.
- threading.Lock :: Waiting on it blocks the whole event loop thread.
- multiprocessing.Lock :: For processes.
- No lock is ever needed in asyncio :: Shared state across awaits can still race.

? Which are true for CPython 3.13? (select all that apply)
+ A free-threaded build exists but is experimental. :: PEP 703; supported in 3.14.
+ sys._is_gil_enabled() reports whether the GIL is active. :: New in 3.13.
- The default Windows build has no GIL. :: The default build keeps the GIL.
+ asyncio.TaskGroup is available. :: Since 3.11.

Summary

Python offers threads (blocking I/O), asyncio (massive I/O concurrency on one thread) and processes (CPU parallelism). The GIL serialises bytecode in standard builds but not compound logic; free-threaded builds are arriving. Use executors and task groups, keep each world's primitives separate, cancel cooperatively, and dump stacks to debug.

Further Reading

Python docs: threading, queue, concurrent.futures, asyncio, multiprocessing, "Python support for free threading".

PEP 703 "Making the Global Interpreter Lock Optional"; PEP 779.

David Beazley, "Understanding the Python GIL" (2010).

Łukasz Langa, "asyncio: we did it wrong" talks; Trio documentation.