How-To's Tutorials Web Development

Python más rápido: simultaneidad con async/await y threading

Read this post in other languages:

Si lleva tiempo codificando con Python, y en especial si ha usado marcos de trabajo y bibliotecas como FastAPI y discord.py, probablemente haya usado async/await o asyncio. Quizás haya oído afirmaciones del tipo «el multihilo en Python no existe» y quizás también conozca el (tristemente) famoso GIL de Python. Vista la negación acerca del multihilo de Python, quizás se esté preguntando cuál es realmente la diferencia entre async/await y el multihilo, especialmente al programar con Python. Si es así, ¡este artículo es para usted!

¿Qué es el multihilo?

En programación, el multihilo se refiere a la capacidad de un programa para ejecutar varias tareas secuenciales (llamadas hilos o subprocesos) de forma simultánea. Estos subprocesos pueden ejecutarse en uno o en varios núcleos del procesador. Sin embargo, debido a la limitación del Global Interpreter Lock (GIL), en Python el multihilo solo se procesa en un núcleo. La excepción es nogil (también llamada thread-free) en Python, que elimina el GIL y de la cual hablaremos en la segunda parte de esta serie. En este artículo del blog, asumiremos que el GIL siempre está presente.

¿Qué es la simultaneidad?

La simultaneidad en programación implica que el ordenador está haciendo más de una cosa a la vez, o parece estar haciéndolo, aunque todas las tareas se ejecuten en un único procesador. Al gestionar los recursos y las interacciones entre las distintas partes de un programa, es posible que todas las tareas avancen de forma independiente y en intervalos de tiempo solapados.

Tanto asyncio como threading se comportan de forma simultánea en Python

En términos generales, las bibliotecas de Python asyncio y threading permiten la apariencia de simultaneidad. Sin embargo, sus CPU no están haciendo varias cosas a la vez exactamente, sino que solo lo parece.

Imagine que está organizando una cena de varios platos para sus invitados y algunos de los platos tardan en cocinarse, como la tarta que hay que meter en el horno o la sopa que se está cociendo a fuego lento. Mientras esperamos a que se terminen de cocinar, no nos quedamos de brazos cruzados, sino que hacemos otra cosa. Esto es parecido a la simultaneidad en Python. A veces, el proceso de Python está esperando a que algo termine. Por ejemplo, el sistema operativo gestiona algunos procesos de entrada/salida (E/S) y, en este lapso, el proceso de Python está en pausa. Entonces, podemos usar async para permitir que otro proceso de Python se ejecute mientras tanto.

Multihilo vs. asyncio en Python

La diferencia es quién se encarga

Si tanto asyncio como threading se comportan de forma simultánea, ¿en qué se diferencian? La principal diferencia es una cuestión de quién y cuándo se encarga del proceso que se está ejecutando. En el caso de async/await, el enfoque a veces se conoce como «simultaneidad cooperativa». Una corrutina o un futuro cede su control a otra corrutina o futuro para darles una oportunidad. Por otro lado, en threading, el gestor del sistema operativo controlará qué proceso se está ejecutando.

La simultaneidad cooperativa es como una reunión en la que se va pasando un micrófono para que la gente tome la palabra. Quien tenga el micrófono puede hablar y, cuando haya terminado o no tenga nada más que añadir, pasará el micrófono a la siguiente persona. Por el contrario, el multihilo es una reunión en la que hay un moderador que decide quién tiene la palabra en cada momento. 

Escribir código simultáneo en Python

Veamos cómo funciona la simultaneidad en Python escribiendo algunos ejemplos de código. Crearemos una simulación de un restaurante de comida rápida usando tanto asyncio como threading.

Cómo funciona async/await en Python

El paquete de asyncio se introdujo en Python 3.4, mientras que las palabras clave async y await se introdujeron en Python 3.5. Una de las principales cosas que hacen posible async/await es el uso de corrutinas. Las corrutinas en Python son, en realidad, generadores reutilizados que poder poner en pausa y pasar de nuevo a la función principal.

Ahora, imagine un restaurante de hamburguesas donde solo haya una persona trabajando. Los pedidos se preparan según el orden en que van entrando y no se pueden realizar acciones asíncronas:

import time


def make_burger(order_num):
    print(f"Preparing burger #{order_num}...")
    time.sleep(5) # time for making the burger
    print(f"Burger made #{order_num}")


