Pensar asíncronamente en un mundo síncrono

Escrito por Ulises Gascón

Apr 24, 201714 min read
Si no has visto Matrix… ¡Deberías!

Con idea de celebrar las más de cuatro mil descargas (desde Enero) de mi libro, “JavaScript, ¡Inspírate!” (Leanpub y OpenLibra), me he decido a compartir con vosotros esta pequeña reflexión sobre los retos a los que nos enfrentamos los artesanos que trabajamos en el ecosistema de Node.js y JavaScript a la hora de lidiar con la asincronía. No nos engañemos. Desde hace un tiempo Node.js, es mainstream y la plataforma ideal para muchas startups que desarrollan su prototipo de una manera rápida y eficiente. Lo mismo se puede decir de empresas líderes en la industria que, cada vez más, migran progresivamente hacia Node.js, dejando atrás arquitecturas LAMP y similares… Lo mejor de Node.js es, a veces, lo peor a los ojos de muchos desarrolladores que piensan que la asincronía es el problema, y por eso desde hace un par de años, cuando empezó a hacerse real ECMA6, muchos basaron su día a día con Node.js en utilizar promesas para todo.

Saber más…

Entendiendo cómo funciona Node.js

No es ningún secreto que soy un enamorado de JavaScript y especialmente de Node.js…

Para entender Node.js, debemos comprender cómo funciona y qué lo hace diferente. No es solamente tener JavaScript en el lado del servidor. Es dejar de limitarse por el navegador y empezar a pensar a lo grande…

Node se define así:

Node.js® es un entorno de ejecución para JavaScript construido con el motor de JavaScript V8 de Chrome. Node.js usa un modelo de operaciones E/S sin bloqueo y orientado a eventos, que lo hace liviano y eficiente.

Y es precisamente esa “orientación a eventos” lo que lo hace tan especial. La asincronía de la que todos hablamos, entre otras cosas, es un efecto secundario y resultante de ello.

Si vemos una arquitectura convencional:

Diagrama de una arquitectura multihilo

Nos damos cuenta que estamos en un entorno familiar y sencillo donde todo ocurre cuando debe de ocurrir. El programador domina la máquina y no hay margen a la duda… Siempre sabemos lo que está sucediendo. Las tareas bloqueantes de cada petición se gestionan sin más drama. Aquí hablamos, a grandes rasgos, de arquitecturas basadas en Java y similares.

El mono-hilo (single thread), es la base de la magia que hay detrás de Node.js

Diagrama de la arquitectura de Nodejs comparada con una arquitectura multihilo

A diferencia del sistema multi-hilos, en Node.js, cuando algo sale mal y se rompe el proceso principal, todo se rompe. Por eso, en ocasiones, con el mono-hilo nos da la sensación de que estamos haciendo malabares con motosierras, como dije en el tweet.

Al hacer un código bloqueante (síncrono y pesado) logramos romper toda la magia que nos aporta Node.js ya que ralentizamos el único canal desde el que Node.js gestiona todo.

Diagrama básico del event loop de Nodejs

Al estar basado en un bucle, nuestra principal tarea como desarrolladores de Node.js es mantener ese bucle funcionando y sin procesos que lo ralenticen o que lo bloqueen.

Cuando un proceso se gestiona erróneamente puede causar que el bucle se rompa comprometiendo todo el sistema, lo que se traduce en la muerte del proceso principal. Cuando esto sucede, todo lo que estaba siendo ejecutado por Node.js deja de funcionar. Por eso existen numerosas librerías que nos ayudarán a reinicializar el proceso y monitorizarlo, como es el caso de PM2, entre otras.

El mejor desarrollador de Node.js es aquel capaz de interiorizar la arquitectura y comprender cómo debe gestionarse todo el proceso. No es una tarea fácil. Por suerte JavaScript nos facilita una serie de herramientas básicas. Por su parte, Node.js nos provee de ciertas herramientas y técnicas especiales que permiten explotar la asincronía.

Saber más…

¿Cómo gestionar la asincronía?

Creo que, hasta el momento, nadie había logrado captar la esencia de cómo gestionar una serie de pasos secuenciales asíncronos en JavaScript en tan solo ¡7 segundos! y además en vídeo.

