W3docs

Python Threading

Aprende Python threading desde cero: crea hilos, sincroniza con locks, usa colas, ThreadPoolExecutor y entiende cuándo importa el GIL.

El módulo threading de Python te permite ejecutar múltiples tareas en el mismo proceso al mismo tiempo. Cada tarea se ejecuta en su propio hilo — una unidad de ejecución ligera que comparte el espacio de memoria del proceso. El threading es la herramienta adecuada cuando tu programa pasa la mayor parte del tiempo esperando (leyendo un archivo, realizando una solicitud HTTP, consultando una base de datos) y quieres hacer trabajo útil durante esa espera en lugar de bloquearte.

Este capítulo cubre:

  • Crear e iniciar hilos con threading.Thread
  • Esperar a que los hilos terminen con join
  • Hilos daemon y tareas en segundo plano
  • Prevenir condiciones de carrera con Lock y with
  • Coordinar hilos con Event y Semaphore
  • Comunicación segura entre hilos usando queue.Queue
  • El ThreadPoolExecutor para grupos de hilos administrados
  • El Global Interpreter Lock (GIL) y por qué los hilos no aceleran el código ligado a CPU
  • Cuándo elegir threading vs. asyncio

Crear e iniciar un hilo

Importa threading y crea un objeto Thread, pasando la función a ejecutar como target. Llama a .start() para lanzar el hilo:

import threading
import time

def greet(name):
    time.sleep(0.5)         # simulate some work
    print(f'Hello, {name}!')

t = threading.Thread(target=greet, args=('Alice',))
t.start()
print('Thread started — main continues running')
t.join()                    # wait for the thread to finish
print('Thread finished')
# Thread started — main continues running
# Hello, Alice!
# Thread finished

Puntos clave:

  • args es una tupla de argumentos posicionales pasados a target. Usa kwargs para argumentos por nombre.
  • Sin .join(), el hilo principal puede salir antes de que el hilo creado termine.
  • .start() retorna inmediatamente; el nuevo hilo se ejecuta de forma concurrente.

Pasar argumentos por nombre

import threading

def connect(host, port=80):
    print(f'Connecting to {host}:{port}')

t = threading.Thread(target=connect, kwargs={'host': 'example.com', 'port': 443})
t.start()
t.join()
# Connecting to example.com:443

Ejecutar múltiples hilos a la vez

El verdadero beneficio del threading es ejecutar varias tareas en paralelo. Lanza todos los hilos primero y luego espera a todos:

import threading
import time

def download(url):
    time.sleep(1)           # simulate a 1-second network request
    print(f'Downloaded: {url}')

urls = [
    'https://example.com/data1',
    'https://example.com/data2',
    'https://example.com/data3',
]

start = time.perf_counter()

threads = [threading.Thread(target=download, args=(url,)) for url in urls]
for t in threads:
    t.start()
for t in threads:
    t.join()

elapsed = time.perf_counter() - start
print(f'All downloads finished in {elapsed:.1f}s')
# Downloaded: https://example.com/data1
# Downloaded: https://example.com/data2
# Downloaded: https://example.com/data3
# All downloads finished in 1.0s

Sin hilos esto tomaría 3 segundos (secuencial). Con tres hilos tarda aproximadamente 1 segundo porque las esperas se superponen.

Crear subclases de Thread

Para lógica más compleja, crea una subclase de threading.Thread y sobreescribe run(). Guarda los resultados como atributos de instancia para que el código que llama pueda leerlos después de join():

import threading
import time

class DownloadThread(threading.Thread):
    def __init__(self, url):
        super().__init__()
        self.url = url
        self.result = None

    def run(self):
        time.sleep(0.5)     # simulate download
        self.result = f'Data from {self.url}'

threads = [DownloadThread(f'https://example.com/page{i}') for i in range(3)]
for t in threads:
    t.start()
for t in threads:
    t.join()

for t in threads:
    print(t.result)
# Data from https://example.com/page0
# Data from https://example.com/page1
# Data from https://example.com/page2

Hilos daemon

Un hilo daemon es un hilo en segundo plano que el intérprete termina automáticamente cuando todos los hilos no-daemon han salido. Marca un hilo como daemon pasando daemon=True (o configurando t.daemon = True antes de .start()):

import threading
import time

def heartbeat():
    while True:
        print('♥ still running')
        time.sleep(1)

