Asincronía y el EventLoop

Me gustaría hacer un repaso al tema de la asincronía en Javascript porque me llama la atención que pese a tener casi 20 años es un tema que sigue madurando y he visto surgir buenas ideas recientemente.

El event loop

Primero lo primero, de donde sale la asincronía. Javascript es un lenguaje cuya ejecución se basa en lo que se llama event loop (bucle de eventos). El event loop es una cola donde se van añadiendo los bloques de código que quieren ejecutarse, por ejemplo: cuando el navegador está renderizando el HTML de una página y se encuentra un tag <script> el contenido de ese elemento se añade al event loop para que sea ejecutado tan pronto como sea posible.

Lo mismo ocurre cuando la página ya está cargada y el usuario hace click. Si tenemos una función listener (también llamado callback) escuchando los eventos click de un objeto esa función se añade a la cola del event loop para que el sistema lo ejecute tan pronto como sea posible.

De esta forma todos los bloques de código que se ejecutan en javascript han sido bloques de código que entraron a la cola del event loop y cuando llegó su turno fueron ejecutados. Podemos entenderlo más fácilmente si implementamos un falso event loop en Javascript:

var eventLoop = {
  _queue: [],

  add: function (fn) {
    // añadimos la función a la cola
    this._queue.push(fn);

    // si está desocupado ejecutar la función
    if (!this.running)
      this.executeNext();
  },

  executeNext: function () {
    if (this.running || this._queue.length === 0)
      return;

    this.running = true;
    var block = this._queue.shift();
    block();
    this.running = false;

    this.executeNext();
  }
};

Pruébame

Esto significa que mientras un bloque esté ejecutándose ningún otro bloque de código puede estar ejecutándose a la vez. Pero si un proceso tarda mucho (como leer el disco, comunicarse con el servidor, esperar una determinada cantidad de tiempo…) lo que hacemos es pasarles una función que será añadida al event loop cuando el proceso acabe.

Cada “bloque” que el event loop ejecuta se llama “un tick del event loop”, de ahí el nombre de la función process.nextTick de NodeJS.

Una forma sencilla de controlar el event loop es mediante setTimeout, setInterval y setImmediate, podemos crear funciones similares que hagan la misma funcionalidad (simplificada) pero para nuestro eventLoop:

function mySetImmediate(fn) {
  eventLoop.add(fn);
}

function mySetTimeout(fn, milliseconds) {
  // la unica forma de dejar pasar el tiempo
  // es mediante el VERDADERO setTimeout ;)
  setTimeout(function() {
    mySetImmediate(fn);
  }, milliseconds)
}

function mySetInterval(fn, milliseconds) {
  function execute() {
    fn();
    mySetTimeout(execute, milliseconds);
  }

  mySetTimeout(execute, milliseconds);
}

Como se puede ver tanto setTimeout como setInterval esperan la cantidad de milisegundos definida y entonces añaden el bloque al event loop, si el event loop está ocupado en ese momento puede tardar un poco más de lo esperado en ejecutarse nuestra función.

Entiendiendo esto es más fácil entender porqué Javascript funciona de la forma que funciona.

var a = 1;
setTimeout(function() {
  // esta función es asíncrona porque el tick que llama a setTimeout
  // tiene que acabar antes que esta función sea invocada.
  a = 2;
}, 100);

En el caso de javascript para el navegador además nos encontramos con que el event loop es compartido por Javascript y el motor de renderizado del navegador. Como el event loop solo puede ejecutar un bloque por vez resulta que si estamos ejecutando Javascript el navegador no puede renderizar la página y vice versa, si la página tarda mucho en renderizarse retrasará la ejecución del Javascript. Esto es así porque desde Javascript podemos modificar el DOM y si el navegador intenta renderizar la página mientras nosotros la modificamos tendríamos otro tipo de problemas peores.

Pero es importante tener esto en cuenta ya que un proceso Javascript que tarde demasiado “congelará” la página, no funcionarán los clicks, scroll, ni siquiera los :hover.

Un poco de historia

Cuando Javascript fue desarrollado la comunicación asíncrona con el DOM se solucionó mediante eventos y tiene todo el sentido del mundo. Quieres saber cuando el usuario hace click en un elemento? Registra el evento y el navegador te avisará, quieres saber cuando el usuario haga scroll? registra el evento!

El problema empezó cuando empezamos a usar eventos para cosas no tan claras, como eventos puntuales que solo se disparaban una vez:

window.addEventListener('load', function() { ... });
someAjax().onready = function() { ... };

Incluso para controlar errores

xhr.onerror = function() { ... };
document.querySelector("script").onerror = function() { ... };

Hasta para controlar un progreso (en APIs modernas incluso)

var reader = new FileReader();
reader.onprogress = function() { ... };

Pero al no estar acostumbrados a trabajar con asincronía de esta forma no fuimos capaces de ver que estabamos usando una herramienta para todo, como dicen por ahí “para un hombre con un martillo todo es un clavo”.

