Atlas

Python Parallel Programming: GIL, threads, processes

Teoría

Programar en paralelo en Python obliga a entender una tensión central: el lenguaje ofrece threading, multiprocessing y asyncio, pero cada modelo resuelve un problema distinto y elegir mal degrada el rendimiento en vez de mejorarlo. La causa raíz de casi todas las decisiones es el GIL de CPython. Esta lección recorre el modelo mental completo, desde el sistema operativo hasta las primitivas de sincronización y el shared memory.

Thread vs process (a nivel de OS)

Un process es una unidad de aislamiento del sistema operativo: tiene su propio espacio de direcciones virtual, su propia tabla de descriptores de archivo, su propio heap. Dos procesos no comparten memoria por defecto; comunicarse requiere IPC explícito (pipes, sockets, shared memory segments, colas). Crear un proceso es caro: el OS reserva y mapea páginas, y en Unix fork() usa copy-on-write.

Un thread vive dentro de un process y comparte con sus hermanos el mismo espacio de direcciones: mismo heap, mismas globales, mismos descriptores. Solo el stack y los registros son propios de cada thread. Por eso crear un thread es barato (del orden de MB de stack reservado, no copia de memoria) y por eso dos threads pueden pisarse datos: comparten estado mutable sin barreras.

ThreadProcess
Espacio de direccionesCompartidoAislado
Coste de creaciónBajo (stack + TCB)Alto (páginas, COW en fork)
ComunicaciónVariables compartidasIPC (pipe, queue, shared mem)
Fallo aisladoNo (un segfault tumba el process)
SchedulingOS scheduler (preemptivo)OS scheduler (preemptivo)

El scheduler del OS es preemptivo: puede interrumpir un thread en cualquier instrucción para dar la CPU a otro. Esto es clave para entender las race conditions más abajo.

CPU-bound vs IO-bound

La clasificación de la carga de trabajo determina qué modelo de concurrencia sirve.

# CPU-bound: quema CPU, no espera nada
def cpu_task(n: int) -> int:
    return sum(i * i for i in range(n))

# IO-bound: pasa el tiempo esperando la red
import urllib.request
def io_task(url: str) -> int:
    with urllib.request.urlopen(url) as resp:
        return len(resp.read())

Regla que se deduce del GIL (siguiente sección): para IO-bound sirven threads o asyncio; para CPU-bound solo escalan los procesos.

El GIL y su conexión con el reference counting

El GIL (Global Interpreter Lock) es un mutex dentro del intérprete CPython que garantiza que solo un thread ejecuta bytecode Python a la vez, incluso en una máquina multi-core. No es parte del lenguaje Python: es un detalle de implementación de CPython. Otras implementaciones (Jython, IronPython) no lo tienen.

Por qué existe — y aquí está la conexión con el reference counting. CPython gestiona memoria contando referencias: cada objeto tiene un campo ob_refcnt. Cuando creás un nuevo nombre que apunta al objeto, se incrementa; cuando un nombre sale de scope, se decrementa; al llegar a cero, el objeto se libera.

import sys
x = []
sys.getrefcount(x)  # p.ej. 2 (x + el argumento temporal de getrefcount)

Estos incrementos y decrementos son operaciones INCREF/DECREF. No son atómicas: leer el contador, sumar uno y volver a escribirlo son varias instrucciones de máquina. Si dos threads sin GIL hicieran INCREF/DECREF sobre el mismo objeto simultáneamente, podrían perder actualizaciones (lost update): el contador quedaría mal. Un contador que baja a cero antes de tiempo libera memoria todavía en uso (use-after-free); un contador que nunca llega a cero produce un leak. El GIL serializa todo el bytecode, así que INCREF/DECREF quedan protegidos sin necesidad de un lock por objeto (que sería carísimo dado lo frecuentes que son).

Consecuencias prácticas:

CPython además libera el GIL periódicamente entre threads CPU-bound cada cierto intervalo (sys.getswitchinterval(), por defecto 5 ms) para que ninguno lo monopolice. Eso reparte tiempo, pero no da paralelismo.

