Tutorial de Concurrencia en Python

Este es un resumen de mi tutorial de la PyCon 2020. Puedes encontrar el vídeo original y el código fuente aquí: https://github.com/santiagobasulto/pycon-concurrency-tutorial-2020

https://www.youtube.com/watch?v=18B1pznaU1o

Esta es una guía/tutorial rápida sobre cómo escribir efectivamente programas concurrentes usando Python. La concurrencia en Python puede ser confusa. Hay múltiples módulos (threading, _thread, multiprocessing, subprocess). También existe el tan odiado GIL, pero sólo para CPython (PyPy y Jython no tienen GIL). Las colecciones no son seguras para los hilos, excepto por algunos detalles de implementación con CPython.

El objetivo de este tutorial es proporcionar recomendaciones realistas sobre cómo abordar cada caso de uso de código concurrente (o paralelo).

Notarás que, a diferencia de otros tutoriales de Python más tradicionales de «sólo multihilo», comienzo con un resumen tanto de la Arquitectura de Computadores como de los conceptos de los Sistemas Operativos. Entender estos conceptos básicos es fundamental para construir correctamente programas concurrentes. Por favor, sáltate estas secciones si ya estás familiarizado con estos temas.

Este tutorial está dividido en las siguientes secciones:

  • Concurrencia vs Paralelismo
  • Arquitectura de Computadores
  • El papel del Sistema Operativo
  • Hilos con Python
  • Sincronización de Hilos
  • Multiprocesamiento
  • Librerías de alto nivel: concurrent.futures y parallel

Concurrencia vs Paralelismo

El paralelismo es cuando varias tareas se ejecutan al mismo tiempo. Es el objetivo final de los programas concurrentes. La concurrencia es menos que el paralelismo, significa que estamos iniciando varias tareas y haciendo malabares con ellas en el mismo periodo de tiempo. Sin embargo, en un momento dado, estamos haciendo sólo una a la vez.

Aplicado a la cocina, estos serían ejemplos de concurrencia y paralelismo.

La concurrencia tiene una sola persona en la cocina:

  • Empezar a cortar cebollas
  • Empezar a calentar la sartén
  • Terminar de cortar las cebollas

En este caso está claro que no podemos hacer varias cosas AL MISMO TIEMPO. Podemos empezarlas todas, pero tenemos que ir rebotando para controlarlas.

El paralelismo tiene varias personas en la cocina:

  • La persona 1 está cortando cebollas
  • La persona 2 está cortando pimientos rojos
  • La persona 3 espera a que se caliente la sartén

En este caso hay varias tareas que se hacen al mismo tiempo.

Arquitectura de los ordenadores

Todos los ordenadores modernos se pueden simplificar utilizando la arquitectura von Neumann, que tiene 3 componentes principales: Computación (CPU), Memoria (RAM), E/S (discos duros, redes, salida de vídeo).

La arquitectura von Neumann

Su código siempre estará realizando tareas relacionadas con cada uno de estos componentes. Algunas instrucciones son «computacionales» ( x + 1), otras son de memoria (x = 1), y otras son de E/S (fp = open('data.csv')).

Para entender correctamente los detalles de la concurrencia en Python, es importante tener en cuenta los diferentes «tiempos de acceso» de estos componentes. Acceder a la CPU es mucho más rápido que acceder a la RAM, por no hablar de la E/S. Hay un estudio que compara la latencia de los ordenadores con los tiempos relativos de los humanos: si un ciclo de la CPU representa 1 segundo de tiempo humano, una petición de red desde San Francisco a Hong Kong tarda 11 años.

Latencia a escala humana (fuente)

La diferencia radical de tiempo con estas «instrucciones» será fundamental para discutir los hilos y el GIL de Python.

El Papel del Sistema Operativo

Si estuviera construyendo un curso universitario sobre Concurrencia, tendría una clase entera sólo cubriendo la Historia de los Sistemas Operativos. Es la única manera de entender cómo evolucionaron los mecanismos de concurrencia.