En node decidieron adaptar el patrón “Continuous Passing Style”, que consiste en pasar callbacks a funciones asíncronas

fs.readFile("foo", function() { ... });

Y por suerte integraron EventEmitter, que permitió crear APIs que usaran eventos sin más complejidad. Y lo que es mejor, incluyeron los streams, una forma de gestión de asincronía creada para que podamos acceder a un recurso por partes. Con el tiempo llegaron los promises que fue lo primero que me hizo plantearme si estábamos enfocando la asincronía de forma coherente.

En total he llegado a resumir los distintos tipos de asincronía en dos, ambos con la característica de que necesitan gestionar también errores:

 Valor asíncrono

En el primer caso tenemos un valor asíncrono, puede ser el valor devuelto por una función asíncrona o el valor puede ser nulo en cuyo caso simplemente funcionaría para detectar cuando el proceso ha finalizado. Este caso está cubierto por los Promises.

Desde mi punto de vista se trata de una especie de meta-programación, tenemos un valor (el promise) que sustituye al valor real para que podamos seguir con nuestra ejecución síncrona.

var contentPromise = file.readContent();
return contentPromise;

 Colección asíncrona

Podemos verlo como un array asíncrono, que a medida que se le van añadiendo elementos va invocando a su callback. En este grupo metería a todo componente asíncrono que invoque a su callback más de una vez:

Una de las funcionalidades que más me gustan de Dart es que han sustituído los eventos DOM por streams, un pequeño detalle pero que es todo un cambio de concepto, los eventos DOM son una lista de tamaño indeterminado. Cada vez que el usuario hace click es como añadir ese evento al stream de eventos click de ese elemento. Al ser un stream podemos escucharlo, filtrarlo, manipularlo… de la misma forma que hacemos con una colección. Incluso hay implementaciones que tienen métodos .forEach y .filter y .map cumpliendo la misma interfaz que el resto de colecciones.

Por otro lado los streams siguen siendo útiles para su funcionalidad primera, entregarnos un contenido que vamos recibiendo por partes, es decir; en lugar de cargar un achivo de 1Gb en memoria, un stream nos lo va entregando en bloques más pequeños así podemos trabajarlos y liberar memoria. Además en casos como medidores de progresos encajan perfectamente, siendo cada actualización del progreso un elemento del stream.

 Y todo junto

Nota: el código a continuación es adaptado de otros lenguajes y no existe en Javascript

Incluso hay implementaciones muy completas que integran perfectamente los stream y los promises, podemos hacer de forma sencilla cosas como capturar el primer click en la pagina

// window.onClick instanceof Stream
var firstClick = window.onClick.first;
firstClick.then(function() { ... });

Comprobar si las primeras diez teclas han sido “flecha derecha”

var firstTenKeys = input.onKeyDown.take(10);
var keyRightTenTimes = firstTenKeys.every(function(event) {
  return event.keyCode === 39;
});
keyRightTenTimes.then(function(value) {
  if (value)
    console.log('You like left arrow! :D');
});

Capturar solo el tercer click en la página

var thirdClick = input.onKeyDown.elementAt(3);
thirdClick.then(function(event) {
  console.log('You clicked three times :)');
});

Obtener todo el contenido del archivo desde el stream, no hace falta un método especial

var stream = file.getReadStream()
var fileContent = stream.join('');
fileContent.then(function(content) { ... });

O detectar el primer evento readystatechange en que el readyState sea 4, convertirlo en promise y devolver la respuesta

return xhr.onReadyStateChange
  .filter(function(event) {
    return xhr.readyState === 4;
  })
  .first
  .then(function() {
    return xhr.responseText;
  });

Unificar varias operaciones

// readFile returns promise
var concat = new Stream([
  readFile('./header.html'),
  readFile('./content.html'),
  readFile('./footer.html'),
]);
stream.listen(function(chunk) {
  response.write(chunk);
});

Incluso cosas más complejas como detectar la primera acción del usuario en la página

// Promise.race devuelve un promise que se completará
// cuando el primer promise de la lista se complete
Promise.race([
  window.onClick.first,
  window.onKeyDown.first,
  window.onMouseMove.first,
]).then(function(event) {
  console.log('El usuario ha disparado el evento ' + event.type);
});

Y hasta capturar el evento load de window aunque ya haya pasado:

setTimeout(function() {
  window.onLoad.first(function() {
    // Me van a invocar aunque el evento
    // ya haya pasado :)
  });
}, 1000 * 60 * 60); // una hora

Resumen

La gestión de la asincronía, que siempre ha sido un caos, se simplifica de forma radical gracias a una buena combinación de Stream/Promises. Espero poder actualizar la entrada de los promises con los nuevos promises estándard de ECMAScript 6 :)

Conocen más patrones de gestión de asincronía?