Python multiproceso: ¿se desliza a través de un cuello de botella de E / S?

Cómo aprovechar el paralelismo en Python puede hacer que su software sea más rápido en órdenes de magnitud.

Recientemente desarrollé un proyecto que llamé Hydra: un verificador de enlaces multiproceso escrito en Python. A diferencia de muchos rastreadores de sitios de Python que encontré mientras investigaba, Hydra usa solo bibliotecas estándar, sin dependencias externas como BeautifulSoup. Está pensado para ejecutarse como parte de un proceso de CI / CD, por lo que parte de su éxito dependía de la rapidez.

Múltiples subprocesos en Python es un tema un poco mordaz (no lo siento) ya que el intérprete de Python en realidad no permite que se ejecuten varios subprocesos al mismo tiempo.

El bloqueo de intérprete global de Python, o GIL, evita que varios subprocesos ejecuten códigos de bytes de Python a la vez. Cada subproceso que desee ejecutarse debe primero esperar a que el subproceso que se está ejecutando actualmente libere el GIL. El GIL es prácticamente el micrófono en un panel de conferencias de bajo presupuesto, excepto cuando nadie puede gritar.

Esto tiene la ventaja de prevenir condiciones de carrera. Sin embargo, carece de las ventajas de rendimiento que ofrece la ejecución de varias tareas en paralelo. (Si desea un repaso sobre simultaneidad, paralelismo y subprocesos múltiples, consulte Simultaneidad, paralelismo y los muchos subprocesos de Santa Claus).

Si bien prefiero Go por sus convenientes primitivas de primera clase que admiten la concurrencia (consulte Goroutines), los destinatarios de este proyecto se sentían más cómodos con Python. ¡Lo aproveché como una oportunidad para probar y explorar!

No es imposible realizar simultáneamente varias tareas en Python; solo requiere un poco de trabajo extra. Para Hydra, la principal ventaja es superar el cuello de botella de entrada / salida (E / S).

Para poder comprobar las páginas web, Hydra debe ir a Internet y buscarlas. En comparación con las tareas que realiza solo la CPU, salir a través de la red es comparativamente más lento. ¿Qué tan lento?

A continuación, se muestran los tiempos aproximados para las tareas realizadas en una PC típica:

Tarea Hora
UPC ejecutar instrucción típica 1 / 1.000.000.000 seg = 1 nanoseg
UPC recuperar de la memoria caché L1 0,5 nanosec
UPC predicción errónea de rama 5 nanosec
UPC recuperar de la memoria caché L2 7 nanosec
RAM Bloqueo / desbloqueo de mutex 25 nanosec
RAM recuperar de la memoria principal 100 nanosec
Red enviar 2K bytes a través de una red de 1Gbps 20.000 nanosegundos
RAM leer 1 MB secuencialmente de la memoria 250.000 nanosec
Disco buscar desde la nueva ubicación del disco (buscar) 8.000.000 nanosec (8 ms)
Disco leer 1 MB secuencialmente desde el disco 20.000.000 nanosec (20 ms)
Red Envíe el paquete de EE. UU. a Europa y viceversa 150,000,000 nanosec (150ms)

Peter Norvig publicó por primera vez estos números hace algunos años en Teach Yourself Programming in Ten Years. Dado que las computadoras y sus componentes cambian año tras año, los números exactos que se muestran arriba no son el punto. Lo que estos números ayudan a ilustrar es la diferencia, en órdenes de magnitud, entre operaciones.

Compare la diferencia entre buscar desde la memoria principal y enviar un paquete simple a través de Internet. Si bien ambas operaciones ocurren en menos de un abrir y cerrar de ojos (literalmente) desde una perspectiva humana, puede ver que enviar un paquete simple a través de Internet es más de un millón de veces más lento que obtenerlo de la RAM. Es una diferencia que, en un programa de un solo hilo, puede acumularse rápidamente para formar cuellos de botella problemáticos.

