Tareas asíncronas con técnicas clásicas

Figura
Figura. Los generadores y las promesas son buenas manejando peticiones asíncronas como las de AJAX (XHR).

Supongamos que tenemos dos funciones muy simples para realizar operaciones aritméticas con dos operandos. Una realiza una suma y la otra una multiplicación. Podemos realizar la operación 4×(2+3) con resultado 20. El siguiente código se ejecuta en un sólo paso. A menos que haya un error de ejecución, desde que se inicia hasta que finaliza no será interrumpido por nada. Después de ejecutar las operaciones obtenemos inmediatamente el resultado. El código es el de una tarea síncrona porque se ejecuta en un único paso.

function sum(a, b){
    return a+b;
}
function mult(a, b){
    return a*b;
}
//Calcular 4 x (2+3) = 20
let resultado = mult(4, sum(2, 3));
console.log(resultado); // 20
    

Así funciona JavaScript. Siempre síncrono, sin múltiples hilos, sin concurrencia, sin múltiples tareas. Pero JavaScript es también conducido por eventos. Un evento es una acción que se define ahora para ser ejecutada en el futuro. Por ejemplo, podemos adjudicar un evento click a un botón en la página de tal forma que cuando alguién en el futuro lo pulse haremos una determinada cosa. O solicitar al servidor un archivo con XHR (Ajax) y quedar a la espera de algún evento load o readystatechange que nos comunique que el archivo ya llegó para entonces procesar su contenido. También son eventos window.setInterval y window.setTimeout que responden al reloj del sistema. Un evento es encolado en la cola de tareas del navegador. Cuando le llegue su turno se llevará a cabo la ejecución. Y son estos eventos los que nos permiten llevar a cabo tareas asíncronas en JavaScript: las definimos dentro de una tarea síncrona y se ejecutarán de forma asíncrona.

Aparte de la concurrencia de los Web Worker cuyas limitaciones no lo hacen práctico para tareas comunes, sólo nos quedan los eventos para simular actuaciones más complejas. Por ejemplo, podemos simular concurrencia usando setTimeout. Usemos este evento en nuestro ejemplo síncrono para convertirlo en asíncrono y ver qué pasa. Supongamos que tenemos una función genérica operar que retrasa el resultado de la ejecución de una función un tiempo aleatorio:

function operar(fun, a, b){
    window.setTimeout(function() {
        return fun(a, b);
    }, Math.floor(Math.random()*100));
}
//Calcular 4 x (2+3) = 20
resultado = operar(mult, 4, operar(sum, 2, 3));
console.log(resultado); // undefined
    

Si realizamos la operación 4×(2+3) el resultado es undefined, porque la ejecución de la función se lleva a cabo en el futuro en otro contexto. En el momento ahora el undefined no es devuelto por fun(a, b), sino por setTimeout(). La devolución en el futuro de return fun(a, b) no será capturada por nadie.

El problema es que este código es asíncrono. Se ejecuta en dos momentos: ahora y transcurrido un tiempo. Ambas ejecuciones son independientes. ¿Podemos arreglarlo con lo que conocemos hasta ahora? Sí, con algún esfuerzo. Por un lado agregamos a las dos operaciones que ya tenemos una nueva función ver(a), que simplemente nos sacará el argumento por la consola:

function sum(a, b){
    return a+b;
}
function mult(a, b){
    return a*b;
}
function ver(a){
    console.log(a);
}    
    

A continuación ponemos la función del setTimeout en una envoltura, de tal forma que guardamos en el el alcance un Array a modo de pila para almacenar los resultados parciales de cada operación. La ejecución de nuevaOperacion nos devolverá un nuevo evento setTimeout para ejecutar las operaciones sumar, multiplicar y ver con retraso y de forma asíncrona. El resultado lo vamos a obtener en la consola pasando una operación ver() que engloba a todas las que vamos a ejecutar.

//Esto no funciona a menos que el tiempo de retraso sea 
//sea siempre el mismo asegurando el orden de ejecución    
function nuevaOperacion(){
    let pila = [];
    return function (fun, a, b){
        window.setTimeout(function() {
            //sólo es para trazar el orden de ejecución
            console.log(fun.name, a, b);
            a = (typeof a === "number") ? a : pila.pop();
            b = (typeof b === "number") ? b : pila.pop();
            pila.push(fun(a, b));
        }, Math.floor(Math.random()*100));
    }
}

//Calcular 4 x (2+3) = 20
let oper = nuevaOperacion();
oper(ver, oper(mult, 4, oper(sum, 2, 3)));
    

Lo anterior funciona sólo si el orden de llegada coincide con el de las operaciones. Veáse que la ejecución de oper(ver, oper(mult, 4, oper(sum, 2, 3))) es llevada a cabo por JavaScript siguiendo un orden de precedencia de operaciones. Primero ejecuta las operaciones más internas en los niveles de paréntesis. Así la ejecución correcta será oper(sum, 2, 3), luego oper(mult, 4, R) siendo R el resultado de la operación anterior y finalmente oper(ver, R). Si el tiempo de retraso es una cantidad fija, es posible que las operaciones lleguen a la cola de tareas del navegador en ese orden y, por tanto, sean ejecutadas en ese orden. Pero si el tiempo es aleatorio el orden de ejecución también lo será y solo funcionará cuando ese orden coincida con el correcto.