Los primeros sistemas operativos enfocados al consumidor (piensa en MS-DOS) no soportaban la multitarea. Como mucho, sólo se ejecutaba un programa en un momento determinado. Les siguieron los sistemas de «multitarea cooperativa» (Windows 3.0). Este tipo de multitarea se parecía más a un «bucle de eventos» (piensa en el asyncio de Python) que a un programa multihilo moderno. En un sistema operativo multitarea cooperativo, la aplicación se encarga de «liberar» la CPU cuando ya no la necesita y dejar que se ejecuten otras aplicaciones. Obviamente, esto no funcionaba, ya que no se puede confiar en que todas las aplicaciones (o incluso en que sus desarrolladores sean lo suficientemente inteligentes como para tomar la decisión correcta sobre cuándo liberar la CPU). Los sistemas operativos modernos (Windows 95, Linux, etc.) cambiaron su diseño de la multitarea a la «multitarea preventiva», en la que el sistema operativo es el encargado de decidir qué aplicaciones reciben la CPU, con qué frecuencia y durante cuánto tiempo.

El sistema operativo, entonces, es el que decide qué procesos se ejecutan y durante cuánto tiempo. Esto es lo que permite que la aplicación siga sintiendo que es «multitarea» aunque sólo tenga 1 CPU. El programador del sistema operativo está cambiando los procesos de un lado a otro muy rápidamente.

En el siguiente diagrama, 3 procesos (P1, P2, P3) se ejecutan simultáneamente, compartiendo la misma CPU (la línea azul es el tiempo de CPU). P1 llega a ejecutarse durante bastante tiempo antes de ser rápidamente cambiado a P2, y luego a P3, y así sucesivamente.

3 procesos se están ejecutando «concurrentemente»

El Proceso

Un Proceso es la abstracción que el Sistema Operativo utiliza para ejecutar su código. Envuelve tu código en este proceso, y asigna memoria y otros recursos compartidos. La abstracción de proceso permite al Sistema Operativo «distinguir» entre los programas que se ejecutan, para que creen conflictos entre ellos. Por ejemplo, el proceso 1 no puede acceder a la memoria reservada por el proceso 2. También es importante en términos de seguridad de los usuarios. Si el usuario 1 inicia un proceso, ese proceso podrá leer los archivos accesibles por ese usuario.

El siguiente diagrama contiene una representación visual de un Proceso. El proceso contiene el código que debe ejecutar, la memoria RAM asignada y todos los datos creados por el programa (variables, ficheros abiertos, etc).

Una representación visual de un Proceso del SO

Ahora podemos ver el mismo «programa» (el mismo trozo de código) ejecutándose en múltiples procesos de forma concurrente.

Ahora podemos pasar a la segunda fase: ¿cómo se consigue realmente la concurrencia (o incluso el paralelismo)? Desde tu perspectiva como desarrollador, hay dos mecanismos que puedes emplear para lograr la «concurrencia»: el multithreading o el multiprocesamiento.

Multithreading

Los threads son flujos de ejecución concurrentes dentro del mismo proceso. El proceso pone en marcha varios hilos de ejecución, que «evolucionan» de forma independiente en función de lo que tengan que hacer.

Por ejemplo, supongamos que tenemos un programa que debe descargar datos de tres webs, y luego combinarlos para un informe final. Cada sitio web tarda unos dos segundos en responder con los datos. Un programa de un solo hilo sería algo como:

d1 = get_website_1() # 2 secs
d2 = get_website_2() # 2 secs
d3 = get_website_3() # 2 secscombine(d1, d2, d3) # very fast

El tiempo total de ejecución será de >6 segundos:

Un programa de un solo hilo

Un programa multihilo empezará a descargar los datos de las tres webs a la vez, y esperará a que terminen todas para hacer el informe final.

t1 = Thread(get_website_1)
t2 = Thread(get_website_2)
t3 = Thread(get_website_3)wait_for_threads()d1 = t1.result()
d2 = t2.result()
d3 = t3.result()combine(d1, d2, d3)

La ejecución ahora será cercana a los ~2 segundos, ya que los tres sitios web responderán más o menos al mismo tiempo:

Un programa multihilo

En Python, los hilos se crean con la clase Thread, a partir del módulo threading, y puedes ver varios ejemplos en el primer cuaderno del repo de mi tutorial: 1. Thread Basics.ipynb.

