viernes, 7 de marzo de 2014

Historia de una promise (1): el modelo de concurrencia con ejecución asíncrona

Como ya habréis podido comprobar, últimamente estoy jugando mucho con el lenguaje Dart. Una de las cosas a las que más me ha costado acostumbrarme es al modelo de concurrencia basado en la ejecución asíncrona (o "basado en eventos"), heredado de Node.js. En Dart los elementos que se usan para esto se llaman futures o promises, patrones que no son realmente nuevos, aunque no ha sido hasta los últimos años que se han puesto de moda. Para alguien como yo acostumbrado al mundo Java y la concurrencia multithread, esto supone todo un cambio de enfoque en la programación, especialmente cuando programo la parte del servidor.

Yendo a las raíces de la asincronía, hoy nos preguntamos... ¿cómo se ha llegado a esto?. ¿Realmente es eficiente este modelo en el lado del servidor?. ¿Qué modelo de concurrencia es mejor?. Y en cualquier caso, ¿qué ventajas tiene cada uno?.

Si tú también te preguntas todo esto, sigue leyendo...


De Geek and Poke



Concurrencia multithread

Antes de la irrupción de Java, cuando se quería desarrollar un servidor que respondiera a algún protocolo de comunicación como por ejemplo RPC, lo más usual era programarlo en C ó C++. Habitualmente, se gestionaba la concurrencia con distintos threads de ejecución (o incluso procesos), y cada programador tenía que hacerse su propio pool de threads y su propia gestión de los mismos. Para controlar el acceso a la memoria compartida entre ellos y que no se pisaran unos a otros, lo más habitual era usar semáforos, aunque existían otras soluciones.

Entonces apareció Java, y encontró un filón en la programación para el servidor. No olvidemos que en C/C++ si se accedía a un puntero null o sin asignar, el servidor entero se caía. Y que cada vez que se reservaba memoria luego había que liberarla explícitamente, con la consiguiente alta probabilidad de memory leaks. Nada de excepciones en el log y nada de garbage collector, comodidades a las que ya nos hemos acostumbrado con cualquier lenguaje y ¡ay, como nos las quiten!.

En cuanto a la concurrencia, Java también añadía facilidades para el control del acceso a memoria compartida, gracias a la palabra clave synchronized, que permite que bloques de código no puedan ejecutarse por varios threads simultáneamente de una forma más intuitiva que con los semáforos.

A pesar de todo esto, programar un acceso concurrente mediante varios hilos a un objeto compartido por todo el servidor, algo muy necesario cuando se quiere cachear información de acceso frecuente, sigue siendo realmente peligroso.

Muchos novatos (o no tan novatos) programarían una información cacheada en un Map que forme parte de un objeto compartido así:

private Map<String, Elephant> elephantCache = null;

public Elephant getElephantByCode(String code) {
   this.loadElephants();
   return this.elephantCache.get(code);
}

private void loadElephants() {
   if (this.elephantCache == null) {
      this.elephantCache = new HashMap<String, Elephant>();
      datos = sqlElephantQuery();
      fillElephantCache(datos);
      log.debug("Elephant cache filled");
   }
   log.debug("Done");
}


¿El problema?. No hay control ninguno del acceso concurrente. Un hilo puede estar intentando meter elementos en el Map de la caché mientras otro intenta acceder a ellos. Lo peor de todo es que lo haces, lo pruebas y... funciona. Y de repente un día tu web empieza a hacer cosas raras. Como poco un error en uno de los hilos, o los dos, y con suerte el objeto se queda incluso en un estado estable.

Pero puede ser mucho peor. En algunas versiones de Java, incluso, puedo prometer que algo tan tonto como esto puede causar un bucle infinito, lo cual puede llegar a originar, con cierta facilidad, que los hilos empiecen a bloquearse y finalmente tu servidor se muera, llevándose junto a tu aplicación cualquier otra que pueda estar instalada en el servidor de aplicaciones. Y a ver entonces quién es el guapo que encuentra el error, que según las circunstancias de la instalación se puede haber convertido incluso en inencontrable...


Concurrencia asíncrona

Saltamos ahora al JavaScript, para explicar cómo se ha popularizado otro modelo completamente distinto.

Por motivos de seguridad y control, o por simplificar cosas que no necesitaban ser más complejas, JavaScript no permite ejecución multithread con memoria compartida. Sólo un hilo de código propio estará en ejecución.

