Threading en Python¶
Un proceso es una instancia de un programa que se ejecuta en un espacio de memoria independiente. Cada proceso tiene su propio espacio de direcciones, recursos del sistema y estado de ejecución.
Un hilo (thread) es la unidad más pequeña de ejecución dentro de un proceso. Múltiples hilos comparten el mismo espacio de memoria del proceso padre, lo que permite comunicación más eficiente pero requiere sincronización para evitar condiciones de carrera.
Un proceso puede contener múltiples hilos, lo que se conoce como programación multihilo.
Regla general de selección: - Para procesos intensivos en entrada/salida (I/O) (lectura/escritura de archivos, operaciones de red, acceso a bases de datos): usar hilos. - Para procesos intensivos en CPU (cálculos matemáticos, procesamiento de datos): usar procesos múltiples (multiprocessing).
Aspectos de Threading en Python¶
GIL (Global Interpreter Lock)¶
El Global Interpreter Lock es un mecanismo de sincronización en CPython (la implementación estándar de Python) que protege el acceso a los objetos internos de Python, permitiendo que solo un hilo ejecute bytecode Python a la vez. Es similar a un baño público con una sola puerta: hasta que una persona no termine de usarlo, la siguiente debe esperar.
Debido al GIL, los hilos en Python no logran paralelismo real para código CPU-bound (intensivo en CPU), ya que hay latencias impuestas por la alternancia del GIL entre hilos. Sin embargo, para código I/O-bound, los hilos son efectivos porque el GIL se libera durante las operaciones de espera de I/O.
Módulo Threading¶
Es un módulo nativo de Python que proporciona una API de alto nivel para trabajar con hilos.
import threading
# Creación de un hilo que ejecutará 'funcion' con los argumentos especificados
t1 = threading.Thread(target=funcion, args=(arg1, arg2, arg3, ..., argn))
t1.start() # Inicia la ejecución del hilo
t1.join() # Espera a que el hilo termine su ejecución
Es fundamental colocar join() para evitar que el programa principal termine antes de que el hilo complete su trabajo. Debe colocarse en la parte del código donde estrictamente necesitamos los resultados del hilo.
Ejemplo: Suma paralela con múltiples hilos¶
import threading
import time
size = 100_000_000 # 100 millones de elementos
l = [2] * size # Lista con 100 millones de doses
def sumar(lst, ini, fin):
"""Suma los elementos de la lista desde el índice 'ini' hasta 'fin'-1"""
sum = 0
for i in range(ini, fin):
sum += lst[i]
return sum
if __name__ == "__main__":
# Probar con diferentes números de hilos
for num_hilos in [2, 4, 6, 8, 10, 12, 14, 16]:
inicial = time.perf_counter() # Tiempo de inicio de alta precisión
hilos = []
ini = 0
fin = size // num_hilos # Tamaño de cada segmento
# Crear y lanzar los hilos
# Por ejemplo, si num_hilos = 4:
# Segmentos: (0, n/4), (n/4, n/2), (n/2, 3n/4), (3n/4, n)
for i in range(num_hilos):
t = threading.Thread(target=sumar, args=(l, ini, fin))
t.start() # Inicia el hilo
ini = fin # Actualiza inicio para el siguiente segmento
fin += size // num_hilos # Actualiza fin para el siguiente segmento
hilos.append(t) # Guarda referencia al hilo
# Esperar a que todos los hilos terminen
for t in hilos:
t.join()
final = time.perf_counter()
print(f"El tiempo de ejecución con {num_hilos} hilos es {final - inicial} s")
Versión optimizada usando NumPy¶
import threading
import time
import numpy as np
size = 100_000_000 # 100 millones de elementos
l = np.ones(size) * 2 # Arreglo NumPy con 100 millones de doses (más eficiente)
def sumar(lst, ini, fin):
"""Suma un segmento del arreglo NumPy usando operaciones vectorizadas"""
sum = lst[ini:fin].sum() # Suma vectorizada del segmento
return sum
if __name__ == "__main__":
for num_hilos in [2, 4, 6, 8, 10, 12, 14, 16]:
inicial = time.perf_counter()
hilos = []
ini = 0
fin = size // num_hilos
for i in range(num_hilos):
t = threading.Thread(target=sumar, args=(l, ini, fin))
t.start()
ini = fin
fin += size // num_hilos
hilos.append(t)
for t in hilos:
t.join()
final = time.perf_counter()
print(f"El tiempo de ejecución con {num_hilos} hilos es {final - inicial} s")
Esta es una implementación del patrón Map-Reduce: - Map: Dividir el problema en segmentos y procesarlos en paralelo (hilos). - Reduce: Combinar los resultados parciales (en este caso, implícitamente).
Thread Pool¶
Un thread pool es un conjunto de hilos pre-creados que son gestionados automáticamente por un ejecutor. No controlamos directamente cuándo se inician o terminan, pero debemos esperar a que el pool complete todas las tareas.
from concurrent.futures import ThreadPoolExecutor
# Creación de un pool con un número específico de workers (hilos)
pool = ThreadPoolExecutor(max_workers=num_hilos)
# Envío de tareas al pool
pool.submit(tarea, arg1, arg2)
# Esperar a que todas las tareas terminen y cerrar el pool
pool.shutdown(wait=True)
submit(): Envía tareas al pool para su ejecución.shutdown(wait=True): Espera a que todas las tareas pendientes se completen antes de cerrar el pool.
Ejemplo con ThreadPoolExecutor¶
from concurrent.futures import ThreadPoolExecutor
import time
size = 100_000_000
l = [2] * size
def sumar(lst, ini, fin):
"""Suma los elementos de la lista desde el índice 'ini' hasta 'fin'-1"""
sum = 0
for i in range(ini, fin):
sum += lst[i]
return sum
if __name__ == "__main__":
for num_hilos in [2, 4, 6, 8, 10, 12, 14, 16]:
# Crear pool con el número especificado de workers
pool = ThreadPoolExecutor(max_workers=num_hilos)
inicial = time.perf_counter()
ini = 0
fin = size // num_hilos
# Enviar tareas al pool
for _ in range(num_hilos):
pool.submit(sumar, l, ini, fin)
ini = fin
fin += size // num_hilos
# Esperar a que todas las tareas terminen y cerrar el pool
pool.shutdown(wait=True)
final = time.perf_counter()
print(f"El tiempo de ejecución con {num_hilos} hilos es {final - inicial} s")
En los resultados observamos que el ThreadPoolExecutor funciona más rápido que la gestión manual de hilos con threading.Thread, dado que la misma librería se encarga de gestionarlos eficientemente (arranque, reuso y terminación).
Tabla de resumen¶
| Concepto | Descripción | Ejemplo/Implementación |
|---|---|---|
| Proceso | Instancia de un programa con espacio de memoria independiente, recursos propios y estado de ejecución aislado. | Programa Python ejecutándose en su propio intérprete. |
| Hilo (Thread) | Unidad mínima de ejecución dentro de un proceso; múltiples hilos comparten memoria y recursos del proceso padre. | threading.Thread(target=func, args=(...)) |
| GIL (Global Interpreter Lock) | Mecanismo en CPython que permite solo un hilo ejecutando bytecode Python a la vez, limitando paralelismo real para CPU-bound. | Causa que hilos Python alternen ejecución en lugar de ejecutar en paralelo real. |
| I/O-bound vs CPU-bound | Clasificación de tareas: I/O-bound espera recursos externos; CPU-bound realiza cálculos intensivos. | I/O: lectura de archivos; CPU: procesamiento matemático. |
Módulo threading |
API nativa de Python para creación y gestión manual de hilos. | Thread(), start(), join(), Lock(), Event(), etc. |
| ThreadPoolExecutor | Ejecutor que gestiona automáticamente un pool de hilos reutilizables, más eficiente que gestión manual. | concurrent.futures.ThreadPoolExecutor(max_workers=N) |
| Map-Reduce | Patrón de programación paralela: dividir (map), procesar en paralelo, combinar resultados (reduce). | Dividir lista en segmentos, sumar cada segmento en hilos, combinar sumas. |
| Condición de carrera | Problema de concurrencia cuando múltiples hilos acceden/modifican datos compartidos sin sincronización. | Se evita con Lock, RLock, Semaphore, o estructuras thread-safe. |
join() |
Método que bloquea el hilo llamante hasta que el hilo objetivo termine su ejecución. | t.join() espera a que el hilo t termine. |
submit() |
Método de ThreadPoolExecutor para enviar tareas al pool de ejecución. | pool.submit(func, arg1, arg2) |
Comentarios adicionales¶
- El GIL es específico de CPython; otras implementaciones como Jython (Java) y IronPython (.NET) no tienen GIL y permiten verdadero paralelismo con hilos.
- Para código CPU-bound en Python, el módulo
multiprocessinges generalmente mejor opción quethreading, ya que usa procesos separados que evitan el GIL. - Los Thread Pools son más eficientes que crear y destruir hilos manualmente porque:
- Reutilizan hilos existentes (reduce overhead de creación/destrucción).
- Gestionan automáticamente la asignación de tareas a hilos disponibles.
- Proporcionan mecanismos para manejar excepciones y recuperar resultados.
- La sincronización entre hilos es crítica cuando comparten datos. Herramientas comunes:
Lock: Exclusión mutua simple.RLock: Lock reentrante (mismo hilo puede adquirirlo múltiples veces).Semaphore: Controla acceso a un número limitado de recursos.Event: Comunicación simple entre hilos (señalización).Condition: Sincronización más compleja con notificaciones.- El parámetro
if __name__ == "__main__":es esencial en scripts que usan concurrencia para evitar problemas en Windows y en multiprocessing. - Para operaciones de I/O asíncronas,
asyncio(disponible desde Python 3.5+) puede ser más eficiente que threading, especialmente para muchas conexiones simultáneas.