Por lo tanto la única forma de solucionarlo es ir almacenando en una cola las funciones con sus argumentos a medida que se van recibiendo y antes de definir su setTimeout. Cuando empecemos a ejecutar funciones iremos comprobando cuando se ejecuta la última mediante un contador comparado con la longitud de la cola. En ese momento ya podemos ejecutar todas las operaciones pues están en ese Array en el orden necesario.

function nuevaOperacion(){
    let cola = [], n = 0;
    return function (fun, a, b){
        cola.push([fun, a, b]);
        window.setTimeout(function() {
            n++;
            if (n == cola.length){
                let pila = [];
                for (let [f, x, y] of cola){
                    x = (typeof x === "number") ? x : pila.pop();
                    y = (typeof y === "number") ? y : pila.pop();
                    pila.push(f(x, y));
                }

            }
        }, Math.floor(Math.random()*100));
    }
}
//Calcular 4 x (2+3) = 20
let oper = nuevaOperacion();
oper(ver, oper(mult, 4, oper(sum, 2, 3)));
//Tras un tiempo en la consola obtenemos 20
//Calcular 4 x ( ((3+2) x (5+3))  + 2 ) = 4x(5x8+2) = 168
oper = nuevaOperacion();
oper(ver, oper(mult, 4, oper(sum, oper(mult, oper(sum,3, 2),
    oper(sum, 5, 3)), 2)));
//Tras un tiempo en la consola obtenemos 168
//Esta línea deber aparecer antes de los resultados
console.log("SINC");
//La ejecución es concurrente con el siguiente bucle
buclear(15);
    

Se observa que la solución pasó por sustituir la cola de tareas del navegador por otra. El objetivo inicial de retrasar cada operación realmente se ha perdido, pues todas las operaciones se llevan a cabo con la ejecución de la útima. El código se ha vuelto tan enrevesado que requiere conocer los detalles para ver de qué va la cosa.

Antes de pasar a la solución con generadores, explicaré que significa las dos últimas líneas del código anterior. La ejecución es tal que primero tendremos en la consola esa línea con la palabra SINC. Luego vendrán los resultados, posiblemente en el orden que se escribieron las operaciones en el código: 20 y 168. Aunque en algún caso estos dos valores también pudieran aparecer al revés en función de los tiempos de retraso aleatorios.

Si agregamos la función buclear(), salidas de ese bucle se intercalarán entre los resultados. El bucle usa un setTimeout para incrementar una variable y sacarla por la consola. Las salidas del bucle son también generadas de forma asíncrona, pero con un tiempo de retraso fijo. Así que nos servirán como guía para ver dónde van apareciendo los dos resultado asíncronos de las operaciones matemáticas. Varias tareas asíncronas ejecutándose es lo más parecido a concurrencia que podemos conseguir en JavaScript, sin contar con los Web Worker que ya comentamos antes.

function buclear(retraso){
    let iteracion = 0;
    (function ejecutarBucle(){
        iteracion++;
        if (iteracion > 10) return;
        console.log("ITER " + iteracion);
        window.setTimeout(ejecutarBucle, retraso);
    })();
}    
    

Una ejecución podría darnos algo como lo siguiente en la consola. Pero no se garantiza el orden de los dos resultados 20 y 168. Todo dependerá de la cola de tareas del navegador.

SINC
ITER 1
ITER 2
ITER 3
ITER 4
20
ITER 5
ITER 6
ITER 7
168
ITER 8
ITER 9
ITER 10

Tareas asíncronas con generadores

Vamos finalmente a aplicar un generador a nuestro problema del apartado anterior. Pero antes vamos a recordar el primer intento con un código síncrono que no funcionó:

function operar(fun, a, b){
    window.setTimeout(function() {
        return fun(a, b);
    }, Math.floor(Math.random()*100));
}
//Calcular 4 x (2+3) = 20
resultado = operar(mult, 4, operar(sum, 2, 3));
console.log(resultado); // undefined
    

Y ahora observe en el siguiente código como ejecutamos las operaciones con un código que parece síncrono, como el anterior. Sólo basta anteponer un yield a cada operación y usar un next() en el setTimeout. El código sigue siendo escrito como síncrono aunque en el trasfondo se ejecuta asíncronamente. Ahora no necesitamos pilas ni colas para conducir el resultado. Observe como la función op(fun, a, b) que retrasa la ejecución de una operación vuelve a ser simple. Tampoco necesitaremos la función ver() para exponerlo en la consola. El resultado lo tendremos disponible cuando se ejecute toda la expresión.