def main():
    for i in range(3):
        make_burger(i)


if __name__ == "__main__":
    s = time.perf_counter()
    main()
    elapsed = time.perf_counter() - s
    print(f"Orders completed in {elapsed:0.2f} seconds.")

Esto tardará un rato en estar listo:

Preparing burger #0...

Burger made #0

Preparing burger #1...

Burger made #1

Preparing burger #2...

Burger made #2

Orders completed in 15.01 seconds.

Ahora, imaginemos que el restaurante contrata a más personal para que sea posible realizar el trabajo de forma simultánea:

import asyncio

import time

async def make_burger(order_num):

    print(f"Preparing burger #{order_num}...")

    await asyncio.sleep(5) # time for making the burger

    print(f"Burger made #{order_num}")

async def main():

    order_queue = []

    for i in range(3):

        order_queue.append(make_burger(i))

    await asyncio.gather(*(order_queue))

if __name__ == "__main__":

    s = time.perf_counter()

    asyncio.run(main())

    elapsed = time.perf_counter() - s

    print(f"Orders completed in {elapsed:0.2f} seconds.")

Veamos en qué se diferencian:

Preparing burger #0...

Preparing burger #1...

Preparing burger #2...

Burger made #0

Burger made #1

Burger made #2

Orders completed in 5.00 seconds.

Con las funciones proporcionadas por asyncio, como run y gather, y las palabras clave async y await, hemos creado corrutinas que pueden preparar hamburguesas de forma simultánea.

Ahora, vayamos un poco más allá y creemos una simulación más complicada. Vamos a imaginar que solo tenemos dos trabajadores y solo podemos hacer dos hamburguesas a la vez.

import asyncio

import time

order_queue = asyncio.Queue()

def take_order():

  for i in range(3):

      order_queue.put_nowait(make_burger(i))

async def make_burger(order_num):

  print(f"Preparing burger #{order_num}...")

  await asyncio.sleep(5)  # time for making the burger

  print(f"Burger made #{order_num}")

class Staff:

  def __init__(self, name):

      self.name = name

  async def working(self):

      while order_queue.qsize() > 0:

          print(f"{self.name} is working...")

          task = await order_queue.get()

          await task

          print(f"{self.name} finished a task...")

async def main():

  staff1 = Staff(name="John")

  staff2 = Staff(name="Jane")

  take_order()

  await asyncio.gather(staff1.working(), staff2.working())

if __name__ == "__main__":

  s = time.perf_counter()

  asyncio.run(main())

  elapsed = time.perf_counter() - s

  print(f"Orders completed in {elapsed:0.2f} seconds.")

Aquí utilizaremos una cola para retener las tareas y el personal las irá recogiendo.

John is working...

Preparing burger #0...

Jane is working...

Preparing burger #1...

Burger made #0

John finished a task...

John is working...

Preparing burger #2...

Burger made #1

Jane finished a task...

Burger made #2

John finished a task...

Orders completed in 10.00 seconds.

En este ejemplo, utilizamos asyncio.Queue para almacenar las tareas, pero será más útil si tenemos varios tipos de tareas, como se muestra en el siguiente ejemplo.

import asyncio

import time

task_queue = asyncio.Queue()

order_num = 0

async def take_order():

   global order_num

   order_num += 1

   print(f"Order burger and fries for order #{order_num:04d}:")

   burger_num = input("Number of burgers:")

   for i in range(int(burger_num)):

       await task_queue.put(make_burger(f"{order_num:04d}-burger{i:02d}"))

   fries_num = input("Number of fries:")

   for i in range(int(fries_num)):

       await task_queue.put(make_fries(f"{order_num:04d}-fries{i:02d}"))

   print(f"Order #{order_num:04d} queued.")

   await task_queue.put(take_order())

async def make_burger(order_num):

   print(f"Preparing burger #{order_num}...")

   await asyncio.sleep(5)  # time for making the burger

   print(f"Burger made #{order_num}")

async def make_fries(order_num):

   print(f"Preparing fries #{order_num}...")

   await asyncio.sleep(2)  # time for making fries

   print(f"Fries made #{order_num}")

class Staff:

   def __init__(self, name):

       self.name = name

   async def working(self):

       while True:

           if task_queue.qsize() > 0:

               print(f"{self.name} is working...")

               task = await task_queue.get()

               await task

               print(f"{self.name} finish task...")

           else:

               await asyncio.sleep(1) #rest