t = threading.Thread(target=heartbeat, daemon=True)
t.start()

time.sleep(2.5)
print('Main thread exiting — daemon will be killed')
# ♥ still running
# ♥ still running
# Main thread exiting — daemon will be killed

Usa hilos daemon para tareas de monitoreo en segundo plano o registro que no deben impedir que el programa salga. Nunca los uses para tareas que deben completarse limpiamente (escrituras de archivos, commits de base de datos) — se terminan sin ninguna limpieza.

Nombres de hilos e introspección

Cada hilo tiene un nombre. Puedes establecerlo explícitamente o dejar que Python asigne uno automáticamente. Usa threading.current_thread() para inspeccionar el hilo en ejecución y threading.active_count() para contar los hilos activos:

import threading

def worker():
    t = threading.current_thread()
    print(f'Running in thread: {t.name}')

t = threading.Thread(target=worker, name='WorkerThread-1')
t.start()
t.join()

print(f'Active threads: {threading.active_count()}')
# Running in thread: WorkerThread-1
# Active threads: 1

Sincronización: prevenir condiciones de carrera

Los hilos comparten la memoria del proceso. Cuando dos hilos leen y escriben la misma variable simultáneamente, el resultado es una condición de carrera — comportamiento no determinista que es difícil de reproducir o depurar.

El siguiente ejemplo sin un lock produce un conteo final impredecible porque los incrementos de diferentes hilos pueden superponerse:

import threading

counter = 0

def unsafe_increment():
    global counter
    for _ in range(100_000):
        counter += 1         # read-modify-write: not atomic!

threads = [threading.Thread(target=unsafe_increment) for _ in range(5)]
for t in threads:
    t.start()
for t in threads:
    t.join()

# counter is somewhere between 100000 and 500000 — unpredictable
print('Final counter:', counter)

Lock

Un threading.Lock garantiza que solo un hilo ejecute la sección protegida a la vez. Úsalo como gestor de contexto con with para que el lock siempre se libere, incluso si se lanza una excepción:

import threading

counter = 0
lock = threading.Lock()

def safe_increment():
    global counter
    for _ in range(100_000):
        with lock:              # acquire before read-modify-write
            counter += 1        # now only one thread at a time can run this

threads = [threading.Thread(target=safe_increment) for _ in range(5)]
for t in threads:
    t.start()
for t in threads:
    t.join()

print('Final counter:', counter)   # always 500000

RLock (lock re-entrante)

Si un hilo necesita adquirir el mismo lock dos veces (por ejemplo, un método llama a otro método que también adquiere el lock), usa threading.RLock. Permite que el mismo hilo adquiera el lock de nuevo sin causar un deadlock:

import threading

lock = threading.RLock()

def outer():
    with lock:
        print('Outer acquired')
        inner()             # inner also acquires the same lock

def inner():
    with lock:              # works because RLock counts acquisitions
        print('Inner acquired')

t = threading.Thread(target=outer)
t.start()
t.join()
# Outer acquired
# Inner acquired

Coordinar hilos: Event y Semaphore

Event

threading.Event es una señal simple. Un hilo llama a .set() para señalizar; otros hilos llaman a .wait() para bloquearse hasta que llegue la señal:

import threading
import time

ready = threading.Event()

def worker():
    print('Worker: waiting for signal...')
    ready.wait()            # blocks here until ready.set() is called
    print('Worker: signal received, starting work')

t = threading.Thread(target=worker)
t.start()

time.sleep(0.5)
print('Main: sending signal')
ready.set()
t.join()
# Worker: waiting for signal...
# Main: sending signal
# Worker: signal received, starting work

Usa un Event para coordinar el orden de inicio — por ejemplo, para retrasar los hilos trabajadores hasta que se establezca una conexión a la base de datos.

Semaphore

Un threading.Semaphore limita el número de hilos que pueden estar dentro de una sección simultáneamente. Es útil para limitar la tasa de acceso a un recurso compartido como un pool de conexiones:

import threading
import time

# Allow at most 2 threads to enter the critical section at once
semaphore = threading.Semaphore(2)

def use_connection(name):
    with semaphore:
        print(f'{name}: using connection')
        time.sleep(0.5)
        print(f'{name}: releasing connection')

threads = [threading.Thread(target=use_connection, args=(f'T{i}',)) for i in range(4)]
for t in threads:
    t.start()
