Simultaneidad y tolerancia a fallas simplificadas: un tutorial de Akka con ejemplos

Publicado: 2022-03-11

El reto

Escribir programas concurrentes es difícil. Tener que lidiar con subprocesos, bloqueos, condiciones de carrera, etc., es muy propenso a errores y puede dar lugar a un código que es difícil de leer, probar y mantener.

Por lo tanto, muchos prefieren evitar los subprocesos múltiples por completo. En su lugar, emplean procesos de subproceso único exclusivamente, confiando en servicios externos (como bases de datos, colas, etc.) para manejar cualquier operación concurrente o asíncrona necesaria. Si bien este enfoque es en algunos casos una alternativa legítima, hay muchos escenarios en los que simplemente no es una opción viable. Muchos sistemas en tiempo real, como aplicaciones comerciales o bancarias, o juegos en tiempo real, no pueden darse el lujo de esperar a que se complete un proceso de subproceso único (¡necesitan la respuesta ahora!). Otros sistemas son tan intensivos en computación o recursos que tomarían una cantidad excesiva de tiempo (horas o incluso días en algunos casos) para ejecutarse sin introducir la paralelización en su código.

Un enfoque de subproceso único bastante común (ampliamente utilizado en el mundo de Node.js, por ejemplo) es usar un paradigma sin bloqueo basado en eventos. Si bien esto ayuda al rendimiento al evitar cambios de contexto, bloqueos y bloqueos, todavía no aborda los problemas del uso de múltiples procesadores al mismo tiempo (hacerlo requeriría iniciar y coordinar entre múltiples procesos independientes).

Entonces, ¿significa esto que no tiene más remedio que viajar profundamente en las entrañas de los subprocesos, bloqueos y condiciones de carrera para crear una aplicación concurrente?

Gracias al framework Akka, la respuesta es no. Este tutorial presenta ejemplos de Akka y explora las formas en que facilita y simplifica la implementación de aplicaciones distribuidas concurrentes.

¿Qué es el Marco Akka?

Esta publicación presenta Akka y explora las formas en que facilita y simplifica la implementación de aplicaciones distribuidas concurrentes.

Akka es un conjunto de herramientas y un tiempo de ejecución para crear aplicaciones altamente concurrentes, distribuidas y tolerantes a fallas en la JVM. Akka está escrito en Scala, con enlaces de lenguaje proporcionados tanto para Scala como para Java.

El enfoque de Akka para el manejo de la concurrencia se basa en el Modelo Actor. En un sistema basado en actores, todo es un actor, de la misma manera que todo es un objeto en el diseño orientado a objetos. Sin embargo, una diferencia clave, particularmente relevante para nuestra discusión, es que el modelo de actor fue diseñado y diseñado específicamente para servir como un modelo concurrente, mientras que el modelo orientado a objetos no lo es. Más específicamente, en un sistema de actores Scala, los actores interactúan y comparten información, sin ninguna presuposición de secuencialidad. El mecanismo por el cual los actores comparten información entre sí y se asignan tareas entre sí es el paso de mensajes.

Toda la complejidad de crear y programar subprocesos, recibir y enviar mensajes, y manejar las condiciones de carrera y la sincronización, se relega al marco para que se maneje de manera transparente.

Akka crea una capa entre los actores y el sistema subyacente de modo que los actores simplemente necesitan procesar los mensajes. Toda la complejidad de crear y programar subprocesos, recibir y enviar mensajes, y manejar las condiciones de carrera y la sincronización, se relega al marco para que se maneje de manera transparente.

Akka se adhiere estrictamente al Manifiesto Reactivo. Las aplicaciones reactivas tienen como objetivo reemplazar las aplicaciones multiproceso tradicionales con una arquitectura que satisfaga uno o más de los siguientes requisitos:

  • Evento conducido. Al usar Actors, se puede escribir código que maneje las solicitudes de forma asíncrona y emplee exclusivamente operaciones sin bloqueo.
  • Escalable. En Akka, es posible agregar nodos sin tener que modificar el código, gracias tanto al paso de mensajes como a la transparencia de la ubicación.
  • Elástico. Cualquier aplicación encontrará errores y fallará en algún momento. Akka proporciona estrategias de "supervisión" (tolerancia a fallas) para facilitar un sistema de autorreparación.
  • Sensible. Muchas de las aplicaciones actuales de alto rendimiento y respuesta rápida necesitan brindar una respuesta rápida al usuario y, por lo tanto, deben reaccionar a los eventos de manera extremadamente oportuna. La estrategia basada en mensajes sin bloqueo de Akka ayuda a lograr esto.

¿Qué es un actor en Akka?

Un actor es esencialmente nada más que un objeto que recibe mensajes y realiza acciones para manejarlos. Está desvinculado de la fuente del mensaje y su única responsabilidad es reconocer correctamente el tipo de mensaje que ha recibido y actuar en consecuencia.

Al recibir un mensaje, un actor puede realizar una o más de las siguientes acciones:

  • Ejecutar algunas operaciones por sí mismo (como realizar cálculos, conservar datos, llamar a un servicio web externo, etc.)
  • Reenviar el mensaje, o un mensaje derivado, a otro actor
  • Crea una instancia de un nuevo actor y reenvíale el mensaje.

Alternativamente, el actor puede optar por ignorar el mensaje por completo (es decir, puede elegir la inacción) si lo considera apropiado.

Para implementar un actor, es necesario extender el rasgo akka.actor.Actor e implementar el método de recepción. El método de recepción de un actor es invocado (por Akka) cuando se envía un mensaje a ese actor. Su implementación típica consiste en la coincidencia de patrones, como se muestra en el siguiente ejemplo de Akka, para identificar el tipo de mensaje y reaccionar en consecuencia:

 import akka.actor.Actor import akka.actor.Props import akka.event.Logging class MyActor extends Actor { def receive = { case value: String => doSomething(value) case _ => println("received unknown message") } }

La coincidencia de patrones es una técnica relativamente elegante para manejar mensajes, que tiende a producir un código "más limpio" y más fácil de navegar que una implementación comparable basada en devoluciones de llamadas. Considere, por ejemplo, una implementación simplista de solicitud/respuesta HTTP.

Primero, implementemos esto usando un paradigma basado en devolución de llamada en JavaScript:

 route(url, function(request){ var query = buildQuery(request); dbCall(query, function(dbResponse){ var wsRequest = buildWebServiceRequest(dbResponse); wsCall(wsRequest, function(wsResponse) { sendReply(wsResponse); }); }); });

Ahora comparemos esto con una implementación basada en coincidencia de patrones:

 msg match { case HttpRequest(request) => { val query = buildQuery(request) dbCall(query) } case DbResponse(dbResponse) => { var wsRequest = buildWebServiceRequest(dbResponse); wsCall(dbResponse) } case WsResponse(wsResponse) => sendReply(wsResponse) }

Si bien el código JavaScript basado en la devolución de llamada es ciertamente compacto, ciertamente es más difícil de leer y navegar. En comparación, el código basado en la coincidencia de patrones hace que sea más evidente qué casos se están considerando y cómo se maneja cada uno.

El sistema de actores

Tomar un problema complejo y dividirlo recursivamente en subproblemas más pequeños es una buena técnica de resolución de problemas en general. Este enfoque puede ser particularmente beneficioso en informática (coherente con el principio de responsabilidad única), ya que tiende a producir un código limpio y modularizado, con poca o ninguna redundancia, que es relativamente fácil de mantener.

En un diseño basado en actores, el uso de esta técnica facilita la organización lógica de los actores en una estructura jerárquica conocida como Sistema de actores. El sistema de actores proporciona la infraestructura a través de la cual los actores interactúan entre sí.

Un ejemplo de cómo funciona el sistema de actores en el marco de Akka.

En Akka, la única forma de comunicarse con un actor es a través de un ActorRef . Un ActorRef representa una referencia a un actor que impide que otros objetos accedan o manipulen directamente las partes internas y el estado de ese actor. Los mensajes se pueden enviar a un actor a través de un ActorRef utilizando uno de los siguientes protocolos de sintaxis:

  • ! (“decir”) – envía el mensaje y regresa inmediatamente
  • ? ("preguntar"): envía el mensaje y devuelve un futuro que representa una posible respuesta

Cada actor tiene un buzón en el que se entregan sus mensajes entrantes. Hay varias implementaciones de buzón de correo entre las que elegir, y la implementación predeterminada es FIFO.

Un actor contiene muchas variables de instancia para mantener el estado mientras procesa varios mensajes. Akka se asegura de que cada instancia de un actor se ejecute en su propio subproceso ligero y que los mensajes se procesen uno a la vez. De esta forma, el estado de cada actor se puede mantener de forma fiable sin que el desarrollador tenga que preocuparse explícitamente por la sincronización o las condiciones de carrera.

Cada actor recibe la siguiente información útil para realizar sus tareas a través de la API de Akka Actor:

  • sender : un ActorRef para el remitente del mensaje que se está procesando actualmente
  • context : información y métodos relacionados con el contexto dentro del cual se ejecuta el actor (incluye, por ejemplo, un método actorOf para instanciar un nuevo actor)
  • supervisionStrategy : define la estrategia que se utilizará para la recuperación de errores
  • self : el ActorRef para el propio actor
