Principios y patrones de diseño para aplicaciones altamente concurrentes

1. Información general

En este tutorial, discutiremos algunos de los principios y patrones de diseño que se han establecido a lo largo del tiempo para construir aplicaciones altamente concurrentes.

Sin embargo, vale la pena señalar que el diseño de una aplicación concurrente es un tema amplio y complejo y, por lo tanto, ningún tutorial puede pretender ser exhaustivo en su tratamiento. ¡Lo que cubriremos aquí son algunos de los trucos populares que se emplean a menudo!

2. Conceptos básicos de la simultaneidad

Antes de continuar, dediquemos un tiempo a comprender los conceptos básicos. Para empezar, debemos aclarar nuestra comprensión de lo que llamamos un programa concurrente. Nos referimos a un programa que es concurrente si se realizan múltiples cálculos al mismo tiempo .

Ahora, tenga en cuenta que hemos mencionado que los cálculos ocurren al mismo tiempo, es decir, están en progreso al mismo tiempo. Sin embargo, pueden o no ejecutarse simultáneamente. Es importante comprender la diferencia, ya que la ejecución simultánea de cálculos se denomina paralela .

2.1. ¿Cómo crear módulos concurrentes?

Es importante comprender cómo podemos crear módulos simultáneos. Hay numerosas opciones, pero aquí nos centraremos en dos opciones populares:

  • Proceso : un proceso es una instancia de un programa en ejecución que está aislado de otros procesos en la misma máquina. Cada proceso de una máquina tiene su propio tiempo y espacio aislado. Por lo tanto, normalmente no es posible compartir memoria entre procesos y deben comunicarse pasando mensajes.
  • Subproceso : un subproceso, por otro lado, es solo un segmento de un proceso . Puede haber varios subprocesos dentro de un programa que comparten el mismo espacio de memoria. Sin embargo, cada hilo tiene una pila y una prioridad únicas. Un hilo puede ser nativo (programado de forma nativa por el sistema operativo) o verde (programado por una biblioteca en tiempo de ejecución).

2.2. ¿Cómo interactúan los módulos concurrentes?

Es bastante ideal si los módulos concurrentes no tienen que comunicarse, pero a menudo ese no es el caso. Esto da lugar a dos modelos de programación concurrente:

  • Memoria compartida : en este modelo, los módulos concurrentes interactúan leyendo y escribiendo objetos compartidos en la memoria . Esto a menudo conduce al entrelazado de cálculos concurrentes, lo que provoca condiciones de carrera. Por tanto, puede conducir de forma no determinista a estados incorrectos.
  • Paso de mensajes : en este modelo, los módulos concurrentes interactúan pasando mensajes entre sí a través de un canal de comunicación . Aquí, cada módulo procesa los mensajes entrantes de forma secuencial. Dado que no hay un estado compartido, es relativamente más fácil de programar, ¡pero aún no está libre de condiciones de carrera!

2.3. ¿Cómo se ejecutan los módulos simultáneos?

Ha pasado un tiempo desde que la Ley de Moore chocó contra una pared con respecto a la velocidad del reloj del procesador. En cambio, dado que debemos crecer, hemos comenzado a empaquetar varios procesadores en el mismo chip, a menudo llamados procesadores multinúcleo. Pero aún así, no es común oír hablar de procesadores que tienen más de 32 núcleos.

Ahora, sabemos que un solo núcleo puede ejecutar solo un hilo, o un conjunto de instrucciones, a la vez. Sin embargo, la cantidad de procesos y subprocesos puede ser de cientos y miles, respectivamente. Entonces, ¿cómo funciona realmente? Aquí es donde el sistema operativo simula la concurrencia para nosotros . El sistema operativo logra esto mediante la división del tiempo , lo que efectivamente significa que el procesador cambia entre subprocesos con frecuencia, de manera impredecible y no determinista.

3. Problemas en la programación concurrente

A medida que analizamos los principios y patrones para diseñar una aplicación concurrente, sería prudente comprender primero cuáles son los problemas típicos.