Nota sobre el futuro: PEP 703 introduce un build experimental free-threaded (sin GIL) desde CPython 3.13, que reemplaza el conteo de referencias global por mecanismos thread-safe (biased reference counting, deferred refcounting). No es el default ni está listo para producción general al momento de escribir esto. El modelo mental de GIL sigue siendo el correcto para código en producción hoy.

Concurrency vs parallelism

Son cosas distintas y confundirlas lleva a decisiones erróneas:

Concurrency is about dealing with many things at once; parallelism is about doing many things at once (Rob Pike).

En Python, por el GIL:

Cooperative vs preemptive multitasking

Dos formas de repartir la CPU entre tareas:

import asyncio

async def worker(name: str):
    print(f"{name} start")
    await asyncio.sleep(1)   # punto de cesión cooperativa
    print(f"{name} done")

async def main():
    await asyncio.gather(worker("A"), worker("B"))

asyncio.run(main())
# A start / B start / (1s) / A done / B done  -> se solapan

Race conditions

Una race condition ocurre cuando el resultado depende del orden no determinista en que se intercalan operaciones sobre estado compartido. El caso clásico: dos threads incrementando un contador. counter += 1 no es atómico: es leer, sumar, escribir. Con scheduling preemptivo, dos threads pueden leer el mismo valor y ambos escribir el mismo resultado, perdiendo un incremento.

import threading

counter = 0

def increment(n: int):
    global counter
    for _ in range(n):
        counter += 1   # NO atómico: read-modify-write

threads = [threading.Thread(target=increment, args=(100_000,)) for _ in range(4)]
for t in threads: t.start()
for t in threads: t.join()

print(counter)  # esperado 400_000, casi siempre sale MENOS

Nótese que el GIL no salva de esto: el GIL puede soltarse entre bytecodes, y counter += 1 son varios bytecodes (LOAD, BINARY_ADD, STORE). El GIL protege la integridad interna del intérprete, no la atomicidad de tus operaciones de alto nivel.

Locks, semaphores, events y otras primitivas de sincronización

La solución a las races es serializar el acceso a la región crítica con primitivas de threading:

Lock (mutex) — solo un thread posee el lock a la vez:

import threading

counter = 0
lock = threading.Lock()

def increment(n: int):
    global counter
    for _ in range(n):
        with lock:          # adquiere; libera al salir del with
            counter += 1     # ahora la región crítica es atómica

# Resultado: exactamente 400_000

Usar with lock en lugar de lock.acquire()/lock.release() garantiza liberación aun si hay excepción.

RLock (reentrant lock) — el mismo thread puede adquirirlo varias veces (útil en recursión o cuando un método con lock llama a otro con el mismo lock). Un Lock normal haría deadlock consigo mismo.

lock = threading.RLock()
def outer():
    with lock:
        inner()      # inner vuelve a adquirir el mismo lock -> OK con RLock
def inner():
    with lock:
        ...

Semaphore — permite hasta N adquisiciones simultáneas. Sirve para limitar concurrencia (p. ej. máximo 5 conexiones simultáneas a un recurso):

sem = threading.Semaphore(5)   # máximo 5 threads a la vez
def worker():
    with sem:
        call_rate_limited_api()

Event — señal booleana para coordinar threads: unos esperan (wait()), otro dispara (set()):

ready = threading.Event()
def consumer():
    ready.wait()          # bloquea hasta que alguien haga set()
    process()
def producer():
    prepare()
    ready.set()           # despierta a todos los que esperan

Condition — permite esperar a que se cumpla un predicado sobre estado compartido, con wait()/notify(). Es la base para patrones productor-consumidor cuando no alcanza con una Queue.

Barrier — bloquea a N threads hasta que todos llegan a un punto, luego los libera juntos.

Regla de oro: minimizá la región crítica (mantené el lock el menor tiempo posible), adquirí locks siempre en el mismo orden global para evitar deadlocks, y preferí estructuras thread-safe de alto nivel (queue.Queue) sobre locks manuales cuando puedas.

queue.Queue: pasar datos entre threads sin locks manuales

queue.Queue es una cola FIFO thread-safe: internamente maneja sus propios locks y condiciones. Es la forma idiomática de comunicar threads (patrón productor-consumidor) sin escribir sincronización a mano.

import threading, queue

q: queue.Queue[int | None] = queue.Queue()

def producer():
    for i in range(10):
        q.put(i)
    q.put(None)          # sentinela de fin

def consumer():
    while True:
        item = q.get()
        if item is None:
            q.task_done()
            break
        print("consumed", item)
        q.task_done()

t1 = threading.Thread(target=producer)
t2 = threading.Thread(target=consumer)
t1.start(); t2.start()
t1.join(); t2.join()

Variantes: queue.LifoQueue (pila), queue.PriorityQueue (por prioridad). get()/put() pueden bloquear o usar timeouts.

Sobre los módulos: en Python 3 el módulo de bajo nivel _thread (antes thread en Python 2) existe pero no se usa directamente; siempre se trabaja con threading, que lo envuelve con una API orientada a objetos.

Thread y process pools con concurrent.futures

concurrent.futures da una API unificada de alto nivel para ambos modelos: ThreadPoolExecutor (IO-bound) y ProcessPoolExecutor (CPU-bound). Misma interfaz, distinto backend. Es la forma recomendada de paralelizar en código moderno.

from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor, as_completed

urls = ["https://a", "https://b", "https://c"]

# IO-bound -> threads
with ThreadPoolExecutor(max_workers=8) as pool:
    results = list(pool.map(io_task, urls))

# CPU-bound -> processes (cada worker tiene su propio GIL)
nums = [10_000_000] * 8
with ProcessPoolExecutor(max_workers=4) as pool:
    futures = [pool.submit(cpu_task, n) for n in nums]
    for fut in as_completed(futures):
        print(fut.result())   # re-lanza la excepción si el worker falló

map preserva el orden de entrada; submit + as_completed entrega resultados a medida que terminan. El with cierra el pool y espera a que todo termine. Ojo: con ProcessPoolExecutor, los argumentos y resultados se serializan con pickle para cruzar el límite de proceso, así que deben ser picklables (nada de lambdas o closures locales como target).

multiprocessing: Process, Queue, Pipe

Cuando necesitás paralelismo CPU-bound real y control fino, multiprocessing replica la API de threading pero con procesos.

from multiprocessing import Process, Queue

def worker(q: Queue, n: int):
    q.put(sum(i * i for i in range(n)))

if __name__ == "__main__":          # obligatorio en spawn (Windows/macOS)
    q = Queue()
    procs = [Process(target=worker, args=(q, 5_000_000)) for _ in range(4)]
    for p in procs: p.start()
    for p in procs: p.join()
    print([q.get() for _ in procs])

El guard if __name__ == "__main__": es obligatorio cuando el start method es spawn (default en Windows y macOS moderno): el proceso hijo re-importa el módulo, y sin el guard re-ejecutaría el código de arranque en bucle. En Linux el default histórico es fork (más rápido, comparte memoria por COW), pero está migrando a spawn por seguridad con threads. Se controla con multiprocessing.set_start_method().

Comunicación entre procesos:

from multiprocessing import Process, Pipe

def worker(conn):
    conn.send([42, "hi"])
    conn.close()

parent_conn, child_conn = Pipe()
p = Process(target=worker, args=(child_conn,))
p.start()
print(parent_conn.recv())   # [42, 'hi']
p.join()

Pool: el atajo para map paralelo

multiprocessing.Pool gestiona un conjunto fijo de procesos worker y reparte tareas:

from multiprocessing import Pool

if __name__ == "__main__":
    with Pool(processes=4) as pool:
        results = pool.map(cpu_task, [1_000_000] * 8)   # bloqueante, ordenado
        # variantes: pool.imap (lazy), pool.apply_async (no bloqueante)
    print(results)

Pool.map es análogo a ProcessPoolExecutor.map; en código nuevo se suele preferir concurrent.futures por la API unificada y el mejor manejo de excepciones, pero Pool sigue siendo común y expone imap, starmap, apply_async.

Shared memory: Value, Array, Manager y shared_memory

Como los procesos no comparten memoria, multiprocessing ofrece mecanismos para estado compartido cuando pasarlo por Queue es demasiado costoso.

Value y Array — comparten un escalar o un array de tipo C en un segmento de memoria compartida, con un lock opcional incorporado:

from multiprocessing import Process, Value, Array

def inc(counter: Value):
    for _ in range(100_000):
        with counter.get_lock():     # protege la región crítica
            counter.value += 1

if __name__ == "__main__":
    counter = Value("i", 0)          # 'i' = C int
    arr = Array("d", [0.0] * 5)      # 'd' = C double
    procs = [Process(target=inc, args=(counter,)) for _ in range(4)]
    for p in procs: p.start()
    for p in procs: p.join()
    print(counter.value)             # 400_000

Las mismas race conditions aplican entre procesos: counter.value += 1 no es atómico, de ahí el get_lock().

Manager — levanta un proceso servidor que hospeda objetos Python compartidos (list, dict, Namespace, etc.); los otros procesos operan sobre proxies. Más flexible (soporta tipos Python arbitrarios) pero más lento (cada acceso es IPC serializado):

from multiprocessing import Manager, Process

def add(shared_list, x):
    shared_list.append(x)

if __name__ == "__main__":
    with Manager() as mgr:
        shared = mgr.list()
        procs = [Process(target=add, args=(shared, i)) for i in range(5)]
        for p in procs: p.start()
        for p in procs: p.join()
        print(list(shared))

multiprocessing.shared_memory (Python 3.8+) — bloque de memoria cruda compartida entre procesos sin serialización, pensado para datos grandes (p. ej. respaldar un NumPy array). Máximo rendimiento, mínimo overhead, pero manejo manual del ciclo de vida (close(), unlink()):

from multiprocessing import shared_memory

shm = shared_memory.SharedMemory(create=True, size=1024)
buf = shm.buf
buf[0:4] = b"\x01\x02\x03\x04"
name = shm.name          # otro proceso hace SharedMemory(name=name) para adjuntarse
shm.close()
shm.unlink()             # libera el segmento (una sola vez, por el owner)

Jerarquía de coste: shared_memory (más rápido, datos crudos) < Value/Array (tipos C, con lock) < Manager (objetos Python arbitrarios, más lento).

Context Variables

contextvars (Python 3.7+) resuelve un problema que las variables globales y el thread-local no cubren bien en código asíncrono: mantener estado “por contexto de ejecución” (por request, por task) que se propaga correctamente a través de await y no se filtra entre coroutines concurrentes.

import contextvars

request_id = contextvars.ContextVar("request_id", default="-")

def log(msg: str):
    print(f"[{request_id.get()}] {msg}")

async def handle(rid: str):
    request_id.set(rid)      # afecta solo a este contexto/task
    log("processing")

# Cada task en asyncio corre en una copia del contexto:
# request_id.set en una task NO pisa el valor de otra task concurrente.

Diferencias clave:

Web app: processes vs threads (tipos de gunicorn workers)

En un servidor WSGI como gunicorn, el tipo de worker materializa todo lo anterior. Gunicorn corre un proceso master que administra N procesos worker; la elección del worker class define el modelo de concurrencia dentro de cada worker:

El patrón general: múltiples procesos para aprovechar los cores (esquivar el GIL para el throughput agregado), y dentro de cada proceso, threads o async para solapar la espera de I/O. Combinás ambos ejes.

Cuándo usar cada modelo — tabla de decisión

