Cuando nos imaginamos un algoritmo simple de programación es lógico pensar en una sucesión de instrucciones que se ejecutan de manera secuencial, donde la próxima instrucción no se ejecutará hasta que no haya terminado la inmediatamente anterior.
No obstante, según la naturaleza del problema al que nos enfrentemos, quizás un flujo secuencial no es la opción más deseada. Es en este punto donde entra el concepto de concurrencia.
En este caso, la concurrencia no es más que la capacidad de que una serie de instrucciones se ejecuten de manera desordenada sin que esto afecte al resultado. Esto puede desembocar en la ejecución en paralelo, es decir, la ejecución de múltiples instrucciones a la vez.
Viaje al Futuro en Scala
El lenguaje de programación funcional Scala nos ofrece una solución elegante y eficiente para la gestión de operaciones en paralelo conocida como Futuros (del inglés Futures).
Básicamente, en Scala un Future
es un tipo de objeto que contendrá un valor que posiblemente todavía no exista, pero se espera que finalmente se obtenga. Es común utilizar este tipo de objetos para gestionar la concurrencia puesto que nos permite avanzar en el flujo de instrucciones mientras que de forma paralela se obtiene el valor esperado.
Antes de conocer cuáles son las distintas formas de obtener el valor del Futuro una vez ya esté disponible, debemos entender dónde se ejecuta este característico objeto de Scala. La definición de un Futuro es simple, pero debemos asegurarnos de que se le asocia un ExecutionContext
para que pueda ser ejecutado. Fundamentalmente, este objeto es el responsable de la ejecución y se encargará de lanzar el cálculo de nuestro Futuro en un nuevo hilo, en un pool de hilos o incluso en el hilo actual, aunque esto último no sea lo más recomendable. Scala proporciona un ExecutionContext por defecto respaldado por una ForkJoinPool
que gestiona la cantidad de hilos ejecutables. Para la mayoría de implementaciones este ExecutionContext debería ser suficiente.
import ExecutionContext.Implicits.global
val inverseFuture : Future[Matrix] = Future {
fatMatrix.inverse()
} // ec is implicitly passed
Ejemplo simple de inicialización de un futuro utilizando el ExecutionContext global proporcionado por defecto. Extraído de la documentación de Scala.
Miremos hacia el Futuro
Por definición, los Futuros permiten que la aplicación no quede en un estado de bloqueo mientras se calcula dicho valor. Aunque Scala también ofrece funciones para generar dicho bloqueo y forzar la ejecución síncrona en casos que sea estrictamente necesario.
Sin embargo, el uso habitual de Futuros genera una ejecución asíncrona del código. Para llevar a cabo este tipo de ejecuciones y obtener su valor una vez haya sido calculado Scala utiliza las retrollamadas o también conocidos como callbacks. Podemos distinguir dos tipos de callbacks principales: el método onComplete
y el foreach
.
Generalmente, el resultado de un Futuro[T]
se puede expresar como un objeto Try[T]
que, en caso de obtener un valor de manera exitosa se convertirá en un objeto Succes[T]
, y, en caso contrario, en uno de tipo Failure[T]
. El método onComplete
permite gestionar tanto si la obtención del valor se ha producido de manera exitosa como si no. Mientras que el foreach
tan solo permite gestionar los resultados exitosos.
A continuación, se muestra un pequeño código simple que ejemplifica el uso de Futuros de manera asíncrona en Scala. Imaginamos la situación en que ponemos a hornear una pizza y mientras se realiza dicho proceso se continúa trabajando. Una representación simple podría ser la siguiente:
import scala.concurrent.Future
import scala.concurrent.ExecutionContext.Implicits.global
import scala.util.{Success, Failure}
import scala.util.Random.nextInt
def cookPizza(timeCooking: Int = 8): Future[String] = Future {
println("Start cooking pizza...")
Thread.sleep(nextInt(timeCooking)*1000)
"Pepperoni pizza"
}
def work(time: Int = 2): Unit = {
println("Working...")
Thread.sleep(time*1000)
for(_ <- 1 to 3) {
println("Still working...")
Thread.sleep(time*1000)
}
print("Work finished! Time to eat!")
}
// Possible main
val futurePizza: Future[String] = cookPizza()
futurePizza.onComplete {
case Success(pizza) => println(s"Your $pizza is done!")
case Failure(_) => println("Oh! There was an error cooking your pizza.")
}
work()
Ejemplo parcial del código con obtención asíncrona de Futures.
Una salida posible de este código podría ser esta:
Start cooking pizza...
Working...
Still working...
Your Pepperoni pizza is done!
Still working...
Still working...
Work finished! Time to eat!
Como podemos observar, en esta ejecución el proceso de terminar la pizza terminó tras la primera iteración del método work()
. Pero la impresión del mensaje se hizo de manera asíncrona, es decir, no bloqueante con el método que simula el trabajo.
También existe una última forma de obtener el valor de un Futuro y es mediante los for-comprehensions o la función map()
. Simplemente, esta función al recibir un futuro y una función que mapee su valor, produce un nuevo futuro completado con el valor mapeado una vez el Futuro original ha sido obtenido de manera correcta. Es decir, transforma el potencial valor del Futuro previo en un valor ya completado.
Conclusión
En definitiva, el uso de Futuros en Scala es una manera sencilla pero a la vez muy potente de gestionar la concurrencia y el paralelismo. Además, las funcionalidades mencionadas en este post no son las únicas y existe un gran variedad de soluciones que combinan los conceptos explicados.
La aplicación de este tipo de objetos es muy habitual en casos donde se necesite consultar a otros sistemas, realizar cálculos pesados o incluso acceder a repositorios externos de manera que no se bloquee la ejecución para obtener el dato.
Para más información, os animamos a consultar la documentación de Scala sobre Futures y no perder de vista los próximos posts de Damavis.