Skip to content

Ejemplos de manejo de grupos de tareas asíncronas en JavaScript

Notifications You must be signed in to change notification settings

lupomontero/space-agency

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

18 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

space-agency

Este repo contiene una serie de ejemplos de ejecución síncrona vs asíncrona, así como secuencial vs concurrente en JavaScript. En particular los ejemplos están orientados a ilustrar diferentes estrategias para orquestar varias tareas asíncronas, y no una sola de forma aislada.

ℹ️ Si todavía no estás familiarizada con los conceptos básicos de asíncronía, callbacks y promesas en JavaScript, no pasa nada, pero te recomiendo comenzar por explorar los links del final, donde encontrarás otros recursos que pueden servir de introducción antes de entrar a hablar de cómo orquestar varias operaciones asíncronas en conjunto.

Índice


Introducción

Para ilustrar diferentes conceptos y estrategias usaremos el ejemplo de la librería space-agency, que esta diseñada para administradoras de agencias espaciales 🚀

En la introducción nos concentrarremos en cuatro funciones que exporta el módulo space-agency: buildRocket, fetchCrew, getFuel y bookLaunchPad. Con estas cuatro funciones asumimos que puedes iniciar una misión!

La librería tiene varias implementaciones y nos ofrece diferentes versiones de estas cuatro funciones: una síncrona bloqueante, una asíncrona no-bloqueante con callbacks y una asíncrona no-bloqueante con promesas. Por ahora solo mencionamos su existencia, y no entraremos en sus detalles de implementación, pero ahí está el código fuente si al final quedas con curiosidad (y aliento :speak_no_evil:).

Ejecución secuencial

El primer caso de estudio es aquel en el que queremos ejecutar una serie de tareas de forma secuencial, una detrás de la otra, siempre esperando a que la anterior se haya completado.

Código síncrono bloqueante

En nuestro rol de administradoras de la agencia espacial, nos toca dar inicio a una nueva misión, para lo cual tenemos que llevar a cabo 4 tareas: construir un cohete (buildRocket), conseguir una tripulación (fetchCrew), conseguir combustible (getFuel) y reservar espacio en la lanzadera (bookLaunchPad).

Si nuestra intención es llevar a cabo las tareas de forma secuencial, y la librería nos ofrece una versión síncrona de estas funciones, podríamos de forma naive plantear nuestro programa así:

const initMission = () => {
  const rocket = buildRocket();
  const crew = fetchCrew();
  const fuel = getFuel();
  const launchPad = bookLaunchPad();
  return { rocket, crew, fuel, launchPad };
};

La función initMission no espera ningún argumento y retorna un objeto con cuatro propiedades: rocket, crew, fuel y launchPad. Cada una de estas propiedades contiene el resultado de invocar a las funciones que nos da la librería.

En el ejemplo de arriba asumimos que estamos usando la versión síncrona de la librería, y por tanto las funciones buildRocket y compañia van a tomar el control, bloqueando el hilo de ejecución, y una vez hayan terminado de hacer lo que tengan que hacer nos retornan el resultado de la operación y nos devuelven el control - en el caso de buildRocket, esta función retorna un objeto rocket (el cohete), el cual podemos asignar a una variable en el ámbito (scope) desde donde iniciamos la tarea. Esto hace que sin mucho esfuerzo podamos tener los cuatro resultados en el mismo ámbito, lo cual necesitamos para retornar un objeto que contenga los cuatro.

Pero, ... ⚠️ esta implementación tiene un problema grave. Asumimos que las funciones de la librería van a requerir un tiempo (probablemente largo) para llevarse a cabo, y al cederle el control a estas funciones, estas van a bloquear el hilo hasta completarse, y solo después nos devuelven el control al retornar el resultado. Como JavaScripters esto es un problema serio por muchos motivos, pero principalmente 2:

  • En el navegador nuestro JavaScript comparte hilo con otras tareas como el re-flow y re-paint que se encargan de actualizar la interfaz. Esto significa que mientras esté el hilo bloqueado la pantalla se queda completamente congelada y sin input del usuario 😱
  • En Node.js, en un servidor todas las consultas (requests) se manejan desde un solo hilo, y mientras estemos bloqueando el hilo nuestro servidor no podrá atender nuevas consultas 💩

