Una guía para la programación orientada a procesos en Elixir y OTP
Publicado: 2022-03-11A la gente le gusta categorizar los lenguajes de programación en paradigmas. Hay lenguajes orientados a objetos (OO), lenguajes imperativos, lenguajes funcionales, etc. Esto puede ser útil para determinar qué lenguajes resuelven problemas similares y qué tipos de problemas pretende resolver un lenguaje.
En cada caso, un paradigma generalmente tiene un enfoque y una técnica "principal" que es la fuerza impulsora de esa familia de lenguajes:
En los lenguajes orientados a objetos, es la clase u objeto como una forma de encapsular el estado (datos) con la manipulación de ese estado (métodos).
En los lenguajes funcionales, puede ser la manipulación de las propias funciones o los datos inmutables que se pasan de una función a otra.
Si bien Elixir (y Erlang antes que él) a menudo se clasifican como lenguajes funcionales porque exhiben los datos inmutables comunes a los lenguajes funcionales, diría que representan un paradigma separado de muchos lenguajes funcionales . Existen y se adoptan debido a la existencia de OTP, por lo que los clasificaría como lenguajes orientados a procesos .
En esta publicación, capturaremos el significado de la programación orientada a procesos cuando se usan estos lenguajes, exploraremos las diferencias y similitudes con otros paradigmas, veremos las implicaciones tanto para la capacitación como para la adopción, y terminaremos con un breve ejemplo de programación orientada a procesos.
¿Qué es la programación orientada a procesos?
Comencemos con una definición: la programación orientada a procesos es un paradigma basado en la comunicación de procesos secuenciales, originalmente de un artículo de Tony Hoare en 1977. Esto también se conoce popularmente como el modelo actor de concurrencia. Otros idiomas con alguna relación con este trabajo original incluyen Occam, Limbo y Go. El documento formal trata solo de la comunicación sincrónica; la mayoría de los modelos de actores (incluido OTP) también usan comunicación asíncrona. Siempre es posible crear una comunicación síncrona además de una comunicación asíncrona, y OTP admite ambas formas.
Sobre esta historia, OTP creó un sistema para computación tolerante a fallas mediante la comunicación de procesos secuenciales. Las instalaciones tolerantes a fallas provienen de un enfoque de "dejar que falle" con una sólida recuperación de errores en forma de supervisores y el uso de procesamiento distribuido habilitado por el modelo actor. El "dejar que falle" se puede contrastar con "evitar que falle", ya que el primero es mucho más fácil de acomodar y se ha demostrado en OTP que es mucho más confiable que el segundo. La razón es que el esfuerzo de programación requerido para evitar fallas (como se muestra en el modelo de excepción comprobada de Java) es mucho más complicado y exigente.
Por lo tanto, la programación orientada a procesos se puede definir como un paradigma en el que la estructura del proceso y la comunicación entre los procesos de un sistema son las principales preocupaciones .
Programación orientada a objetos frente a programación orientada a procesos
En la programación orientada a objetos, la estructura estática de los datos y la función es la principal preocupación. Qué métodos se requieren para manipular los datos adjuntos y cuáles deberían ser las conexiones entre objetos o clases. Por lo tanto, el diagrama de clases de UML es un excelente ejemplo de este enfoque, como se ve en la Figura 1.
Se puede notar que una crítica común de la programación orientada a objetos es que no hay un flujo de control visible. Debido a que los sistemas se componen de una gran cantidad de clases/objetos definidos por separado, puede ser difícil para una persona menos experimentada visualizar el flujo de control de un sistema. Esto es especialmente cierto para los sistemas con mucha herencia, que usan interfaces abstractas o no tienen tipificación fuerte. En la mayoría de los casos, se vuelve importante para el desarrollador memorizar una gran cantidad de la estructura del sistema para que sea efectivo (qué clases tienen qué métodos y cuáles se usan de qué manera).
La fortaleza del enfoque de desarrollo orientado a objetos es que el sistema se puede ampliar para admitir nuevos tipos de objetos con un impacto limitado en el código existente, siempre que los nuevos tipos de objetos se ajusten a las expectativas del código existente.
Programación Funcional vs. Orientada a Procesos
Muchos lenguajes de programación funcional abordan la concurrencia de varias maneras, pero su enfoque principal es el paso de datos inmutables entre funciones o la creación de funciones a partir de otras funciones (funciones de orden superior que generan funciones). En su mayor parte, el enfoque del lenguaje sigue siendo un solo espacio de direcciones o ejecutable, y las comunicaciones entre dichos ejecutables se manejan de una manera específica del sistema operativo.
Por ejemplo, Scala es un lenguaje funcional basado en la máquina virtual de Java. Si bien puede acceder a las funciones de Java para la comunicación, no es una parte inherente del lenguaje. Si bien es un lenguaje común que se usa en la programación de Spark, nuevamente es una biblioteca que se usa junto con el lenguaje.
Una fortaleza del paradigma funcional es la capacidad de visualizar el flujo de control de un sistema dada la función de nivel superior. El flujo de control es explícito en que cada función llama a otras funciones y pasa todos los datos de una a la siguiente. En el paradigma funcional no hay efectos secundarios, lo que facilita la determinación del problema. El desafío con los sistemas funcionales puros es que se requiere que los "efectos secundarios" tengan un estado persistente. En sistemas bien diseñados, la persistencia del estado se maneja en el nivel superior del flujo de control, lo que permite que la mayor parte del sistema esté libre de efectos secundarios.
Elixir/OTP y Programación Orientada a Procesos
En Elixir/Erlang y OTP, las primitivas de comunicación son parte de la máquina virtual que ejecuta el lenguaje. La capacidad de comunicarse entre procesos y entre máquinas está integrada y es central en el sistema de lenguaje. Esto enfatiza la importancia de la comunicación en este paradigma y en estos sistemas de lenguaje.
Si bien el lenguaje Elixir es predominantemente funcional en términos de la lógica expresada en el lenguaje, su uso está orientado al proceso .
¿Qué significa estar orientado a procesos?
Estar orientado a procesos como se define en esta publicación es diseñar un sistema primero en la forma de qué procesos existen y cómo se comunican. Una de las preguntas principales es qué procesos son estáticos y cuáles son dinámicos, cuáles se generan a pedido de las solicitudes, cuáles cumplen un propósito a largo plazo, cuáles mantienen el estado compartido o parte del estado compartido del sistema, y qué características de el sistema son inherentemente concurrentes. Así como OO tiene tipos de objetos y funcional tiene tipos de funciones, la programación orientada a procesos tiene tipos de procesos.
Como tal, un diseño orientado a procesos es la identificación del conjunto de tipos de procesos necesarios para resolver un problema o abordar una necesidad .
El aspecto del tiempo entra rápidamente en los esfuerzos de diseño y requisitos. ¿Cuál es el ciclo de vida del sistema? ¿Qué necesidades personalizadas son ocasionales y cuáles son constantes? ¿Dónde está la carga en el sistema y cuál es la velocidad y el volumen esperados? Es solo después de entender este tipo de consideraciones que un diseño orientado a procesos comienza a definir la función de cada proceso o la lógica a ejecutar.
Implicaciones de entrenamiento
La implicación de esta categorización para la capacitación es que la capacitación no debe comenzar con la sintaxis del lenguaje o los ejemplos de "Hola mundo", sino con el pensamiento de ingeniería de sistemas y un enfoque de diseño en la asignación de procesos .
Las preocupaciones de codificación son secundarias al diseño y la asignación de procesos, que se abordan mejor en un nivel superior e implican un pensamiento multifuncional sobre el ciclo de vida, el control de calidad, DevOps y los requisitos comerciales del cliente. Cualquier curso de capacitación en Elixir o Erlang debe (y generalmente lo hace) incluir OTP, y debe tener una orientación de proceso desde el principio, no como el enfoque del tipo "Ahora puede codificar en Elixir, así que hagamos concurrencia".
Implicaciones de la adopción
La implicación para la adopción es que el lenguaje y el sistema se aplican mejor a problemas que requieren comunicación y/o distribución de computación. Los problemas que son una sola carga de trabajo en una sola computadora son menos interesantes en este espacio y pueden abordarse mejor con otro idioma. Los sistemas de procesamiento continuo de larga duración son un objetivo principal para este lenguaje porque tiene tolerancia a fallas integrada desde cero.
Para el trabajo de documentación y diseño, puede ser muy útil usar una notación gráfica (como la figura 1 para lenguajes orientados a objetos). La sugerencia para Elixir y la programación orientada a procesos de UML sería el diagrama de secuencia (ejemplo en la figura 2) para mostrar las relaciones temporales entre procesos e identificar qué procesos están involucrados en atender una solicitud. No existe un tipo de diagrama UML para capturar el ciclo de vida y la estructura del proceso, pero podría representarse con un diagrama simple de caja y flecha para los tipos de proceso y sus relaciones. Por ejemplo, Figura 3:
Un ejemplo de orientación a procesos
Finalmente, veremos un breve ejemplo de cómo aplicar la orientación a procesos a un problema. Supongamos que tenemos la tarea de proporcionar un sistema que apoye las elecciones globales. Se elige este problema porque muchas actividades individuales se realizan en ráfagas, pero la agregación o el resumen de los resultados es deseable en tiempo real y podría generar una carga significativa.

