El GIL (Global Interpreter Lock) es el mecanismo que impide que dos threads ejecuten bytecode Python al mismo tiempo dentro del mismo proceso. Eso suena a que threading no sirve para nada, y mucho código en internet repite esa simplificación. Pero hay una pieza clave que esa versión omite: el GIL se libera durante operaciones de I/O.
Cuando un thread hace una llamada bloqueante —un socket.recv(), una lectura de archivo, una petición HTTP— el intérprete cede el GIL antes de entrar al kernel y lo recupera cuando la llamada regresa. Mientras tanto, otro thread puede ejecutar Python con normalidad. El resultado práctico: si tienes diez peticiones HTTP que toman 200ms cada una, con un solo thread tardas ~2000ms; con diez threads concurrentes tardas ~200ms. No es paralelismo real de CPU, pero el speedup es completamente real porque el cuello de botella es la red, no el procesador.
La clase threading.Thread acepta un target (un callable) y args o kwargs para pasarle parámetros. Llamas a start() para lanzar el thread y a join() para bloquear el thread actual hasta que ese thread termine. Si no haces join(), el proceso principal puede terminar antes de que tus threads acaben su trabajo. La excepción es daemon=True: un thread daemon no bloquea la salida del proceso, útil para workers de fondo que pueden morir limpiamente cuando termina el programa principal.
Donde threading genuinamente no ayuda es en cómputo puro en Python: parsear JSON masivo, calcular estadísticas en listas, procesar imágenes con loops Python. En esos casos el GIL nunca se libera de forma significativa y los threads se pelean por él sin ganar nada. Para eso existen multiprocessing o extensiones en C como NumPy, que sí liberan el GIL internamente.
import threading
import time
import urllib.request
from typing import NamedTuple
class Result(NamedTuple):
url: str
status: int
elapsed: float
results: list[Result] = []
results_lock = threading.Lock() # protege la lista compartida
def fetch(url: str) -> None:
start = time.perf_counter()
with urllib.request.urlopen(url, timeout=10) as response:
status = response.status
elapsed = time.perf_counter() - start
# La lista no es thread-safe por defecto para operaciones compuestas
with results_lock:
results.append(Result(url, status, elapsed))
URLS = [
"https://httpbin.org/delay/1",
"https://httpbin.org/delay/1",
"https://httpbin.org/delay/1",
"https://httpbin.org/delay/1",
"https://httpbin.org/uuid",
]
def main() -> None:
threads = [
threading.Thread(target=fetch, args=(url,))
for url in URLS
]
wall_start = time.perf_counter()
for t in threads:
t.start()
for t in threads:
t.join() # esperamos a TODOS antes de leer results
wall_elapsed = time.perf_counter() - wall_start
for r in results:
print(f"{r.status} {r.elapsed:.2f}s {r.url}")
print(f"\nTotal wall time: {wall_elapsed:.2f}s (vs ~{sum(r.elapsed for r in results):.2f}s secuencial)")
if __name__ == "__main__":
main()
Lo que hace interesante este código
El loop que crea los threads y el loop que los inicia están separados intencionalmente. Si llamaras t.start() y t.join() en el mismo loop, el segundo thread no arrancaría hasta que el primero terminara — perdiendo toda la concurrencia. Separar los dos pasos garantiza que todos los threads están vivos antes de que el principal se ponga a esperar.
results_lock merece atención. list.append() en CPython es atómico en la práctica porque es una sola operación de bytecode y el GIL no cambia de mano a mitad de ella. Aun así, usar el lock es correcto: esa atomicidad es un detalle de implementación de CPython, no una garantía del lenguaje. En Jython o PyPy el comportamiento puede diferir, y en operaciones compuestas (if x not in lista: lista.append(x)) la atomicidad no se puede asumir nunca.
daemon=True no aparece aquí porque sí queremos esperar los resultados. Tiene sentido en un worker de logging, un heartbeat, o un listener de señales: threads que sirven de soporte y cuya muerte abrupta al salir el proceso es aceptable. Si tienes dudas, no uses daemon=True — un join explícito siempre es más claro sobre la intención.
El parámetro args=(url,) lleva una coma al final deliberadamente: (url) es simplemente url entre paréntesis, no una tupla. Con la coma, Python lo trata como una tupla de un elemento, que es lo que Thread espera.
Errores que debes conocer
Error: Leer variables compartidas sin sincronización cuando la operación no es atómica, asumiendo que el GIL lo protege todo.
# ❌ Wrong
counter = 0
def increment():
global counter
for _ in range(100_000):
counter += 1 # read-modify-write, no es atómico
threads = [threading.Thread(target=increment) for _ in range(10)]
for t in threads: t.start()
for t in threads: t.join()
print(counter) # casi nunca imprime 1_000_000
# ✅ Right
import threading
counter = 0
lock = threading.Lock()
def increment():
global counter
for _ in range(100_000):
with lock:
counter += 1
counter += 1 se compila a tres instrucciones bytecode (LOAD, ADD, STORE) y el GIL puede cambiar de mano entre ellas, causando race conditions clásicas.
Error: Olvidar join() en threads que escriben a archivos o bases de datos, terminando el proceso antes de que flush ocurra.
# ❌ Wrong
def save(data):
with open("out.txt", "a") as f:
f.write(data + "\n")
t = threading.Thread(target=save, args=("resultado",))
t.start()
# el proceso puede terminar aquí antes de que t escriba
# ✅ Right
t = threading.Thread(target=save, args=("resultado",))
t.start()
t.join() # garantiza que la escritura ocurrió antes de continuar
Sin join(), el sistema operativo puede reclamar los file descriptors abiertos antes de que el thread termine de escribir.
N° 137