jueves, noviembre 08, 2007

Un lenguaje de ciencia ficción

Desde hace algún tiempo vengo trabajando cada vez más con Erlang, un lenguaje de programación de propósito general diseñado específicamente para la implementación de sistemas robustos, distribuidos y masivamente paralelos.

Erlang es un lenguaje funcional de evaluación estricta con tipos dinámicos y asignación única. En términos más prácticos, esto significa que no existe la asignación destructiva, las variables se declaran con un valor y no pueden cambiar (como en las matemáticas), así que se parecen más a las constantes de otros lenguajes, tampoco tiene estructuras de control de ciclos, ya que son inútiles en ausencia de la asignación destructiva.

Si piensa que un lenguaje así es prácticamente inútil, verifique si en su lenguaje favorito puede expresar el factorial de manera más concisa que en Erlang:

1 factorial(0) -> 1;
2 factorial(N) -> N * factorial(N-1).

El paradigma funcional, utilizando recursión, en conjunto con el reconocimiento de patrones (pattern matching), permiten expresar los algoritmos de manera clara y concisa, haciendo de la asignación un mal del pasado.

El ambiente de ejecución del lenguaje denominado: Open Telecom Platform (OTP), similar en funciones al JRE en Java, provee servicios de concurrencia, distribución, tolerancia a fallas, compilación, carga y reemplazo incremental de código a tiempo de ejecución, que en conjunto con los tipos dinámicos propician un ambiente para el desarrollo rápido de aplicaciones, compitiendo directamente con lenguajes como Perl y Ruby.

En el pasado se consideraba a los lenguajes funcionales como una curiosidad académica, se argumentaba que el paradigma funcional no escalaba como para implementar los sistemas complejos requeridos por la industria y el comercio.

Erlang/OTP demolió este mito, cuando Ericsson desarrolló el switch ATM: AXD-301, cuyo software contiene 1.7 millones de líneas en Erlang, adicionalmente Ericsson ha desarrollado otros sistemas de hasta 3.5 millones de líneas, que funcionan confiablemente desde hace años.

El lenguaje fue diseñado por Ericsson hace unos 20 años, para limitar la complejidad y mejorar la productividad del software desarrollado internamente, particularmente las centrales telefónicas, que además de ser masivamente paralelas, deben ser sistemas escalables y tolerar casi cualquier tipo de falla.

El resultado de todo esto es un plataforma de ejecución madura, cuyo lema es:
Evitar la programación defensiva
es decir, no maneje los errores, deje que alguien más lo haga.

En los demás lenguajes de programación se consume una cantidad considerable de recursos de diseño intentando manejar las anomalías durante la ejecución del programa, cuando en realidad se pueden presentar fallas que ni siquiera imaginamos, y que inútilmente intentamos manejar acomodando ligeramente los datos y delegando el problema.

En consecuencia obtenemos un montón de código de verificación de errores, que obscurece los algoritmos, complicando el mantenimiento del software y creando una pesadilla en la depuración, si el problema delegado finalmente causa un error, pues generalmente es difícil de relacionar con el evento original.

Erlang sigue el principio de diseño que expresa:
Si se va a fallar, se debe fallar rápido y ruidosamente

Facilitando la determinación del origen de la falla. Por ello los programas se diseñan para manejar el caso general, asumiendo que todo va a salir bien, abortando inmediatamente ante cualquier falla.

A primera vista esta estrategia no parece muy efectiva para lograr sistemas que deben operar continuamente tolerando cualquier tipo de fallas, pero lo hace, debido a una combinación de características del lenguaje y su plataforma de ejecución.

En Erlang el código de las aplicaciones se divide en procesos, definidos como unidades de ejecución autónomas, independientes y aisladas, que no comparten memoria, ni pueden afectarse entre sí. Por eso la falla de algunos no causa problemas a los demás, convirtiéndolos en un excelente mecanismo para contener los efectos de una falla.

Utilizando inteligentemente estas propiedades, se puede ingeniar un sistema donde además de procesos trabajadores, existan procesos supervisores, atentos a reparar las posibles fallas de otros procesos, OTP provee un sistema completo de supervisión de tareas, que permite la reactivación de procesos abortados e incluso cambiar el código de la aplicación o partes de ella, en caliente, es decir sin detener la ejecución de la misma. ¿Alguna vez su teléfono dejó de funcionar durante una actualización de software de la central telefónica?

Los supervisores se aseguran de registrar los eventos de falla y reiniciar a sus supervisados, según las políticas programadas, si alguno de los trabajadores muere con una frecuencia superior a la permitida, el supervisor mata al resto de sus trabajadores y aborta, escalando el problema al supervisor padre. Así se establece una jerarquía o árbol de supervisión, donde el máximo supervisor es parte de OTP, está muy bien depurado y maneja graciosamente casi cualquier tipo de falla. Garantizando que las aplicaciones diseñadas según la filosofía Erlang/OTP no fallan, aunque partes de la aplicación pueden fallar.

¿Será que esto es eficiente?

Si, considerando el paradigma, OTP es un sistema operativo completo que funciona dentro de un sistema operativo anfitrión como Linux, de hecho todo OTP es un solo proceso del S.O. anfitrión, aun cuando el kernel de OTP gestione muchos micro procesos internamente.