Acá puedes ver en una interfaz web cómo la ejecución de nuestro programa bloquea el hilo y no permite que se actualice la interfaz hasta que no termine.

Código asíncrono secuencial no-bloqueante

Ok, entonces veamos cómo evitamos bloquear el hilo. Los ejemplos a continuación muestran varias estrategias para ejecutar las cuatro tareas que necesitamos.

Callback Hell

Pasemos a usar la implementación de la librería basada en callbacks. Ahora, cuando aterrizamos en el mundo de JavaScript es común caer en situaciones en las cuales, a partir de lógica como la que hemos visto en el primer ejemplo de ejecución síncrona, terminamos escribiendo cosas como lo siguiente al tratar de amoldarnos a la interfaz de callbacks:

const initMission = (cb) => {
  buildRocket((err, rocket) => {
    if (err) {
      return cb(err);
    }
    fetchCrew((err, crew) => {
      if (err) {
        return cb(err);
      }
      getFuel((err, fuel) => {
        if (err) {
          return cb(err);
        }
        bookLaunchPad((err, launchPad) => {
          if (err) {
            return cb(err);
          }
          cb(null, { rocket, crew, fuel, launchPad });
        });
      });
    });
  });
};

En esta nueva implementación recibimos un argumento (un callback - cb), que usaremos para comunicar el resultado de la operación, en vez de retornar un valor. Si nos fijamos, esta implementación también produce como resultado un objeto igualito al ejemplo anterior, pero esta vez este valor se pasará como argumento al callback que recibimos originalmente (cb) y no como valor de retorno de la función initMission, ya que a la hora de retornar en la función initMission todavía no tenemos el resultado. De hecho la función initMission implicitamente retorna undefined (ya que no hay una sentencia return), y desde el punto de vista del invocador, ignoramos completamente el valor de retorno (fíjate que no asignamos el valor de retorno de las funciones de la librería, si no que la comunicación con la operación se hace a través del callback). En este caso cedemos el control a la función a la hora de invocarla, y esta nos devuelve el control inmediatamente al retornar, pero el resultado no lo tendremos hasta más adelante (en otro ciclo del bucle de eventos), cuando volvamos a tomar control en el futuro cuando se invoque el callback.

En este ejemplo todavía estamos ejecutando las tareas de forma secuencial, pero por lo menos ya no estamos bloquenado el hilo. Pero ... probablemente ya te diste cuenta de que este ejemplo se llama callback hell, y como puedes ver, al continuar la ejecución del programa a través de los callbacks, rápidamente nuestro código se va anidando, con un montón de repetición en el manejo de errores, y poco a poco se hace cada vez más difícil de entender. Para ver un ejemplo más obvio de cómo aumenta la complejidad de nuestro código, acá puedes ver una misión de ejemplo usando este enfoque naive de callbacks produciendo un callback hell horroroso :fire:

Acá puedes ver en una interfaz web cómo evitamos bloquear el hilo con callbacks. Fíjate que, ahora que ya no bloqueamos el hilo, el navegador ahora sí tiene la oportunidad de actualizar el DOM durante la ejecución. Eso sí, al seguir siendo secuencial, toavía demora aprox. 20 segundos en completarse.

Promesas encadenadas acumulando resultados

Existen varias estrategias para mitigar el callback hell, y si nuestra intención es ejecutar tareas asíncronas en serie, de forma secuencial, podemos valernos de que las promesas se pueden encadenar a través de su .then.

Si no nos interesara el valor al que resuelven las promesas, podríamos imaginar algo así:

const initMission = () => (
  buildRocket()
    .then(fetchCrew)
    .then(getFuel)
    .then(bookLaunchPad)
    .then(() => {
      // Llegado a este punto sabemos que se han resuelto las cuatro promesas.
    });
);

Pero nuestra función initMission debe retornar un objeto con los valores a los que han resuelto las promesas. Así que necesitamos tener los cuatro valores en el mismo ámbito para poder combinarlos en un objeto. Para hacer más visble el problema que se presenta, hagamos explícito que las promesas resuelven a valores que sí nos interesan dando nombre a los argumentos que reciben los callbacks que les pasamos a los .then:

const initMission = () => (
  buildRocket()
    .then(rocket => fetchCrew())
    .then(crew => getFuel())
    .then(fuel => bookLaunchPad())
    .then((launchPad) => {
      // Acá tenemos acceso a `launchPad`, ...
      // ... pero no a `rocket`, ni `crew`, ni `fuel` :-(
    });
);

