El bucle de eventos (event loop) por Juanda


Publicado el sáb 24 marzo 2018 por Juanda
Actualizado el sáb 03 noviembre 2018 por Juanda
Categoría: desarrollo

Etiquetas: javascript v8 web multiplataforma www aplicacion software


Al hablar de Node.js hemos introducido un curioso componente denominado el bucle de eventos que constituye un elemento básico en la plataforma. Sin embargo hemos esperado hasta este momento para hablar de él por que no es algo exclusivo de Node.js. También el browser tiene su propio bucle de eventos que, aunque con distinta implementación, básicamente funciona igual que el de Node.js.

Para entender qué es y para qué sirve el bucle de evento necesitamos ponernos en situación con un poquito de teoría sobre la ejecución de programas y las llamadas al sistema bloqueantes o síncronas y no bloqueantes o asíncronas.

Las instrucciones de un programa son ejecutadas secuencialmente, de manera que hay que esperar a que una instrucción finalice para que comience la siguiente. Cuando llamamos a funciones, de la propia aplicación o de APIs externas (llamadas al sistema, por ejemplo), hemos de esperar hasta que finalice su ejecución y se devuelva el control al elemento que la llamó. Una llamada a una función significa colocar dicha función en la pila de llamadas del proceso. Cuando la función termina su ejecución y devuelve el control al elemento que la llamó, se elimina de la pila.

Si la función que se llama requiere mucho tiempo en resolverse, ya sea porque realice cálculos pesados y utilice mucho tiempo de CPU o por que se trate de una función de entrada/salida, las cuales suelen ser bastante lentas, el hilo donde se ejecuta quedará bloqueado durante todo ese tiempo. Dependiendo del tipo de aplicación esto puede ser admisible o no.

Pensemos en una aplicación de escritorio que obtiene datos mediante peticiones a un servicio web. No sabemos cuánto tiempo tardará en resolverse las peticiones que hagamos, en ocasiones puede que sea inmediato, pero otras veces puede tardar varios segundos. Hasta puede que dé un fallo por timeout y tengamos que esperar unos minutos.

Pensemos ahora en un servidor web que lanza frecuentemente peticiones SQL a una base de datos. Tampoco podemos saber si se resolverán rápidamente o tardarán un tiempo considerable. Si durante el tiempo que está consultando la base de datos no puede atender a ninguna petición más, mal asunto.

Ninguna de las aplicaciones anteriores estaría bien diseñadas si su fluidez dependiera de los tiempos invertidos en las llamadas de entrada salida. Es decir, si las llamadas de entrada/salida se ejecutaran en el hilo principal de la aplicación.

La solución, obviamente, pasa por la creación de hilos adicionales que traten dichas llamadas de entrada/salida. Existen dos estrategias fundamentales para la creación de dichos hilos adicionales.

La primera, llamémosla “clásica”, consiste en crear nuevos hilos (o procesos) donde se ejecutarán las funciones “lentas” de entrada/salida, liberando al hilo principal que puede continuar con su tarea; recibir nuevas peticiones en el caso del servidor o atender las órdenes del usuario en el caso de la aplicación de escritorio. El resultado de las operaciones de entrada/salida, una vez resueltas, se pasarán al hilo principal mediante algún mecanismo de comunicación entre hilos. Teniendo en cuenta que los hilos de un proceso comparten la memoria no es difícil colocar el resultado en el hilo principal.

Esta estrategia ha sido ampliamente usada en el diseño de servidores. El hilo principal del proceso escucha en un socket y cuando entra una nueva petición crea un nuevo hilo para procesarla y vuelve a escuchar en el socket para repetir el proceso con las sucesivas peticiones. El problema de este enfoque es la escalabilidad, es decir, cuando el número de peticiones (casi) simultáneas aumenta, también lo hace el número de hilos secundarios, pudiendo llegar a colapsar el servidor por sobrecarga de la CPU o por agotamiento de la memoria. Para evitar este problema los servidores diseñados con esta estrategia suelen limitar el máximo número de hilos secundarios que el servidor puede crear. También suelen crear, al arrancar el servidor, un pool de hilos (o procesos) para eliminar el tiempo que se tarda en crear un hilo cuando llega una nueva petición.