Hilos y datos compartidos

Los hilos son creados explícitamente por tu programa, por lo que viven dentro del mismo proceso. Eso significa que comparten todos los mismos datos del proceso. Pueden leer y modificar variables locales, y acceder a la misma memoria o a los mismos recursos (ficheros, sockets de red, etc).

Los hilos comparten los datos del proceso

Esto puede causar algunos problemas, especialmente la mutación inesperada de los datos compartidos y las Race Conditions. Puedes ver un ejemplo de una condición de carrera en el cuaderno 2. Thread Data & Race Conditions.ipynb. En el mismo cuaderno, introducimos algunos mecanismos para «sincronizar» hilos para evitar Race Conditions, pero esos mismos mecanismos pueden potencialmente introducir el problema de un Deadlock. Hay un ejemplo de un Deadlock en el cuaderno: 3. Deadlocks.ipynb.

Productores, Consumidores y Colas

Los problemas de sincronización se pueden resolver simplemente cambiando el paradigma.

La mejor manera de evitar los problemas de sincronización es evitar la sincronización por completo.

Esto se consigue utilizando el patrón Productor-Consumidor. Los hilos productores crean «tareas pendientes» para realizar y luego las colocan en una cola compartida. Los hilos consumidores leerán las tareas de la cola y harán el trabajo real.

En Python, la clase Queue del módulo queue es una cola a prueba de hilos que se utiliza para estos fines.

Hay un ejemplo de un modelo Productor-Consumidor en el cuaderno
4. Producer-Consumer model.ipynb.

El bloqueo del intérprete global de Python

El GIL es una de las características más odiadas de Python. En realidad no es tan mala, pero tiene una reputación terrible.

El GIL es un bloqueo «global» que coloca el intérprete de Python y que garantiza que sólo 1 hilo puede acceder a la CPU en un momento dado. Esto suena estúpido: ¿por qué vas a preocuparte por el código multihilo si sólo un hilo podrá acceder a la CPU en un momento dado de todos modos?

Aquí es donde es importante entender que tu código no es todo sobre el uso de la CPU. Tu código hará diferentes tareas, como leer archivos, obtener información de la red, etc. ¿Recuerdas nuestros tiempos de acceso a la latencia? Aquí están de nuevo:

Latencia a escala humana (fuente)

Esto es clave para combatir la limitación del GIL. En el momento en que un hilo necesita leer algo de un archivo, puede liberar la CPU y dejar que otros hilos se ejecuten. Esto se debe a que la mayoría de los programas son lo que llamamos I/O-Bound. Es decir, utilizan muchas funciones de E/S (red, sistema de archivos, etc).

Puedes ver una «prueba» de una tarea ligada a la E/S en el cuaderno: 5. El GIL de Python.ipynb.

Multiprocesamiento

Algunos otros programas están inevitablemente ligados a la CPU, como los programas que necesitan hacer muchos cálculos. A veces, usted quiere acelerar las cosas. Si usas multihilos para las tareas ligadas a la CPU, te darás cuenta de que tus programas no son más rápidos gracias al GIL (en realidad pueden volverse incluso más lentos).

Entra el multiprocesamiento, el segundo mecanismo que tenemos para escribir programas concurrentes. En lugar de tener múltiples hilos, en realidad engendraremos múltiples procesos hijos que se encargarán de realizar diferentes tareas de forma concurrente.

Hay un ejemplo de programa basado en multiprocesamiento en el cuaderno 6. Multiprocessing.ipynb

concurrent.futures

Para terminar mi tutorial, me gustaría señalar que existe un módulo de nivel superior que forma parte de la Python Standard Library que debería utilizarse siempre que sea posible: concurrent.futures.

Proporciona abstracciones de alto nivel para crear pools de hilos y procesos, y es lo suficientemente maduro como para ser considerado, siempre que sea posible, la alternativa por defecto para escribir código concurrente.

¡Ahí lo tienes! Hazme saber si tienes alguna pregunta. Estaré encantado de responderlas.

Deja una respuesta

Tu dirección de correo electrónico no será publicada.