Para conseguir juntar los valores de rocket, crew, fuel y launchPad, podemos seguir varias estrategias. La primera que les quiero presentar es esta que hemos llamado promesas encadenadas acumulando resultados.

const initMission = () => (
  buildRocket()
    .then(rocket => fetchCrew().then(crew => ({ rocket, crew })))
    .then(results => getFuel().then(fuel => ({ ...results, fuel })))
    .then(results => bookLaunchPad().then(launchPad => ({ ...results, launchPad })));
);

Como vemos en el snippet de arriba, ahora interceptamos la resolución cada promesa individual (ver los .then anidados), para agregar el resultado de la promesa actual a un objeto que contiene las respuestas de las promesas anteriores. De esta manera logramos juntar todos estos valores en un mismo ámbito, pero la implementación resulta menos elegante...

Promesas encadenadas compartiendo estado

Alternativamente a las promesas encadenadas acumulando resultados, podemos también usar una variable compartida para ir acumulando los resultados, consiguiendo un efecto parecido:

const initMission = () => {
  const results = {};

  return buildRocket()
    .then((rocket) => {
      results.rocket = rocket;
      return fetchCrew();
    })
    .then((crew) => {
      results.crew = crew;
      return getFuel();
    })
    .then((fuel) => {
      results.fuel = fuel;
      return bookLaunchPad();
    })
    .then((launchPad) => {
      results.launchPad = launchPad;
    })
    .then(() => {
      // Ya tenemos todos los resultados!
      return results;
    });
};

Esta última versión es un poco más verbose y hace uso de estado compartido y mutable a través de la variable results.

async/await

Como tercera alternativa a una implementación secuencial no-bloqueante con promesas, podemos en este caso hacer uso de async/await, que para este caso concreto (ejecución secuencial) nos permite expresar nuestra función initMission de una forma muy parecida al ejemplo original bloqueante, donde el orden de ejecución corresponde al orden del código, pero con el beneficio de no bloquear el hilo.

const initMission = async () => {
  const rocket = await buildRocket();
  const crew = await fetchCrew();
  const fuel = await getFuel();
  const launchPad = await bookLaunchPad();
  return { rocket, crew, fuel, launchPad };
};

Como referencia de uso de async/await, en las pruebas unitarias de la versión que usa promesas de nuestra librería space-agency puedes ver como se ha usado en los tests, quedando iguales que los tests de la versión síncrona, pero con la adición de las palabras claves async y await.

Ejecución concurrente (asíncrona y no-bloqueante)

Después de varias misiones, vamos aprendiendo sobre nuestro rol de administradoras de una agencia espacial, y nos damos cuenta de que las cuatro tareas con las que preparamos una misión (buildRocket, fetchCrew, getFuel y bookLaunchPad), no tienen ninguna dependencia entre ellas. Esto significa que ninguna de estas funciones necesita como input un valor producido por alguna de las otras. Básicamente no necesitamos esperar a que una tarea termine para comenzar con la siguiente.

Hasta este punto hemos visto estrategias para organizar tareas de forma secuencial. Ahora veamos cómo podríamos ejecutar estas tareas de forma concurrente. Con esto queremos decir que en vez de ejecutar las tareas una detrás de otra, siempre esperando a que termine la anterior para comenzar la siguiente, ahora queremos inciar todas a la vez, y esperar a que todas completen para producir un resultado. De esta forma las tareas comparten el tiempo de espera y reducimos el tiempo total que lleva completar el conjunto de tareas.

Callbacks refinados

Hasta la llegada de las promesas a JavaScript, la orquestación de tareas asíncronas era un pain que el lenguaje en sí no terminaba de solucionar, y tocaba apoyarse de librerías como async de Caolan McMahon, que durante mucho tiempo fue la librería más descargada en npm, para contar con abstracciones que nos permirieran orquestar operaciones asíncronas basadas en callbacks.

La idea, en la ejecución concurrente de funciones asíncronas con callbacks, es iniciar todas las tareas dentro del mismo ciclo del bucle de eventos (event loop).

