Computación Paralela y Asíncrona en Scala: Conceptos y Aplicaciones

Clase 31 de 36Curso de Scala básico

Una de las áreas donde Scala es particularmente famoso, es en el manejo de la computación paralela. En este campo hay varios términos que están relacionados:

  • Paralelo: que puede ejecutar varias tareas independientes entre sí.
  • Asíncrono: que puede ejecutar varias tareas (a veces dependientes entre si) sin necesidad de bloquearlas.
  • Concurrente: que puede ejecutarse en distintos medios al mismo tiempo de manera coordinada.

En esencia, los tres términos se refieren a lo mismo, aunque se suelen usar en distintos contextos.

  • Paralelo se suele usar en un contexto de bajo nivel, como un procesador. Una CPU tiene dos núcleos puede ejecutar cosas en paralelo por cada núcleo.
  • Asíncrono se usa a nivel de código, puedes usar estilos de programación asíncrona para optimizar el uso de los recursos de procesamiento.
  • Concurrente va más a nivel de infraestructura o incluso de arquitectura. Un sistema concurrente puede manejar grandes volúmenes de manera coordinada en un clúster con muchas máquinas.

En Scala hay distintas aplicaciones para cada caso.

Paralelo

Para el caso del procesamiento en paralelo, existe un módulo especial que permite procesar casi todos los tipos de colecciones de forma paralela, generalmente usando .map() o .filter() , mediante una función llamada .par. Así por ejemplo, podrías aprovechar los múltiples procesadores disponibles para procesar listas muy largas de forma más eficiente sin tener que escribir código extra.

Te recomiendo que le des un vistazo a la documentación de esta funcionalidad: https://docs.scala-lang.org/overviews/parallel-collections/overview.html

Asíncrono

En el caso de la programación asíncrona, vimos cómo los Futuros simplifican el manejo de los datos que se van procesando sin tener que preocuparnos por sincronizarlos, o dicho de otra manera, de coordinar los tiempos que estos demoren en ser procesados para después leer sus resultados.

Aunque, eso no significa que esa coordinación no exista... ¿cómo sucede esto?, en realidad, los Futuros de Scala y las demás librerías de programación asíncrona como Monix o ZIO, en el fondo hacen uso de las funcionalidades de bajo nivel de la JVM para el manejo de hilos de ejecución (execution threads).

Hilos de ejecución

Quiero darte una explicación rápida de esto: Quizás te has dado cuenta que al usar futuros, se hace necesario importar algo que se conoce como un contexto de ejecución global (global execution context).

Los contextos de ejecución determinan cómo y dónde se debe ejecutar el código que escribiste dentro de un Futuro. Quien finalmente va a ejecutar ese código es algo que se conoce como un ejecutor (executor).

El ejecutor hace uso de un ThreadPool, esto es, un grupo de hilos de ejecución que el contexto de ejecución le provee, para ejecutar tu código.

Por defecto el contexto de ejecución global de Scala ejecuta el código usando un ForkJoinExecutor. Por supuesto este no es el único tipo, existen otros tipos de ejecutores provenientes de Java.

No es el objetivo explicar todo esto en detalle, aunque si te interesa entender un poco más qué pasa tras bambalinas, te recomiendo que le des un vistazo a la documentación oficial en Java: https://docs.oracle.com/javase/8/docs/api/java/util/concurrent/Executor.html#java.util.concurrent.Executor

¡Pero no te preocupes!, la explicación anterior es compleja porque la programación asíncrona es muy compleja por naturaleza. Pero lo bonito de Scala es que no necesitas adentrarte a esas profundidades para poder sacar provecho de sus grandes ventajas.

Haciendo uso eficiente de los recursos de procesamiento que poseas mediante la programación asíncrona, ya sea con Futuros u otras librerías, puedes crear software muy robusto, capaz de afrontar grandes cargas de trabajo.

En el caso particular de Play, te recomiendo que le des un vistazo a la sección de su documentación donde explican esto en más detalle: https://www.playframework.com/documentation/2.7.x/ThreadPools

Comparación con NodeJS

Si comparamos la manera de afrontar la programación asíncrona en Scala con la manera de hacerlo en NodeJS por ejemplo, a nivel de código ambas son muy similares, pero a nivel de ejecución la de Scala es mejor porque nos permite aprovechar de mejor manera los recursos de procesamiento cuando necesitamos hacer procesos intensivos a nivel de CPU.

NodeJS fue diseñado para solo usar un procesador por instancia, que es muy bueno cuando hay que manejar muchas pequeñas tareas, pero en procesos intensivos a nivel de CPU el rendimiento se vuelve lamentablemente pésimo. Este es justamente un caso de uso conocido donde no deberías usar NodeJS, y donde Scala se vuelve una excelente alternativa.

Concurrente

Por último, el caso de sistemas concurrentes puede afrontarse usando frameworks como Akka o incluso con librerías como ZIO. En nuestro caso usando Play, este hace uso de una combinación de programación asíncrona con otras capacidades de Akka que potencialmente podrían ser usadas para crear clusters de microservicios, pero es algo que no veremos aquí.

Crear sistemas puramente concurrentes es un tema bastante más avanzado que se sale del propósito de este curso, pero es una de las posibilidades más interesantes y potentes cuando usamos este tipo de tecnologías.

Los sistemas concurrentes son también la base de lo que se conoce como programación distribuida. Este es el tipo de sistemas que más impacto tienen a nivel mundial.

Solo por ponerte un ejemplo, ¿has pensando cómo es posible procesar un pago por tarjeta de crédito?, ¿cuantos sistemas se comunican entre sí?... la empresa del lector de tarjetas, la franquicia de la tarjeta, el banco que emitió la tarjeta... ¿qué pasa cuando uno de esos sistemas falla?, ¿y cómo es que puedo usar mi tarjeta del banco pollito si estoy de viaje en japón?

Con esto espero que veas y te emociones con el potencial de este tipo de sistemas.

En la siguiente clase continuaremos con temas más terrenales, veremos cómo funciona la serialización de objetos de Scala a JSON en Play.