async def main():

   task_queue.put_nowait(take_order())

   staff1 = Staff(name="John")

   staff2 = Staff(name="Jane")

   await asyncio.gather(staff1.working(), staff2.working())

if __name__ == "__main__":

   s = time.perf_counter()

   asyncio.run(main())

   elapsed = time.perf_counter() - s

   print(f"Orders completed in {elapsed:0.2f} seconds.")

En este ejemplo, hay varias tareas, como hacer patatas fritas, que lleva menos tiempo, o tomar nota de los pedidos, que implica recibir información del usuario. 

Tenga en cuenta que el programa deja de esperar la información del usuario e incluso el personal que no está tomando nota del pedido deja de trabajar en segundo plano. Esto ocurre porque la función input no es async y, por lo tanto, no se la espera. Recuerde que el control en código async solo se libera cuando se le espera. Para solucionarlo, podemos sustituir

input("Number of burgers:")

Por 

await asyncio.to_thread(input, "Number of burgers:")

Y haremos lo mismo en el caso de las patatas fritas (fíjese en el siguiente código). Observe que ahora el programa se ejecuta en un bucle infinito. Si queremos detenerlo, podemos bloquear de forma deliberada el programa con una entrada no válida.

import asyncio

import time

task_queue = asyncio.Queue()

order_num = 0

async def take_order():

   global order_num

   order_num += 1

   print(f"Order burger and fries for order #{order_num:04d}:")

   burger_num = await asyncio.to_thread(input, "Number of burgers:")

   for i in range(int(burger_num)):

       await task_queue.put(make_burger(f"{order_num:04d}-burger{i:02d}"))

   fries_num = await asyncio.to_thread(input, "Number of fries:")

   for i in range(int(fries_num)):

       await task_queue.put(make_fries(f"{order_num:04d}-fries{i:02d}"))

   print(f"Order #{order_num:04d} queued.")

   await task_queue.put(take_order())

async def make_burger(order_num):

   print(f"Preparing burger #{order_num}...")

   await asyncio.sleep(5)  # time for making the burger

   print(f"Burger made #{order_num}")

async def make_fries(order_num):

   print(f"Preparing fries #{order_num}...")

   await asyncio.sleep(2)  # time for making fries

   print(f"Fries made #{order_num}")

class Staff:

   def __init__(self, name):

       self.name = name

   async def working(self):

       while True:

           if task_queue.qsize() > 0:

               print(f"{self.name} is working...")

               task = await task_queue.get()

               await task

               print(f"{self.name} finish task...")

           else:

               await asyncio.sleep(1) #rest

async def main():

   task_queue.put_nowait(take_order())

   staff1 = Staff(name="John")

   staff2 = Staff(name="Jane")

   await asyncio.gather(staff1.working(), staff2.working())

if __name__ == "__main__":

   s = time.perf_counter()

   asyncio.run(main())

   elapsed = time.perf_counter() - s

   print(f"Orders completed in {elapsed:0.2f} seconds.")

Con asyncio.to_thread, hemos puesto la función input en un subproceso independiente (vea esta referencia). Sin embargo, debe tener en cuenta que este truco solo desbloquea tareas limitadas por E/S si el GIL de Python está presente.

Si ejecuta el código anterior, también verá que la E/S estándar en el terminal aparece desordenada. La E/S del usuario y el registro de lo que está sucediendo deben estar separados. Podemos poner el registro en un log para inspeccionarlo más tarde. 

import asyncio

import logging

import time

logger = logging.getLogger(__name__)

logging.basicConfig(filename='pyburger.log', level=logging.INFO)

task_queue = asyncio.Queue()

order_num = 0

closing = False

async def take_order():

   global order_num, closing

   try:

       order_num += 1

       logger.info(f"Taking Order #{order_num:04d}...")

       print(f"Order burger and fries for order #{order_num:04d}:")

       burger_num = await asyncio.to_thread(input, "Number of burgers:")

       for i in range(int(burger_num)):

           await task_queue.put(make_burger(f"{order_num:04d}-burger{i:02d}"))

       fries_num = await asyncio.to_thread(input, "Number of fries:")

       for i in range(int(fries_num)):

           await task_queue.put(make_fries(f"{order_num:04d}-fries{i:02d}"))

       logger.info(f"Order #{order_num:04d} queued.")

       print(f"Order #{order_num:04d} queued, please wait.")

       await task_queue.put(take_order())

   except ValueError:

       print("Goodbye!")

       logger.info("Closing down... stop taking orders and finish all tasks.")

       closing = True