const initMission = (cb) => {
  const results = {};

  buildRocket((err, rocket) => {
    if (err) {
      return cb(err);
    }
    results.rocket = rocket;
    if (result.crew) {
      // Si además de rocket también tenemos `crew`, significa que ya terminamos!
      cb(null, results);
    }
  });

  fetchCrew((err, crew) => {
    if (err) {
      return cb(err);
    }
    results.crew = crew;
    if (results.rocket) {
      // Si además de crew también tenemos `rocket`, significa que ya terminamos!
      cb(null, results);
    }
  });
};

En el ejemplo de arriba, por brevedad hemos omitido dos tareas, pero ilustra la idea de iniciar todas las tareas a la vez. Eso sí, terminamos con bastante repetición y maquinaria para determinar cuándo se han terminado las tareas, además de necesitar un mecanismo para compartir o acumular un resultado final. Por eso la necesidad de librerías o herramientas para este tipo de estrategias.

Como ejemplo, acá pueden ver un par de helpers (concurrent y series) que usa la impementación modelo del ejemplo de callbacks para abstraer ejecución en serie y concurrente. Usando estas utilidades, podríamos llegar a expresar la intención de ejecutar una conjunto de tareas de forma concurrente con algo así:

const initMission = cb => concurrent({
  rocket: buildRocket,
  crew: fetchCrew,
  fuel: getFuel,
  launchPad: bookLaunchPad,
}, cb);

Acá puedes ver en una interfaz web cómo evitamos bloquear el hilo con callbacks, y hacemos las tareas de forma concurrente, como en el ejemplo de arriba. Fíjate que ahora que hacemos las tareas de initMission de forma concurrente, el tiempo total de la misión ha disminuido considerablemente (de veintitantos segundos a 11!).

Promise.all()

En la sección anterior (Callbacks refinados), comenzamos por decir que hasta la llegada de las promesas..., y las promesas ya llegaron hace rato! 😉

Y con la llegada de las promesas, ya no necesitaríamos ninguna utilidad externa (o que implementemos nosotros), si no que podemos usar la API de promesas para solucionar el mismo problema de una forma más elegante. En particular, el constructor Promise tiene un método estático que se llama Promise.all, que nos permite justamente darle un arreglo de promesas como input, y nos devuelve una nueva promesa que se resolverá cuando resuelvan todas las promesas que recibió como input, produciendo como resultado un arreglo con un elemento por cada promesa en el arreglo del input.

const initMission = () => Promise.all([
  buildRocket(),
  fetchCrew(),
  getFuel(),
  bookLaunchPad(),
])
  .then(([rocket, crew, fuel, launchPad]) => ({
    rocket,
    crew,
    fuel,
    launchPad
  }));

En caso de que cualquiera de las promesas recibidas por Promise.all sea rechazada, la promesa que las engloba (la retornada por Promise.all) será también rechazada.

Acá puedes ver en una interfaz web cómo se comporta esta implementación que hace uso de promesas y concurrencia con Promise.all como en el ejemplo de arriba.

⚠️ Ten en cuenta que a Promise.all le pasamos promesas ya creadas. Por ejemplo, si le pasamos un arreglo con 500 promesas creadas con fetch...

// Imagina que `users` es una arreglo con 500 elementos...
const promises = users.map(user => fetch(`/users/${user.id}`));

// Promises es un arreglo con 500 promesas ya iniciadas!!
Promise.all(promises)
  .then((results) => {
    // Las 500 promesas completaron con éxito.
    // `results` es un arreglo con 500 elementos.
  })
  .catch((err) => {
    // Una de las promesas fue rechazada, pero no sabemos nada sobre las demás.
    // `err` contiene el error de la promesa rechazada.
  });

... estaríamos tratando de hacer 500 consultas de red de forma concurrente, lo cual nos puede traer otro tipo de problemas, como exceder límites impuestos en APIs o simplemente sobrecargar la red o servicio encargado de manejar estas consultas. Si tienes curiosidad sobre cómo manejar también el nivel de concurrencia, throttling, así como manejo de errores más granular (sin reventar en caso de que una promesa falle), te invito a explorar la librería porch, que justamente se centra en estos issues. Disclaimer: porch no es una librería muy popular, pero la conozco como autor, así que un poco de publicherry 🍒 🙈

Implementaciones de ejemplo

Síncrona bloqueante

Callback hell

Callbacks refinados

Promesas

Links

About

Ejemplos de manejo de grupos de tareas asíncronas en JavaScript

Resources

Stars

Watchers

Forks