Python asíncrono: varias formas de competencia

Con el advenimiento de Python 3, hay bastante ruido sobre el "asincronismo" y la "concurrencia", podemos suponer que Python introdujo recientemente estas características / conceptos. Pero esto no es así. Hemos usado estas operaciones muchas veces. Además, los principiantes podrían pensar que asyncio es la única o la mejor manera de recrear y usar operaciones asincrónicas / paralelas. En este artículo, veremos varias formas de lograr paralelismo, sus ventajas y desventajas.

Definición de términos:


Antes de profundizar en los aspectos técnicos, es importante tener una comprensión básica de los términos que se usan a menudo en este contexto.

Sincrónico y asincrónico:

En operaciones sincrónicas , las tareas se realizan una tras otra. En las tareas asincrónicas se pueden iniciar y completar independientemente uno del otro. Una tarea asincrónica puede comenzar y continuar ejecutándose mientras la ejecución se mueve a una nueva tarea. Las tareas asincrónicas no bloquean (no fuercen a esperar a que se complete la tarea) operaciones y generalmente se realizan en segundo plano.

Por ejemplo, debe comunicarse con una agencia de viajes para planificar sus próximas vacaciones. Debe enviar una carta a su supervisor antes de volar. En modo síncrono, primero debe llamar a la agencia de viajes y, si se le pide que espere, esperará hasta que le respondan. Entonces comenzará a escribir una carta al líder. Por lo tanto, completa las tareas una tras otra. [ejecución síncrona, aprox. traductor] Pero, si eres inteligente, te han pedido que esperes [cuelga el teléfono, aprox. traductor] comenzará a escribir un correo electrónico y cuando vuelva a hablar pausará la escritura, hablará y luego agregará la carta. También puede pedirle a un amigo que llame a la agencia y que escriba una carta usted mismo. Esto es asincronía, las tareas no se bloquean entre sí.

Competitividad y concurrencia:

La competitividad implica que dos tareas se realizan conjuntamente . En nuestro ejemplo anterior, cuando consideramos el ejemplo asincrónico, progresamos gradualmente al escribir una carta y luego en una conversación con un recorrido. agencia. Esto es competitividad .

Cuando pedimos llamar a un amigo y escribimos una carta nosotros mismos, las tareas se llevaron a cabo en paralelo .

La concurrencia es esencialmente una forma de competencia. Pero la concurrencia depende del hardware. Por ejemplo, si la CPU tiene solo un núcleo, entonces dos tareas no se pueden ejecutar en paralelo. Simplemente comparten el tiempo del procesador entre ellos. Entonces esto es competencia, pero no concurrencia. Pero cuando tenemos varios núcleos [como amigo en el ejemplo anterior, que es el segundo núcleo, aprox. traductor] podemos realizar varias operaciones (dependiendo del número de núcleos) al mismo tiempo.

Para resumir:

  • Sincronización: bloquea operaciones (bloqueo)
  • Asincronía: no bloquea operaciones (sin bloqueo)
  • Competitividad: progreso conjunto (conjunto)
  • Concurrencia: progreso paralelo (paralelo)

La concurrencia implica competencia. Pero la competencia no siempre implica concurrencia.

Hilos y procesos


Python ha estado soportando hilos por mucho tiempo. Los hilos le permiten realizar operaciones competitivamente. Pero hay un problema con Global Interpreter Lock (GIL) debido a que los hilos no pueden proporcionar una verdadera concurrencia. Y, sin embargo, con el advenimiento del multiprocesamiento, puede usar múltiples núcleos con Python.

Hilos

Considere un pequeño ejemplo. En el siguiente código, la función de trabajo se ejecutará en varios subprocesos de forma asincrónica y simultánea.

import threading import time import random def worker(number): sleep = random.randrange(1, 10) time.sleep(sleep) print("I am Worker {}, I slept for {} seconds".format(number, sleep)) for i in range(5): t = threading.Thread(target=worker, args=(i,)) t.start() print("All Threads are queued, let's see when they finish!") 

Y aquí hay un ejemplo de salida:

 $ python thread_test.py All Threads are queued, let's see when they finish! I am Worker 1, I slept for 1 seconds I am Worker 3, I slept for 4 seconds I am Worker 4, I slept for 5 seconds I am Worker 2, I slept for 7 seconds I am Worker 0, I slept for 9 seconds 

Por lo tanto, comenzamos 5 hilos para la colaboración y después de que comiencen (es decir, después de ejecutar la función de trabajo), la operación no espera a que se completen los hilos antes de pasar a la siguiente declaración de impresión. Esta es una operación asincrónica.

