Buscando la manera de optimizar mi código he visto que existen tres maneras de crear tareas asíncronas:
- Callback
- Encadenamiento de Promises
- Async/await
¿Qué diferencias hay entre callbacks, promises y async/await?
Buscando la manera de optimizar mi código he visto que existen tres maneras de crear tareas asíncronas:
¿Qué diferencias hay entre callbacks, promises y async/await?
Imagina que tienes que invocar a una función que va a tardar en responder. Pero necesitas su respuesta para poder invocar otra a continuación, pues la segunda necesita como parámetro lo que ha producido la primera. Por ejemplo, la primera podría descargar una página Web, extraer de ella una URL y la segunda necesitar ésta URL para acceder a otro recurso.
Usando código síncrono, todo se entiende fácilmente
function main() {
r1 = serv1(parametros);
r2 = serv2(r1);
// Lo anterior también se podría haber escrito r2 = serv2(serv1(parametros))
console.log("Resultado final: " + r2);
}
pues suponemos que las llamadas a serv1()
y serv2()
se hacen de forma síncrona, es decir, que hasta que esas funciones no tengan su respuesta la ejecución no continuará por la línea siguiente. Este tipo de código es fácil de escribir y comprender, pero es muy ineficiente. Se supone que el tiempo que tardan serv1()
y serv2()
en obtener su respuesta es mayormente tiempo de espera (comunicaciones de red, por ejemplo), tiempo durante el cual el hilo está detenido esperando la respuesta, y que podría haber aprovechado haciendo otra cosa. De hecho, en JavaScript este hilo es el que se ocupa de interactuar con el DOM y con el usuario, por lo que la página quedaría como "colgada" este tiempo. Y si el tiempo es excesivo, el Navegador acabará por detectar que el hilo no está procesando los eventos de la GUI y sacará un aviso del estilo "Un script en esta página está tardando demasiado. ¿Deseas abortarlo?"
La solución es hacer uso de versiones asíncronas de estas funciones. Llamémoslas asinc1()
y asinc2()
. Estas funciones retornan inmediatamente en el hilo principal, pero dejan "encargado" al Navegador que en otro hilo (dedicado a comunicaciones de red) haga las peticiones correspondientes.
Nota el hacer una función asíncrona no hace que se ejecute automáticamente en otro hilo, ni es un método mágico de ejecución "en paralelo". Si la rutina que llamamos de forma asíncrona se pone a hacer cálculos que acaparen la CPU, el hilo de GUI se resentiría igualmente. Sólo si la función asíncrona hace uso de entrada/salida bloqueante, típicamente acceso a red o disco, se puede beneficiar, pues en ese caso no se queda esperando a que la operación de entrada/salida termine, sino que vuelve inmediatamente y sólo más tarde, cuando la operación ha terminado (otros procesos como el operativo u otro hilo en el navegador la habrá completado), la tarea se reanuda y puede obtener la respuesta.
El problema es ¿qué ocurre una vez que esas peticiones se han completado? ¿A dónde va a parar la respuesta? Ya que necesitamos la respuesta de asinc1()
antes de poder invocar asinc2()
¿cómo se entera nuestro programa de cuándo terminó asinc1()
y cuál ha sido el resultado?
Hay dos posibles implementaciones para resolver este problema:
La primera solución (callbacks) es más directa, pero da lugar a un código más anidado, como puedes ver en el siguiente ejemplo:
// Versión asíncrona. Se supone que asinc1() y asinc2() son funciones que admiten
// un callback como parámetro, al cual llamarán pasándole el resultado
function main() {
asinc1(parametros, function(r1){
// Tenemos el resultado de asinc1
asinc2(r1, function(r2) {
console.log("Resultado final: " + r2);
});
});
}
La anidación sería peor a medida que tengamos más funciones encadenadas (que se necesite el resultado de una para llamar a la siguiente), dando lugar a funciones que son parámetros de otras funciones, que son parámetros de otras, etc. y el código fuente adquiere una característica forma en la que se va indentando más y más para luego ir deshaciendo esa indentación a medida que se cierran llaves y paréntesis, estructura conocida como Pyramid of Doom, o Callback hell.
Por otro lado, en este código toda la lógica está "al revés" de algún modo, pues las funciones no retornan resultados, sino que pasan esos resultados como parámetros (a otras funciones), y las funciones que manejan la respuesta son a su vez pasadas como parámetros. El flujo de errores (en el que no entraré en esta respuesta) también se complica, pues ya no pueden usarse excepciones, sino que típicamente habría dos funciones callback, una a la que se llamaría en caso de éxito y otra en caso de error.
Las Promises evitan esta anidación, y hacen más simple el manejo de errores. Una Promise tiene un método then()
al que le pasas una función, que será ejecutada automáticamente cuando la promesa se resuelva (o inmediatamente si ya estaba resuelta en el momento en que invocamos .then()
). Esta función recibirá como parámetro el valor de la promesa (el resultado esperado). Y lo mejor es que .then()
retorna a su vez una nueva Promise, que se resolverá cuando se ejecute la función que le habíamos asociado. De este modo se pueden encadenar varios .then()
para "simular" un código secuencial, del estilo de "cuando esta promesa se resuelva haz esto, y después, cuando esta otra se resuelva, haz esto otro, y después...)
Esta sería la nueva sintaxis:
// Versión con Promises
// Ahora asinc1 y asinc2 se supone que retornan una Promise
function main() {
asinc1(parametros)
.then(function(r1){ return asinc2(r1); })
.then(function(r2){
console.log("Resultado final: " + r2);
})
}
// Lo anterior puede escribirse aún más concisamente así:
function main() {
asinc1(parametros)
.then(asinc2)
.then(function(r2){
console.log("Resultado final: " + r2);
})
}
Desde 2017 JavaScript tiene además las palabras async/await
, que son azúcar sintáctico para usar Promesas con una nueva sintaxis que las oculta, y las hace parecer código síncrono.
Si usamos la palabra await
delante de una llamada a una función, se entiende que esa función retorna una promesa. Entonces de algún modo (no voy a entrar en los farragosos detalles de cómo lo hace "por dentro") la ejecución se pausa en ese punto (pero sin bloquear al hilo principal) y sólo se reanuda cuando la promesa haya sido resuelta, y entonces await
retorna como resultado el valor de la promesa.
Gracias a esto el código quedaría ahora:
async function main() {
r1 = await asinc1(parametros);
r2 = await asinc2(r1);
console.log("Resultado final: " + r2);
}
Compara esta última con la primera (síncrona). La estructura es idéntica y sólo se ha puesto await
delante de las llamadas asíncronas, y async
delante de la función (ya que sólo una función declarada con async
puede usar await
).
La eficiencia en cuanto al tiempo de ejecución de las tres soluciones asíncronas es similar. Lo que cambia mucho es la estructura del código, que se simplifica con las Promises y más aún con await
, lo que posiblemente revierta en menos errores, y mejor mantenibilidad y depuración.
Para responder tu pregunta tienes que responder primero algo fundamental
Que es asincronía?
El término es usado sin discriminación en Javascript y los programadores usualmente lo asocian con la capacidad de ejecutar una tarea sin bloquear el hilo de Javascript lo cual es completamente falso. Lo que significa en realidad asincronía es la capacidad de ejecutar instrucciones en una etapa diferente del ciclo de eventos o event loop como se le conoce en inglés. Se puede perfectamente programar un trabajo con setTimeout
, por ejemplo y este, cuando le toque ejecutar, te bloquee el hilo principal.
No presiones el botton en este snippet a menos que estés listo para ver a tu navegador bloquearse y eso que estoy usando un setTimeout
para programar la tarea tareaIntensiva
que es la que contiene el ciclo. De todas formas el navegador te mostrará un cartel que hay un script bloqueando la página.
function start() {
function tareaIntensiva() {
for (let i = 0; i < 10000000; i++) {
console.log(1);
}
}
setTimeout(tareaIntensiva);
}
button {
padding: 10px 5px;
background-color: royalblue;
border: solid 1px lightgray;
border-radius: 3px;
color: white;
cursor: pointer;
}
<button id="doom" type="button" onclick="start()">
Destruir navegador
</button>
El event loop está compuesto de manera muy simplificada por distintos mensajes o funciones que se ejecutan unos detrás de otros siguiendo cierto orden preestablecido usando un sistema especifico de prioridades hasta que no queden más mensajes que procesar. En otro modelo más avanzado de abstracción está compuesto por código normal del usuario, micro-tareas y macro-tareas. Este tema es bastante complejo pero puedes encontrar aquí un artículo bastante extenso y detallado de como funciona todo. Para explicarlo de manera simplificada cuando usas setTimeout
creas una macro-tarea que se ejecutará en algún ciclo posterior, las promesas crean micro-tareas se ejecutan en el mismo ciclo en el que estás justo después de ejecutar todo el código tradicional que escribes sin estos artefactos. De esto se desprende que si llamas a setTimeout
y a una promesa esta ultima se ejecutará primero.
Tengo que explicar todo lo anterior para que entiendas como es que aplica a tu problema:
Callbacks
En materia de estilos son la peor decisión ya que son conocidos por crear el callback hell que vuelve el código anidado completamente ilegible. En cuanto a manejo de errores en Nodejs hay una convención de pasar el error como primer parámetro pero este estándar se puede violar ya que los callbacks son simples funciones que pasas como parámetros.
En materia de ejecución los callbacks son agnósticos ya que depende del implementador usar micro-tareas o macro-tareas para su ejecución. Esta podría no incurrir en asincronía en lo absoluto. Aquí hay un ejemplo:
function miFuncion(param, callback) {
if (param > 20) {
leeUnFichero(callback);
} else {
callback();
}
}
Este código inofensivo sólo se ejecuta en una etapa posterior del event loop si y sólo si param > 20
, de lo contrario sería lo mismo que escribir por ejemplo:
miFuncion(19);
callback();
Este código ni siquiera ejecuta una tarea lo cual a veces genera auténticos dolores de cabeza tratando de descifrar que es lo que no ha funcionado bien. En este caso lo correcto sería usar setTimeout
para simular asincronía y que la función se comporte de la misma forma sin importar los parámetros.
function miFuncion(param, callback) {
if (param > 20) {
leeUnFichero(callback);
} else {
setTimeout(callback);
}
}
Promesas
Se ejecutan usando micro-tareas por lo general (sólo en casos extremos cuando no haya nada más disponibles se usan macro-tareas). Si estás usando promesas nativas tanto en Node como en el browser está garantizado que serán micro-tareas así que puedes afirmar sin lugar a dudas que, hagan lo que hagan, las promesas SI son asincrónicas.
En cuanto a estilo es una mejora sobre los callbacks pero en realidad tanto el then
como el catch
usan callbacks para programar la ejecución de código. La ventaja es que no se crea la pirámide ya que las promesas pueden ser encadenadas. Entre otras razones están diseñadas para hacer que el código asincrónico retenga propiedades de código sincrónico como indentación plana y un sólo canal de error lo cual es algo imposible de lograr con callbacks puros.
Es importante entender que puedes crear una cadena tan larga como quieras pues cada vez que retornas algo en un then
se crea una nueva promesa (retornas undefined
de una función si no retornas nada) pero para cada uno de los then
se programa una nueva micro-tarea que aunque es menos costosa que una macro-tarea sigue teniendo un pequeño costo en el event loop.
// puede ser tan larga como quieras
promesa().then(() => {}).then(() => {}).then(() => {}).then(() => {})
Un aspecto que muchos desarrolladores olvidan es que se debe manejar siempre el error al final de una cadena ya que, por ejemplo, en Node una promesa con error que no es capturada podría terminar el proceso. Esto es importante de tener en cuenta para lo que voy a decir en el próximo punto.
Async/await
Es azúcar sintáctico sobre las promesas. Por esa misma razón usan micro-tareas también. Existen afirmaciones de que se pueden optimizar para que sean más rápidas. Esto por supuesto depende del navegador y no es 100% seguro todas las veces.
En cuanto a estilo "prometen" menos indentación que las promesas pero si recuerdas lo que dije antes de manejar errores deberás escribir un try/catch
para manejar el error e inevitablemente crearás indentación. Si empleas algún modelo de abstracción puedes manejar el error en otra parte obteniendo código completamente plano y muy fácil de leer.
async function miCodigo() {
const valor1 = await funcionAsincrona1();
const valor2 = await funcionAsincrona2();
}
// En otra parte podrías tener:
async function main() {
try {
await miCodigo();
} catch (e) {
// Manejar error
}
}
main();
Hay ventajas también al manejar errores con async/await ya que cualquier error lanzado dentro de la funcion async
será capturado por la función y convertido en una promesa rechazada automáticamente.
Dicho esto es claro que no puedes comparar promesas y callbacks ya que cumplen una función diferente pero en cuanto a estilo y si tienes código verdaderamente asincrónico (por ejemplo llamadas ajax, lectura de ficheros, conexiones websockets, etc) serán una gran mejora en cuanto a legibilidad se refiere. Si tu código es verdaderamente complejo el async/await será la mejor opción en cuanto a promesas para escribir y entender tu código.
Para completar debes saber que hay muchisimas formas de crear asincronía, no sólo promesas. Puedes encontrar métodos estandard como setTimeout
, setInterval
, requestAnimationFrame
, process.nextTick
, setInmediate
y métodos informales como MutationObserver que te permite crear una micro-tarea en el navegador de manera artificial. Si quieres hacer verdaderas operaciones intensivas que puedan bloquear el hilo principal deber usar Web Workers
Las dos respuestas anteriores, de @devconcept y @abulafia, explican con mucho detalle los conceptos, pero quiero aportar una versión para consultas rápidas, donde se vea cómo podemos pasar de callbacks a promesas, y con éstas cómo usar la sintaxis async / await
Por tanto, esta respuesta será un ejemplo práctico donde tomaremos una llamada asíncrona e iremos refactorizando paso a paso hasta obtener un código lo más elegante posible.
Imaginemos que tenemos la clásica llamada AJAX mediante XHR:
const url = 'https://swapi.dev/api/people/1/';
function callbackFunction(event) {
const resultado = JSON.parse(event.target.responseText);
console.log(resultado.name)
}
const xhr = new XMLHttpRequest();
xhr.open("GET", url)
xhr.addEventListener('load',callbackFunction);
xhr.send();
console.log('Petición hecha');
La llamada AJAX es asíncrona, lo que significa que sabemos cuándo se hace la petición (xhr.send();
) pero no cuándo se obtendrá la respuesta. Lo único que sabemos es que, cuando el navegador reciba la respuesta, se llamará a la función callbackFunction
porque así lo hemos definido.
Pero ahora imaginemos que queremos crear una función que nos permita hacer llamadas AJAX de manera sencilla, para tener las llamadas hechas en una línea:
function callbackFunction(event) {
const resultado = JSON.parse(event.target.responseText);
console.log(resultado.name)
}
//función que hace una llamada ajax
function myFetch(url, callback) {
const xhr = new XMLHttpRequest();
xhr.open("GET", url)
xhr.addEventListener('load',callback);
xhr.send();
console.log('Petición hecha');
}
//ahora podemos hacer varias llamadas en una línea:
myFetch('https://swapi.dev/api/people/1/', callbackFunction);
myFetch('https://swapi.dev/api/people/2/', callbackFunction);
myFetch('https://swapi.dev/api/people/3/', callbackFunction);
Esto es un avance, pero tenemos un problema si tenemos dependencias: ¿Qué pasa si la segunda llamada depende de la primera? Nos quedaría algo así:
//función que hace una llamada ajax
function myFetch(url, callback) {
const xhr = new XMLHttpRequest();
xhr.open("GET", url)
xhr.addEventListener('load',callback);
xhr.send();
console.log('Petición hecha');
}
//llamadas AJAX anidadas
myFetch('https://swapi.dev/api/people/1/', function callbackFunction({target}) {
const personaje = JSON.parse(target.responseText);
console.log('Personaje:',personaje.name);
console.log('Nació en',personaje.homeworld, '(oops, necesitamos otra llamada)');
//por seguridad las llamadas serán vía HTTPS
const url = personaje.homeworld.replace('http','https');
myFetch(url, function callbackfunction2({target}) {
const planeta = JSON.parse(target.responseText);
console.log('El planeta era:',planeta.name);
const url2 = planeta.films[0].replace('http','https');
myFetch(url2, function callbackfunction3({target}) {
const film = JSON.parse(target.responseText);
console.log('El título de la primera película en la que aparece es', film.title);
}); //fin tercer callback
}); //fin segundo callback
}); //fin primer callback
En este caso aún tenemos algo manejable (3 llamadas anidadadas), pero este anidamiento nos puede causar el llamado Callback Hell (infierno de llamadas anidadas):
¿Cómo evitarlo? Pues con promesas. Una promesa es un objeto que permite encadenar acciones asíncronas, diciendo "realiza la acción A, cuando termines A, ejecuta B, entonces ejecuta C, entonces ejecuta ..." de un modo algo más sencillo:
//Creemos promesas!!
function myFetchPrometido(url) {
//creamos una promesa que se resuelve cuando la llamada AJAX obtiene una respuesta
return new Promise(function (tenemosRespuesta, tenemosError) {
const xhr = new XMLHttpRequest();
xhr.open("GET", url)
xhr.addEventListener('load',({target}) => {
tenemosRespuesta(JSON.parse(target.responseText));
});
xhr.send();
console.log('Petición hecha');
});
}
//ahora podemos hacer varias llamadas dependientes
// sin anidamiento extra.
const promesa = myFetchPrometido('https://swapi.dev/api/people/1/');
promesa.then(personaje => {
console.log('Personaje:',personaje.name);
console.log('Nació en',personaje.homeworld, '(oops, necesitamos otra llamada)');
//por seguridad las llamadas serán vía HTTPS
return myFetchPrometido(personaje.homeworld.replace('http','https'));
}).then(planeta => {
console.log('Planeta', planeta.name);
return myFetchPrometido(planeta.films[0].replace('http','https'));
}).then(film => {
console.log('Primera aparición en la película', film.title);
});
Como vemos, una promesa tiene el método then
, al que se le pasa la función callback. Lo bueno de este método es que si la función callback devuelve algo, el método lo devuelve a su vez como el resultado de una nueva promesa (si la función devuelve una promesa, no se anidan, es así de inteligente), con lo que puedes tener un número indefinido de métodos then
encadenados.
async / await
De este modo ya nos hemos ahorrado una anidación exagerada, pero el código sigue siendo un poco feo. ¿Se puede hacer más elegante?
La respuesta es sí: existe una sintaxis que es equivalente a usar Promesas pero las "oculta", haciendo que el código parezca síncrono, sin necesidad de callbacks, anidadas o no:
//Creemos promesas!!
function myFetchPrometido(url) {
//creamos una promesa que se resuelve cuando la llamada AJAX obtiene una respuesta
return new Promise(function (tenemosRespuesta, tenemosError) {
const xhr = new XMLHttpRequest();
xhr.open("GET", url)
xhr.addEventListener('load',({target}) => {
tenemosRespuesta(JSON.parse(target.responseText));
});
xhr.send();
console.log('Petición hecha');
});
}
// await sólo se puede usar dentro de una función asíncrona,
// es la única limitación o pega
async function main () {
//await es literalmente "espera", estamos esperando la resolución de una promesa
const personaje = await myFetchPrometido('https://swapi.dev/api/people/1/');
console.log('Personaje:',personaje.name);
console.log('Nació en',personaje.homeworld, '(oops, necesitamos otra llamada)');
const planeta = await myFetchPrometido(personaje.homeworld.replace('http','https'));
console.log('Planeta', planeta.name);
const film = await myFetchPrometido(planeta.films[0].replace('http','https'));
console.log('Primera aparición en la película', film.title);
};
//llamamos a la función asíncrona
main();