En gran parte, nuestra experiencia con la programación concurrente implica el uso de subprocesos nativos con memoria compartida . Por lo tanto, nos centraremos en algunos de los problemas comunes que emanan de él:

  • Exclusión mutua (primitivas de sincronización) : los subprocesos entrelazados deben tener acceso exclusivo al estado o memoria compartidos para garantizar la corrección de los programas . La sincronización de recursos compartidos es un método popular para lograr la exclusión mutua. Hay varias primitivas de sincronización disponibles para usar, por ejemplo, un bloqueo, monitor, semáforo o mutex. Sin embargo, la programación para la exclusión mutua es propensa a errores y, a menudo, puede provocar cuellos de botella en el rendimiento. Hay varios temas bien discutidos relacionados con esto, como el punto muerto y el bloqueo activo.
  • Cambio de contexto ( subprocesos pesados) : cada sistema operativo tiene soporte nativo, aunque variado, para módulos concurrentes como procesos e subprocesos. Como se discutió, uno de los servicios fundamentales que proporciona un sistema operativo es la programación de subprocesos para que se ejecuten en un número limitado de procesadores a través del tiempo. Ahora, esto significa efectivamente que los subprocesos se cambian con frecuencia entre diferentes estados . En el proceso, su estado actual debe guardarse y reanudarse. Esta es una actividad que requiere mucho tiempo y que afecta directamente al rendimiento general.

4. Patrones de diseño para alta concurrencia

Ahora que comprendemos los conceptos básicos de la programación concurrente y los problemas comunes que conlleva, es hora de comprender algunos de los patrones comunes para evitar estos problemas. Debemos reiterar que la programación concurrente es una tarea difícil que requiere mucha experiencia. Por lo tanto, seguir algunos de los patrones establecidos puede facilitar la tarea.

4.1. Simultaneidad basada en actores

El primer diseño que discutiremos con respecto a la programación concurrente se llama Modelo de actor. Este es un modelo matemático de computación concurrente que básicamente trata todo como un actor . Los actores pueden transmitirse mensajes entre sí y, en respuesta a un mensaje, pueden tomar decisiones locales. Esto fue propuesto por primera vez por Carl Hewitt y ha inspirado varios lenguajes de programación.

La construcción principal de Scala para la programación concurrente son los actores. Los actores son objetos normales en Scala que podemos crear instanciando la clase Actor . Además, la biblioteca Scala Actors proporciona muchas operaciones de actor útiles:

class myActor extends Actor { def act() { while(true) { receive { // Perform some action } } } }

En el ejemplo anterior, una llamada al método de recepción dentro de un bucle infinito suspende al actor hasta que llega un mensaje. A su llegada, el mensaje se elimina del buzón del actor y se toman las acciones necesarias.

El modelo de actor elimina uno de los problemas fundamentales de la programación concurrente: la memoria compartida . Los actores se comunican a través de mensajes y cada actor procesa los mensajes de sus buzones de correo exclusivos de forma secuencial. Sin embargo, ejecutamos actores sobre un grupo de subprocesos. Y hemos visto que los subprocesos nativos pueden ser pesados ​​y, por lo tanto, limitados en número.

Por supuesto, hay otros patrones que pueden ayudarnos aquí, ¡los cubriremos más adelante!

4.2. Concurrencia basada en eventos

Los diseños basados ​​en eventos abordan explícitamente el problema de que los subprocesos nativos son costosos de generar y operar. Uno de los diseños basados ​​en eventos es el bucle de eventos. El bucle de eventos funciona con un proveedor de eventos y un conjunto de controladores de eventos. En esta configuración, el bucle de eventos bloquea el proveedor de eventos y envía un evento a un controlador de eventos a su llegada .

Básicamente, el bucle de eventos no es más que un despachador de eventos. El bucle de eventos en sí puede ejecutarse en un solo hilo nativo. Entonces, ¿qué sucede realmente en un bucle de eventos? Veamos el pseudocódigo de un bucle de eventos realmente simple como ejemplo:

while(true) { events = getEvents(); for(e in events) processEvent(e); }

Básicamente, todo lo que hace nuestro bucle de eventos es buscar eventos continuamente y, cuando se encuentran, procesarlos. El enfoque es realmente simple, pero se beneficia de un diseño basado en eventos.

La creación de aplicaciones simultáneas con este diseño le da más control a la aplicación. Además, elimina algunos de los problemas típicos de las aplicaciones de subprocesos múltiples, por ejemplo, interbloqueo.

JavaScript implementa el bucle de eventos para ofrecer programación asincrónica . Mantiene una pila de llamadas para realizar un seguimiento de todas las funciones a ejecutar. También mantiene una cola de eventos para enviar nuevas funciones para su procesamiento. El bucle de eventos comprueba constantemente la pila de llamadas y agrega nuevas funciones de la cola de eventos. Todas las llamadas asíncronas se envían a las API web, generalmente proporcionadas por el navegador.

El bucle de eventos en sí puede ejecutarse en un solo hilo, pero las API web proporcionan hilos separados.

4.3. Algoritmos sin bloqueo

En los algoritmos sin bloqueo, la suspensión de un hilo no conduce a la suspensión de otros hilos. Hemos visto que solo podemos tener un número limitado de subprocesos nativos en nuestra aplicación. Ahora, un algoritmo que bloquea un subproceso obviamente reduce el rendimiento de manera significativa y nos impide crear aplicaciones altamente concurrentes.

Los algoritmos de no bloqueo invariablemente hacen uso de la primitiva atómica de comparar e intercambiar que proporciona el hardware subyacente . Esto significa que el hardware comparará el contenido de una ubicación de memoria con un valor dado, y solo si son iguales actualizará el valor a un nuevo valor dado. Esto puede parecer simple, pero efectivamente nos proporciona una operación atómica que de otra manera requeriría sincronización.

Esto significa que tenemos que escribir nuevas estructuras de datos y bibliotecas que hagan uso de esta operación atómica. Esto nos ha proporcionado un gran conjunto de implementaciones sin espera ni bloqueos en varios idiomas. Java tiene varias estructuras de datos sin bloqueo como AtomicBoolean , AtomicInteger , AtomicLong y AtomicReference .

Considere una aplicación en la que varios subprocesos intentan acceder al mismo código:

boolean open = false; if(!open) { // Do Something open=false; }

Claramente, el código anterior no es seguro para subprocesos y su comportamiento en un entorno de subprocesos múltiples puede ser impredecible. Nuestras opciones aquí son sincronizar este fragmento de código con un candado o usar una operación atómica:

AtomicBoolean open = new AtomicBoolean(false); if(open.compareAndSet(false, true) { // Do Something }

Como podemos ver, el uso de una estructura de datos sin bloqueo como AtomicBoolean nos ayuda a escribir código seguro para subprocesos sin caer en los inconvenientes de los bloqueos.

5. Soporte en lenguajes de programación

Hemos visto que hay varias formas en que podemos construir un módulo concurrente. Si bien el lenguaje de programación marca la diferencia, es principalmente la forma en que el sistema operativo subyacente apoya el concepto. Sin embargo, como la concurrencia basada en subprocesos soportada por subprocesos nativos está golpeando nuevos muros con respecto a la escalabilidad, siempre necesitamos nuevas opciones.

La implementación de algunas de las prácticas de diseño que discutimos en la última sección demuestra ser efectiva. Sin embargo, debemos tener en cuenta que complica la programación como tal. Lo que realmente necesitamos es algo que proporcione el poder de la concurrencia basada en subprocesos sin los efectos indeseables que trae.

Una solución disponible para nosotros son los hilos verdes. Los subprocesos verdes son subprocesos programados por la biblioteca de tiempo de ejecución en lugar de ser programados de forma nativa por el sistema operativo subyacente. Si bien esto no elimina todos los problemas de la concurrencia basada en subprocesos, ciertamente puede brindarnos un mejor rendimiento en algunos casos.

Ahora, no es trivial usar hilos verdes a menos que el lenguaje de programación que elijamos usar lo admita. No todos los lenguajes de programación tienen este soporte integrado. Además, lo que llamamos vagamente hilos verdes se puede implementar de formas muy singulares mediante diferentes lenguajes de programación. Veamos algunas de estas opciones disponibles para nosotros.

5.1. Goroutines en Go

Goroutines en el lenguaje de programación Go son hilos livianos. Ofrecen funciones o métodos que pueden ejecutarse simultáneamente con otras funciones o métodos. Las gorutinas son extremadamente baratas ya que, para empezar, ocupan solo unos pocos kilobytes en tamaño de pila .

Lo más importante es que las gorutinas se multiplexan con un número menor de subprocesos nativos. Además, las gorutinas se comunican entre sí mediante canales, lo que evita el acceso a la memoria compartida. Obtenemos prácticamente todo lo que necesitamos, y adivinen qué, ¡sin hacer nada!

5.2. Procesos en Erlang

En Erlang, cada hilo de ejecución se denomina proceso. ¡Pero no es como el proceso que hemos discutido hasta ahora! Los procesos de Erlang son livianos con una pequeña huella de memoria y son rápidos de crear y eliminar con una baja sobrecarga de programación.

Bajo el capó, los procesos de Erlang no son más que funciones para las que el tiempo de ejecución maneja la programación. Además, los procesos de Erlang no comparten ningún dato y se comunican entre sí mediante el paso de mensajes. ¡Esta es la razón por la que llamamos a estos "procesos" en primer lugar!

5.3. Fibras en Java (propuesta)

La historia de la concurrencia con Java ha sido una evolución continua. Java tenía soporte para hilos verdes, al menos para los sistemas operativos Solaris, para empezar. Sin embargo, esto se suspendió debido a obstáculos más allá del alcance de este tutorial.

Desde entonces, la concurrencia en Java se trata de hilos nativos y cómo trabajar con ellos de forma inteligente. Pero por razones obvias, pronto tendremos una nueva abstracción de concurrencia en Java, llamada fibra. Project Loom propone introducir continuaciones junto con fibras, lo que puede cambiar la forma en que escribimos aplicaciones concurrentes en Java.

Esto es solo un adelanto de lo que está disponible en diferentes lenguajes de programación. Hay formas mucho más interesantes en las que otros lenguajes de programación han tratado de lidiar con la concurrencia.

Además, vale la pena señalar que una combinación de patrones de diseño discutidos en la última sección, junto con el soporte del lenguaje de programación para una abstracción similar a un hilo verde, puede ser extremadamente poderosa al diseñar aplicaciones altamente concurrentes.

6. Aplicaciones de alta concurrencia

Una aplicación del mundo real a menudo tiene varios componentes que interactúan entre sí a través del cable. Por lo general, accedemos a él a través de Internet y consta de múltiples servicios como servicio proxy, puerta de enlace, servicio web, base de datos, servicio de directorio y sistemas de archivos.

¿Cómo aseguramos una alta concurrencia en tales situaciones? Exploremos algunas de estas capas y las opciones que tenemos para construir una aplicación altamente concurrente.

Como hemos visto en la sección anterior, la clave para crear aplicaciones de alta concurrencia es utilizar algunos de los conceptos de diseño que se analizan allí. Necesitamos elegir el software adecuado para el trabajo, aquellos que ya incorporan algunas de estas prácticas.

6.1. Capa web

La web suele ser la primera capa a la que llegan las solicitudes de los usuarios, y aquí es inevitable el aprovisionamiento de alta concurrencia. Veamos cuáles son algunas de las opciones:

  • Node (también llamado NodeJS o Node.js) es un tiempo de ejecución de JavaScript multiplataforma de código abierto construido en el motor JavaScript V8 de Chrome. Node funciona bastante bien en el manejo de operaciones de E / S asincrónicas. La razón por la que Node lo hace tan bien es porque implementa un bucle de eventos en un solo hilo. El bucle de eventos con la ayuda de devoluciones de llamada maneja todas las operaciones de bloqueo como E / S de forma asincrónica.
  • nginx es un servidor web de código abierto que usamos comúnmente como proxy inverso entre sus otros usos. La razón por la que nginx proporciona una alta concurrencia es que utiliza un enfoque asíncrono impulsado por eventos. nginx opera con un proceso maestro en un solo hilo. El proceso maestro mantiene los procesos de trabajo que realizan el procesamiento real. Por lo tanto, los procesos de trabajador procesan cada solicitud al mismo tiempo.

6.2. Capa de aplicación

Al diseñar una aplicación, existen varias herramientas que nos ayudan a construir para una alta concurrencia. Examinemos algunas de estas bibliotecas y marcos que están disponibles para nosotros:

  • Akka es un conjunto de herramientas escrito en Scala para crear aplicaciones altamente concurrentes y distribuidas en la JVM. El enfoque de Akka para manejar la concurrencia se basa en el modelo de actor que discutimos anteriormente. Akka crea una capa entre los actores y los sistemas subyacentes. El marco maneja las complejidades de crear y programar hilos, recibir y enviar mensajes.
  • Project Reactor es una biblioteca reactiva para crear aplicaciones sin bloqueo en la JVM. Se basa en la especificación Reactive Streams y se centra en la transmisión eficiente de mensajes y la gestión de la demanda (contrapresión). Los operadores y planificadores de reactores pueden mantener altas tasas de rendimiento de mensajes. Varios marcos populares proporcionan implementaciones de reactores, incluidos Spring WebFlux y RSocket.
  • Netty es un marco de aplicación de red asincrónico, impulsado por eventos. Podemos usar Netty para desarrollar servidores y clientes de protocolos altamente concurrentes. Netty aprovecha NIO, que es una colección de API de Java que ofrece transferencia de datos asincrónica a través de búferes y canales. Nos ofrece varias ventajas como mejor rendimiento, menor latencia, menor consumo de recursos y minimizar la copia de memoria innecesaria.

6.3. Capa de datos

Finalmente, ninguna aplicación está completa sin sus datos y los datos provienen de un almacenamiento persistente. Cuando hablamos de alta concurrencia con respecto a las bases de datos, la mayor parte del enfoque permanece en la familia NoSQL. Esto se debe principalmente a la escalabilidad lineal que pueden ofrecer las bases de datos NoSQL, pero es difícil de lograr en variantes relacionales. Veamos dos herramientas populares para la capa de datos:

  • Cassandra es una base de datos distribuida NoSQL gratuita y de código abierto que proporciona alta disponibilidad, alta escalabilidad y tolerancia a fallas en hardware básico. Sin embargo, Cassandra no proporciona transacciones ACID que abarquen varias tablas. Entonces, si nuestra aplicación no requiere transacciones y consistencia sólidas, podemos beneficiarnos de las operaciones de baja latencia de Cassandra.
  • Kafka es una plataforma de transmisión distribuida . Kafka almacena un flujo de registros en categorías llamadas temas. Puede proporcionar escalabilidad horizontal lineal tanto para los productores como para los consumidores de los registros y, al mismo tiempo, proporciona una alta fiabilidad y durabilidad. Las particiones, las réplicas y los corredores son algunos de los conceptos fundamentales sobre los que proporciona simultaneidad distribuida masivamente.

6.4. Capa de caché

Bueno, ninguna aplicación web en el mundo moderno que apunta a una alta concurrencia puede permitirse acceder a la base de datos cada vez. Eso nos deja elegir un caché, preferiblemente un caché en memoria que pueda admitir nuestras aplicaciones altamente concurrentes:

  • Hazelcast es un motor de cálculo y almacenamiento de objetos en memoria distribuido y compatible con la nube que admite una amplia variedad de estructuras de datos como Map , Set , List , MultiMap , RingBuffer e HyperLogLog . Tiene replicación incorporada y ofrece alta disponibilidad y particionamiento automático.
  • Redis es un almacén de estructura de datos en memoria que usamos principalmente como caché . Proporciona una base de datos de clave-valor en memoria con durabilidad opcional. Las estructuras de datos admitidas incluyen cadenas, hashes, listas y conjuntos. Redis tiene replicación incorporada y ofrece alta disponibilidad y particionamiento automático. En caso de que no necesitemos persistencia, Redis puede ofrecernos una caché en memoria en red, rica en funciones y con un rendimiento excepcional.

Por supuesto, apenas hemos arañado la superficie de lo que está disponible para nosotros en nuestra búsqueda de construir una aplicación altamente concurrente. Es importante señalar que, más que el software disponible, nuestro requisito debe guiarnos para crear un diseño adecuado. Algunas de estas opciones pueden ser adecuadas, mientras que otras pueden no serlo.

Y no olvidemos que hay muchas más opciones disponibles que pueden adaptarse mejor a nuestros requisitos.

7. Conclusión

En este artículo, discutimos los conceptos básicos de la programación concurrente. Entendimos algunos de los aspectos fundamentales de la concurrencia y los problemas que puede ocasionar. Además, revisamos algunos de los patrones de diseño que pueden ayudarnos a evitar los problemas típicos de la programación concurrente.

Finalmente, revisamos algunos de los marcos, bibliotecas y software disponibles para crear una aplicación de extremo a extremo altamente concurrente.