En nuestro ejemplo, pasamos la función al constructor Thread. Si quisiéramos, podríamos implementar una subclase con un método (estilo OOP).

Lectura adicional:

Para obtener más información sobre las transmisiones, utilice el siguiente enlace:


Bloqueo global de intérpretes (GIL)

GIL se introdujo para facilitar el manejo de la memoria de CPython y proporcionar la mejor integración con C (por ejemplo, con extensiones). GIL es un mecanismo de bloqueo cuando el intérprete de Python solo ejecuta un hilo a la vez. Es decir solo se puede ejecutar un hilo en Python bytecode a la vez. GIL asegura que múltiples hilos no se ejecutan en paralelo .

Detalles rápidos de GIL:

  • Un hilo puede ejecutarse a la vez.
  • El intérprete de Python cambia entre hilos para lograr competitividad.
  • GIL es aplicable a CPython (implementación estándar). Pero como, por ejemplo, Jython y IronPython no tienen GIL.
  • GIL hace que los programas de un solo subproceso sean rápidos.
  • GIL generalmente no interfiere con las E / S.
  • GIL facilita la integración de bibliotecas seguras para subprocesos en C, gracias a GIL tenemos muchas extensiones / módulos de alto rendimiento escritos en C.
  • Para tareas dependientes de la CPU, el intérprete verifica cada N ticks y cambia los hilos. Por lo tanto, un hilo no bloquea los otros.

Muchos ven a GIL como debilidad. Considero esto como una bendición, porque se crearon bibliotecas como NumPy, SciPy, que ocupan una posición especial y única en la comunidad científica.

Lectura adicional:

Estos recursos le permitirán profundizar en el GIL:


Procesos

Para lograr la simultaneidad en Python, se ha agregado un módulo de multiprocesamiento que proporciona una API y se ve muy similar si utilizó subprocesos antes.

Vamos a cambiar el ejemplo anterior. Ahora la versión modificada usa el Proceso en lugar de la Corriente .

 import multiprocessing import time import random def worker(number): sleep = random.randrange(1, 10) time.sleep(sleep) print("I am Worker {}, I slept for {} seconds".format(number, sleep)) for i in range(5): t = multiprocessing.Process(target=worker, args=(i,)) t.start() print("All Processes are queued, let's see when they finish!") 

¿Qué ha cambiado? Acabo de importar el módulo de multiprocesamiento en lugar de subprocesos . Y luego, en lugar de un hilo, utilicé un proceso. Eso es todo! Ahora, en lugar de muchos subprocesos, utilizamos procesos que se ejecutan en diferentes núcleos de CPU (a menos, por supuesto, que su procesador tenga varios núcleos).

Usando la clase Pool, también podemos distribuir la ejecución de una función entre varios procesos para diferentes valores de entrada. Un ejemplo de los documentos oficiales:

 from multiprocessing import Pool def f(x): return x*x if __name__ == '__main__': p = Pool(5) print(p.map(f, [1, 2, 3])) 

Aquí, en lugar de iterar sobre la lista de valores y llamar a la función f de uno en uno, en realidad ejecutamos la función en diferentes procesos. Un proceso hace f (1), el otro f (2) y el otro f (3). Finalmente, los resultados se combinan nuevamente en una lista. Esto nos permite dividir los cálculos pesados ​​en partes más pequeñas y ejecutarlos en paralelo para un cálculo más rápido.

Lectura adicional:


Concurrent.futures module

El módulo concurrent.futures es grande y hace que escribir código asincrónico sea muy fácil. Mis favoritos son ThreadPoolExecutor y ProcessPoolExecutor . Estos artistas admiten un grupo de hilos o procesos. Enviamos nuestras tareas al grupo y ejecuta las tareas en un hilo / proceso accesible. Se devuelve un objeto Futuro que se puede utilizar para consultar y recuperar el resultado cuando finaliza la tarea.

Y aquí hay un ejemplo de ThreadPoolExecutor:

 from concurrent.futures import ThreadPoolExecutor from time import sleep def return_after_5_secs(message): sleep(5) return message pool = ThreadPoolExecutor(3) future = pool.submit(return_after_5_secs, ("hello")) print(future.done()) sleep(5) print(future.done()) print(future.result()) 

Tengo un artículo sobre concurrent.futures masnun.com/2016/03/29/python-a-quick-introduction-to-the-concurrent-futures-module.html . Puede ser útil para un estudio más profundo de este módulo.

Lectura adicional:


Asyncio: ¿qué, cómo y por qué?


Probablemente tenga una pregunta que muchas personas en la comunidad de Python tienen: ¿qué trae asyncio nuevo? ¿Por qué había otra forma de usar E / S asíncrona? ¿No teníamos ya hilos y procesos? A ver!