Al principio ni falta que hacía más, el código prácticamente no podía hacer mucho más que manipular el DOM y los formularios. Si había varios eventos, se van encolando todos y procesando uno detrás del otro, nunca varios a la vez.

Luego llegó Ajax, y con él la posibilidad de hacer llamadas HTTP al servidor desde el propio código JavaScript, para luego manipular el resultado. Como sólo hay un hilo de ejecución, no nos conviene que se quede bloqueado esperando el resultado de la llamada HTTP, que obviamente puede ser lenta, como sí se haría en el modelo de ejecución de Java. Lo que nos conviene es que una vez lanzada la petición HTTP, sigamos procesando los siguientes eventos que pueda haber pendientes, y procesemos la respuesta ya cuando se reciba el evento y le toque dentro de la cola.

El tema se fue complicando cada vez más. La programación en el se ha ido haciendo cada vez más ambiciosa, moviendo al cliente buena parte de la lógica de negocio. Pero sobre todo, un día alguien pensó: ¿por qué no hacer un modelo de servidor basado en JavaScript, que mantenga el mismo lenguaje pero consiga ser concurrente y eficiente sin la problemática de la ejecución multithread?. Y entonces apareció Node.js, y JavaScript pasó al servidor.

Hay que considerar que en el servidor se usa este mismo sistema de parar la ejecución de un evento y pasar al siguiente cuando se accede a un fichero, o se llama a una base de datos, o se comunica con otro proceso... o sea, para un montón de cosas. Y que mientras que en el cliente se entiende que sólo va a haber una persona interactuando, en el servidor nos pueden llegar muchísimas peticiones en cuestión de segundos. Cada petición HTTP que recibamos se considera un evento, así que no pasaremos a la siguiente hasta que la actual no haya acabado o haga un acceso de entrada / salida. La concurrencia, por tanto, no se produce nunca en nuestro código, sino entre nuestro código y las peticiones de E/S. Es una concurrencia "falsa", en cierto modo. En el fondo se vuelve al modelo de multitarea de Windows 3.1 (y ya sabemos cómo acabó aquello... aunque, bueno, ¡aquello era un sistema operativo!).

Así que la pregunta es: ¿realmente este modelo funciona para una ejecución en servidor?.


Funcionamiento de los dos modelos y comparativa

Vamos a aprovechar que tengo aquí a los hermanos Marx y les voy a pedir que me ayuden. Groucho (hablando en azul) y Chico (en rojo) quieren hablar a la vez pero no pueden pronunciar ningún sonido al mismo tiempo (olvidad a Harpo, porque... bueno, es mudo, y además vive en su mundo). Supongamos que cada palabra es una ejecución de código de procesamiento puro: operaciones matemáticas, procesamiento de imágenes, gestión de colecciones en memoria, lo que sea; y que las pausas entre cada palabra son, por ejemplo, accesos a una base de datos. El modelo multithread sería así:

Concurrencia multi-thread

Aparentemente los dos están hablando al mismo tiempo, aunque en realidad la ejecución está pasando de uno a otro continuamente. El modelo asíncrono, por otra parte, sería así:

Concurrencia con ejecución asíncrona

Como se puede observar, en este caso se ejecuta todo el procesamiento de Groucho un tirón hasta que se llega a un acceso a base de datos (una pausa entre palabras), y mientras se espera a que la base de datos responda, se libera a Chico, hasta que él también llegue a otro acceso a base de datos.

¿Este modelo funciona, es eficiente?. Pues... depende.

Supongamos ahora que Groucho suelta su irrefrenable lengua y se pone a hablar y hablar sin hacer pausas entre palabras:

Atasco en la concurrencia asíncrona

O dicho de otra forma, supongamos que tenemos una ejecución que requiere mucho procesamiento pero no tiene apenas entrada/salida, es decir acceso a procesos o dispositivos externos como pueda ser los ficheros o una base de datos. Hasta que Groucho no termine su procesamiento, Chico se queda en espera sin poder hacer su parte, con lo que se causa un atasco absolutamente innecesario y que nos llevaría a una situación potencialmente muy ineficiente.

En el lado positivo, sin embargo, para ejecuciones cortas es de esperar que se ahorre algo de tiempo por no tener que cambiar de contexto entre los threads, y por no tener que controlar la concurrencia en la memoria compartida.