La segunda solución no implica la creación de un nuevo hilo por cada nueva llamada a una operación de entrada/salida. Es una estrategia utilizada desde hace mucho tiempo en el diseño de interfaces gráficas. Se trata de registrar handlers o funciones callbacks asociados a eventos que se producen en la interfaz gráfica. Dicho registro se lleva a cabo en un hilo distinto al principal. Cuando se produce un evento dicho hilo notifica al hilo principal que ejecute la función de callback asociada al evento.

Esta idea se ha extendido a la ejecución de llamadas al sistema de entrada/salida gracias a la incorporación en los sistemas operativos modernos de llamadas al sistema no bloqueantes o asíncronas. Estas llamadas devuelven el control inmediatamente sin necesidad de haber completado la operación de lectura o escritura en el recurso. Cuando finalizan generan un evento que notifica al hilo que las llamó para que las procese. Como vemos el mecanismo es similar al de los eventos generados por los widgets de una interfaz gráfica, solo que la generación de eventos la realiza el sistema operativo cuando la operación ha concluido.

Y con este fondo teórico ya podemos explicar el funcionamiento del bucle de eventos tanto en Node.js como en Chromium. El siguiente gráfico proporciona un modelo que sirve para explicar el funcionamiento del bucle de eventos en ambas tecnologías:

Cuando se lleva a cabo la ejecución de la aplicación, cada vez que se invoca una función se añade a la pila de llamadas. Cuando las funciones terminan y devuelven el control, se sacan de la pila. Si todas las llamadas fuesen bloqueantes (síncronas), llega un momento en que la pila se vacía completamente que se corresponderá con el fin del programa.

Pero tanto en Node.js como en Chrome desde cualquiera de las funciones de la pila puede ocurrir que se realicen llamadas funciones no bloqueantes (asíncronas) de la API (cuadro superior - izquierdo) que responden al siguiente patrón:

1
2
3
4
5
6
7
 function funcionX(){
     // código síncrono

     funcAsincrona(callback(data), args);

     // código síncrono
 }

Lo normal es que la función asíncrona funcAsícrona() sea mucho más lenta que la propia función funcionX(), y como es no bloqueante el código sigue ejecutándose finalizando la función funcionX() antes que la función funcAsincrona(). La función funcionX() desaparece de la pila y se ejecuta la que queda en su cima. Por otro lado la función asíncrona se está ejecutando en otro hilo y cuando finaliza se añade su función callback asociada a la cola de eventos, pasándole como argumento el resultado de la función asíncrona.

Mientras tanto, el bucle de eventos vigila por un lado que no haya más funciones en la pila de llamadas y por otro la existencia de callbacks con eventos resueltos en la cola de eventos. Cuando la pila esté vacía, el bucle de eventos colocará en dicha pila el primer callback de la cola de eventos. Por supuesto, las funciones de callback, que no dejan de ser funciones de Javascript, también pueden hacer llamadas asíncronas a la API.

El proceso se repite indefinidamente hasta que no haya más elementos (registros de eventos de widget o funciones asíncronas en ejecución) en el hilo donde residen las API’s. Es decir hasta que no haya más posibilidades de generación de eventos. Con lo cual puede ocurrir que el proceso se repita indefinidamente y sólo finalice abortando o cerrando la aplicación. Es el caso de las aplicaciones de escritorio y los servidores.

Un experto desarrollador Javascript llamado Philip Roberts ha construido una aplicación (Loupe: simulador del bucle de eventos, en Javascript, por supuesto) que emula el comportamiento del bucle de eventos tal y como lo hemos explicado aquí. Puedes introducir cualquier código Javascript con llamadas asíncronas y reproducir a “cámara lenta” las idas y venidas de las funciones, funciones asíncronas y callbacks por la pila, la API y la cola de eventos. Un buen rato jugando con ella y un par de lecturas de todo lo anterior y tendrás asimilado el funcionamiento esencial del bucle de eventos en Node.js y Chrome.