for t in threads:
    t.join()
# T0: using connection
# T1: using connection          <- only 2 at a time
# T0: releasing connection
# T2: using connection
# T1: releasing connection
# T3: using connection
# T2: releasing connection
# T3: releasing connection

Datos locales por hilo

threading.local() crea un object que almacena valores separados por hilo. Es útil para cachés o cursores de base de datos por hilo:

import threading

local_data = threading.local()

def set_user(name):
    local_data.user = name       # each thread writes its own copy
    print(f'{threading.current_thread().name}: user = {local_data.user}')

threads = [
    threading.Thread(target=set_user, args=(f'user{i}',), name=f'Thread-{i}')
    for i in range(3)
]
for t in threads:
    t.start()
for t in threads:
    t.join()
# Thread-0: user = user0
# Thread-1: user = user1
# Thread-2: user = user2

Leer local_data.user en un hilo donde nunca se ha establecido lanza AttributeError, igual que cualquier otro acceso a un atributo.

Colas seguras para hilos

La clase queue.Queue (del módulo queue de la biblioteca estándar, no de asyncio) es una FIFO segura para hilos. Los hilos pueden usar put y get en elementos sin un lock — toda la sincronización se maneja internamente.

El patrón clásico es productor-consumidor: uno o más hilos productores generan trabajo, los hilos consumidores lo procesan:

import threading
import queue
import time

q = queue.Queue(maxsize=5)

def producer():
    for i in range(1, 5):
        q.put(f'item-{i}')
        print(f'Produced item-{i}')
        time.sleep(0.05)

def consumer():
    while True:
        item = q.get()
        if item is None:        # sentinel: stop when None is received
            break
        print(f'Consumed {item}')
        q.task_done()

prod = threading.Thread(target=producer)
cons = threading.Thread(target=consumer)

cons.start()
prod.start()
prod.join()
q.put(None)     # signal consumer to stop
cons.join()
# Produced item-1
# Consumed item-1
# Produced item-2
# Consumed item-2
# Produced item-3
# Consumed item-3
# Produced item-4
# Consumed item-4

queue.Queue también ofrece task_done() y join() para rastrear cuándo se han procesado todos los elementos encolados, y queue.LifoQueue / queue.PriorityQueue para ordenaciones alternativas.

ThreadPoolExecutor: grupos de hilos administrados

Crear un nuevo objeto Thread para cada tarea es costoso cuando tienes muchas tareas de corta duración. concurrent.futures.ThreadPoolExecutor administra un pool de hilos trabajadores reutilizables y devuelve objetos Future por cada tarea enviada:

import concurrent.futures
import time

def fetch_url(url):
    time.sleep(0.5)     # simulate network I/O
    return f'Response from {url}'

urls = [
    'https://api.example.com/users',
    'https://api.example.com/posts',
    'https://api.example.com/comments',
]

with concurrent.futures.ThreadPoolExecutor(max_workers=3) as executor:
    # submit all tasks and get Future objects
    futures = {executor.submit(fetch_url, url): url for url in urls}

    for future in concurrent.futures.as_completed(futures):
        url = futures[future]
        print(future.result())

# Response from https://api.example.com/users    (order may vary)
# Response from https://api.example.com/comments
# Response from https://api.example.com/posts

executor.map(fn, iterable) es una forma más corta cuando no necesitas objetos Future individuales:

import concurrent.futures
import time

def square(n):
    time.sleep(0.01)
    return n * n

with concurrent.futures.ThreadPoolExecutor(max_workers=4) as executor:
    results = list(executor.map(square, range(10)))

print(results)
# [0, 1, 4, 9, 16, 25, 36, 49, 64, 81]

executor.map preserva el orden de entrada en su salida, a diferencia de as_completed que produce resultados en orden de finalización.

El Global Interpreter Lock (GIL)

CPython (el intérprete estándar de Python) tiene un Global Interpreter Lock — un mutex que permite que solo un hilo ejecute bytecode de Python a la vez. Esto significa que los hilos en CPython no pueden ejecutar código Python en verdadero paralelo en múltiples núcleos de CPU.