A diferencia de la semántica de protección de los sistemas operativos POSIX, los procesos de OTP están aislados automáticamente por la semántica de Erlang, carente de estado global y mutabilidad, permitiendo la implementación eficiente de los mismos, por ejemplo en mi laptop (un Centrino 1.7GHz, con 1GB RAM):

OperaciónErlangLinux
Cambiar de proceso400 ns2 us (5 veces más)
Arrancar un proceso5 us500 us (100 veces más)
Enviar un mensaje2 us800 us (400 veces más)

Adicionalmente un proceso recién creado consume solo unos 1500 bytes, que en las máquinas de hoy es prácticamente nada, otros sistemas como pthreads suelen consumir mucho más memoria, aún para programas de demostración sin utilidad práctica (pthreads en linux por defecto usa como 10MB! de RAM, aunque esto se puede ajustar cuando se crea un thread).

Dado que los procesos no pueden afectarse entre si, ni compartir memoria es pertinente la pregunta: ¿cómo cooperan?.

Erlang utiliza el modelo de actores, donde cada proceso es un actor que intercambia mensajes con los demás actores del sistema, el mecanismo de intercambio de mensajes está eficientemente implementado en el kernel de OTP y plenamente integrado con el lenguaje, mediante un operador y una estructura de control. Por ejemplo: si la variable Pid contiene una referencia a un proceso, podremos enviarle un mensaje con la expresión:

1 Pid ! "Este es un mensaje en un string"

En realidad cualquier término de datos del lenguaje puede enviarse en un mensaje, así que se puede intercambiar cualquier estructura arbitrariamente compleja, en este caso una tupla que contiene el átomo "dibujar" y una lista de otras 2 tuplas, etc.:

1 Mensaje = { dibujar, [ {elipse,1,1,4,6},
2 {cuadro,4,8,7,9} ] },
3 Proceso ! Mensaje

Este mensaje puede ser recibido por el Proceso referido, utilizando la instrucción receive:

1 receive
2 Msg -> procesar_mensaje(Msg)
3 end

Utilizando el reconocimiento de patrones, receive puede discriminar con facilidad entre diversos tipos de mensajes, para tomar las acciones correspondientes:

1 receive
2 {dibujar, Lista} ->
3 dibujar_elementos(Lista);
4 X when is_string(X) ->
5 dibujar_string(X);
6 X ->
7 alerta("Mensaje no identificado", X)
8 end

Si ningún patrón coincide, receive lanza una excepción que de no ser capturada, abortará la ejecución del proceso.

Al modularizar una aplicación como un conjunto de procesos que intercambian mensajes, se obtiene una aplicación dividida en celdas que contienen la propagación de los efectos de las fallas, pero además estos procesos podrían efectuar muchas de las operaciones concurrentemente, beneficiándose automáticamente de las arquitecturas de procesamiento paralelas existentes hoy en día.

OTP permite la operación distribuida, donde varias instancias forman una comunidad de nodos que se ejecutan en máquinas posiblemente separadas y se ofrece un servicio de páginas amarillas que permite el registro y ubicación de procesos por nombre. Una vez obtenida una referencia a un proceso mediante las páginas amarillas, no se diferencia entre las referencias a procesos remotos y a procesos locales, brindando un sistema de comunicación totalmente transparente.

OTP enruta, serializa y deserializa eficientemente los datos contenidos en cualquier mensaje. El protocolo de comunicación entre estos nodos es adaptable y puede ser reemplazado para aplicaciones específicas (si esta dispuesto a programar código de esa complejidad en C), la plataforma soporta TCP/IP por defecto, pero hay implementaciones para algunos otros protocolos.

Una aplicación diseñada para aprovechar el modo distribuido de Erlang, puede funcionar en una máquina de un procesador o escalar a varias máquinas con múltiples procesadores, sin cambiar una sola línea de código. La ausencia de estado global y mutabilidad permite que procesos diseñados bajo ciertos parámetros sencillos, sean reemplazados por nuevas versiones de código, mientras se ejecutan, sin perder la secuencia o estado de ejecución de los mismos!

El gestor de aplicaciones permite la especificación estática de un sistema distribuido, con capacidad de ejecutar las aplicaciones en un número fijo de nodos, con un sistema de supervisión que en segundos reconoce las fallas en un nodo, y arranca todos los actores desaparecidos durante la falla en otros nodos (failover), permitiendo la operación continua del sistema (con rendimiento degradado), además si el nodo original vuelve a funcionar, los procesos vuelven a su lugar como si nada hubiera pasado (takeover). Todo ello sucede automáticamente, sin necesidad de intervención del administrador.

Estas técnicas llevadas al extremo, combinadas con el uso del servicio de monitoreo de carga distribuido de OTP, permiten el diseño de sistemas con escalabilidad dinámica, en los cuales simplemente agregar un nodo a una comunidad causaría que los agentes en diversos nodos del sistema decidan mudarse para aprovechar el nuevo poder de procesamiento agregado a la comunidad. Esto es mucho más difícil de lograr que los failovers y takeovers, sin embargo, el que se pueda hacer, nos pone en el terreno de la ciencia ficción, muy cerca de historias como "Piso 13" o "La Matriz".

No hay comentarios.: