martes, 27 de septiembre de 2022

Por que usar Akka?


Las transmisiones reactivas fluyen los datos desde los productores hasta los consumidores. Pero todo va más o menos en una dirección. Los actores te dan más libertad que eso. Porque pueden colaborar y comunicarse como lo hacemos los humanos.

En particular, hablaremos sobre cómo los actores pueden crear otros actores. Cómo cambian su comportamiento con el tiempo, cómo intercambian mensajes. 

Antes de sumergirnos en lo que son los Actores, primero echemos un vistazo a por qué queremos investigarlos y de dónde provienen.

El formalismo Actor fue publicado por primera vez por Hewitt, Bishop y Steiger en 1973. Y lo que querían lograr es crear un modelo en el que puedan formular los programas para su investigación de inteligencia artificial.

Uno de los estudiantes de Hewitt publicó su tesis doctoral en 1986. Gul Agha formuló lenguajes de actores, cómo escribir programas de actores, cómo razonar sobre ellos. Describió los patrones de comunicación entre los Actores y cómo usarlos para resolver problemas reales.

En el mismo año, Ericsson comenzó a desarrollar un lenguaje de programación llamado Erlang. Este es un lenguaje de programación puramente funcional, cuyo modelo de concurrencia se basa en Actors. Y que luego se utilizó posteriormente en productos comerciales.

En 1995, Ericsson presentó su nueva plataforma de telecomunicaciones. El cual tuvo un gran éxito. Hubo alrededor de 30 milisegundos de tiempo de inactividad por año. Esta solidez y resiliencia fue posible gracias al modelo Actor.

Inspirado por el éxito de Erlang de Ericsson, Philipp Haller agregó Actors a la biblioteca estándar de Scala en 2006.

Luego, Jonas Bonér fue influenciado por Erlang, así como por Scala Actors para crear Akka en 2009. Akka es un framework de la JVM con APIs para Java y Scala que hace que el modelo Actor esté disponible para una amplia gama de desarrolladores.

Los actores son aplicables en una amplia gama de problemas. En el pasado, los programas se volvieron más rápidos al usar la próxima generación de CPU. Esto se debió a una frecuencia central cada vez mayor. Pero esto se detuvo alrededor del año 2005. Desde entonces, las CPU no son cada vez más rápidas, sino más anchas. Esto significa que, en lugar de hacer que un núcleo de ejecución sea más potente, se incorporan múltiples núcleos de este tipo dentro de un chip, accediendo a la memoria compartida.

Y en algunos de estos, los núcleos incluso están virtualizados, de modo que un núcleo de ejecución física puede albergar múltiples subprocesos de ejecución lógica. Hay diferentes formas de beneficiarse de estas CPU más amplias.

La primera es que puede ejecutar varios programas en paralelo en la misma computadora. Esto se llama multitarea y se ha hecho desde las primeras versiones de Unix. Pero si tiene un solo programa que necesita ejecutar más rápido y no tiene más remedio que ejecutar partes del mismo programa en paralelo. Y esto se llama subprocesos múltiples.

Para lograr subprocesos múltiples, su programa debe estar escrito de una manera diferente a la forma secuencial tradicional.

La diferencia entre ejecutar programas separados en paralelo y usar subprocesos del mismo programa en paralelo es que estos subprocesos colaboran en una tarea común.

Y si piensas en un grupo de personas haciendo algo juntas, necesitarán sincronizar sus acciones. De lo contrario, se pisan. Lo mismo puede suceder en un programa de subprocesos múltiples. Hay una nueva clase de errores y problemas que te esperan allí. Y esta es la razón por la cual los programas deben formularse de manera diferente para estar listos para subprocesos múltiples. Para entender lo que eso significa, echemos un vistazo a una cuenta bancaria:

class BankAccount {

private val balance = 0

def deposit(amount: Int): Unit = if (amount > 0) balance = balance + amount

def withdraw(amount: Int): Int = 

    if (amount  > 0 && amount <  balance) {

        balance = balance - amount 

        balance

    } else throw new Error("Fondo insuficiente")

}


Contiene un campo para el saldo y tiene dos métodos para depositar una cantidad y retirar una cantidad.

Ahora veamos el método de retiro en detalle. Entonces, echemos un vistazo a lo que sucede si dos subprocesos ejecutan este código al mismo tiempo.

Digamos que este es el subproceso 1 y este es el subproceso 2. Son ejecutados por diferentes CPU, por lo que pueden ejecutarse en paralelo.

Entran al método con una cantidad. Digamos que el hilo 1 quiere retirar 50 francos suizos, por ejemplo. Y el hilo 2 quiere retirar 40. Lo primero que harán ambos será leer el saldo actual. En ambos casos, digamos el saldo ahora mismo es 80, por lo que ambos verán 80. Luego, ambos ingresarán la declaración if, del chequeo. ¿La cantidad es positiva? Sí, lo es. ¿Y hay realmente suficientes fondos en la cuenta? Sí hay. Así ambos continuarán. Ellos calcularán el nuevo saldo.