async def make_burger(order_num):

   logger.info(f"Preparing burger #{order_num}...")

   await asyncio.sleep(5)  # time for making the burger

   logger.info(f"Burger made #{order_num}")

async def make_fries(order_num):

   logger.info(f"Preparing fries #{order_num}...")

   await asyncio.sleep(2)  # time for making fries

   logger.info(f"Fries made #{order_num}")

class Staff:

   def __init__(self, name):

       self.name = name

   async def working(self):

       while True:

           if task_queue.qsize() > 0:

               logger.info(f"{self.name} is working...")

               task = await task_queue.get()

               await task

               task_queue.task_done()

               logger.info(f"{self.name} finish task.")

           elif closing:

               return

           else:

               await asyncio.sleep(1) #rest

async def main():

   global task_queue

   task_queue.put_nowait(take_order())

   staff1 = Staff(name="John")

   staff2 = Staff(name="Jane")

   print("Welcome to Pyburger!")

   logger.info("Ready for business!")

   await asyncio.gather(staff1.working(), staff2.working())

   logger.info("All tasks finished. Closing now.")

if __name__ == "__main__":

   s = time.perf_counter()

   asyncio.run(main())

   elapsed = time.perf_counter() - s

   logger.info(f"Orders completed in {elapsed:0.2f} seconds.")

En este bloque de código final, hemos registrado la información de la simulación en pyburger.log y reservado el terminal para mensajes dirigidos a los clientes. También detectamos entradas no válidas durante el proceso de tomar nota del pedido y cambiamos el indicador closing a True si la entrada no era válida, asumiendo que el usuario quería salir. Cuando el indicador closing se establece en True, el trabajador hará return (regresará), lo que pondrá fin al bucle while infinito de la corrutina.

¿Cómo funcionan los threading en Python?

En el ejemplo anterior, pusimos una tarea de E/S en otro subproceso. Se estará preguntando si podemos poner todas las tareas en subprocesos separados y dejar que se ejecuten de forma simultánea. Vamos a probar usando threading en lugar de asyncio.

Fíjese en el siguiente código, donde creamos hamburguesas de forma simultánea sin ninguna limitación:

import asyncio

import time

async def make_burger(order_num):

    print(f"Preparing burger #{order_num}...")

    await asyncio.sleep(5) # time for making the burger

    print(f"Burger made #{order_num}")

async def main():

    order_queue = []

    for i in range(3):

        order_queue.append(make_burger(i))

    await asyncio.gather(*(order_queue))

if __name__ == "__main__":

    s = time.perf_counter()

    asyncio.run(main())

    elapsed = time.perf_counter() - s

    print(f"Orders completed in {elapsed:0.2f} seconds.")

```

Instead of creating async coroutines to make the burgers, we can just send functions down different threads like this:

```

import threading

import time

def make_burger(order_num):

   print(f"Preparing burger #{order_num}...")

   time.sleep(5) # time for making the burger

   print(f"Burger made #{order_num}")

def main():

   order_queue = []

   for i in range(3):

       task = threading.Thread(target=make_burger, args=(i,))

       order_queue.append(task)

       task.start()

   for task in order_queue:

       task.join()

if __name__ == "__main__":

   s = time.perf_counter()

   main()

   elapsed = time.perf_counter() - s

   print(f"Orders completed in {elapsed:0.2f} seconds.")

En el primer bucle for de main, las tareas se crean en diferentes subprocesos y se ponen en marcha rápidamente. El segundo bucle for se asegura de que todas las hamburguesas estén hechas antes de que el programa siga adelante (es decir, antes de que vuelva a main).

Se complica cuando solo hay dos personas trabajando. Los trabajadores están representados con un subproceso y seleccionarán tareas de una lista normal donde se almacenan todas.

import threading

import time

order_queue = []

def take_order():

   for i in range(3):

       order_queue.append(make_burger(i))

def make_burger(order_num):

   def making_burger():

       print(f"Preparing burger #{order_num}...")

       time.sleep(5)  # time for making the burger

       print(f"Burger made #{order_num}")

   return making_burger

def working():

     while len(order_queue) > 0:

         print(f"{threading.current_thread().name} is working...")

         task = order_queue.pop(0)

         task()

         print(f"{threading.current_thread().name} finish task...")

