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.
| Thread | Process | |
|---|---|---|
| Espacio de direcciones | Compartido | Aislado |
| Coste de creación | Bajo (stack + TCB) | Alto (páginas, COW en fork) |
| Comunicación | Variables compartidas | IPC (pipe, queue, shared mem) |
| Fallo aislado | No (un segfault tumba el process) | Sí |
| Scheduling | OS 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: el cuello de botella es el procesador. Cálculo puro: hashing, compresión, procesamiento de imágenes, serialización pesada, cálculo numérico sin librerías que liberen el GIL. El proceso está ocupado ejecutando instrucciones.
- IO-bound: el cuello de botella es esperar a algo externo. Llamadas de red, lectura/escritura de disco, consultas a base de datos. El proceso pasa la mayor parte del tiempo bloqueado esperando un syscall, sin usar CPU.
# 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:
- CPU-bound: agregar threads NO da paralelismo real. Solo un thread corre bytecode a la vez; el resto espera el GIL. Se paga overhead de context switch sin ganancia. Solución:
multiprocessing(cada proceso tiene su propio intérprete y su propio GIL) o extensiones C que liberen el GIL (NumPy lo hace en operaciones pesadas; también lo hacen muchas libs de compresión/criptografía). - IO-bound: los threads SÍ ayudan. Cuando un thread entra en un syscall bloqueante (red, disco), CPython libera el GIL antes de bloquearse, dejando correr a otro thread. Por eso
threadingescala bien en cargas de I/O.
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: estructurar el programa como tareas que progresan de forma solapada en el tiempo. Es una propiedad del diseño. Un solo core puede ser concurrente intercalando tareas (mientras una espera I/O, otra avanza).
- Parallelism: ejecutar literalmente varias cosas al mismo instante, lo cual requiere varios cores.
Concurrency is about dealing with many things at once; parallelism is about doing many things at once (Rob Pike).
En Python, por el GIL:
asyncio= concurrency sin parallelism (un solo thread, cooperativo).threading= concurrency sin parallelism para CPU-bound (GIL serializa), pero concurrency real para IO-bound (se solapa la espera).multiprocessing= parallelism real (varios intérpretes en varios cores).
Cooperative vs preemptive multitasking
Dos formas de repartir la CPU entre tareas:
- Preemptive: un scheduler externo (el OS) interrumpe la tarea cuando quiere, sin su consentimiento. Así funcionan threads y processes. Ventaja: una tarea que se cuelga no congela a las demás. Desventaja: la interrupción puede ocurrir en cualquier punto → race conditions, hace falta sincronización.
- Cooperative: cada tarea cede el control voluntariamente en puntos definidos. Así funciona
asyncio: una coroutine corre hasta que haceawait, y solo ahí el event loop puede pasar a otra. Ventaja: entre dosawaitel código es efectivamente atómico → menos races. Desventaja: una tarea que nunca cede (untime.sleep()síncrono, un bucle CPU-bound) congela todo el loop.
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:
Queue: cola FIFO multi-productor/multi-consumidor, serializa con pickle por detrás. Ideal para repartir trabajo y recolectar resultados.Pipe: canal bidireccional (o unidireccional) entre exactamente dos procesos, más liviano que una Queue.
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:
- Un
threading.local()es por thread; unContextVares por contexto lógico, que asyncio copia por task. Es la forma correcta de llevarrequest_id, usuario autenticado o locale a través de una cadena deawaitsin pasarlos como parámetro en cada función. set()devuelve unTokenque permite restaurar el valor previo conreset(token).- Frameworks async (FastAPI, structlog) usan
contextvarspara correlacionar logs por request sin acoplar la firma de las funciones.
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:
sync(default): cada worker es un proceso que atiende un request a la vez. Paralelismo = número de procesos worker. Simple y robusto, pero un request lento bloquea ese worker. Regla habitual de dimensionamiento:workers ≈ (2 × núcleos) + 1.gthread: cada worker (proceso) usa un pool de threads, atendiendo varios requests concurrentes por worker. Como es IO-bound (esperar la BD/servicios), los threads ayudan pese al GIL: mientras un thread espera I/O suelta el GIL. Concurrencia =workers × threads.gevent/eventlet: workers con concurrencia cooperativa basada en greenlets; parchean la librería estándar para volver no bloqueante el I/O. Miles de conexiones concurrentes por worker, ideal para cargas muy IO-bound con muchas conexiones lentas.uvicorn.workers.UvicornWorker: para apps ASGI async (FastAPI/Starlette). Cada worker corre un event loop asyncio; la concurrencia dentro del worker es cooperativa víaasync/await.
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
| Carga | Modelo recomendado | Por qué |
|---|---|---|
| CPU-bound (cálculo) | multiprocessing / ProcessPoolExecutor | GIL impide escalar con threads; procesos = paralelismo real |
| IO-bound, lib síncrona | threading / ThreadPoolExecutor | Los threads sueltan el GIL en el syscall; bajo overhead |
| IO-bound, lib async, alta concurrencia | asyncio | Miles de conexiones en 1 thread, sin overhead de threads |
| Mezcla en un web server | procesos (gunicorn workers) + threads/async dentro | Cores vía procesos, espera I/O vía threads/async |
| Bloquear el loop con cómputo/lib síncrona en asyncio | loop.run_in_executor | Delega 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
- Python docs —
threading: https://docs.python.org/3/library/threading.html - Python docs —
multiprocessing: https://docs.python.org/3/library/multiprocessing.html - Python docs —
multiprocessing.shared_memory: https://docs.python.org/3/library/multiprocessing.shared_memory.html - Python docs —
concurrent.futures: https://docs.python.org/3/library/concurrent.futures.html - Python docs —
queue: https://docs.python.org/3/library/queue.html - Python docs —
contextvars: https://docs.python.org/3/library/contextvars.html - Python docs —
asyncio: https://docs.python.org/3/library/asyncio.html - CPython dev guide — GIL & memory management: https://devguide.python.org/internals/garbage-collector/
- PEP 703 — Making the GIL optional in CPython: https://peps.python.org/pep-0703/
- Gunicorn — design & worker types: https://docs.gunicorn.org/en/stable/design.html