Translate

lunes, 23 de diciembre de 2024

Concurrencia en Erlang parte 2


En su día, el desarrollo de Erlang como lenguaje fue extremadamente rápido y recibía comentarios frecuentes de los ingenieros que trabajaban en los conmutadores telefónicos de Erlang. Estas interacciones demostraron que la concurrencia basada en procesos y el paso asincrónico de mensajes eran una buena forma de modelar los problemas a los que se enfrentaban. Además, el mundo de la telefonía ya tenía cierta cultura que favorecía la concurrencia antes de que Erlang existiera. Esto se heredó de PLEX, un lenguaje creado anteriormente en Ericsson, y de AXE, un conmutador desarrollado con él. Erlang siguió esta tendencia e intentó mejorar las herramientas disponibles anteriormente.

Erlang tenía algunos requisitos que satisfacer antes de ser considerado bueno. Los principales eran poder escalar y dar soporte a muchos miles de usuarios en muchos conmutadores, y luego lograr una alta confiabilidad, hasta el punto de no detener nunca el código.

Escalabilidad: Me centraré primero en la escalabilidad. Algunas propiedades se consideraron necesarias para lograr la escalabilidad. Como los usuarios se representarían como procesos que solo reaccionan ante ciertos eventos (por ejemplo, recibir una llamada, colgar, etc.), un sistema ideal soportaría procesos que realizan pequeños cálculos y cambian entre ellos muy rápidamente a medida que se producen los eventos. Para que sea eficiente, tenía sentido que los procesos se iniciaran muy rápidamente, se destruyeran muy rápidamente y se pudieran cambiar realmente rápido. Para lograr esto era obligatorio que fueran livianos. También era obligatorio porque no querías tener cosas como grupos de procesos (una cantidad fija de procesos entre los que dividir el trabajo). En cambio, sería mucho más fácil diseñar programas que pudieran usar tantos procesos como necesiten.

Otro aspecto importante de la escalabilidad es poder eludir las limitaciones de tu hardware. Hay dos formas de hacer esto: mejorar el hardware o agregar más hardware. La primera opción es útil hasta cierto punto, después del cual se vuelve extremadamente costosa (por ejemplo, comprar una supercomputadora). La segunda opción suele ser más barata y requiere que agregues más computadoras para hacer el trabajo. Aquí es donde puede ser útil tener la distribución como parte de tu lenguaje.

De todos modos, para volver a los procesos pequeños, debido a que las aplicaciones de telefonía necesitaban mucha fiabilidad, se decidió que la forma más limpia de hacer las cosas era prohibir que los procesos compartieran memoria. La memoria compartida podía dejar las cosas en un estado inconsistente después de algunos fallos (especialmente en los datos compartidos entre diferentes nodos) y tenía algunas complicaciones. En cambio, los procesos deberían comunicarse enviando mensajes donde se copian todos los datos. Esto podría ser más lento, pero más seguro.

Tolerancia a fallos: Esto nos lleva al segundo tipo de requisitos para Erlang: la fiabilidad. Los primeros escritores de Erlang siempre tuvieron en cuenta que los fallos son comunes. Puedes intentar evitar los errores todo lo que quieras, pero la mayoría de las veces algunos de ellos seguirán ocurriendo. En el caso de que los errores no ocurran, nada puede detener los fallos de hardware todo el tiempo. La idea es, por tanto, encontrar buenas formas de manejar los errores y los problemas en lugar de intentar evitarlos todos.

Resulta que adoptar el enfoque de diseño de múltiples procesos con paso de mensajes fue una buena idea, porque el manejo de errores se podía incorporar en él con relativa facilidad. Tomemos como ejemplo los procesos livianos (creados para reinicios y apagados rápidos). Algunos estudios demostraron que las principales fuentes de tiempo de inactividad en sistemas de software a gran escala son errores intermitentes o transitorios (fuente). Luego, existe un principio que dice que los errores que corrompen los datos deben hacer que la parte defectuosa del sistema muera lo más rápido posible para evitar propagar errores y datos incorrectos al resto del sistema. Otro concepto aquí es que existen muchas formas diferentes para que un sistema termine, dos de las cuales son apagados limpios y fallas (que terminan con un error inesperado).

Aquí, el peor caso es obviamente la falla. Una solución segura sería asegurarse de que todas las fallas sean iguales a los apagados limpios: esto se puede hacer a través de prácticas como la no compartición de datos y la asignación única (que aísla la memoria de un proceso), evitar bloqueos (un bloqueo podría no desbloquearse durante una falla, lo que evitaría que otros procesos accedan a los datos o dejaría los datos en un estado inconsistente) y otras cosas que no cubriré más, pero que eran todas parte del diseño de Erlang. La solución ideal en Erlang es, por tanto, matar los procesos lo más rápido posible para evitar la corrupción de datos y los errores transitorios. Los procesos ligeros son un elemento clave en esto. Otros mecanismos de manejo de errores también forman parte del lenguaje para permitir que los procesos monitoreen otros procesos, con el fin de saber cuándo mueren los procesos y decidir qué hacer al respecto.