def main():

   take_order()

   staff1 = threading.Thread(target=working, name="John")

   staff1.start()

   staff2 = threading.Thread(target=working, name="Jane")

   staff2.start()

   staff1.join()

   staff2.join()

if __name__ == "__main__":

 s = time.perf_counter()

 main()

 elapsed = time.perf_counter() - s

 print(f"Orders completed in {elapsed:0.2f} seconds.")

Al ejecutar el código anterior, puede producirse un error en uno de los subprocesos que indique que está intentando seleccionar una tarea de una lista vacía. Quizás se pregunte por qué ocurre esto, ya que tenemos una condición en el bucle while que hace que continúe solo si la tarea task_queue no está vacía. Sin embargo, sigue apareciendo el error porque hemos encontrado condiciones de carrera.

Condiciones de carrera

Las condiciones de carrera pueden producirse cuando varios subprocesos intentan acceder al mismo recurso o a los mismos datos a la vez y causan problemas en el sistema. El momento y el orden en que se accede al recurso son importantes para la lógica del programa, y un momento impredecible o la intercalación de varios subprocesos accediendo y modificando datos compartidos puede causar errores.

Para resolver la condición de carrera en nuestro programa, vamos a implementar un bloqueo a la tarea task_queue:

queue_lock = threading.Lock()

Para que funcione, necesitamos asegurarnos de que tenemos derechos de acceso a la cola al comprobar su longitud y seleccionar tareas. Mientras tengamos los derechos, no habrá otros subprocesos que puedan acceder a la cola:

def working():

   while True:

       with queue_lock:

           if len(order_queue) == 0:

               return

           else:

               task = order_queue.pop(0)

       print(f"{threading.current_thread().name} is working...")

       task()

       print(f"{threading.current_thread().name} finish task...")

```

Based on what we have learned so far, we can complete our final code with threading like this:

```

import logging

import threading

import time

logger = logging.getLogger(__name__)

logging.basicConfig(filename="pyburger_threads.log", level=logging.INFO)

queue_lock = threading.Lock()

task_queue = []

order_num = 0

closing = False

def take_order():

   global order_num, closing

   try:

       order_num += 1

       logger.info(f"Taking Order #{order_num:04d}...")

       print(f"Order burger and fries for order #{order_num:04d}:")

       burger_num = input("Number of burgers:")

       for i in range(int(burger_num)):

           with queue_lock:

               task_queue.append(make_burger(f"{order_num:04d}-burger{i:02d}"))

       fries_num = input("Number of fries:")

       for i in range(int(fries_num)):

           with queue_lock:

               task_queue.append(make_fries(f"{order_num:04d}-fries{i:02d}"))

       logger.info(f"Order #{order_num:04d} queued.")

       print(f"Order #{order_num:04d} queued, please wait.")

       with queue_lock:

           task_queue.append(take_order)

   except ValueError:

       print("Goodbye!")

       logger.info("Closing down... stop taking orders and finish all tasks.")

       closing = True

def make_burger(order_num):

   def making_burger():

       logger.info(f"Preparing burger #{order_num}...")

       time.sleep(5)  # time for making the burger

       logger.info(f"Burger made #{order_num}")

   return making_burger

def make_fries(order_num):

   def making_fries():

       logger.info(f"Preparing fried #{order_num}...")

       time.sleep(2)  # time for making fries

       logger.info(f"Fries made #{order_num}")

   return making_fries

def working():

   while True:

       with queue_lock:

           if len(task_queue) == 0:

               if closing:

                   return

               else:

                   task = None

           else:

               task = task_queue.pop(0)

       if task:

           logger.info(f"{threading.current_thread().name} is working...")

           task()

           logger.info(f"{threading.current_thread().name} finish task...")

       else:

           time.sleep(1)  # rest

def main():

   print("Welcome to Pyburger!")

   logger.info("Ready for business!")

   task_queue.append(take_order)

   staff1 = threading.Thread(target=working, name="John")

   staff1.start()

   staff2 = threading.Thread(target=working, name="Jane")

   staff2.start()

   staff1.join()

   staff2.join()

   logger.info("All tasks finished. Closing now.")

if __name__ == "__main__":

   s = time.perf_counter()

   main()

   elapsed = time.perf_counter() - s

   logger.info(f"Orders completed in {elapsed:0.2f} seconds.")