Buscando un poco se pueden encontrar un montón de comparativas de rendimiento entre los dos modelos. Pero... como podréis suponer, el resultado depende enormemente del tipo de procesos que se estén ejecutando. Es tan fácil hacer trampa con los datos para concluir que el de Node.js es el modelo más eficiente... tan tentador cuando Node.js es tan "cool" y aumenta tanto tu molómetro... hay tanta diferencia entre hacer la prueba con un tipo de procesamiento y con otro... Al final lo que parece que está ocurriendo (como impresión puramente personal) es que muchos piensan que el modelo de Node.js es más rápido y más escalable sólo porque "lo han oído por ahí".

Mi conclusión principal en cuanto al rendimiento, sin haber hecho yo ninguna prueba real y basándome sólo en la lógica, es que cada modelo será más o menos adecuado según el tipo de aplicación. Si no se hacen procesamientos pesados (pudiendo considerar "pesadas" ejecuciones incluso de sólo unas pocas centésimas de segundo), creo que el modelo asíncrono sí debería funcionar bastante bien. Al fin y al cabo, la mayor parte de las webs actuales dedican la mayor parte del tiempo en servidor a acceder a la base de datos, más que a hacer cálculos o manipulaciones costosas de memoria. Y cuando lo hacen, normalmente es en funcionalidades muy muy localizadas.

Aparte de con los procesamientos "pesados", es importante tener muchísimo cuidado con no programar ninguna situación que pueda llevar a un bucle infinito, porque eso podría llevar a que se bloqueara por completo el servidor. Y a Chico le podría dar un patatús con tanta espera.


Necesita un modelo complementario

En cualquier caso, en mi opinión no basta con eso. Además de usar el modelo asíncrono de forma general, sí queremos hacer aplicaciones de cierto tamaño pienso que el lenguaje nos debe proporcionar otro modelo de concurrencia con el que podamos crear procedimientos que sí se puedan ejecutar sin bloquear a los demás, aunque sea algo puntual y sólo para determinados tipos de procesos. Primero para que podamos aislar de esta forma procesos que podamos tener con uso intensivo del procesador, sin que bloqueen a los demás. Y si no tenemos aún procesos intensivos, porque las aplicaciones crecen, y en cualquier momento, cuando menos lo esperemos, es muy probable que nos vaya a hacer falta alguno.

Aparte de eso, hay otro motivo: los procesadores actuales tienen varios núcleos, cada vez más, y por tanto realmente que son capaces de ejecutar varias cosas a la vez. Vale que siempre podemos ejecutar varios procesos de nuestro servidor dentro de la misma máquina y balancearlos, pero aun con eso todo lo que podamos hacer para facilitar el aprovechamiento de los diferentes núcleos dentro de la misma instancia va a ser bueno.

Otro tema es cómo sea ese mecanismo alternativo que nos pueda proporcionar el lenguaje, y que no tiene por qué tener memoria compartida como en el caso de los threads, con los problemas y los quebraderos de cabeza que hemos visto antes, ya que al fin y al cabo es lo que estamos intentando evitar con todo esto.

El caso es que todos los lenguajes con ejecución asincronía dan una solución a esto, y todas son muy similares. Básicamente lo que permiten crear son elementos de ejecución en paralelo que tienen su propio espacio de memoria, y que se comunican entre sí mediante paso de mensajes. En JavaScript en el cliente (HTML5) existen los webworkers. En Node.js son subprocesos.

En Dart estos elementos reciben el nombre de isolates, y por definición no se indica cómo se implementan en el servidor, si con subprocesos o con threads. La diferencia entre hacerlo con uno u otro, al ser memoria independiente, no afecta a la forma de programarlos, pero sí al rendimiento. Crear un subproceso es bastante más costoso que un thread, y la comunicación entre procesos también es más lenta.

Lo curioso del caso es que los isolates se iban a implementar originalmente en Node.js, pero finalmente lo descartaron. Al parecer probaron a hacerlo pero esto causó inestabilidad en el servidor, así que decidieron pasar y quedarse sólo con los subprocesos. Más allá de que se me quedan un poco los ojos como platos con el tema, el acercamiento de Dart de crear la semántica de los isolates sin indicar cómo se implementan internamente parece prudente porque se cura en salud "por si acaso".


Conclusiones