Suponiendo que reiniciar los procesos muy rápido sea suficiente para lidiar con los fallos, el siguiente problema que se presenta son los fallos de hardware. ¿Cómo se asegura de que su programa siga funcionando cuando alguien patea la computadora en la que se está ejecutando?  La idea es simplemente que el programa se ejecute en más de una computadora a la vez, algo que era necesario para escalar de todos modos. Esta es otra ventaja de los procesos independientes sin canal de comunicación fuera del paso de mensajes. Puedes hacer que funcionen de la misma manera ya sea que estén en una computadora local o en otra, lo que hace que la tolerancia a fallas a través de la distribución sea casi transparente para el programador.

El hecho de estar distribuido tiene consecuencias directas en cómo los procesos pueden comunicarse entre sí. Uno de los mayores obstáculos de la distribución es que no puedes asumir que, porque un nodo (una computadora remota) estaba allí cuando realizaste una llamada de función, seguirá estando allí durante toda la transmisión de la llamada o que incluso la ejecutará correctamente. Si alguien se tropieza con un cable o desenchufa la máquina, tu aplicación se quedará colgada. O tal vez se bloqueará. ¿Quién sabe?

Bueno, resulta que la elección del paso de mensajes asincrónico también fue una buena elección de diseño. En el modelo de procesos con mensajes asincrónicos, los mensajes se envían de un proceso a otro y se almacenan en un buzón dentro del proceso receptor hasta que se extraen para leerlos. Es importante mencionar que los mensajes se envían sin siquiera verificar si el proceso receptor existe o no, porque no sería útil hacerlo. Como se implica en el párrafo anterior, es imposible saber si un proceso se bloqueará entre el momento en que se envía y se recibe un mensaje. Y si se recibe, es imposible saber si se actuará en consecuencia o, nuevamente, si el proceso receptor morirá antes de eso. Los mensajes asincrónicos permiten llamadas de funciones remotas seguras porque no se supone qué sucederá; el programador es quien debe saberlo. Si necesita tener una confirmación de entrega, debe enviar un segundo mensaje como respuesta al proceso original. Este mensaje tendrá la misma semántica segura, y también la tendrá cualquier programa o biblioteca que cree sobre este principio.

Implementación: Bien, se decidió que los procesos livianos con paso de mensajes asincrónicos eran el enfoque a adoptar para Erlang. ¿Cómo hacer que esto funcione? En primer lugar, no se puede confiar en el sistema operativo para que se encargue de los procesos. Los sistemas operativos tienen muchas formas diferentes de gestionar los procesos y su rendimiento varía mucho. La mayoría, si no todos, son demasiado lentos o demasiado pesados ​​para lo que necesitan las aplicaciones estándar de Erlang. Al hacer esto en la máquina virtual, los implementadores de Erlang mantienen el control de la optimización y la fiabilidad. Hoy en día, los procesos de Erlang ocupan unas 300 palabras de memoria cada uno y se pueden crear en cuestión de microsegundos, algo que no se puede hacer en los principales sistemas operativos de la actualidad.

Las colas de ejecución de Erlang en los núcleos: Para gestionar todos estos procesos potenciales que podrían crear sus programas, la máquina virtual inicia un hilo por núcleo que actúa como programador. Cada uno de estos programadores tiene una cola de ejecución, o una lista de procesos de Erlang en los que dedicar una parte del tiempo. Cuando uno de los programadores tiene demasiadas tareas en su cola de ejecución, algunas se migran a otra. Es decir, cada máquina virtual de Erlang se encarga de realizar todo el equilibrio de carga y el programador no tiene que preocuparse por ello. Se realizan otras optimizaciones, como limitar la velocidad a la que se pueden enviar mensajes en procesos sobrecargados para regular y distribuir la carga.

Todo el material pesado está ahí, administrado. Eso es lo que hace que sea fácil trabajar en paralelo con Erlang. Trabajar en paralelo significa que el programa debería funcionar el doble de rápido si agrega un segundo núcleo, cuatro veces más rápido si hay 4 más y así sucesivamente, ¿cierto? Depende. Este fenómeno se denomina escalamiento lineal en relación con la ganancia de velocidad en comparación con la cantidad de núcleos o procesadores. En la vida real, no existe nada gratis.