La implicación práctica:

  • Tareas ligadas a I/O: los hilos genuinamente aceleran el programa. Mientras un hilo espera una respuesta de red, el GIL se libera y otro hilo se ejecuta. Los ejemplos anteriores demuestran este comportamiento.
  • Tareas ligadas a CPU: los hilos no aceleran las cosas y pueden incluso ser ligeramente más lentos debido al costo de cambio de contexto.
import threading
import time

def cpu_bound(n):
    total = 0
    for i in range(n):
        total += i
    return total

# Sequential
start = time.perf_counter()
cpu_bound(5_000_000)
cpu_bound(5_000_000)
single = time.perf_counter() - start

# Two threads — GIL prevents true parallelism
start = time.perf_counter()
t1 = threading.Thread(target=cpu_bound, args=(5_000_000,))
t2 = threading.Thread(target=cpu_bound, args=(5_000_000,))
t1.start(); t2.start()
t1.join(); t2.join()
threaded = time.perf_counter() - start

print(f'Single-threaded: {single:.2f}s')
print(f'Two threads:     {threaded:.2f}s')
# Two threads are NOT faster (similar elapsed time)

Para verdadero paralelismo de CPU en Python, usa multiprocessing o concurrent.futures.ProcessPoolExecutor en su lugar — cada proceso tiene su propio GIL.

Threading vs. asyncio

Tanto threading como asyncio hacen más rápidos los programas ligados a I/O, pero funcionan de forma diferente:

threadingasyncio
Modelo de concurrenciaPreventivo — el SO cambia entre hilosCooperativo — las corrutinas ceden en await
Mejor paraBibliotecas de terceros bloqueantesBibliotecas con soporte async (aiohttp, asyncpg)
Estado compartidoRequiere locks explícitosSeguro dentro de un único event loop
SobrecargaUn hilo del SO por tareaMuy baja — miles de corrutinas en un hilo
Curva de aprendizajeFamiliar (código de estilo síncrono)Requiere async/await en todo el código

Regla general: si estás usando una biblioteca que tiene una versión compatible con async (por ejemplo, aiohttp en lugar de requests), usa asyncio. Si estás atascado con bibliotecas bloqueantes síncronas, usa threading. Para trabajo ligado a CPU, usa multiprocessing.

Errores comunes

Iniciar un hilo dos veces. Llamar a .start() en el mismo objeto Thread más de una vez lanza RuntimeError. Crea una nueva instancia de Thread para cada ejecución.

Olvidar hacer join. Un hilo al que no se le hace join puede seguir ejecutándose cuando el programa termina. Siempre llama a join en los hilos cuya finalización importa, o hazlos daemons si verdaderamente son de tipo "lanzar y olvidar".

Mantener un lock demasiado tiempo. Bloquear un bloque grande de código derrota el propósito de la concurrencia. Mantén las secciones bloqueadas lo más cortas posible — solo protege la operación real de leer-modificar-escribir.

Deadlock. Un deadlock ocurre cuando dos hilos cada uno mantiene un lock que el otro está esperando. Prevenlo adquiriendo siempre múltiples locks en el mismo orden en todos los hilos.

import threading

lock_a = threading.Lock()
lock_b = threading.Lock()

# DEADLOCK: Thread 1 holds lock_a, waits for lock_b
#           Thread 2 holds lock_b, waits for lock_a

# FIX: always acquire locks in the same order (lock_a then lock_b) in every thread

Modificar una lista mientras se itera en otro hilo. Envuelve todo acceso (lectura y escritura) a colecciones compartidas con un lock para evitar RuntimeError: list changed size during iteration.

Resumen de referencia rápida

HerramientaPropósito
threading.Thread(target=fn, args=(...))Crear un nuevo hilo
t.start()Lanzar el hilo
t.join()Esperar a que el hilo termine
t.daemon = TrueMarcar como hilo en segundo plano (se termina al salir)
threading.Lock()Exclusión mutua — solo un hilo a la vez
threading.RLock()Lock re-entrante — el mismo hilo puede adquirirlo varias veces
threading.Event()Señal de un solo uso entre hilos
threading.Semaphore(n)Limitar a n hilos concurrentes en una sección
threading.local()Almacenamiento por hilo
queue.QueueFIFO segura para hilos para patrones productor-consumidor
ThreadPoolExecutor(max_workers=n)Pool administrado de hilos trabajadores reutilizables

Práctica

Práctica
Which of the following tasks would benefit most from Python threading?
Which of the following tasks would benefit most from Python threading?
Was this page helpful?