Diseño y asignación del proceso inicial
Inicialmente, podemos ver que la emisión de votos por parte de cada individuo es una ráfaga de tráfico al sistema desde muchas entradas discretas, no está ordenada por tiempo y puede tener una gran carga. Para respaldar esta actividad, querríamos una gran cantidad de procesos que recopilen todos estos insumos y los envíen a un proceso más central para la tabulación. Estos procesos podrían ubicarse cerca de las poblaciones de cada país que estarían generando votos y, por lo tanto, proporcionar una latencia baja. Retendrían los resultados locales, registrarían sus entradas inmediatamente y los enviarían para su tabulación en lotes para reducir el ancho de banda y la sobrecarga.
Inicialmente, podemos ver que será necesario que haya procesos que rastreen los votos en cada jurisdicción en la que se deben presentar los resultados. Supongamos para este ejemplo que necesitamos realizar un seguimiento de los resultados de cada país y dentro de cada país por provincia/estado. Para respaldar esta actividad, nos gustaría que al menos un proceso por país realizara el cálculo y retuviera los totales actuales, y otro conjunto para cada estado/provincia en cada país. Esto supone que necesitamos poder responder totales por país y estado/provincia en tiempo real o con baja latencia. Si los resultados se pueden obtener de un sistema de base de datos, podríamos elegir una asignación de proceso diferente donde los totales se actualicen mediante procesos transitorios. La ventaja de usar procesos dedicados para estos cálculos es que los resultados ocurren a la velocidad de la memoria y se pueden obtener con baja latencia.
Finalmente, podemos ver que mucha gente verá los resultados. Estos procesos se pueden particionar de muchas maneras. Es posible que deseemos distribuir la carga colocando procesos en cada país responsable de los resultados de ese país. Los procesos podrían almacenar en caché los resultados de los procesos de cómputo para reducir la carga de consultas en los procesos de cómputo, y/o los procesos de cómputo podrían enviar sus resultados a los procesos de resultados adecuados periódicamente, cuando los resultados cambien en una cantidad significativa, o en el momento el proceso de cálculo se vuelve inactivo, lo que indica una tasa de cambio más lenta.
En los tres tipos de procesos, podemos escalar los procesos de forma independiente, distribuirlos geográficamente y garantizar que los resultados nunca se pierdan mediante el reconocimiento activo de las transferencias de datos entre procesos.
Como se discutió, hemos comenzado el ejemplo con un diseño de proceso independiente de la lógica de negocios en cada proceso. En los casos en que la lógica empresarial tenga requisitos específicos para la agregación de datos o la geografía que pueden afectar la asignación del proceso de forma iterativa. Nuestro diseño de proceso hasta el momento se muestra en la figura 4.
El uso de procesos separados para recibir votos permite que cada voto se reciba independientemente de cualquier otro voto, se registre al recibirlo y se lote al siguiente conjunto de procesos, lo que reduce significativamente la carga en esos sistemas. Para un sistema que consume una gran cantidad de datos, reducir el volumen de datos mediante el uso de capas de procesos es un patrón común y útil.
Al realizar el cálculo en un conjunto aislado de procesos, podemos administrar la carga en esos procesos y garantizar su estabilidad y requisitos de recursos.
Al colocar la presentación de resultados en un conjunto aislado de procesos, controlamos la carga del resto del sistema y permitimos que el conjunto de procesos se escale dinámicamente para la carga.
Requerimientos adicionales
Ahora, agreguemos algunos requisitos complicados. Supongamos que en cada jurisdicción (país o estado), la tabulación de votos puede dar como resultado un resultado proporcional, un resultado en el que el ganador se lo lleva todo o ningún resultado si se emiten votos insuficientes en relación con la población de esa jurisdicción. Cada jurisdicción tiene control sobre estos aspectos. Con este cambio, los resultados de los países no son una simple agregación de los resultados brutos de la votación, sino una agregación de los resultados de los estados/provincias. Esto cambia la asignación de procesos del original para requerir que los resultados de los procesos estatales/provinciales se incorporen a los procesos del país. Si el protocolo utilizado entre la recolección de votos y los procesos de estado/provincia y de provincia a país es el mismo, entonces la lógica de agregación se puede reutilizar, pero se necesitan procesos distintos que contienen los resultados y sus rutas de comunicación son diferentes, como se muestra en la Figura 5.
El código
Para completar el ejemplo, revisaremos una implementación del ejemplo en Elixir OTP. Para simplificar las cosas, este ejemplo supone que se usa un servidor web como Phoenix para procesar solicitudes web reales, y esos servicios web realizan solicitudes al proceso identificado anteriormente. Esto tiene la ventaja de simplificar el ejemplo y mantener el enfoque en Elixir/OTP. En un sistema de producción, tener estos procesos separados tiene algunas ventajas, además de separar las preocupaciones, permite una implementación flexible, distribuye la carga y reduce la latencia. El código fuente completo con pruebas se puede encontrar en https://github.com/technomage/voting. La fuente está abreviada en esta publicación para facilitar la lectura. Cada proceso a continuación se ajusta a un árbol de supervisión de OTP para garantizar que los procesos se reinicien en caso de falla. Consulte la fuente para obtener más información sobre este aspecto del ejemplo.
Registrador de votos
Este proceso recibe votos, los registra en un almacén persistente y envía los resultados por lotes a los agregadores. El módulo VoteRecoder usa Task.Supervisor para administrar tareas de corta duración para registrar cada voto.
defmodule Voting.VoteRecorder do @moduledoc """ This module receives votes and sends them to the proper aggregator. This module uses supervised tasks to ensure that any failure is recovered from and the vote is not lost. """ @doc """ Start a task to track the submittal of a vote to an aggregator. This is a supervised task to ensure completion. """ def cast_vote where, who do Task.Supervisor.async_nolink(Voting.VoteTaskSupervisor, fn -> Voting.Aggregator.submit_vote where, who end) |> Task.await end endAgregador de votos
Este proceso agrega los votos dentro de una jurisdicción, calcula el resultado de esa jurisdicción y envía los resúmenes de los votos al siguiente proceso superior (una jurisdicción de mayor nivel o un presentador de resultados).
defmodule Voting.Aggregator do use GenStage ... @doc """ Submit a single vote to an aggregator """ def submit_vote id, candidate do pid = __MODULE__.via_tuple(id) :ok = GenStage.call pid, {:submit_vote, candidate} end @doc """ Respond to requests """ def handle_call {:submit_vote, candidate}, _from, state do n = state.votes[candidate] || 0 state = %{state | votes: Map.put(state.votes, candidate, n+1)} {:reply, :ok, [%{state.id => state.votes}], state} end @doc """ Handle events from subordinate aggregators """ def handle_events events, _from, state do votes = Enum.reduce events, state.votes, fn e, votes -> Enum.reduce e, votes, fn {k,v}, votes -> Map.put(votes, k, v) # replace any entries for subordinates end end # Any jurisdiction specific policy would go here # Sum the votes by candidate for the published event merged = Enum.reduce votes, %{}, fn {j, jv}, votes -> # Each jourisdiction is summed for each candidate Enum.reduce jv, votes, fn {candidate, tot}, votes -> Logger.debug "@@@@ Votes in #{inspect j} for #{inspect candidate}: #{inspect tot}" n = votes[candidate] || 0 Map.put(votes, candidate, n + tot) end end # Return the published event and the state which retains # Votes by jourisdiction {:noreply, [%{state.id => merged}], %{state | votes: votes}} end endPresentador de resultados
Este proceso recibe votos de un agregador y almacena esos resultados en caché para atender las solicitudes de presentación de resultados.
defmodule Voting.ResultPresenter do use GenStage … @doc """ Handle requests for results """ def handle_call :get_votes, _from, state do {:reply, {:ok, state.votes}, [], state} end @doc """ Obtain the results from this presenter """ def get_votes id do pid = Voting.ResultPresenter.via_tuple(id) {:ok, votes} = GenStage.call pid, :get_votes votes end @doc """ Receive votes from aggregator """ def handle_events events, _from, state do Logger.debug "@@@@ Presenter received: #{inspect events}" votes = Enum.reduce events, state.votes, fn v, votes -> Enum.reduce v, votes, fn {k,v}, votes -> Map.put(votes, k, v) end end {:noreply, [], %{state | votes: votes}} end endQuitar
Esta publicación exploró Elixir/OTP desde su potencial como lenguaje orientado a procesos, comparó esto con paradigmas funcionales y orientados a objetos, y revisó las implicaciones de esto para el entrenamiento y la adopción.
La publicación también incluye un breve ejemplo de cómo aplicar esta orientación a un problema de muestra. En caso de que desee revisar todo el código, aquí hay un enlace a nuestro ejemplo en GitHub nuevamente, solo para que no tenga que desplazarse hacia atrás para buscarlo.
El punto clave es ver los sistemas como una colección de procesos de comunicación. Planifique el sistema desde el punto de vista del diseño del proceso en primer lugar y, en segundo lugar, desde el punto de vista de la codificación lógica.