//Ahora ya no necesitamos una operación ver()
function sum(a, b){
    return a+b;
}
function mult(a, b){
    return a*b;
}
//El operador de funciones devuelve resultados
function op(fun, a, b){
    window.setTimeout(function() {
        iter.next(fun(a, b));
    }, Math.floor(Math.random()*100));
}
//Con un generador el código "parece" síncrono
//en su interior
function* gen(){
    //Calcular 4 x (2+3) = 20
    let resultado = yield op(mult, 4, yield op(sum, 2, 3));
    console.log(resultado); // 20
    //Calcular 4 x ( ((3+2) x (5+3))  + 2 ) = 4x(5x8+2) = 168
    resultado = yield op(mult, 4, yield op(sum, yield op(mult, 
        yield op(sum,3,2),  yield op(sum,5,3)),  2));
    console.log(resultado); // 168
    //Código síncrono no puede ir aquí pues no saldrá hasta 
    //obtener los resultados anteriores
    //console.log("SINC");
}
//Iniciamos el generador
let iter = gen();
iter.next();
//Esta línea deber aparecer antes de los resultados
console.log("SINC");
//Y estas iteraciones se intercalarán entre resultados
buclear(50);
    

Pedimos un resultado y sólo tenemos que esperar a que se completen las operaciones. Sacamos ese resultado por la consola y pedimos el siguiente resultado. Síncrono en su exposición y asíncrono en su ejecución. Cualquiera que lea este código sabiendo como funciona un generador no tendrá problemas para saber que está haciendo.

Ahora el orden de salida a la consola es siempre exactamente como está escrito en el código: primero obtenemosSINC y luego 20 y 168 en ese orden e intercalados con valores del bucle. Si hubiésemos decidido poner SINC dentro del generador sería un error de concepto, pues lo que hay dentro del generador es asíncrono con el script exterior. En resumen, lo que estamos haciendo es separar la asincronicidad de una parte del código y ponerla escrita como si fuera síncrona dentro de un generador.

En el siguiente ejemplo interactivo podrá ver ambas versiones sin y con generador del ejemplo anterior. El código es básicamente igual que el expuesto en los párrafos anteriores con dos añadidos: un control de errores y una tercera técnica usando Promises, detalles que comentaré en el siguiente apartado.

Ejemplo: Generador para manejar código asíncrono (y Promesas)

Primera operación 4x(2+3) = 20, donde puede modificar los valores:

mult(, sum(, ))

Segunda operación 4x(((3+2)x(5+3))+2) = 168 (valores no modificables):

mult(4, sum(mult(sum(3, 2), sum(5, 3)), 2))
Simular error en
Técnica a usar
Este ejemplo usa ES6 en modo estricto. Puedes consultar el código JS original de este ejemplo.

Generadores con Promesas

Las promesas (Promises) es algo nuevo también de ES6 y tienen mucho que hacer con los generadores. Y parece que no es fácil estudiar los generadores sin hacerlo al mismo tiempo que las promesas. Éstas son ideales para expresar y, especialmente, asegurar tareas asíncronas. Dada la importancia de las Promises es mejor dedicarle un tema específico, por lo que ahora sólo expondré una breve descripción y el código usado en el ejemplo anterior, intentando explicar que ventaja aporta las promesas a los generadores.

Una Promesa (Promise) es un objeto que representa un valor futuro, que en su momento podría resolverse a un valor o rechazarse por un motivo. Es, por tanto, algo para usar claramente con tareas asíncronas. Un poco de código ayudará a entenderlo. Declaramos una nueva promesa cuyo argumento es una función para ejecutarla, que a su vez tiene dos argumentos. El primero es una referencia a una función que resolverá la promesa, mientras que el segundo argumento la rechazará. Para hacerlo sencillo, supongamos que lanzamos un setTimeout con un tiempo aleatorio. Esa misma variable de tiempo la usaremos para devolver la tarea como resuelta si el número es par y rechazada si es impar.

let miPromesa = new Promise(function(resuelve, rechaza){
    let tiempo = Math.floor(Math.random()*100);
    window.setTimeout(function(){
        if (tiempo % 2 === 0) {
            resuelve(tiempo);
        } else {
            rechaza(tiempo);
        }
    }, tiempo);
});
    

Los dos argumentos a modo de callbacks resuelve y rechaza se les suele denominar con su nombre en inglés resolve y reject. El nombre de estos argumentos es indiferente. Aunque en este ejemplo los puse en español, es preferible usar los nombres en inglés para no dejar de olvidar el verdadero significado de dichos argumentos. Lo que se devuelve se lleva a cabo haciendo ejecutar el callback con un único argumento que representa el valor o el error devuelto. En este ejemplo estamos devolviendo la misma variable, pero lo lógico es devolver un motivo de error en el caso de rechazo.

Una vez creada una promesa vamos a ejecutarla usando su método then(f1, f2), donde los argumentos son las funciones que resuelven y rechazan respectivamente la promesa. Todas las resueltas mostrarán tiempos pares, mientras que las rechazadas llevarán tiempos impares.

miPromesa.then(
    function(tiempo){
        console.log(`Resuelta en ${tiempo} ms`);
    },
    function(tiempo){
        console.log(`Rechazada en ${tiempo} ms`);
    }
);
    