A lo largo del vídeo podemos ver cómo pasamos de un clásico callback hell a una estructura de promesas para posteriormente solventar el mismo problema con async/Await.

A muchos os habrá resultado extraño el paso de argumentos en las promesas… Algo que resuelve con 2 segundos extra al vídeo.

Repasemos las diferencias entre las tres formas que nos muestra Wassim:

Callbacks

La idea básica de los callbacks es añadir una función como parámetro que será ejecutada cuando la función haya terminado de realizar todo lo que está pendiente.

var fs = require("fs");

fs.readFile("fichero-1.txt", 'utf-8',  function(error, contenido) {
    console.log(error ? "Error en la lectura" : "Contenido del fichero:", contenido);
});

En principio, esta estructura es ideal para gestionar un escenario que requiera la ejecución de una única función después de completarse la operación asíncrona.

Si necesitamos encadenar más operaciones asíncronas de manera secuencial, el anidamiento de callbacks acaba irremediablemente en un infierno de callbacks como el que hemos visto en el vídeo. Por supuesto, existen formas más elegantes y sencillas de resolver este problema que evitan que se dispare la complejidad ciclomática rápidamente.

Promesas

Desde ES6 disponemos de una estructura nativa para gestionar este tipo de escenario, de una forma más amistosa e intuitiva, haciendo uso de then() y catch(). En el momento de escribir estas líneas, contamos con un 87.84% de soporte y varios polyfills.

Async/Await

Con ES2017 llegamos a un nuevo nivel en la gestión de las promesas. Podemos hacer uso del controvertido Async/Await que nos permite hacer una sintaxis más azucarada, aunque para la gestión de errores tendremos que utilizar el clásico try/catch.

Más herramientas y otra forma de pensar…

Es curioso que mucha gente se olvide que existen otras formas de control de la asincronía, como los eventos, generadores «function*» y otras formas basadas en el uso de patrones (en las que no voy a entrar en este artículo ya que se haría demasiado largo).

Si tienes curiosidad sobre patrones, te invito a investigar las clases de mi curso de JavaScript Avanzado en Fictizia, en concreto la clase 15 que versa sobre (Iterator, Façade, Mediator, Mixins, Observer, Chain of Responsability, MVC).

O estos dos libros clásicos, que deberías leer si quieres ser un buen desarrollador de JavaScript:

Saber más…

El enfoque mainstream

Parece que últimamente, si no se hace alguna mención a los millennials se deja de ser hipster… así que aquí viene la mía:

Vengo observando, desde hace tiempo, un abuso sistemático en la utilización de promesas. Diría incluso que parece una fiebre desatada que suele venir acompañada de otros síntomas como la sobreingeniería o la implementación forzada de la programación orientada a objetos al estilo ES6 en nuestro querido JavaScript.

No me entendáis mal… no es un problema trabajar con POO, ni con promesas… el problema está en cegarnos, no comprender y no profundizar en las cosas más básicas.

JavaScript es mucho más que caos y destrucción. Para mí, es la libertad de JavaScript lo que realmente hace a este lenguaje tan especial. El no tener que casarnos con un paradigma específico, sino crear soluciones a medida de cada problema que se nos presenta.

Es un lenguaje que nos permite exprimir la creatividad del programador hasta límites insospechados. Pero con esta libertad viene una gran responsabilidad. La de asumir con humildad que el aprendizaje de JavaScript es inmenso, que en ocasiones puede resultar agotador y que nunca jamás se llega a dominar del todo.

Pero, como decía en mi último post, existe un abismo que nos separa a los artesanos del código de los mercenarios. El mercenario siempre busca la solución universal, el camino fácil, lo que es tendencia… mientras que el artesano se queda pensando si mañana será un mejor artesano de lo que es hoy.

Esta actitud personal hará que tomes un camino populista hacía frameworks que nacen y mueren, o que te centres en aprender las bases que sustentan todo este ecosistema. Son muchos los conceptos y temas a investigar, pero a medida que aprendas no solo serás mejor programador de JavaScript, también de todos los lenguajes y frameworks que utilices en el futuro.

No existe cosa más decepcionante que un Senior JavaScript Developer que no ve la conexión entre los generadores de ES6 con el patrón iterador, o la conexión entre el patrón observador y el emisor de eventos de Node.js (y así un largo etcétera) pero que está convencido que los frameworks, POO con ES6 y las maravillas varias de [ponga aquí moderneces diversas] son el único camino.