CargaModelo recomendadoPor qué
CPU-bound (cálculo)multiprocessing / ProcessPoolExecutorGIL impide escalar con threads; procesos = paralelismo real
IO-bound, lib síncronathreading / ThreadPoolExecutorLos threads sueltan el GIL en el syscall; bajo overhead
IO-bound, lib async, alta concurrenciaasyncioMiles de conexiones en 1 thread, sin overhead de threads
Mezcla en un web serverprocesos (gunicorn workers) + threads/async dentroCores vía procesos, espera I/O vía threads/async
Bloquear el loop con cómputo/lib síncrona en asyncioloop.run_in_executorDelega a un pool sin congelar el event loop

Heurística de una línea: CPU-bound → procesos; IO-bound → threads o asyncio.

Ejercicios

Ejercicio 1 — Arreglar una race condition con un lock

El siguiente código debería contar 500 000 pero imprime menos. Identificá por qué e implementá la corrección mínima.

import threading

total = 0
def add(n):
    global total
    for _ in range(n):
        total += 1

threads = [threading.Thread(target=add, args=(100_000,)) for _ in range(5)]
for t in threads: t.start()
for t in threads: t.join()
print(total)   # imprime < 500_000
Solución

total += 1 es un read-modify-write de varios bytecodes; el GIL puede soltarse en medio, así que dos threads leen el mismo valor y pierden incrementos. Se serializa la región crítica con un Lock:

import threading

total = 0
lock = threading.Lock()
def add(n):
    global total
    for _ in range(n):
        with lock:
            total += 1

threads = [threading.Thread(target=add, args=(100_000,)) for _ in range(5)]
for t in threads: t.start()
for t in threads: t.join()
print(total)   # 500_000 exacto

Optimización: si el lock por iteración duele, cada thread acumula en una variable local y suma al total una sola vez bajo lock. Menos contención, mismo resultado.

Ejercicio 2 — Paralelizar una tarea CPU-bound con ProcessPoolExecutor

Tenés que calcular sum(i*i for i in range(n)) para una lista de n grandes. Hacelo aprovechando todos los cores y medí que efectivamente escale (a diferencia de threads).

Solución
from concurrent.futures import ProcessPoolExecutor
import os, time

def cpu_task(n: int) -> int:
    return sum(i * i for i in range(n))

if __name__ == "__main__":
    work = [8_000_000] * 8

    t0 = time.perf_counter()
    with ProcessPoolExecutor(max_workers=os.cpu_count()) as pool:
        results = list(pool.map(cpu_task, work))
    print("processes:", time.perf_counter() - t0)

    # Comparación: con ThreadPoolExecutor NO escala (GIL serializa el cómputo)

Puntos clave: if __name__ == "__main__" es obligatorio (spawn re-importa el módulo); cpu_task debe ser picklable (función a nivel de módulo, no lambda); con ThreadPoolExecutor el tiempo no baja porque el GIL serializa el bytecode CPU-bound.

Ejercicio 3 — Productor/consumidor con queue.Queue

Implementá un productor que genere 20 items y varios consumidores que los procesen concurrentemente, terminando limpiamente sin busy-waiting ni race conditions.

Solución
import threading, queue

q: queue.Queue = queue.Queue()
NUM_CONSUMERS = 3

def producer():
    for i in range(20):
        q.put(i)
    for _ in range(NUM_CONSUMERS):
        q.put(None)          # una sentinela por consumidor

def consumer(cid: int):
    while True:
        item = q.get()
        try:
            if item is None:
                return
            print(f"consumer {cid} -> {item}")
        finally:
            q.task_done()

p = threading.Thread(target=producer)
cs = [threading.Thread(target=consumer, args=(i,)) for i in range(NUM_CONSUMERS)]
p.start(); [c.start() for c in cs]
p.join(); [c.join() for c in cs]

Queue es thread-safe internamente: no hacen falta locks manuales. Las sentinelas None (una por consumidor) permiten terminar sin daemon threads ni flags compartidos.

Ejercicio 4 — Contador compartido entre procesos con Value