Hay mucho más que aprender sobre las Promesas. Pero por ahora lo anterior será suficiente para entender porqué son apropiadas usarlas con un generador como el del ejemplo del apartado anterior. En primer lugar el ejemplo interactivo tiene un control de errores. Para probarlo simulamos errores en varios puntos del código. Uno de ellos está en la función sum(a,b), observando que a propósito no capturamos el error con un try-catch para que se propague en la ejecución. También incorporamos otra función onError(e) que simplemente saca el mensaje de error por la consola.

function sum(a, b){
    if (simulaError==="sum(a,b)") {
        throw(Error("Error simulado en sum(a,b)"));
    }
    return a+b;
}
function onError(e){
    console.log(`ERROR! ${e.message}`);
}
    

Y ahora vamos a crear una función que ejecutará un generador con promesas para nuestro ejemplo. Esta función es genérica por lo que podría servir para cualquier caso similar. Le pasamos un generador que devuelve valores que pueden ser promesas y una función para manejar el error. En primer lugar ejecutamos el generador y luego construimos una función para iterar por él. Con next() tendremos una respuesta del generador que, como ya sabemos, tiene las propiedades value (que contiene una promesa o un resultado) y done. Si la iteración no se ha completado done será falso. Si respuesta.value tiene la propiedad then entonces es que el generador nos ha respondido con una promesa. Le aplicamos then(f1, f2) para resolverla o rechazarla. Vea que la resolución implicar volver a iterar de forma recursiva para extraer la siguiente iteración del generador. Además para capturar errores en el propio generador finalizamos con un catch(cbError). Si la respuesta del generador no es una promesa lo iteramos otra vez.

function ejecutarGeneradorConPromesas(generador, cbError){
    let iterProm = generador();
    function iterar(valor){
        let respuesta = iterProm.next(valor);
        if (!respuesta.done){
            if (respuesta.value.then){
                respuesta.value.then(iterar, cbError).catch(cbError);
            } else {
                iterar(respuesta.value);
            }
        }
    }
    iterar();
}
    

Al igual que en el ejemplo sólo con generador, necesitamos también una función opc(fun, a, b) que envía una función para sumar o multiplicar dos operandos aplicándole un tiempo de espera. Hacemos let res = fun(a,b) y si no hay errores resolvemos la promesa devolviendo el resultado. En otro caso la rechazamos.

function opc(fun, a, b){
    return new Promise(function(resolve, reject){
        window.setTimeout(function(){
            try {
                if (simulaError==="setTimeout") {
                    throw(Error("Error simulado en setTimeout"));
                }
                let res = fun(a,b);
                if (isNaN(res)){
                    reject(Error(`Resultado parcial no válido ${res}`));
                } else {
                    resolve(res);
                }
            } catch (e){
                reject(e);
            }
        }, Math.floor(Math.random()*100));
    });
}
    

El generador que vamos a usar con promesas es igual que el que habíamos usado sin promesas, aunque ahora le vamos a poner un error simulado y un bloque try-catch para capturarlo. En ese bloque creamos una promesa rechazada y la devolvemos en el yield.

function* genc(){
    try {
        let resultado = yield opc(mult, 4, yield opc(sum, 2, 3));
        console.log(resultado); // 20
        if (simulaError==="generador") {
            throw(Error("Error simulado en generador"));
        }
        resultado = yield opc(mult, 4, yield opc(sum, yield opc(mult,
                    yield opc(sum,3,2),  yield opc(sum,5,3)),  2));
        console.log(resultado); // 168
    } catch (e){
        yield Promise.reject(e);
    }
}
//La ejecución se lleva a cabo con lo siguiente
ejecutarGeneradorConPromesas(genc, onError);
console.log("SINC (generador + promesas)");
buclear(50);
    

En el ejemplo interactivo con generador y sin promesas controlamos un error en el generador llamando directamente al manejador de errores onError(e), porque no caben muchas cosas que hacer dado que esa es la última sentencia del generador y finalizará la iteración.

function op(fun, a, b){
    try {
        if (simulaError==="setTimeout") {
            throw(Error("Error simulado en setTimeout"));
        }
        let res = fun(a,b);
        if (isNaN(res)){
            iter.next(onError(Error(`Resultado parcial no 
                                     válido ${res}`)));
        } else {
            iter.next(res);
        }
    } catch (e){
        iter.next(onError(e));
    }
}
function* gen(){
    try {
        let resultado = yield op(mult, 4, yield op(sum, 2, 3));
        console.log(resultado); // 20
        if (simulaError==="generador") {
            throw(Error("Error simulado en generador"));
        }
        .............
    } catch(e){
        onError(e);
    }
}
    

Indudablemente rechazar una promesa porque encontramos un error en el generador parece que es un control más potente que simplemente llamando a un manejador de error. Pero donde mas se evidencia el mejor control de errores con promesas es con los que aparecen en la ejecución de la tarea asíncrona, es decir, en el setTimeout. Y también en la ejecución de las funciones de sumar y multiplicar. En el ejemplo interactivo puede simular esos errores e incluso pasar caracteres que no sean números a la primera expresión.

