Gestión de Concurrencia: Pools de Procesos, Pools de Hilos y Corrutinas

La gestión eficiente de la concurrencia es crucial para maximizar el rendimiento de las aplicaciones, especialmente cuando se trata de operaciones de I/O intensivas o tareas computacionales. Los pools de procesos, pools de hilos y las corrutinas ofrecen diferentes anfoques para lograr este objetivo.

Pools de Procesos y Pools de Hilos

Los sistemas operativos tienen limitaciones en cuanto a la cantidad de procesos e hilos que pueden ejecutarse simultáneamente. Crear un número ilimitado de estos puede agotar los recursos del sistema y provocar inestabilidad. Los pools de procesos y hilos abordan este problema creando un conjunto predefinido de procesos o hilos que se reutilizan para ejecutar tareas.

Cuando se presenta una nueva tarea, se asigna a un proceso o hilo disponible en el pool. Si el pool está lleno, la tarea debe esperar hasta que un recurso se libere. Esto evita la sobrecarga del sistema y garantiza que siempre haya recursos disponibles.

Implementación de Pools

Python, a través del módulo concurrent.futures, facilita la creación y gestión de estos pools.


from concurrent.futures import ProcessPoolExecutor, ThreadPoolExecutor
import os
import time
import random

# Configuración del pool (ejemplo con 5 procesos)
# El número de workers se puede omitir para que se determine automáticamente según la CPU.
pool_gestionado = ProcessPoolExecutor(max_workers=5)

def operacion_secuencial(identificador):
   """Simula una tarea que consume tiempo."""
   tiempo_espera = random.randint(1, 3)
   print(f"Iniciando tarea {identificador}...")
   time.sleep(tiempo_espera)
   print(f"Tarea {identificador} completada después de {tiempo_espera} segundos.")
   return f"Resultado de tarea {identificador}"

if __name__ == '__main__':
   NUMERO_TAREAS = 20
   print(f"Enviando {NUMERO_TAREAS} tareas a un pool de {pool_gestionado._max_workers} workers.")
   
   # Envío de tareas al pool
   for i in range(NUMERO_TAREAS):
       pool_gestionado.submit(operacion_secuencial, i)
       
# Nota: La ejecución de estas tareas se solapa hasta el límite del pool. 
# Las tareas adicionales esperan su turno, evitando la creación excesiva de procesos/hilos.
   

Mecanismo de Callback

Los pools permitan definir funciones de callback que se ejecutan automáticamente cuando una tarea finaliza. Estas funciones pueden recibir el objeto de la tarea completada, permitiendo acceder a su resultado o realizar acciones posteriores de forma asíncrona.


from concurrent.futures import ProcessPoolExecutor, ThreadPoolExecutor
import time
import random

pool_gestionado = ProcessPoolExecutor(max_workers=5)

def operacion_con_retorno(identificador):
   """Tarea que retorna un valor."""
   tiempo_espera = random.randint(1, 3)
   print(f"Tarea con retorno {identificador} en ejecución.")
   time.sleep(tiempo_espera)
   return f"Valor de retorno de tarea {identificador}"

def procesar_resultado(objeto_tarea):
   """Función de callback para manejar el resultado de una tarea."""
   try:
       resultado = objeto_tarea.result()
       print(f"Callback: Tarea completada con resultado: '{resultado}'")
   except Exception as e:
       print(f"Callback: Error al obtener resultado: {e}")

if __name__ == '__main__':
   NUMERO_TAREAS = 10
   print(f"Enviando {NUMERO_TAREAS} tareas con callbacks.")
   
   for i in range(NUMERO_TAREAS):
       tarea_futura = pool_gestionado.submit(operacion_con_retorno, i)
       # Asociar la función de callback a la tarea
       tarea_futura.add_done_callback(procesar_resultado)

   # Es importante mantener el programa principal vivo hasta que las tareas en el pool terminen.
   # En aplicaciones reales, se usarían mecanismos de espera más robustos.
   pool_gestionado.shutdown(wait=True) 
   print("Todas las tareas y callbacks completados.")
   

Corrutinas

Las corrutinas ofrecen un modelo de concurrencia diferente, permitiendo la ejecución de múltiples tareas dentro de un único hilo. A diferencia de los procesos y hilos, donde el cambio de contexto es gestionado por el sistema operativo, las corrutinas permiten al programador controlar cuándo se cede la ejecución. Esto es particularmente útil para operaciones de I/O, donde una tarea puede "pausarse" y permitir que otras se ejecuten mientras espera la respuesta de I/O, sin necesidad de que el sistema operativo intervenga.