Si compara los dos fragmentos de código utilizando asyncio y threading, los resultados deberían ser similares. Quizás se pregunte cuál es mejor y por qué debería elegir uno u otro.

En la práctica, escribir código asyncio es más fácil que el multihilo porque no tenemos que preocuparnos por las posibles condiciones de carrera y los interbloqueos. Las corrutinas pasan controles de forma predeterminada, por lo que no se necesitan bloqueos. Sin embargo, los subprocesos de Python pueden ejecutarse en paralelo, aunque no siempre con el GIL presente. Podemos volver sobre esto cuando hablemos de Python nogil (sin subprocesos) en el próximo artículo del blog.

Ventajas de la simultaneidad

¿Por qué utilizar la simultaneidad en programación? Por una sencilla razón: la velocidad. Como hemos visto anteriormente, las tareas pueden completarse más rápidamente si podemos reducir el tiempo de espera. Hay varios tipos de espera en computación y, en cada caso, tendemos a usar diferentes métodos para ahorrar tiempo.

Tareas ligadas a la E/S

Se considera que una tarea o programa está ligado a la entrada/salida (E/S) cuando su velocidad de ejecución está limitada principalmente por la velocidad de las operaciones de E/S, como la lectura de un archivo o de la red, o la espera de la información que introduzca del usuario. Las operaciones de E/S suelen ser más lentas que otras operaciones de la CPU y, por tanto, las tareas que implican muchas de ellas pueden ser bastante más lentas. Algunos ejemplos de ello son la lectura de datos de una base de datos, la gestión de peticiones web o el trabajo con archivos de gran tamaño.

El uso de la simultaneidad async/await puede ayudar a optimizar el tiempo de espera durante las tareas vinculadas a E/S desbloqueando la secuencia de procesamiento y permitiendo que se lleven a cabo otras tareas mientras se espera.

La simultaneidad async/await tiene ventajas en muchas aplicaciones de Python, como las aplicaciones web que necesitan mucha comunicación con bases de datos y el manejo de peticiones web. Las interfaces gráficas de usuario también pueden beneficiarse de la simultaneidad async/await al permitir que se lleven a cabo tareas en segundo plano mientras el usuario interactúa con la aplicación.

Tareas ligadas a la CPU

Una tarea o programa se considera ligado a la CPU cuando su velocidad de ejecución está limitada principalmente por la velocidad de la CPU. Algunos ejemplos típicos son el procesamiento de imágenes o vídeo, como el cambio de tamaño o la edición, y los cálculos matemáticos complejos, como la multiplicación de matrices o el entrenamiento de modelos de aprendizaje automático.

A diferencia de las tareas de E/S, las tareas de CPU no suelen poder optimizarse con simultaneidad async/await, ya que la CPU ya está ocupada trabajando en las tareas. Si tiene más de una CPU en la máquina, o si puede descargar algunas de estas tareas a una o más GPU, las tareas ligadas a la CPU pueden terminarse más rápidamente creando más subprocesos y con multiprocesamiento. El multiprocesamiento puede optimizar el uso de las CPU y las GPU, por lo que muchos modelos de aprendizaje automático e inteligencia artificial se entrenan actualmente en varias GPU.

Sin embargo, esto es difícil de llevar a cabo con código Python puro, ya que Python está diseñado para proporcionar capas abstractas de forma que los usuarios no tengan que controlar los procesos de computación de nivel inferior. Además, el GIL de Python limita el uso compartido de los recursos de Python entre varios subprocesos del ordenador. Recientemente, Python 3.13 incorporó la posibilidad de eliminar el GIL, lo que permite lograr un multihilo real. En el próximo artículo del blog, hablaremos del GIL y de la posibilidad de prescindir de él.

A veces, ninguno de los métodos mencionados anteriormente es capaz de acelerar suficientemente las tareas ligadas a la CPU. En ese caso, puede que sea necesario dividir las tareas ligadas a la CPU en otras más pequeñas para que puedan ejecutarse de forma simultánea en varios subprocesos, varios procesadores o incluso varias máquinas. Esto es el procesamiento paralelo y puede que tenga que reescribir el código por completo para implementarlo. En Python, el paquete multiprocessing ofrece simultaneidad local y remota, que puede utilizarse para evitar la limitación del GIL. También veremos algunos ejemplos de ello en el próximo artículo del blog.