Cuatro procesos incrementan un contador compartido 100 000 veces cada uno. Garantizá el resultado correcto (400 000).

Solución
from multiprocessing import Process, Value

def inc(counter):
    for _ in range(100_000):
        with counter.get_lock():
            counter.value += 1

if __name__ == "__main__":
    counter = Value("i", 0)          # C int en memoria compartida, con lock incorporado
    procs = [Process(target=inc, args=(counter,)) for _ in range(4)]
    for p in procs: p.start()
    for p in procs: p.join()
    print(counter.value)             # 400_000

Sin get_lock() el resultado sale menor: la race condition existe también entre procesos porque counter.value += 1 sigue siendo read-modify-write sobre el segmento compartido.

Ejercicio 5 — No bloquear el event loop con una lib síncrona

Un handler async necesita llamar a cpu_task(n) (síncrona, CPU-bound). Llamarla directo congela el loop. Delegala correctamente.

Solución
import asyncio
from concurrent.futures import ProcessPoolExecutor

def cpu_task(n: int) -> int:
    return sum(i * i for i in range(n))

async def handler(n: int) -> int:
    loop = asyncio.get_running_loop()
    # ProcessPoolExecutor para CPU-bound; ThreadPoolExecutor si fuera IO-bound síncrono
    with ProcessPoolExecutor() as pool:
        return await loop.run_in_executor(pool, cpu_task, n)

async def main():
    results = await asyncio.gather(handler(5_000_000), handler(5_000_000))
    print(results)

if __name__ == "__main__":
    asyncio.run(main())

run_in_executor mueve el trabajo bloqueante fuera del thread del event loop. Para CPU-bound se usa un ProcessPoolExecutor (esquiva el GIL); para una lib de I/O síncrona bastaría el thread pool por defecto (None).

Preguntas tipo entrevista (EN)

Q1 — How exactly does the GIL relate to reference counting?

Wrong / trap: “The GIL exists because Python is interpreted and slow, so it can only do one thing at a time.” Why it’s wrong: conflates “interpreted” with the GIL and gives no mechanism. Being interpreted has nothing to do with it — the GIL is a specific memory-safety decision.

Correct: “CPython manages memory with per-object reference counts. INCREF/DECREF are non-atomic read-modify-write operations. Without a lock, concurrent threads could corrupt a refcount, causing use-after-free or leaks. The GIL serializes bytecode execution so refcounting stays safe.”

Optimal: adds the trade-off and the alternative: “A per-object lock would be correct but ruinously expensive given how frequently refcounts change, so CPython chose one global lock instead. The cost is no multi-core parallelism for CPU-bound Python code. The escape hatches are multiprocessing (separate interpreters, separate GILs) and C extensions that release the GIL. PEP 703’s free-threaded build removes the GIL by replacing global refcounting with thread-safe schemes like biased reference counting — still experimental, not the production default.”

Q2 — Does the GIL make your code thread-safe?

Wrong / trap: “Yes, since only one thread runs at a time, you don’t need locks.” Why it’s wrong: the GIL can be released between bytecodes, and most high-level operations compile to multiple bytecodes. counter += 1 is LOAD/ADD/STORE — a thread switch in the middle loses updates.

Correct: “No. The GIL protects the interpreter’s internal state, not the atomicity of your operations. Compound operations like x += 1 or check-then-act on shared state still race and still need a Lock.”

Optimal: notes what is atomic and why not to rely on it: “A handful of single-bytecode operations happen to be atomic (e.g. list.append), but relying on that is fragile and implementation-specific. Use explicit synchronization (Lock, queue.Queue) for any read-modify-write or invariant across multiple values.”

Q3 — You have a CPU-bound job. Why won’t ThreadPoolExecutor speed it up, and what do you use?

Wrong / trap: “Add more threads to use more cores.” Why it’s wrong: the GIL means only one thread executes Python bytecode at a time; extra threads for CPU work add context-switch overhead with no parallelism.