Akka se asegura de que cada instancia de un actor se ejecute en su propio subproceso ligero y que los mensajes se procesen uno a la vez. De esta forma, el estado de cada actor se puede mantener de forma fiable sin que el desarrollador tenga que preocuparse explícitamente por la sincronización o las condiciones de carrera.

Para ayudar a unir estos tutoriales, consideremos un ejemplo simple de contar el número de palabras en un archivo de texto.

Para los propósitos de nuestro ejemplo de Akka, descompondremos el problema en dos subtareas; a saber, (1) una tarea "secundaria" de contar el número de palabras en una sola línea y (2) una tarea "principal" de sumar esos recuentos de palabras por línea para obtener el número total de palabras en el archivo.

El actor principal cargará cada línea del archivo y luego delegará en un actor secundario la tarea de contar las palabras en esa línea. Cuando el niño haya terminado, enviará un mensaje al padre con el resultado. El padre recibirá los mensajes con los recuentos de palabras (para cada línea) y mantendrá un contador para el número total de palabras en todo el archivo, que luego devolverá a su invocador al finalizar.

(Tenga en cuenta que los ejemplos de código del tutorial de Akka que se proporcionan a continuación tienen la intención de ser solo didácticos y, por lo tanto, no necesariamente se ocupan de todas las condiciones de borde, optimizaciones de rendimiento, etc. Además, una versión compilable completa de los ejemplos de código que se muestran a continuación está disponible en esta esencia.)

Primero veamos una implementación de muestra de la clase secundaria StringCounterActor :

 case class ProcessStringMsg(string: String) case class StringProcessedMsg(words: Integer) class StringCounterActor extends Actor { def receive = { case ProcessStringMsg(string) => { val wordsInLine = string.split(" ").length sender ! StringProcessedMsg(wordsInLine) } case _ => println("Error: message not recognized") } }

Este actor tiene una tarea muy simple: consumir mensajes ProcessStringMsg (que contienen una línea de texto), contar la cantidad de palabras en la línea especificada y devolver el resultado al remitente a través de un mensaje StringProcessedMsg . Tenga en cuenta que hemos implementado nuestra clase para usar el ! (“decir”) para enviar el mensaje StringProcessedMsg (es decir, para enviar el mensaje y devolverlo inmediatamente).

Bien, ahora centremos nuestra atención en la clase principal WordCounterActor :

 1. case class StartProcessFileMsg() 2. 3. class WordCounterActor(filename: String) extends Actor { 4. 5. private var running = false 6. private var totalLines = 0 7. private var linesProcessed = 0 8. private var result = 0 9. private var fileSender: Option[ActorRef] = None 10. 11. def receive = { 12. case StartProcessFileMsg() => { 13. if (running) { 14. // println just used for example purposes; 15. // Akka logger should be used instead 16. println("Warning: duplicate start message received") 17. } else { 18. running = true 19. fileSender = Some(sender) // save reference to process invoker 20. import scala.io.Source._ 21. fromFile(filename).getLines.foreach { line => 22. context.actorOf(Props[StringCounterActor]) ! ProcessStringMsg(line) 23. totalLines += 1 24. } 25. } 26. } 27. case StringProcessedMsg(words) => { 28. result += words 29. linesProcessed += 1 30. if (linesProcessed == totalLines) { 31. fileSender.map(_ ! result) // provide result to process invoker 32. } 33. } 34. case _ => println("message not recognized!") 35. } 36. }

Están sucediendo muchas cosas aquí, así que examinemos cada una de ellas con más detalle (tenga en cuenta que los números de línea a los que se hace referencia en la discusión que sigue se basan en el ejemplo de código anterior) ...

Primero, observe que el nombre del archivo a procesar se pasa al constructor WordCounterActor (línea 3). Esto indica que el actor solo se utilizará para procesar un solo archivo. Esto también simplifica el trabajo de codificación para el desarrollador, al evitar la necesidad de restablecer las variables de estado ( running , totalLines , linesProcessed y result ) cuando se realiza el trabajo, ya que la instancia solo se usa una vez (es decir, para procesar un solo archivo) y luego descartado.

A continuación, observe que WordCounterActor maneja dos tipos de mensajes:

  • StartProcessFileMsg (línea 12)
    • Recibido del actor externo que inicia inicialmente el WordCounterActor .
    • Cuando se recibe, WordCounterActor primero verifica que no esté recibiendo una solicitud redundante.
    • Si la solicitud es redundante, WordCounterActor genera una advertencia y no se hace nada más (línea 16).
    • Si la solicitud no es redundante:
      • WordCounterActor almacena una referencia al remitente en la variable de instancia fileSender (tenga en cuenta que se trata de una Option[ActorRef] en lugar de una Option[Actor] ; consulte la línea 9). Este ActorRef es necesario para acceder más tarde y responder a él cuando se procesa el StringProcessedMsg final (que se recibe de un elemento secundario StringCounterActor , como se describe a continuación).
      • A continuación, WordCounterActor lee el archivo y, a medida que se carga cada línea del archivo, se crea un elemento secundario StringCounterActor y se le pasa un mensaje que contiene la línea que se va a procesar (líneas 21 a 24).
  • StringProcessedMsg (línea 27)
    • Recibido de un StringCounterActor secundario cuando completa el procesamiento de la línea que se le asignó.
    • Cuando se recibe, WordCounterActor incrementa el contador de líneas del archivo y, si se han procesado todas las líneas del archivo (es decir, cuando totalLines y linesProcessed son iguales), envía el resultado final al fileSender original (líneas 28 a 31).

Una vez más, observe que en Akka, el único mecanismo para la comunicación entre actores es el paso de mensajes. Los mensajes son lo único que comparten los actores y, dado que los actores pueden acceder potencialmente a los mismos mensajes al mismo tiempo, es importante que sean inmutables para evitar condiciones de carrera y comportamientos inesperados.

Las clases de casos en Scala son clases regulares que proporcionan un mecanismo de descomposición recursivo a través de la coincidencia de patrones.

Por lo tanto, es común pasar mensajes en forma de clases de casos, ya que son inmutables de forma predeterminada y por la forma en que se integran perfectamente con la coincidencia de patrones.

Concluyamos el ejemplo con el ejemplo de código para ejecutar toda la aplicación.

 object Sample extends App { import akka.util.Timeout import scala.concurrent.duration._ import akka.pattern.ask import akka.dispatch.ExecutionContexts._ implicit val ec = global override def main(args: Array[String]) { val system = ActorSystem("System") val actor = system.actorOf(Props(new WordCounterActor(args(0)))) implicit val timeout = Timeout(25 seconds) val future = actor ? StartProcessFileMsg() future.map { result => println("Total number of words " + result) system.shutdown } } }
En la programación concurrente, un "futuro" es esencialmente un objeto de marcador de posición para un resultado que aún no se conoce.

Observe cómo esta vez el ? El método se utiliza para enviar un mensaje. De esta manera, la persona que llama puede usar el futuro devuelto para imprimir el resultado final cuando esté disponible y salir del programa cerrando el ActorSystem.

Estrategias de supervisión y tolerancia a fallas de Akka

En un sistema de actores, cada actor es el supervisor de sus hijos. Si un actor no puede manejar un mensaje, se suspende a sí mismo y a todos sus hijos y envía un mensaje, generalmente en forma de excepción, a su supervisor.

En Akka, las estrategias de supervisor son el mecanismo primario y directo para definir el comportamiento tolerante a fallas de su sistema.

En Akka, la forma en que un supervisor reacciona y maneja las excepciones que se filtran desde sus hijos se denomina estrategia de supervisor. Las estrategias de supervisor son el mecanismo primario y directo mediante el cual define el comportamiento tolerante a fallas de su sistema.

Cuando un mensaje que significa una falla llega a un supervisor, puede tomar una de las siguientes acciones:

  • Reanudar el niño (y sus hijos), manteniendo su estado interno. Esta estrategia se puede aplicar cuando el estado secundario no se corrompió por el error y puede continuar funcionando correctamente.
  • Reinicie el niño (y sus hijos), limpiando su estado interno. Esta estrategia se puede utilizar en el escenario opuesto al que acabamos de describir. Si el estado secundario ha sido corrompido por el error, es necesario restablecer su estado antes de que pueda usarse en el futuro.
  • Detener al niño (y sus hijos) de forma permanente. Esta estrategia se puede emplear en casos en los que no se cree que la condición de error sea rectificable, pero no pone en peligro el resto de la operación que se está realizando, que se puede completar en ausencia del niño fallido.
  • Detenerse y escalar el error. Se emplea cuando el supervisor no sabe cómo manejar la falla y entonces la escala a su propio supervisor.

Además, un Actor puede decidir aplicar la acción solo a los hijos fallidos o también a sus hermanos. Hay dos estrategias predefinidas para esto:

  • OneForOneStrategy : aplica la acción especificada solo al niño fallido
  • AllForOneStrategy : aplica la acción especificada a todos sus hijos

Aquí hay un ejemplo simple, usando OneForOneStrategy :

 import akka.actor.OneForOneStrategy import akka.actor.SupervisorStrategy._ import scala.concurrent.duration._ override val supervisorStrategy = OneForOneStrategy() { case _: ArithmeticException => Resume case _: NullPointerException => Restart case _: IllegalArgumentException => Stop case _: Exception => Escalate }

Si no se especifica ninguna estrategia, se emplea la siguiente estrategia predeterminada:

  • Si hubo un error al inicializar el actor o si el actor murió, el actor se detiene.
  • Si hubiera algún otro tipo de excepción, el actor simplemente se reinicia.

La implementación proporcionada por Akka de esta estrategia predeterminada es la siguiente:

 final val defaultStrategy: SupervisorStrategy = { def defaultDecider: Decider = { case _: ActorInitializationException ⇒ Stop case _: ActorKilledException ⇒ Stop case _: Exception ⇒ Restart } OneForOneStrategy()(defaultDecider) }

Akka permite la implementación de estrategias de supervisor personalizadas, pero como advierte la documentación de Akka, hágalo con precaución ya que las implementaciones incorrectas pueden generar problemas como sistemas de actores bloqueados (es decir, actores suspendidos permanentemente).

Transparencia de ubicación

La arquitectura de Akka admite la transparencia de la ubicación, lo que permite a los actores ser totalmente independientes del origen de los mensajes que reciben. El remitente del mensaje puede residir en la misma JVM que el actor o en una JVM separada (ya sea ejecutándose en el mismo nodo o en un nodo diferente). Akka permite que cada uno de estos casos se maneje de una manera completamente transparente para el actor (y, por lo tanto, para el desarrollador). La única advertencia es que los mensajes enviados a través de múltiples nodos deben ser serializables.

La arquitectura de Akka admite la transparencia de la ubicación, lo que permite a los actores ser totalmente independientes del origen de los mensajes que reciben.

Los sistemas Actor están diseñados para ejecutarse en un entorno distribuido sin necesidad de ningún código especializado. Akka solo requiere la presencia de un archivo de configuración ( application.conf ) que especifica los nodos a los que enviar mensajes. Aquí hay un ejemplo simple de un archivo de configuración:

 akka { actor { provider = "akka.remote.RemoteActorRefProvider" } remote { transport = "akka.remote.netty.NettyRemoteTransport" netty { hostname = "127.0.0.1" port = 2552 } } }

Algunos consejos de despedida…

Hemos visto cómo el marco Akka ayuda a lograr la concurrencia y el alto rendimiento. Sin embargo, como señaló este tutorial, hay algunos puntos a tener en cuenta al diseñar e implementar su sistema para aprovechar al máximo el poder de Akka:

  • En la mayor medida posible, a cada actor se le debe asignar la tarea más pequeña posible (como se discutió anteriormente, siguiendo el Principio de Responsabilidad Única)
  • Los actores deben manejar eventos (es decir, procesar mensajes) de forma asincrónica y no deben bloquearse, de lo contrario, se producirán cambios de contexto que pueden afectar negativamente al rendimiento. Específicamente, es mejor realizar operaciones de bloqueo (IO, etc.) en un futuro para no bloquear al actor; es decir:

     case evt => blockingCall() // BAD case evt => Future { blockingCall() // GOOD }
  • Asegúrese de que todos sus mensajes sean inmutables, ya que los actores que se los pasan entre sí se ejecutarán simultáneamente en sus propios hilos. Es probable que los mensajes mutables den como resultado un comportamiento inesperado.
  • Dado que los mensajes enviados entre nodos deben ser serializables, es importante tener en cuenta que cuanto más grandes sean los mensajes, más tiempo llevará serializarlos, enviarlos y deserializarlos, lo que puede afectar negativamente el rendimiento.

Conclusión

Akka, escrito en Scala, simplifica y facilita el desarrollo de aplicaciones altamente concurrentes, distribuidas y tolerantes a fallas, ocultando gran parte de la complejidad del desarrollador. Hacer justicia a Akka requeriría mucho más que este único tutorial, pero esperamos que esta introducción y sus ejemplos hayan sido lo suficientemente cautivadores como para que quieras leer más.

Amazon, VMWare y CSC son solo algunos ejemplos de empresas líderes que utilizan activamente Akka. Visite el sitio web oficial de Akka para obtener más información y explorar si Akka también podría ser la respuesta correcta para su proyecto.