¿Por qué necesitamos asyncio?

Los procesos son muy caros [en términos de consumo de recursos, aprox. traductor] para crear. Por lo tanto, para las operaciones de E / S, los hilos se seleccionan principalmente. Sabemos que la E / S depende de elementos externos: las unidades lentas o los retrasos desagradables de la red hacen que la E / S a menudo sea impredecible. Ahora supongamos que usamos hilos para E / S. 3 hilos realizan varias tareas de E / S. El intérprete tendría que cambiar entre flujos competitivos y darles a cada uno algo de tiempo. Llame a los flujos T1, T2 y T3. Tres hilos comenzaron su operación de E / S. T3 lo completa primero. T2 y T1 siguen esperando E / S. El intérprete de Python está cambiando a T1, pero todavía está esperando. Bueno, el intérprete se mueve a T2, y el intérprete todavía está esperando, y luego se mueve a T3, que está listo y ejecuta el código. ¿Ves esto como un problema?

T3 estaba listo, pero el intérprete primero cambió entre T2 y T1; esto incurrió en costos de cambio, lo que podríamos haber evitado si el intérprete hubiera cambiado primero a T3, ¿verdad?

¿Qué es asynio?

Asyncio nos proporciona un ciclo de eventos junto con otras cosas interesantes. El bucle de eventos supervisa los eventos de E / S y cambia las tareas que están listas y esperando las operaciones de E / S [el bucle de eventos es una construcción de software que espera la llegada y envía eventos o mensajes en el programa, aprox. traductor] .

La idea es muy simple. Hay un bucle de eventos. Y tenemos funciones que realizan E / S asíncronas. Transferimos nuestras funciones al bucle de eventos y le pedimos que las ejecute por nosotros. El bucle de eventos nos devuelve un objeto Futuro, como una promesa de que en el futuro obtendremos algo. Nos aferramos a una promesa, verificamos de vez en cuando si es importante (realmente no podemos esperar), y finalmente, cuando se recibe el valor, lo usamos en otras operaciones [es decir enviamos una solicitud, inmediatamente nos dieron un boleto y nos dijeron que esperemos hasta que llegue el resultado. Verificamos periódicamente el resultado y tan pronto como se recibe, tomamos un boleto y obtenemos un valor, aprox. traductor] .

Asyncio usa generadores y corutinas para detener y reanudar tareas. Puedes leer los detalles aquí:


¿Cómo usar asyncio?

Antes de comenzar, veamos un ejemplo:

 import asyncio import datetime import random async def my_sleep_func(): await asyncio.sleep(random.randint(0, 5)) async def display_date(num, loop): end_time = loop.time() + 50.0 while True: print("Loop: {} Time: {}".format(num, datetime.datetime.now())) if (loop.time() + 1.0) >= end_time: break await my_sleep_func() loop = asyncio.get_event_loop() asyncio.ensure_future(display_date(1, loop)) asyncio.ensure_future(display_date(2, loop)) loop.run_forever() 

Tenga en cuenta que la sintaxis async / await es solo para Python 3.5 y versiones posteriores. Veamos el código:

  • Tenemos una función asincrónica display_date que toma un número (como identificador) y un bucle de eventos como parámetros.
  • La función tiene un bucle infinito, que se interrumpe después de 50 segundos. Pero durante este período, ella imprime repetidamente el tiempo y hace una pausa. La función de espera puede esperar a que se completen otras funciones asincrónicas (de rutina).
  • Pasamos la función al bucle de eventos (usando el método allow_future).
  • Comenzamos un ciclo de eventos.

Cada vez que se llama a esperar, asyncio se da cuenta de que la función probablemente llevará algún tiempo. Por lo tanto, detiene la ejecución, comienza a monitorear cualquier evento de E / S asociado y le permite ejecutar tareas. Cuando asyncio se da cuenta de que la E / S de la función en pausa está lista, reanuda la función.

Haciendo la elección correcta.


Acabamos de pasar por las formas más populares de competitividad. Pero la pregunta sigue siendo: ¿qué se debe elegir? Depende de los casos de uso. Desde mi experiencia, tiendo a seguir este pseudocódigo:

 if io_bound: if io_very_slow: print("Use Asyncio") else: print("Use Threads") else: print("Multi Processing") 

  • CPU enlazada => Procesamiento múltiple
  • E / S enlazada, E / S rápida, número limitado de conexiones => Multi Threading
  • I / O Bound, I / O lenta, muchas conexiones => Asyncio

[Nota traductor

Source: https://habr.com/ru/post/es421625/


All Articles