Depuración de código simultáneo en PyCharm

Depurar código async o simultáneo puede ser difícil, ya que el programa no se ejecuta en secuencia, lo que significa que es difícil ver dónde y cuándo se está ejecutando el código. Muchos desarrolladores utilizan print para ayudar a trazar el flujo del código, pero no recomendamos este enfoque, ya que es poco preciso y utilizarlo para investigar un programa complejo, como uno simultáneo, no es nada fácil. Además, después no siempre es fácil poner orden.

Muchos IDE tienen depuradores, que son geniales para inspeccionar variables y el flujo del programa. Los depuradores también proporcionan un seguimiento claro de la pila a través de varios subprocesos. Veamos cómo podemos rastrear la tarea task_queue de nuestro ejemplo de simulación de restaurante en PyCharm.

Primero, pondremos algunos puntos de interrupción en el código. Puede hacerlo haciendo clic en el número de la línea donde quiere que el depurador haga una pausa. El número de línea se convertirá en un punto rojo, indicando que allí se establece un punto de interrupción. Pondremos puntos de interrupción en las líneas 23, 27 y 65, donde se modifica la tarea task_queue en varios subprocesos.

A continuación, podemos ejecutar el programa en modo de depuración haciendo clic en el pequeño icono de error de la parte superior derecha.

Tras hacer clic en el icono, se abrirá la ventana Debug. El programa se ejecutará hasta que llegue al primer punto de interrupción resaltado en el código.

Aquí vemos que el subproceso John está intentando retomar la tarea y la línea 65 está resaltada. En este punto, la línea resaltada aún no se ha ejecutado. Esto es útil cuando queremos inspeccionar las variables antes de entrar en el punto de interrupción.

Vamos a inspeccionar lo que hay en la tarea task_queue. Puede hacerlo simplemente comenzando a escribir en la ventana Debug, como se muestra a continuación.

Seleccione o escriba «task_queue» y, a continuación, pulse Intro. Verá que la tarea take_order está en la cola.

Ahora, vamos a ejecutar el punto de interrupción pulsando el botón Step in, como se muestra a continuación.

Después de pulsarlo e inspeccionar la ventana de Special Variables que aparece, vemos que ahora la variable de tarea es take_order en el subproceso John.

Al consultar de nuevo la tarea task_queue, vemos que ahora la lista está vacía.

Ahora, vamos a pulsar el botón Resume Program y a dejar que el programa se ejecute.

Cuando el programa llegue a la parte de entrada del usuario, PyCharm nos llevará a la ventana Console para que podamos proporcionar la entrada. Pongamos que queremos dos hamburguesas. Escriba «2» y pulse Intro.

Ahora, llegamos al segundo punto de interrupción. Si hacemos clic en Threads & Variables para volver a esa ventana, veremos que burger_num es dos, tal como lo introdujimos.

Ahora, vamos a entrar en el punto de interrupción e inspeccionar la tarea task_queue, como hicimos antes. Vemos que se ha añadido una tarea make_burger.

Dejamos que el programa se ejecute de nuevo y, si entramos en el punto de interrupción cuando se detiene, vemos que Jane está recogiendo la tarea.

Si quiere, puede inspeccionar el resto del código. Cuando haya terminado, pulse el botón rojo Stop en la parte superior de la ventana.

Con el depurador en PyCharm, puede seguir la ejecución del programa a través de varios subprocesos e inspeccionar diferentes variables muy fácilmente.


Conclusión

Ahora, hemos aprendido lo básico de la simultaneidad en Python y esperamos que, con la práctica, sea capaz de dominarla. En el próximo artículo del blog, echaremos un vistazo a la GIL de Python, qué papel desempeña y qué cambia cuando está ausente.

PyCharm cuenta con herramientas potentes para trabajar con código Python simultáneo. Como demostramos en este artículo del blog, el depurador permite inspeccionar paso a paso el código async y thread, lo que le ayuda rastrear el flujo de ejecución, supervisar los recursos compartidos y detectar los problemas. Con puntos de interrupción intuitivos, vistas de variables en tiempo real, integración perfecta de la consola para la entrada del usuario y una sólida compatibilidad de registro, PyCharm facilita la escritura, las pruebas y la depuración de aplicaciones con confianza y claridad.

Artículo original en inglés de:

Cheuk Ting Ho

Cheuk Ting Ho

image description

Discover more