El nuevo saldo en el primer subproceso será 30, y en el segundo será 40. Lo siguiente que harán los subprocesos será escribir el nuevo saldo en el campo de saldo de la cuenta bancaria, objeto.

El primer subproceso escribirá 30 y el segundo escribirá 40. Esto está claramente en conflicto, porque solo uno de los escritores puede ganar al final. El que viene en último lugar, sobrescribirá al que vino antes.

Este es el primer problema que vemos aquí. Es que en realidad se pierde una de las actualizaciones del saldo.

El otro problema es que se viola el invariante de la cuenta bancaria. En eso hemos retirado 50 y 40 francos suizos que son 90 en total, y el saldo era solo 80, lo que no debería haber sido posible. Uno de los hilos debería haber fallado. Y eso, eso no sucedió es el otro problema con este código.

Ahora, ¿qué podemos hacer para solucionar este problema? Necesitamos agregar sincronización. Cuando varios subprocesos trabajan con los mismos datos, necesitan sincronizar sus acciones.

Porque de lo contrario, serían propensos a pisarse. Lo que debemos hacer es asegurarnos de que cuando un subproceso esté trabajando con los datos, los demás se mantengan al margen. Como si pusieras un cartel de no molestar en la puerta de tu hotel.

Entonces, digamos que en este ejemplo estamos viendo el saldo, como los datos que se protegerán. Y lo que tenemos que hacer es poner una valla alrededor, de modo que cuando un subproceso esté trabajando con los datos, digamos el subproceso 1. Que este tenga acceso exclusivo a él. Lo que significa que la transacción 2, si intenta acceder a los datos, en realidad se le negará el acceso en este momento. Y tiene que esperar hasta que la transacción 1 termine con ella. De esta manera, el saldo estará protegido. Y todas las modificaciones realizadas en él se realizan de manera consistente, una tras otra. También decimos serializado.

Las herramientas principales para lograr este tipo de sincronización son lock o mutex. Que es básicamente el mismo concepto que se mostró anteriormente. O un semáforo donde la diferencia es que múltiples pero solo un número definido de subprocesos pueden ingresar a esta región.

En Scala, cada objeto tiene un candado asociado. Al que puede acceder llamando al método sincronizado en él. Y acepta un bloqueo de código que se ejecutará en esta región protegida.

¿Cómo aplicamos esto a la cuenta bancaria para sincronizarla?


def withdraw(amount: Int): Int = this.synchronized {

        if (amount  > 0 && amount <  balance) {

            balance = balance - amount 

            balance

        } else throw new Error("Fondo insuficiente")

}


Bueno, aquí tenemos el método de retiro. Y si lo ponemos todo dentro de un bloque sincronizado, entonces leemos el balance aquí. Realizar el cheque y volver a escribirlo, todo se hará como una acción atómica. Que no puede ser perturbado por otro hilo que ejecuta retirar, al mismo tiempo. Ese otro hilo tendrá que esperar.

Pero, ¿debemos sincronizar también aquí en el método de depósito? El método de depósito también modifica el saldo. Y si no esta sincronizado, entonces podría modificarlo sin protección. Y una vez que el retiro devuelve el saldo aquí, anularía la anulación de la actualización realizada por depósito al mismo tiempo.

Esto es para ilustrar que todos los accesos al saldo deben estar sincronizados, y no solo el que hemos demostrado que es problemático.

Ahora, intentemos transferir algo de dinero de una cuenta bancaria a otra.

Lo que debemos hacer es sincronizar ambos objetos, de modo que estén en un estado consistente. Porque de lo contrario, alguien leyendo el saldo de las cuentas podría encontrar el dinero en fuga. Retiramos de una cuenta. Depositamos después en otro.

Durante este tiempo, el dinero básicamente no está en ninguna parte. Y si la invariante que debe cumplirse es que la suma de from y to debe ser la misma, esto se violará. Por lo tanto, necesitamos sincronizar.

Entonces, primero tomamos el bloqueo en la cuenta de origen. Luego tomamos el bloqueo en la cuenta. Ahora estamos seguros de que ningún otro hilo puede modificar estas dos cuentas, pero nosotros podemos. Una propiedad de los bloqueos en Scala es que son reentrantes, lo que significa que el mismo subproceso puede tomarlo dos veces o cualquier cantidad de veces.

Hay un problema con este código, ya que introduce la posibilidad de un interbloqueo.

Digamos que un hilo quiere transferir de la cuenta A a la cuenta B en un hilo.

Y otro hilo intenta transferir en la dirección opuesta. Si ambos comienzan al mismo tiempo, toman el primer candado, este en la cuenta A, este en la cuenta B.

Luego van a tomar la otra cerradura. Este no logrará tomar el bloqueo, porque ya fue tomado por el otro subproceso para la cuenta B. Lo mismo es cierto para la cuenta A en el otro subproceso.

Esto significa que ninguno de los subprocesos puede progresar, ambos se atascarán para siempre. Porque no hay posibilidad de que ninguno de los dos ceda el candado que ya tienen. Esto se llama interbloqueo o deadlock

Hay soluciones. Por ejemplo, llevar siempre las transacciones en el mismo orden. Debe definir un orden para las cuentas, etc. y entonces podrías potencialmente resolver esto.

Descubrirá que la mayoría de las veces hay soluciones de ese tipo, pero se agregarán y harán que su código sea mucho más complicado con el tiempo. Y en este caso es sencillo, porque ambas son cuentas bancarias. Pero, ¿qué sucede si desea que colaboren objetos que no provienen de la misma base de código, por ejemplo, en los que no puede modificar?

En ese sentido, sería mucho mejor que nuestros objetos no requirieran sincronización o bloqueo. Porque el bloqueo es lo que realmente hace que ocurra el interbloqueo.

El otro problema con el bloqueo es que es malo para la utilización de la CPU. Si hay otros subprocesos para ejecutar, el sistema operativo los ejecutará. Pero de lo contrario, la CPU estará inactiva. Y despertarlo o hacer que un subproceso vuelva a ejecutarse cuando otro subproceso lo ha interrumpido lleva mucho tiempo. Por lo tanto, su programa se ejecutará más lentamente si usa el bloqueo.

Otro problema con el bloqueo de objetos es que la comunicación sincrónica une al emisor y al receptor con bastante fuerza. Porque el remitente debe esperar hasta que el receptor esté listo. Entonces, si llamo a una cuenta bancaria que está sincronizada, esa cuenta bancaria me bloqueará hasta que esté lista. Los objetos que no bloquean son exactamente lo que son los Actores.

Los actores representan objetos y el modelo de actor describe cómo estos objetos interactúan, y todo esto está inspirado en cómo nos organizamos los humanos y respeta especialmente las leyes de la física. ¿Qué quiero decir con esto? Digamos que una persona sin reloj que quiere saber la hora actual y hay otra persona con un reloj. Ahora la primera persona se hará la pregunta, ¿qué hora es?  Y la segunda persona podría responder, son las 12:43. Este es un intercambio simple entre dos humanos y todos hemos hecho algo así antes, así que espero que todos puedan identificarse con este ejemplo. Pero hay más de lo que es visible en la superficie. En primer lugar, tomamos nota de que transmitimos información hablando y escuchando las palabras. Eso significa que la primera persona envía un mensaje a la segunda persona. La segunda persona luego piensa un poco, mira el reloj y responde con otro mensaje, viajando hacia atrás, en ondas de sonido. Las dos cualidades fundamentales aquí son, en primer lugar, que se basa únicamente en mensajes y, en segundo lugar, los mensajes tardan en viajar de un objeto a otro. Es útil pensar en los actores no como estos objetos abstractos a los que llamas métodos. Pero en lugar de visualizarlos como personas que hablan entre sí. Una cosa también a tener en cuenta es que cuando los humanos hablamos, no nos metemos en el cerebro de los demás, por ejemplo, leyendo la mente. nos basamos únicamente en los mensajes, y eso es una parte muy fundamental de los actores. Más formalmente, un actor, tal como lo definen Hewitt, Bishop y Steiger, es un objeto con identidad. Y también tiene un comportamiento, que ya hemos encontrado, y solo interactúa mediante el paso de mensajes. Lo que sigue siendo compatible con la definición de objeto normal, lo especial de los actores es que su paso de mensajes siempre es asíncrono. 

Tenemos un actor, llamémoslo Actor A. Y este quiere enviar un mensaje al actor B. El actor A enviará el mensaje y luego puede continuar haciendo lo que quiera después de eso, sin tener que esperar por el  mensaje, este viajará a B y será procesado por B, especialmente el procesamiento ocurre en un contexto diferente, en un momento posterior diferente según lo determine el sistema en el que se ejecutan los actores. 

Esta es la propiedad de las más importante de los actores. Para esto, usamos los trait de actor en Akka. El tipo de actor define un método abstracto que se llama recibir y este recibir devuelve algo del tipo Receive. Recibir es una función parcial de Any a Unit y describe la respuesta del actor a un mensaje.

Cualquier mensaje podría entrar, por lo tanto es Any, y el actor puede hacer muchas cosas, pero al final, no devuelve nada, debido al paso asincrónico del mensaje.

Por ahora vamos a ver solo estos conseptos, y luego los profundizaremos en post posteriores. 

Dejo link : https://akka.io/

No hay comentarios.:

Publicar un comentario