Simulando un error en sum(a,b) podemos encontrarnos resultados como los siguientes. Se observa que con promesas cuando se produce el primer error ya no sigue ejecutando el resto del generador, mientras que las otras técnicas no son capaces de detener la ejecución. Aunque podría agregarse código para conseguirlo no dejaría de ser un añadido que vendría a complicar más ese código.

ClásicaCon generadorGenerador + Promesas
SINC (clásico)
ITER 1
ITER 2
ITER 3
ITER 4
ITER 5
ITER 6
ERROR! Error simulado en sum(a,b)
ERROR! Resultado parcial no válido NaN
undefined
ERROR! Error simulado en sum(a,b)
ERROR! Error simulado en sum(a,b)
ERROR! Resultado parcial no válido NaN
ERROR! Error simulado en sum(a,b)
ERROR! Resultado parcial no válido NaN
undefined
ITER 7
ITER 8
ITER 9
ITER 10
        
SINC (generador)
ITER 1
ITER 2
ERROR! Error simulado en sum(a,b)
ITER 3
ERROR! Resultado parcial no válido NaN
undefined
ERROR! Error simulado en sum(a,b)
ITER 4
ERROR! Error simulado en sum(a,b)
ERROR! Resultado parcial no válido NaN
ITER 5
ERROR! Error simulado en sum(a,b)
ITER 6
ITER 7
ERROR! Resultado parcial no válido NaN
undefined
ITER 8
ITER 9
ITER 10
        
SINC (generador + promesas)
ITER 1
ITER 2
ERROR! Error simulado en sum(a,b)
ITER 3
ITER 4
ITER 5
ITER 6
ITER 7
ITER 8
ITER 9
ITER 10
        

En resumen, una tarea asíncrona se escribe como si fuera síncrona usando generadores. Si al mismo tiempo usamos promesas conseguimos un control de errores más fácil de implementar y con mayores prestaciones.

Generadores para peticiones de archivos con XHR (Ajax)

En el apartado anterior vimos un ejemplo de como un generador consigue que varias tareas asíncronas se ejecuten en un orden específico. Pero aquel ejemplo de realizar operaciones aritméticas no es muy práctico. Hagámos algo con peticiones de archivos al servidor usando XHR. Es una abreviatura de XMLHttpRequest, lo que antes se le conocía como AJAX. Supongamos que tenemos que pedir tres archivos archivo1.txt, archivo2.txt y archivo3.txt y queremos meter sus contenidos de texto ARCHIVO 1, ARCHIVO 2 y ARCHIVO 3 respectivamente en un Array en el mismo orden en que se pidieron. Esto es un problema porque cada petición es asíncrona. Los pedimos con un orden pero pueden llegar con otro.

Empecemos viendo primero una función para solicitar un archivo del servidor mediante XHR. La función podría ser tan simple como la siguiente:

function pedir(ruta, callback){
    try {
        let request =  new XMLHttpRequest();
        request.open("GET", ruta, true);
        request.addEventListener("readystatechange", function(){
            if (request.readyState == 4) {
                if (request.status == 200){
                    callback(false, request.responseText);
                } else {
                    callback(true, `Error ${request.status} 
                                    al recuperar ${ruta}`);
                }
            }
        }, false);
        request.addEventListener("error", function(){
            callback("Error conexión en pedir()");
        });
        request.send();
    } catch(e){
        callback("Error en pedir()");
    }
} 
    

Un callback es una función que le pasamos para atender la resolución de la petición. Suele ponerse con dos argumentos: el primero es un booleano que indica si hubo error y el segundo es el texto del archivo o bien la descripción del error. Podría ser como el siguiente, donde ignoramos los errores para simplificar el código.

function manejarPeticion(error, texto){
    arrResGlobal.push(texto);
    quedan--;
    if (quedan == 0) {
        console.log(arrResGlobal);
    }
}
    

Con cada recepción ponemos el texto del archivo en el Array. La variable global quedan se inicializa con el número de archivos que vamos a pedir. Se va decrementando cada vez que un archivo es recibido y cuando ya estén todos recibidos hacemos lo que proceda con el Array, por ejemplo sacarlo por la consola. Para ejecutar el ejemplo vamos a pedir tres archivos:

let arrResGlobal = [];
let tresRutas = ["ejemplos/archivo1.txt", "ejemplos/archivo2.txt",
                 "ejemplos/archivo3.txt"];
let quedan = tresRutas.length;
for (let ruta of tresRutas){
    pedir(ruta, manejarPeticion);
}
    

Lo anterior funcionará pero no se garantiza que los archivos se reciban en el mismo orden. El Array de resultados podría finalizar con cualquier orden, pues las tres peticiones son asíncronas y cada una finalizará en su momento. Se podría arreglar el código anterior para descubrir que archivo estamos recibiendo y por tanto que orden le corresponde en el Array. Por ejemplo, podríamos pasar la ruta en callback(error, texto, ruta) y descubrir el índice en el Array tresRutas para entonces poner el contenido en la misma posición del Array de resultados. Pero partiendo de la base de que los generadores expresan mejor los códigos asíncronos, intentemos hacer lo mismo con un generador.