Con esto no quiero decir que las librerías y los frameworks son diabólicos o instrumentos del mal para corromper nuestro muy fragmentado y sensible ecosistema. Es más, yo mismo participo en el desarrollo de librerías y frameworks, pero me gustaría pensar que los artesanos somos mucho más que las herramientas que utilizamos y no unos simples fanboys.

Saber más…

No se trata de hacer asíncrono lo síncrono. Se trata de pensar asíncronamente.

Me gustaría cerrar este post con un sencillo ejemplo que ilustra como, en ocasiones, no exprimimos todo el potencial que los eventos pueden ofrecernos.

Si no has visto Matrix… ¡Deberías!

Veamos el siguiente ejemplo de código:

Nota: Al igual que hago en mis clases, he utilizado ES5.1 y nomenclatura en español para hacer más fácil su comprensión por programadores noveles.

En este ejemplo intentamos resolver que la información de cada fichero esté disponible cuando Node.js haya terminado de leerlos (por separado), y además saber cuándo se han terminado de leer todos.

var fs = require("fs");

function leerArchivo(nombre) {
    return new Promise(function(resolver, rechazar) {
        console.log("Empezando la lectura de ", nombre);
        fs.readFile(nombre, "utf-8", function(error, contenido) {
            if (error) {
                console.log("Error en la lectura");
                return rechazar(error);
            }
            console.log("Lectura finalizada en ", nombre);
            resolver({
                "nombre": nombre,
                "longitud": contenido.length
            });
        });
    });
}

Promise.all([
    leerArchivo("./fichero-1.txt"),
    leerArchivo("./fichero-2.txt"),
    leerArchivo("./fichero-3.txt")
]).then(function(respuestas) {
    console.log(respuestas[0].nombre + " tiene " + respuestas[0].longitud + " caracteres");
    console.log(respuestas[1].nombre + " tiene " + respuestas[1].longitud + " caracteres");
    console.log(respuestas[2].nombre + " tiene " + respuestas[2].longitud + " caracteres");
    console.log("¿Tenemos todas las respuestas?", respuestas.length === 3);
}).catch(function(err) {
    console.log("No tuvimos éxito!");
    console.log("Err:", err);
});

/* ---- Console output ----
Empezando la lectura de  ./fichero-1.txt
Empezando la lectura de  ./fichero-2.txt
Empezando la lectura de  ./fichero-3.txt
Lectura finalizada en  ./fichero-2.txt
Lectura finalizada en  ./fichero-3.txt
Lectura finalizada en  ./fichero-1.txt
./fichero-1.txt tiene 8 caracteres
./fichero-2.txt tiene 111 caracteres
./fichero-3.txt tiene 15 caracteres
¿Tenemos todas las respuestas? true
*/

Aunque sutil, en este fragmento de código observaréis que hay un cuello de botella que hará que tu aplicación sea lenta e ineficiente.

Promise.all (línea 20) será capaz de lanzar todas las promesas de forma paralela, pero será tan rápido como su promesa más lenta, lo que hace que tengamos que esperar a que toda la información de todos los archivos se haya procesado, aunque el contenido de ciertos ficheros ya vaya estando disponible durante el proceso. Debido a esto, nos podemos encontrar con la situación de que, aunque algún fichero ocupe solo unos pocos kilobytes, si uno de ellos pesa varios gigas… habrá que esperar a que ese archivo enorme también termine de procesarse para poder gestionar cualquiera de ellos.

El problema, por tanto, es que estamos haciendo una programación síncrona encubierta. Estamos pagando nuestra falta de confianza en Node.js y en la asincronía.

Realmente no debemos esperar a que todos los ficheros se hayan procesado para empezar a trabajar con ellos individualmente. Si utilizamos eventos podemos programar pensando en diversos escenarios.

var fs = require("fs");
var events = require('events');

var ee = events.EventEmitter;
var emisorEventos = new ee();

function leerArchivo(nombre) {
    console.log("Empezando la lectura de ", nombre);
    fs.readFile(nombre, "utf-8", function(error, contenido) {
        if (error) {
            console.log("Error en la lectura");
            return emisorEventos.emit('fichero:error', error);
        }
        console.log("Lectura finalizada en ", nombre);
        emisorEventos.emit('fichero:leido', {
            "nombre": nombre,
            "longitud": contenido.length
        });
    });
}