Al detectar operaciones de I/O, una corrutina puede ceder el control a otras corrutinas en ejecución. Cuando la operación de I/O se completa, la corrutina puede reanudar su ejecución. Este mecanismo mejora la utilización de la CPU y permite una alta concurrencia en operaciones I/O-bound.

Implementación de Corrutinas con Gevent

La biblioteca gevent es una popular implementación de corrutinas en Python, que utiliza un enfoque de "monkey patching" para hacer que las operaciones de I/O estándar sean compatibles con el modelo de corrutinas.


import time
from gevent import monkey
monkey.patch_all()  # Aplica parches para hacer las operaciones de I/O "gevent-friendly"
from gevent import spawn

def tarea_corutina_uno():
   print('Corrutina Uno: Iniciada.')
   time.sleep(3)  # Simula I/O o trabajo
   print('Corrutina Uno: Finalizada.')

def tarea_corutina_dos():
   print('Corrutina Dos: Iniciada.')
   time.sleep(5)  # Simula I/O o trabajo
   print('Corrutina Dos: Finalizada.')

if __name__ == '__main__':
   inicio = time.time()
   
   # Crear y lanzar corrutinas
   corutina1 = spawn(tarea_corutina_uno)
   corutina2 = spawn(tarea_corutina_dos)
   
   # Esperar a que las corrutinas terminen
   corutina1.join()
   corutina2.join()
   
   fin = time.time()
   print(f"Tiempo total de ejecución (corutinas): {fin - inicio:.2f} segundos.")
   # El tiempo será significativamente menor que la suma de las duraciones individuales (aprox. 5s en lugar de 8s).
   

Servidor Concurrente con Corrutinas

Las corrutinas son ideales para construir servidores de red que manejan múltiples conexiones simultáneamente con un uso eficiente de recursos.


import socket
from gevent import monkey
monkey.patch_all()
from gevent import spawn

def manejar_conexion_cliente(socket_cliente):
   """Maneja la comunicación con un cliente individual."""
   try:
       while True:
           datos_recibidos = socket_cliente.recv(1024)
           if not datos_recibidos:
               break # Cliente desconectado
           mensaje = datos_recibidos.decode('utf-8')
           print(f"Recibido: {mensaje}")
           respuesta = f"Eco: {mensaje.upper()}"
           socket_cliente.send(respuesta.encode('utf-8'))
   except Exception as e:
       print(f"Error en conexión: {e}")
   finally:
       socket_cliente.close()
       print("Conexión cerrada.")

def iniciar_servidor_gevent(host='127.0.0.1', port=8080):
   """Inicia un servidor que maneja conexiones entrantes con corrutinas."""
   servidor_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
   servidor_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) # Para reutilizar la dirección
   servidor_socket.bind((host, port))
   servidor_socket.listen(5)
   print(f"Servidor gevent escuchando en {host}:{port}...")
   
   while True:
       socket_cliente, direccion_cliente = servidor_socket.accept() # Operación de I/O que cede el control
       print(f"Conexión aceptada desde {direccion_cliente}")
       # Spawn una nueva corrutina para manejar esta conexión
       spawn(manejar_conexion_cliente, socket_cliente)

if __name__ == '__main__':
   try:
       servidor_principal = spawn(iniciar_servidor_gevent)
       servidor_principal.join() # Mantener el proceso principal activo
   except KeyboardInterrupt:
       print("Servidor detenido.")
   finally:
       # En una aplicación real, se necesitaría un mecanismo de apagado ordenado.
       pass

# Para probar: ejecutar este script y luego conectarse usando telnet o netcat:
# telnet 127.0.0.1 8080
# Escribir mensajes y presionar Enter.
   

Una estrategia común para maximizar la concurrencia es combinar estos enfoques: utilizar múltiples procesos para aprovechar los núcleos de la CPU, cada proceso con su propio pool de hilos, y dentro de cada hilo, utilizar corrutinas para manejar operaciones de I/O de manera eficiente.

Etiquetas: Python concurrent.futures ProcessPoolExecutor ThreadPoolExecutor gevent

Publicado el 7-15 05:23