Correct: “Threads don’t give parallelism for pure-Python CPU work because of the GIL. Use ProcessPoolExecutor / multiprocessing so each worker has its own interpreter and GIL, running truly in parallel across cores.”

Optimal: qualifies the exception and the cost: “The exception is CPU work done inside C extensions that release the GIL (NumPy, many crypto/compression libs) — there threads can parallelize. Otherwise processes are the answer, at the cost of pickling arguments/results across the process boundary and higher startup cost; for large data, shared_memory avoids the serialization overhead.”

Q4 — Concurrency vs parallelism, and where does asyncio sit?

Wrong / trap: “They’re the same thing — running tasks at the same time.” Why it’s wrong: erases the distinction that drives model choice. Concurrency is a structuring property; parallelism is simultaneous execution requiring multiple cores.

Correct: “Concurrency is dealing with many tasks by interleaving their progress; parallelism is executing them at the same instant on multiple cores. asyncio is concurrency without parallelism: one thread, cooperative scheduling.”

Optimal: maps all three models: “threading gives concurrency but, for CPU work, no parallelism because of the GIL — though real overlap for I/O since the GIL is released on blocking syscalls. multiprocessing gives true parallelism. asyncio gives high-scale concurrency on a single thread with the lowest overhead, ideal for many I/O-bound connections.”

Q5 — Cooperative vs preemptive multitasking — implications for correctness?

Wrong / trap: “Async is just faster threads.” Why it’s wrong: different scheduling model entirely, not a speed tweak.

Correct: “Threads are preemptively scheduled by the OS — a switch can happen at any instruction, so shared state needs locks. asyncio is cooperative — a coroutine only yields at await, so code between two awaits is effectively atomic, reducing races.”

Optimal: states the failure mode of each: “Cooperative’s weakness is that a task that never yields — a synchronous time.sleep, a tight CPU loop — freezes the entire loop; you offload those via run_in_executor. Preemptive’s weakness is the constant need for synchronization and the risk of deadlocks. Neither is strictly better; they trade different failure modes.”

Q6 — In gunicorn, when would you pick sync vs gthread vs gevent vs uvicorn workers?

Wrong / trap: “Always use the most workers you can, more is always faster.” Why it’s wrong: ignores worker class and memory; over-forking thrashes and each process has real memory cost.

Correct:sync = one request per worker process; scale by adding processes, sized around 2×cores+1. gthread adds a thread pool per worker so each handles several I/O-bound requests concurrently (threads release the GIL on I/O). gevent/eventlet use cooperative greenlets for thousands of concurrent slow connections. UvicornWorker runs an asyncio loop for ASGI apps like FastAPI.”

Optimal: ties it to the CPU/IO split: “The general pattern is multiple processes to use all cores and dodge the GIL for aggregate throughput, and within each process threads or async to overlap I/O waits. Pick sync for simple CPU-ish endpoints, gthread/gevent for synchronous I/O-bound apps, UvicornWorker for natively async apps. Match the worker model to whether your workload is CPU- or I/O-bound and whether your code is sync or async.”

Q7 — Why does contextvars exist if we already have thread-local storage?

Wrong / trap: “They’re the same, contextvars is just the newer name for thread-local.” Why it’s wrong: thread-local is keyed per OS thread; in asyncio many tasks share one thread, so thread-local can’t isolate per-task state.

Correct:threading.local() is scoped per thread. In asyncio, many coroutines run on a single thread, so thread-local can’t distinguish them. ContextVar is scoped per logical context, and asyncio copies the context per task, so a set() in one task doesn’t leak into a concurrent task.”

Optimal: adds the propagation and reset semantics: “ContextVar values propagate correctly across await boundaries, which is exactly what carrying a request_id, authenticated user, or locale through an async call chain needs — without threading them through every function signature. set() returns a Token for reset(), and frameworks like structlog/FastAPI rely on this for per-request log correlation. It works in sync code too, but its reason for existing is correct behavior under async concurrency.”

Referencias