Terminaremos el artículo respondiendo a la siguiente pregunta: ¿Realmente es necesario conocer el funcionamiento del bucle de eventos para programar en Javascript?. Si deseas construir aplicaciones que vayan más allá de enriquecer el HTML con efectos manipulando el DOM y que sean reactivas, es decir, que no se queden pilladas cuando se utilizan, necesariamente tendrás que utilizar las capacidades asíncronas de Javascript. Es en ese momento en el que la mayoría de programadores que se acercan a Javascript terminan perdidos con el comportamiento del código. Y es ahí donde muchos de ellos se rinden y lo abandonan o, si no tienen más remedio que continuar con él, lo califican de una soberbia porquería. Y es que no hay nada más sencillo y auto reconfortante que confundir la falta de conocimiento con la calidad de un producto software. Si uno se introduce un poco más en las entrañas de la bestia y conoce estos detalles que acabamos de contar, el camino para comprender los vericuetos de la programación asíncrona con Javascript se facilita muchísimo.

Pongamos varios ejemplos de “cosas raras” que pasan con la programación asíncrona con Javascript. Y lo pongo entre comillas porque solo son raras si no se conoce la lógica que subyace.

Ejemplo 1. ¿Cual es la salida del siguiente trozo de código?

1
2
3
4
5
 console.log("Hola");

 setTimeout(function(){
     console.log("Caracola");
 }, 2000);

console.log("Adios");

Alguien con experiencia programando pero neófito en Javascript seguramente diría que la salida es:

1
2
3
 Hola
 Caracola  // después de 2 segundos
 Adios

Sin embargo la salida correcta es:

1
2
3
 Hola
 Adios
 Caracola // después de 2 segundos

Ya que setTimeout() es una función asíncrona y no bloquea el flujo de código.

Ahora otro ejemplo para alguien que ya se ha iniciado en Javascript pero aún no tiene una intuición clara pues no conoce la historia del bucle de eventos.

Ejemplo2. ¿Cual es la salida del siguiente trozo de código?

1
2
3
4
5
6
7
 console.log("Hola");

 setTimeout(function(){
 console.log("Caracola");
 }, 0);

 console.log("Adios");

Ahora podría decir que, ya que el delay que indicamos es 0, debería ser la salida:

1
2
3
 Hola
 Caracola
 Adios

Sin embargo podemos comprobar que de nuevo la salida es:

1
2
3
 Hola
 Adios
 Caracola

Podríamos objetar que dado que la función setTimeout() debe inicializar un temporizador que provoca el retraso suficiente para que se pase a la siguiente línea de código y por eso se resuelve después. Sin embargo veamos qué pasa con el siguiente trozo de código:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
 function pause(milliseconds) {
     var dt = new Date();
     while ((new Date()) - dt <= milliseconds) { }
 }

 console.log("Hola");

 setTimeout(function(){
 console.log("Caracola");
 }, 0);

 pause(2000);
 console.log("Adios");

Hacemos una pausa síncrona de 2 segundos mediante un bucle con la función pause(). Por tanto ahora sí que debería aparecer Caracola antes que Adios. Sin embargo ejecutamos el código y vemos que no es así. De nuevo la salida es:

1
2
3
 Hola
 Adios // después de 2 segundos
 Caracola

¿Cómo puede ser esto posible si el temporizador de la función setTimeout() está fijado a 0 segundos (¿inmediato?) y hacemos que pasen 2 segundos hasta ejecutar la última salida?

Solo conociendo los “misterios” del bucle de evento podemos contestar con fundamento a esta cuestión. No la vamos a resolver aquí. Dejaremos que el astuto lector encuentre la solución por sí mismo con ayuda del texto.

Son sólo 3 ejemplos de “cosas raras” que pasan cuando se cruza la frontera de la asincronía con Javascript, pero son representan bastante bien la frustación que puede ocasionar en el programador novel en Javascript cuando no conoce suficientemente la máquina que ejecuta su código.

Por tanto. ¿Realmente es necesario conocer el funcionamiento del bucle de eventos para programar en Javascript?.

Respuesta: SI . Además comenzará a molarte de verdad a partir de ese momento.