Ahora ejecutamos el siguiente generador con la lista de rutas y un primer next(). Luego hay otro next(iter) adicional para pasar la referencia al iterador, puesto que la vamos a necesitar en el callback de la petición.

function* recuperarBloqueante(rutas){
    let iter = yield, arrRes = [];
    for (let ruta of rutas){
        arrRes.push(yield pedir(ruta, function(error, texto) {
            iter.next(texto);
        }));
    }
    console.log(arrRes);
}
//Ejecutar el ejemplo
let tresRutas = ["ejemplos/archivo1.txt", "ejemplos/archivo2.txt",
                 "ejemplos/archivo3.txt"];
let iter = recuperarBloqueante(tresRutas);
iter.next();
iter.next(iter);
    

Lo anterior funciona y además se garantiza que el Array de resultados tiene los contenidos de los archivos en el orden en que se pidieron. Hemos resuelto ese problema y además hemos simplificado el código síncrono (ya no hay variables globales quedan y arrRes y hemos separado el código asíncrono (es decir, la función pedir()) dentro del generador.

Pero ahora nos aparece otro problema, pues con este generador cada petición bloquea a las siguientes. Es, por lo tanto, un generador bloqueante. Esto no es que sea una desventaja en sí mismo. Resultaba imprescindible para el primer ejemplo de este tema con operaciones aritméticas. Pero ahora no solo es prescindible sino que además ocasiona un retraso innecesario en las peticiones. Hasta que no se resuelva una no se pedirá la siguiente. Cada resolución conlleva todo el tiempo necesario para enviar la petición y recibir el archivo. Y es una lástima que no se puedan hacer las tres peticiones consecutivas y luego esperar por las resoluciones, como en el ejemplo sin generador. Recuerde que un navegador puede realizar múltiples conexiones simultáneas (o conocidas también como conexiones en paralelo). Intentaremos solucionarlo con el siguiente código:

function requestGen(rutas, iter){
    let quedan = rutas.length, arr = [];
    for (let i=0; i<rutas.length; i++){
        pedir(rutas[i], function(error, texto){
            arr[i] = texto;
            quedan--;
            if (quedan==0) iter.next(arr);
        });
    }
}
function* recuperarNoBloqueante(rutas){
    let iter = yield;
    let arrRes = yield requestGen(rutas, iter);
    console.log(arrRes);
}
//Ejecutar el ejemplo
let tresRutas = ["ejemplos/archivo1.txt", "ejemplos/archivo2.txt",
                 "ejemplos/archivo3.txt"];
let iter = recuperar(tresRutas);
iter.next();
iter.next(iter);
    

Hemos modificado el generador y en lugar de solicitar las tres rutas una tras otra haremos un yield sobre otra función requestGen() que manejará el Array de rutas y el iterador del generador. Ahora sí que pedimos los archivos de forma simultánea y controlamos cuando se reciban todos de forma similar a como hicimos en el primer ejemplo. Es en ese momento cuando reanudamos el generador devolviendo el Array de resultados. Siguen vieniendo en orden y además las peticiones son simultáneas.

Si las tres sentencias finales que inicializan el generador se van a repetir en varias partes del código podríamos construir un función al efecto. Tendríamos requestGen(), el propio generador recuperar() y ejecutarGenerador() formando un grupo de funciones genéricas que podríamos cargar al inicio del script para tenerlas siempre disponibles. Para esa generalización no tenemos que tocar requestGen, pero si sería necesario dotar de otro callback que se ejecute en lugar del que pusimos antes console.log(arrRes) para realizar pruebas.

function* recuperar(rutas, callback){
    let iter = yield;
    let arrRes = yield requestGen(rutas, iter);
    //Deriva a otro lugar para procesar resultados
    callback(arrRes);
}    
//Ejecutar el ejemplo    
function ejecutarGenerador(gen, rutas, callback){
    let iter = gen(rutas, callback);
    iter.next();
    iter.next(iter);
}
    

Así en cualquier parte del código podríamos solicitar un conjunto de archivos y procesarlos en un Array en el mismo orden que se pidieron. De esta forma ocultamos los detalles de como funciona el trasfondo de las peticiones y sólo nos concentramos en estas pocas líneas de código, sin variables globales ni otras cosas que intefieran a su entendimiento:

let tresRutas = ["ejemplos/archivo1.txt", "ejemplos/archivo2.txt",
                 "ejemplos/archivo3.txt"];
function procesarResultados(arr){
    //Aquí procesamos los resultados que vienen en el Array
}  
ejecutarGenerador(recuperar, tresRutas, procesarResultados);
    

Con todo lo anterior vamos a hacer un ejemplo interactivo:

Ejemplo: Generadores y Promesas para peticiones asíncronas de archivos

Técnica a usar
Tiempo:
Este ejemplo usa ES6 en modo estricto. Puedes consultar el código JS original de este ejemplo.

Observará como con la técnica clásica el orden de recepción de los archivos puede ser cualquiera, mientras que con generadores se respeta el orden de la petición. El retraso real de una petición viene influenciado por la velocidad de la red y el tamaño del archivo entre otros muchos factores. Para observarlo mejor desactivaremos la opción Igual retraso en cada petición. Con ello reemplazamos la sentencia request.send() en el XHR por un retraso aleatorio menor 300ms, usando el código window.setTimeout(() => request.send(), Math.floor(Math.random()*300)). Así habrá diferencias aleatorias entre los tiempos de cada recepción.

También seguimos los tiempos de ejecución para ver como se comporta el caso del generador bloqueante. Para ello activamos Igual retraso en cada petición, con lo que usamos un retraso fijo de 300ms en lugar del aleatorio anterior. Si los tres archivos ya están cacheados, el tiempo para ejecutar cada petición no será muy alto. En el caso del generador bloqueante la espera total será de no menos de 900 ms. Para el resto de casos estará en algo más de 300 ms, puesto que las tres peticiones son realizadas en paralelo por el navegador.

Otra cosa que agregamos es una simulación de error modificando la ruta del segundo archivo con objeto de obtener un error 404. Se trata de evidenciar el mejor comportamiento de la técnica generador con promesas que comentaremos a continuación.

Peticiones asíncronas de varios archivos usando generadores y promesas

Y por supuesto, también hay un mejor solución con promesas para el ejercicio del apartado anterior. En primer lugar vamos a definir una función para manejar un texto de error y sacarlo por la consola, muy simple a efectos de simplificar este ejemplo:

function manejarError(mensajeError){
    console.log(mensajeError);
}
    

Necesitamos una función que ejecute un generador con promesas. Le pasamos el generador, el manejador de mensajes de errores anterior y finalmente el resto de argumentos que seran particularizados para cada aplicación. Este ejecutor ya lo describimos en un apartado anterior, adaptándolo para promesas y añadiéndole ahora el resto de argumentos.

//Esta función es genérica para ejecutar cualquier 
//generador con promesas
function ejecutarGeneradorConPromesas(generador, cbError, ...args){
    let iter = generador.apply(null, args);
    function iterar(valor){
        let respuesta = iter.next(valor);
        if (!respuesta.done){
            if (respuesta.value.then){
                respuesta.value.then(iterar, cbError).catch(cbError);
            } else {
                iterar(respuesta.value);
            }
        }
    }
    iterar();
}
    

Necesitamos una función que gestione la resolución o rechazo de una promesa. Se trata de crear una nueva promesa con cada petición de un archivo, pues la función pedir(ruta, callback) es la misma que ya teníamos para usar con técnicas clásicas.

function requestProm(ruta){
    return new Promise(function(resolve, reject){
        pedir(ruta, function(error, texto){
            if (error){
                reject(texto);
            } else {
                resolve(texto);
            }
        });
    });
}
    

Y ahora el generador. La clave del ejemplo está en el método Promise.all() que recibe un Array de promesas que se resuelve cuando se resuelvan todas. O cuando se rechace alguna:

function* recuperarAll(rutas, callback){
    try {
        let arrRes = yield Promise.all(rutas.map(v => requestProm(v)));
        callback(arrRes);
    } catch(e){
        yield Promise.reject(e);
    }
}
    

La ejecución no puede ser más simple:

let tresRutas = ["ejemplos/archivo1.txt", "ejemplos/archivo2.txt",
                 "ejemplos/archivo3.txt"];
function procesarResultados(arr){
    //Aquí procesamos los resultados que vienen en el Array
    console.log(arr);
}  
ejecutarGeneradorConPromesas(recuperarAll, manejarError, tresRutas, 
                             procesarResultados);
    

La ventaja de las promesas se evidencia en que si hay error en una petición se cancela el proceso de resultados. Y eso es precisamente lo que queremos, pues si nos falta un archivo no podríamos procesar sólo el resto. Por supuesto que con las otras técnicas podría controlarse este aspecto, pero con promesas el control de errores es más simple e intuitivo.

En una ejecución con simulación de error obtuvimos estos resultados

ClásicaGenerador bloqueanteGenerador no bloqueanteGenerador + Promesas
CLASICA
-------
ARCHIVO 1
ARCHIVO 3
Error 404 al recuperar ejemplos/no_existe.txt
        
GENERADOR BLOQUEANTE
--------------------
ARCHIVO 1
Error 404 al recuperar ejemplos/no_existe.txt
ARCHIVO 3
        
GENERADOR NO BLOQUEANTE
-----------------------
ARCHIVO 1
Error 404 al recuperar ejemplos/no_existe.txt
ARCHIVO 3
        
GENERADOR+PROMESAS
------------------
Error 404 al recuperar ejemplos/no_existe.txt
        

El generador bloqueante tiene la desventaja de no poder hacer peticiones en paralelo. Pero tiene una ventaja. Supongamos que pedimos 20 archivos necesarios para que una aplicación funcione. Si faltara alguno la aplicación no funcionaría.

Con un generador no bloqueante (con o sin promesas), se solicitan todos los archivos, independientemente de que pudiera darse un error con alguna petición. Supongamos que hay un error en la segunda petición. El resto no podrá cancelarse pues ya todas las peticiones fueron enviadas. Inclusos el generador con promesas también recibirá esos archivos, aunque no haya nada abierto para procesarlos.

Sin embargo con un generador bloqueante vamos haciendo peticiones secuencialmente. Si obtenemos un error en una no seguimos pidiendo el resto. Por supuesto, esta forma de actuar con 20 archivos puede dar lugar a una larga espera.

Una solución pasaría por dividir el total de archivos en grupos de 5, pues por ejemplo el navegador Chrome puede realizar hasta 6 conexiones en paralelo. Cada grupo sería no bloqueante y entre grupos sería bloqueante. Envíamos un grupo y si se resuelven todas envíamos el siguiente grupo. Por lo tanto hay que sopesar la solución óptima para cada problema teniendo en cuenta estos pormenores.

El efecto callback hell

Cuando dicen que los generadores, y especialmente las promesas, evitan el efecto callback hell no están diciendo la verdad completa. El Infierno del callback es debido a un código mal estructurado, no por falta de generadores y promesas. Éstas técnicas pueden hacernos la vida más fácil con tareas asíncronas. Pero tendremos problemas igualmente si no estructuramos bien nuestro código.

En el siguiente ejemplo se puede probar el mismo código mal estructurado (con callback hell) y bien estructurado (sin callback hell). Ambos funcionan.

Ejemplo: Explicando el callback hell

Tiempo: 0
Recibido:
Tiempo: 0
Recibido:
Este ejemplo usa ES6 en modo estricto. Puedes consultar el código JS original de este ejemplo.

Éste es el código mal estructurado. Todo dentro del callback de un evento del botón, que es un tarea asíncrona. Contiene a su vez tres tareas asíncronas: la del contador de tiempo, la del XHR y una al final para retrasar la petición. El ejemplo es muy simple, pero podría complicarse si con necesidades futuras hubiera que, por ejemplo, realizar una petición a un segundo archivo tras recibir el primero. ¿Otra tarea asíncrona anidada?

//CON callback hell
document.getElementById("pedir-con-cbh").addEventListener("click", 
function(){
    let contenidoArchivo = document.
        getElementById("archivo-con-cbh").textContent = "";
    let timeout = null;
    let contador = document.getElementById("contar-tiempo-con-cbh");
    contador.textContent = 0;
    let inicio = new Date();
    function ejecutarBucle(){
        contador.textContent = (new Date() - inicio) + "ms";
        timeout = window.setTimeout(ejecutarBucle, 1);
    };
    let request =  new XMLHttpRequest();
    request.open("GET", "ejemplos/archivo1.txt", true);
    request.addEventListener("readystatechange", function(){
        if (request.readyState == 4) {
            window.clearTimeout(timeout);
            if (request.status == 200){
                document.getElementById("archivo-con-cbh").
                    textContent = request.responseText;
            } else {
                alert(`Error ${request.status} al recuperar ${ruta}`);
            }
        }
    }, false);
    ejecutarBucle();
    window.setTimeout(function(){
        request.send();
    }, 1000);
}, false);
    

No es que no funcione. Pero hay que evitar anidar tareas asíncronas, porque pasado un tiempo de escribir el código, ni nosotros mismos seremos capaces de desenredarlo en caso de modificaciones o corrección de errores. Ya vimos en apartados anteriores como separar el código por funcionalidades. Y ése es precisamente uno de los objetivos de las funciones: evitar el código espaguetti. Y en este caso el anidamiento de tareas asíncronas o callbacks para atenderlas.

//SIN callback hell
function pedir(ruta, callback){
    let request =  new XMLHttpRequest();
    request.open("GET", ruta, true);
    request.addEventListener("readystatechange", function(){
        if (request.readyState == 4) {
            if (request.status == 200){
                callback(false, request.responseText);
            } else {
                callback(true, `Error ${request.status} al 
                                recuperar ${ruta}`);
            }
        }
    }, false);
    window.setTimeout(function(){
        request.send();
    }, 1000);
}

function manejarPeticion(error, texto){
    window.clearTimeout(timeout);
    if (!error){
        document.getElementById("archivo-sin-cbh").textContent = texto;
    } else {
        alert(`ERROR: ${texto}`);
    }
}

let timeout = null;

function contarTiempo(){
    let contador = document.getElementById("contar-tiempo-sin-cbh");
    contador.textContent = 0;
    let inicio = new Date();
    (function ejecutarBucle(){
        contador.textContent = (new Date() - inicio) + "ms";
        timeout = window.setTimeout(ejecutarBucle, 1);
    })();
}

document.getElementById("pedir-sin-cbh").addEventListener("click", 
function(){
    document.getElementById("archivo-sin-cbh").textContent = "";
    contarTiempo();
    pedir("ejemplos/archivo2.txt", manejarPeticion);
}, false);
    

Y hasta aquí todo lo que he podido aprender sobre generadores y algo de promesas. Pero aún queda mucho más que aprender, especialemente sobre las Promesas. Y quedar a la espera de novedades en próximas versiones de EcmaScript sobre el tema de tareas asíncronas, como Async Functions. Eso simplificará aún más el uso de generadores y promesas en tareas asíncronas.