Diablos, no me cae bien Node.js. Es hipster, sorprendentemente se ha puesto de moda y parece que se ha exacerbado de forma absurda. ¿Programar en JavaScript también en el servidor?. ¿Como si no tuviéramos bastante con el cliente?. ¡Uf!

Sin embargo, tengo que reconocer que sí me gusta su modelo de ejecución asíncrona. Yo ya estoy curtido con los threads y no tengo ningún problema en utilizarlos, pero reconozco que cada vez que veo a un novato creando código con objetos compartidos en memoria tiemblo tanto que al final le tengo que poner una velita a la Santa Virgen de los Javitos Desamparados.

Imagino que en el fondo lo que me pasa es que por mucho que no me caiga bien Node.js, odio mucho más la programación concurrente. Tantos ConcurrentModificationException, tantos servidores fritos, tantos días dedicados a perseguir errores imposibles, tantos WTF! al revisar código concurrente hecho por otros... (como ya sabemos, seamos quien seamos los WTF siempre son con el código de los demás, nunca con el nuestro propio!).

Salvo necesidad perentoria o extorsión, no pienso ponerme a programar en Node.js. Pero al final este mismo modelo de ejecución es el que ha heredado el lenguaje Dart. Y como ya comenté hace tiempo, Dart me parece un grandísimo avance sobre JavaScript.

En definitiva, el modelo me parece una buena alternativa. Principalmente, porque soluciona problemas antes de que ocurran. Y mientras se tenga un poco de cuidado en no poner ejecuciones con mucho procesamiento matemático o en memoria, no sé si el rendimiento será un poco mejor o un poco peor que en el caso de los threads, pero estoy seguro de que en cualquier caso será más que suficiente.

Como tantas y tantas cosas en la vida, esto también tiene un precio. Y es que aunque elimine los errores de concurrencia, programar el código con este modelo puede llegar a ser un pequeño dolor de cabeza, y requiere acostumbrarse a ello. Pero de eso ya hablaré en la segunda parte del artículo... pronto en su kiosko o ultramarinos más cercano.


3 comentarios:

  1. Vaya! Grandioso post! En serio, y mira que a mi me gusta (me sale) escribir parrafadas... Pero no has desperdiciado una sola palabra.

    A mi tampoco me gusta Node.js, y es que yo creo que últimamente con el salto de Android, HTML5, y que los navegadores cada vez implementan más características antes sólo reservadas a otras aplicaciones (Videollamadas, Localización, Almacenamiento...), la diferencia entre la web y el ordenador está mas difusa.

    Hace años, los lenguajes de script estaban confinados a usos nicho, como eran la programación web, utilidades específicas y poco más. ¿Y porqué era? Por el rendimiento.
    En utilidades en Bash o en la web el rendimiento es algo secundario. En el caso de la web dado por el inevitable retardo de red, con lo cual las aplicaciones podían ser algo más ineficientes.

    Pero con el aumento de las potencias de proceso y la demanda de experiencias de usuario que cada vez exigen la creación de paradigmas de programación más complicados (Cita que encontraré...), la migración desde lenguajes de programación eficientes y de bajo nivel como C/C++, y un poco más arriba con Java o .NET, hacia lenguajes de script que permiten una codificación super rápida y fácil es una realidad.

    Es un poco lo del Made in China, yo más bien pondría una pegata a los proyectos que diria: Made with [Python|Ruby|Node.js| etc..] Son lenguajes que permiten un desarrollo masivo y rápido, a la altura de los siempre cambiantes gustos y demandas del público.

    Pero eso, esta moda de los lenguajes de script no significa que sean la panacea, y en el caso de Javascript su entrada en el mundo del HTML5 le ha dado una sonoridad sin precedentes, copiando su especificación en cosas como Processing.js, y otros que ya no son horas de mencionar.

    Saludos y perdón por el "alargamiento" del post ;)

    ResponderEliminar
    Respuestas
    1. Hola, Dark eye. Claro, es que pienso que precisamente por ese acercamiento el JavaScript se ha quedado pequeño, no creo que sea adecuado para aplicaciones grandes. Mis esperanzas están en el lenguaje Dart:

      http://apagayvuelveaencender.blogspot.com/2013/11/aparta-javascript-llega-dart.html

      Y oye, si no te extiendes en un blog de megaposts como este no sé dónde ibas a poder hacerlo! ;-)

      Eliminar
  2. Brillante post, muchas gracias por explicar tan detalladamente el problema.

    ResponderEliminar

cookie consent