var ficherosLeidos = 0;
emisorEventos.on('fichero:leido', function(fichero) {
    console.log(fichero.nombre + " tiene " + fichero.longitud + " caracteres");
    ficherosLeidos++;
    if (ficherosLeidos === 3) {
        emisorEventos.emit('fichero:terminado');
    }
});

emisorEventos.on('fichero:terminado', function() {
    console.log("¿Tenemos todas las respuestas?", ficherosLeidos === 3);
});
emisorEventos.on('fichero:error', function(err) {
    console.log("No tuvimos éxito!");
    console.log("Err:", err);
});

leerArchivo("./fichero-1.txt");
leerArchivo("./fichero-2.txt");
leerArchivo("./fichero-3.txt");

/* ---- Console output ----
Empezando la lectura de  ./fichero-1.txt
Empezando la lectura de  ./fichero-2.txt
Empezando la lectura de  ./fichero-3.txt
Lectura finalizada en  ./fichero-3.txt
./fichero-3.txt tiene 15 caracteres
Lectura finalizada en  ./fichero-1.txt
./fichero-1.txt tiene 8 caracteres
Lectura finalizada en  ./fichero-2.txt
./fichero-2.txt tiene 111 caracteres
¿Tenemos todas las respuestas? true
*/

Como puedes ver, ahora tenemos dos escenarios bien diferenciados. Uno cuando un archivo se ha leído y su contenido esta disponible y otro cuando todos los ficheros ya han sido leídos.

Por tanto, dejamos que Node.js gestione sus propios procesos, aprovechamos el tiempo y no tenemos que esperar a que todos los gigas del último fichero hayan sido procesados para continuar trabajando con el resto de ficheros de forma individual.

Como te habrás dado cuenta, las arquitecturas basadas en eventos son pura dinamita, potentes e increíblemente rápidas, pero pueden fácilmente hundirte la aplicación si no llevas un control estricto.

Este cambio de paradigma requiere de tiempo y mucha práctica para llegar a dominarlo y existen varias maneras de ejecutar su enfoque. Cada una de ellas tiene fortalezas y debilidades que como buenos artesanos debemos conocer para elegir la mejor herramienta en cada escenario.

Una manera particularmente divertida e interesante de gestionar ciertos problemas en Node.js es usar estos eventos de tal forma que vas configurando tu aplicación en función de los escenarios que pueden darse como, por ejemplo, cuando llegan nuevos datos de otro servidor o cuando se completa cierto tipo de tarea.

En el fondo llevamos mucho tiempo utilizando este tipo de enfoque. Cosas como los eventos del front o websockets nos ayudan a entender esta mecánica, que si no la conoces en profundidad puede parecer pura magia cuando se aplica directamente sobre estructuras de datos.

Si descompones, analizas y asimilas cómo se hace esta magia, podrás contar con un recurso tan útil como la programación orientada a eventos, es decir, el pensar en escenarios.

No es una decisión fácil de tomar. Aunque no lo vayas a usar en producción, merece la pena seguir el sendero a cambio de tener este conocimiento. ¿Tal vez puedas enfocar a eventos, en vez de a promesas, tu próxima librería?

En resumen

The three great virtues of a programmer: laziness, impatience, and hubris. Larry Wall

Un artesano no debería definirse por sus herramientas, sino por su hacktitud y su capacidad para hacer magia con los recursos que tiene disponibles.

Los frameworks, librerías, avances en el lenguaje… son ¡solo eso! Una serie de nuevas herramientas que puedes probar y adoptar. Incluso pueden remplazar herramientas más arcaicas, pero al final no existe una solución universal a la asincronía, así como no lo existe para cada problema al que nos enfrentamos.

La belleza de la programación reside en superarte a ti mismo cada día, resolviendo problemas de una manera más optima y elegante. No rompas la magia usando siempre la herramienta más versátil de tu set solo porque te sea la más rápida de escribir.

Seguir dogmas sin plantearte si existe una manera mejor de hacer las cosas, aunque sea difícil, solo te acerca más a ser un mercenario del código.

Imágenes | Strongloop, Joe Stanco, Twitter y propias