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.
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:).
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.
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, ...
- 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.
Ok, entonces veamos cómo evitamos bloquear el hilo. Los ejemplos a continuación muestran varias estrategias para ejecutar las cuatro tareas que necesitamos.
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.
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...
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
.
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
.
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.
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!).
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.
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 🍒 🙈
- Callbacks - Laboratoria/bootcamp
- Promesas - Laboratoria/bootcamp
- Función Callback - MDN
- Promise - MDN
- Promise.all - MDN
- Loupe
- What the heck is the event loop anyway? | Philip Roberts | JSConf EU - YouTube
- Formas de manejar la asincronía en JavaScript - Carlos Azaustre
- Event Loop: la naturaleza asincrónica de Javascript - @ubykuo - Medium
- Javascript Asíncrono: La guía definitiva - Lemon Code
- porch - Promise Orchestrator