En Hydra, la tarea de analizar los datos de respuesta y reunir los resultados en un informe es relativamente rápida, ya que todo sucede en la CPU. La parte más lenta de la ejecución del programa, en más de seis órdenes de magnitud, es la latencia de la red. ¡Hydra no solo necesita buscar paquetes, sino también páginas web completas!

Una forma de mejorar el rendimiento de Hydra es encontrar una manera de que las tareas de búsqueda de páginas se ejecuten sin bloquear el hilo principal.

Python tiene un par de opciones para realizar tareas en paralelo: múltiples procesos o múltiples subprocesos. Estos métodos le permiten eludir el GIL y acelerar la ejecución de un par de formas diferentes.

Múltiples procesos

Para ejecutar tareas paralelas usando múltiples procesos, puede usar Python ProcessPoolExecutor. Una subclase concreta Executordel concurrent.futuresmódulo, ProcessPoolExecutorutiliza un conjunto de procesos generados con el multiprocessingmódulo para evitar el GIL.

Esta opción utiliza subprocesos de trabajo que, de forma predeterminada, se ajustan al número máximo de procesadores en la máquina. El multiprocessingmódulo le permite paralelizar al máximo la ejecución de funciones en todos los procesos, lo que realmente puede acelerar las tareas vinculadas al cálculo (o al CPU).

Dado que el principal cuello de botella de Hydra es la E / S y no el procesamiento que debe realizar la CPU, es mejor utilizar varios subprocesos.

Múltiples hilos

Con un nombre apropiado, Python ThreadPoolExecutorusa un grupo de subprocesos para ejecutar tareas asincrónicas. También es una subclase de Executor, utiliza un número definido de subprocesos de trabajo máximo (al menos cinco de forma predeterminada, según la fórmula min(32, os.cpu_count() + 4)) y reutiliza los subprocesos inactivos antes de iniciar otros nuevos, lo que lo hace bastante eficiente.

Aquí hay un fragmento de Hydra con comentarios que muestran cómo Hydra usa ThreadPoolExecutorpara lograr la felicidad de múltiples hilos paralelos:

# Create the Checker class class Checker: # Queue of links to be checked TO_PROCESS = Queue() # Maximum workers to run THREADS = 100 # Maximum seconds to wait for HTTP response TIMEOUT = 60 def __init__(self, url): ... # Create the thread pool self.pool = futures.ThreadPoolExecutor(max_workers=self.THREADS) def run(self): # Run until the TO_PROCESS queue is empty while True: try: target_url = self.TO_PROCESS.get(block=True, timeout=2) # If we haven't already checked this link if target_url["url"] not in self.visited: # Mark it as visited self.visited.add(target_url["url"]) # Submit the link to the pool job = self.pool.submit(self.load_url, target_url, self.TIMEOUT) job.add_done_callback(self.handle_future) except Empty: return except Exception as e: print(e) 

Puede ver el código completo en el repositorio de GitHub de Hydra.

Un solo hilo a multihilo

Si desea ver el efecto completo, comparé los tiempos de ejecución para verificar mi sitio web entre un programa prototipo de un solo hilo y el de múltiples encabezados, quiero decir, de múltiples subprocesos, Hydra.

time python3 slow-link-check.py //victoria.dev real 17m34.084s user 11m40.761s sys 0m5.436s time python3 hydra.py //victoria.dev real 0m15.729s user 0m11.071s sys 0m2.526s 

El programa de un solo hilo, que bloquea las E / S, se ejecutó en unos diecisiete minutos. Cuando ejecuté por primera vez la versión multiproceso, terminó en 1m13.358s; después de algunos perfiles y ajustes, tardó un poco menos de dieciséis segundos.

Una vez más, los tiempos exactos no significan mucho; variarán según factores como el tamaño del sitio que se rastrea, la velocidad de su red y el equilibrio de su programa entre la sobrecarga de la administración de subprocesos y los beneficios del paralelismo.

Lo más importante, y el resultado que tomaré cualquier día, es un programa que se ejecuta algunos órdenes de magnitud más rápido.