Guía para entender Concurrencia, Parte 1: Hilos
La concurrencia es algo difícil de abordar en programación porque el perfil de quienes escriben código puede ir desde egresados formales en ciencias computacionales hasta iniciados que tomaron un bootcamp de código.
Lo curioso es que, para ambos casos, la concurrencia puede ser algo fácil de entender, pero difícil de implementar. Esta serie de artículos busca dar una introducción muy completa a los diferentes sistemas concurrentes con los que interactuamos en nuestro día a día, así como algunas implementaciones usando Linux, Node.js, Ruby y Python. Esta serie es ideal si:
- Nunca has leído sobre concurrencia.
- Quieres refrescar los conceptos.
- Eres usuario/a de alguno de estos lenguajes.
- Quieres entender a fondo la concurrencia.
Este artículo en particular pretende marcar los precedentes que llevan a explicar y entender mucho mejor la concurrencia. Empecemos con Procesos e Hilos y sus implementaciones en Ruby y Python.
Procesos
Para entender a fondo la concurrencia debemos primero entender algunos conceptos básicos de sistemas operativos. Para ello utilizaremos Linux (aunque todo lo visto en este articulo aplica para sistemas Unix), empezando por procesos.
Los procesos son básicamente programas en ejecución. Dentro de Linux, cada proceso contiene un espacio de memoria, tiempo de procesador y recursos de entrada y salida (I/O), que pueden ser administrados y monitoreados.
Los programas, en cambio, son archivos que contienen información sobré cómo se va a construir un proceso en tiempo de ejecución. Básicamente, es el código que escribes en Node.js, Python, Ruby, etc., por lo que podemos entender la importancia de los procesos: ¡son todos los programas que tu sistema operativo ejecuta cuando enciendes tu computadora!
Cada proceso está dividido de forma lógica entre los siguientes segmentos:
- Texto: instrucciones del programa.
- Datos: variables estáticas usadas por el programa.
- Heap: área desde donde un programa puede asignar memoria extra (adicional al espacio de memoria que se le asigna).
- Stack: espacio de memoria que crece y se encoge conforme se van llamando funciones. Es usado para asignar almacenamiento para variables locales y llamadas a funciones con información enlazada.
Llamando al siguiente comando podemos monitorear los procesos en ejecución: ps aux.
Dado que el alcance de este artículo no es analizar a fondo los procesos, vamos a describir solo algunos de los indicadores del comando ps aux.
- PID: Identificador único del proceso. Será de mucha ayuda durante el resto del artículo.
- %CPU: Porcentaje de CPU que usa el proceso.
- %MEM: Porcentaje de memoria real usada por el proceso.
- TIME: Tiempo del CPU usado por el proceso.
- Command: Comando que manda a llamar el proceso junto con sus argumentos.
El siguiente programa de Python nos ayudará a entender el concepto de proceso:
Una vez ejecutado el programa en background con el siguiente comando (nótese el & al final) python wait_input.py &...
…notaremos que el PID es 14686, el %CPU es 0.0, %MEM 0.0 TIME 0.0 y el Command python3 wait_input.py.
En este caso, el programa no ha usado nada de recursos porque básicamente está esperando por valores de entrada en: raw_input().
Ahora que conocemos el PID del proceso podemos administrarlo. En este caso, detendremos el proceso ejecutando: kill -9 14686.
Es muy importante recalcar que cada proceso tiene al menos un Hilo por defecto. Un Hilo es un contexto de ejecución dentro de un proceso. Cada hilo tiene su propio stack y contexto de CPU pero opera dentro del espacio de direcciones asignado al proceso que lo contiene.
Cada proceso es capaz de crear múltiples hilos para que se ejecuten de manera cuasi-paralela. Se indica ‘cuasi-paralelo’ cuando un hilo es ejecutado en una máquina con un solo CPU. Tomando en cuenta que arquitecturas recientes de hardware ya tienen múltiples CPUs y múltiples núcleos (multicore), los hilos sí se pueden ejecutar de manera paralela en este tipo de máquinas. Aunque esta decisión se toma en la máquina virtual de los lenguajes (al menos los que usan máquinas virtuales) y por el sistema operativo.
Una parte importante a entender dentro del mundo de la concurrencia es el concepto de multiprocesamiento (multiprocessing) pero éste lo veremos en otro artículo. A continuación, ampliaremos más el concepto de hilos.
Hilos
"Hilo es a proceso como proceso es a máquina".
Andrew S. Tanenbaum.
Pongamos como ejemplo cuatro procesos corriendo en Linux. Cada uno tiene su propia información descrita anteriormente: espacio de memoria, stack, etc. Entre sí no comparten nada, salvo que pueden comunicarse entre ellos mediante primitivas propias de sistemas operativos tales como: semáforos, monitores, mensajes etc. Por otro lado, tenemos un proceso con múltiples hilos.
Cada hilo:
- Se ejecuta de forma estrictamente secuencial (en una máquina con un solo CPU) al igual que los procesos.
- Puede crear hilos hijos.
- Puede bloquearse a sí mismo.
- Dentro del mismo proceso se puede ejecutar un hilo, siempre y cuando el otro se encuentre bloqueado.
Pudiéramos decir que un hilo es un proceso corriendo en un proceso, ya que tiene casi el mismo concepto de diseño (como lo vimos en el párrafo anterior). De ahí la cita de Tanenbaum.
Hilos en Linux
Linux utiliza POSIX threads o PThreads, un estándar propuesto por la IEEE para escribir programas de hilos portables. Esto quiere decir que el kernel de Linux utiliza el api de PThreads para crear programas que utilizan múltiples hilos. Si quisieras adentrarte en el mundo de los hilos en Linux, ingresa en: https://man7.org/linux/man-pages/man7/pthreads.7.html.
Si ejecutamos el comando top -H podremos ver la lista de procesos en ejecución y el número de hilos totales actuales del sistema:
Comúnmente, programas de usuario, como Google Chrome, tendrán múltiples hilos corriendo. Por lo que si escribimos el comando ps -T -p 22725 donde 22725 es el PID de chrome, podremos ver los múltiples hilos en ejecución:
Ahora que vimos los diferentes casos de uso de hilos en el ejemplo más común (sistemas operativos), podemos ver como crear hilos en Ruby y Python.
Hilos en Ruby
Podemos pensar que utilizar hilos nos va a ayudar a reducir el desempeño de un programa. En muchos casos es así, en otros no. Depende del problema que se quiera resolver y el lenguaje.
En Ruby, la forma de crear hilos es utilizando la clase Thread, built-in en Ruby, como lo vemos a continuación:
Esta clase nos permite ejecutar el código utilizando el paradigma de múltiples hilos (algo parecido a lo que sucede con Linux). Podemos aplicar un poco la idea de divide y conquista, por lo que el siguiente caso de uso es perfecto.
Caso de Uso: Múltiples llamadas para consultar una página web
En este caso de uso podemos ver que es ideal para Ruby ya que es I/O bound. Este concepto se refiere a tiempos de espera largos debido a partes del código que esperan a que las operaciones de entrada o salida terminen. Estos tiempos suelen ser en espera del sistema de archivos, comunicación de red, entre otros.
Pero, ¿es en realidad más eficiente? Para resolver esta pregunta tenemos que volver a correr el programa, utilizando hilos y no hilos.
¿Notas la diferencia? Tal vez no sea tanta como esperábamos (es 5 veces mejor y tenemos 20 hilos en lugar de uno) y esto es debido a un mecanismo que tiene la máquina virtual de Ruby MRI llamada: GVL o GIL, un mecanismo para sincronizar hilos que utiliza Ruby, así cómo otros lenguajes dinámicos como Python (cuando corre en CPython).
A pesar de poder crear múltiples hilos de ejecución en un programa, este mecanismo corre un hilo a la vez, aunque la computadora tenga multicore.
Existe una forma de determinar el tiempo de mejora cuando un código se implementa utilizando hilos (en Ruby).
Amdahl’s Law
Amdahl se dio cuenta de que la velocidad que obtienes por agregar paralelismo adicional está relacionada a una proporción de tiempo de ejecución que puede ser realizada de forma paralela. Esta regla es simple: 1 / (1 - p + p/s), donde p es el porcentaje de la tarea que puede ser realizada en paralelo y s es el factor de velocidad de la parte de dicha tarea que obtiene la mejora en resultados. La siguiente es una gráfica popular que representa esta ley:
Podemos ver que entre mayor porcentaje de la tarea se trabaja en paralelo, mejora su velocidad dependiendo del número de procesadores con que se cuenta.
En el ejemplo que pusimos podemos notar que el 50% de la actividad se puede realizar de forma paralela (la llamada a la página). Mientras que el número de hilos (o processors en el contexto de la ley de Amdahl) es de 20.
p = 0.85
s = 20
1 / (1 - 0.5 + 0.5/20) = 5.19
Hilos en Python
Las abstracciones son herramientas superpoderosas de la mente humana. En el caso de los Hilos (en el contexto de programación) es una abstracción que, una vez entendida, nos ayuda a aplicarla en múltiples lenguajes de programación sin redefinir su definición. En esta ocasión, lo único que cambia, con respecto a lo que hemos visto hasta el momento, es la implementación y las particularidades del lenguaje.
En Python tanto las librerías Threading y Asyncio son utilizadas para implementar el concepto de hilos. En este artículo hablaremos de Threading, mientras que en las siguientes entradas de esta serie abordaremos Asyncio.
Cuando se utilice la librería Threading vamos a tener algo similar a Ruby: hilos corriendo uno a la vez o, como aclaramos anteriormente, cuasi-paralelos, .inclusive aunque tengamos hilos corriendo en una computadora con multicore. Dada la implementación de Python en CPython, las interacciones con GIL (Global Interpreter Lock, muy similar a lo que vimos en Ruby con GVL) limitan el Hilo para su ejecución una vez.
Sin embargo, algo particular de la librería Threading es que el sistema operativo decide realizar este switch entre actividades, en vez de Python. Esto se llama multitarea apropiativa, dado que el sistema operativo provee los recursos necesarios al hilo y es quién realiza el switch de tareas.
Tal como vimos en la sección de Threading con Ruby, los programas que se benefician del uso de hilos para mejorar el desempeño son aquéllos que gastan mucho de su tiempo en la espera de eventos externos.
Veamos el mismo ejemplo de obtener una página web implementado en Python (CPython).
Ahora utilizando 20 hilos de ejecución:
Como podemos ver, la funcionalidad de GIL hace que el código no sea 20 veces más rápido. Sin embargo, hay una mejoria notable al reescribir el script de Python en hilos.
Conclusión
Hemos abordado los conocimientos básicos para entender concurrencia: procesos, hilos, así como diferentes ejemplos, casos de uso e implementaciones más conocidas. En las siguientes entradas, veremos las nuevas formas de implementar hilos en Ruby (Reactors) y Python (Asyncio), al igual que otros lenguajes como Node.js y técnicas como multiprocessing.
Referencias
- The Linux programming interface, Michael Kerrisk, 2010.
- Modern Operating Systems 4th Edition, Andrew Tanenbaum (Author), Herbert Bos, 2014.
- https://realpython.com/python-gil
- https://realpython.com/python-concurrency
- https://docs.python.org/3/library/threading.html
- https://ruby-doc.org/core-2.5.0/Thread.html
- https://www.speedshop.co/2020/05/11/the-ruby-gvl-and-scaling.html
Revelo Content Network da la bienvenida a todas las razas, etnias, nacionalidades, credos, géneros, orientaciones, puntos de vista e ideologías, siempre y cuando promuevan la diversidad, la equidad, la inclusión y el crecimiento profesional de los profesionales en tecnología.